2025-07-07 18:21:02 +07:00

489 lines
12 KiB
JavaScript

// app/api/seo-check/route.js
import { NextResponse } from "next/server";
import * as cheerio from "cheerio";
export async function POST(request) {
const startTime = Date.now();
try {
// Validate request
let url;
try {
const body = await request.json();
url = body?.url;
if (!url) throw new Error("URL is required");
} catch (e) {
return NextResponse.json(
{ error: "Invalid request format" },
{ status: 400 }
);
}
// Validate URL format
let parsedUrl;
try {
parsedUrl = new URL(url);
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
throw new Error("Invalid protocol");
}
} catch (e) {
return NextResponse.json(
{ error: "Please provide a valid HTTP/HTTPS URL" },
{ status: 400 }
);
}
// Fetch HTML with enhanced configuration
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
let response;
try {
response = await fetch(url, {
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; SEO-Analyzer/1.0; +https://github.com)",
Accept: "text/html,application/xhtml+xml",
},
redirect: "follow",
signal: controller.signal,
});
clearTimeout(timeout);
} catch (e) {
return NextResponse.json(
{
error:
e.name === "AbortError"
? "Request timed out"
: `Fetch failed: ${e.message}`,
},
{ status: 400 }
);
}
// Verify response
if (!response.ok) {
return NextResponse.json(
{
error: `HTTP ${response.status}`,
status: response.status,
url: response.url,
},
{ status: 400 }
);
}
const contentType = response.headers.get("content-type");
if (!contentType?.includes("text/html")) {
return NextResponse.json(
{ error: "URL does not return HTML content" },
{ status: 400 }
);
}
// Parse HTML
const html = await response.text();
const finalUrl = response.url;
let $;
try {
$ = cheerio.load(html);
} catch (e) {
return NextResponse.json(
{ error: "Failed to parse HTML content" },
{ status: 500 }
);
}
// Extract security headers
const securityHeaders = {
https: finalUrl.startsWith("https://"),
xFrameOptions: response.headers.get("x-frame-options"),
xXSSProtection: response.headers.get("x-xss-protection"),
contentTypeOptions: response.headers.get("x-content-type-options"),
strictTransportSecurity: response.headers.get(
"strict-transport-security"
),
};
// Add this new function to analyze charset
function analyzeCharset($) {
const charsetMeta = $("meta[charset]");
if (charsetMeta.length > 0) {
return {
exists: true,
value: charsetMeta.attr("charset")?.toUpperCase() || "UTF-8",
declaredInMeta: true,
};
}
const httpEquiv = $('meta[http-equiv="Content-Type"]');
if (httpEquiv.length > 0) {
const content = httpEquiv.attr("content") || "";
const charsetMatch = content.match(/charset=([^;]+)/i);
if (charsetMatch) {
return {
exists: true,
value: charsetMatch[1].toUpperCase(),
declaredInMeta: true,
};
}
}
return {
exists: false,
value: null,
declaredInMeta: false,
};
}
// Title Tag Analysis
function analyzeTitle($) {
const title = $("title").first().text().trim();
return {
exists: title.length > 0,
text: title,
length: title.length,
status:
title.length >= 30 && title.length <= 60
? "optimal"
: title.length < 30
? "too_short"
: "too_long",
};
}
// Meta Description Analysis
function analyzeMetaDescription($) {
const desc = $('meta[name="description"]').attr("content") || "";
return {
exists: desc.length > 0,
text: desc,
length: desc.length,
status:
desc.length >= 50 && desc.length <= 160
? "optimal"
: desc.length < 50
? "too_short"
: "too_long",
};
}
// Meta Robots Analysis
function analyzeMetaRobots($) {
const content = $('meta[name="robots"]').attr("content") || "";
return {
exists: content.length > 0,
content,
noindex: content.includes("noindex"),
nofollow: content.includes("nofollow"),
};
}
// Viewport Analysis
function analyzeViewport($) {
const viewport = $('meta[name="viewport"]').attr("content") || "";
return {
exists: viewport.length > 0,
content: viewport,
mobileFriendly: viewport.includes("width=device-width"),
};
}
// Text Analysis Functions
function calculateReadabilityScore(text) {
// Simple readability score calculation (Flesch-Kincaid approximation)
const words = text
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
const syllables = words.reduce(
(count, word) => count + countSyllables(word),
0
);
if (words.length === 0 || sentences.length === 0) return 0;
const wordsPerSentence = words.length / sentences.length;
const syllablesPerWord = syllables / words.length;
// Flesch Reading Ease Score
const score =
206.835 - 1.015 * wordsPerSentence - 84.6 * syllablesPerWord;
// Normalize to 0-100 scale
return Math.max(0, Math.min(100, Math.round(score)));
}
function countSyllables(word) {
// Simple syllable counting approximation
word = word.toLowerCase().replace(/[^a-z]/g, "");
if (word.length <= 3) return 1;
let syllables = word.replace(/[^aeiouy]/g, "").length;
syllables -= word.match(/e$/) ? 1 : 0; // Silent e
syllables -= word.match(/[aeiouy]{2,}/g)?.length || 0; // Diphthongs
return Math.max(1, syllables);
}
// Content Analysis
function analyzeContent($) {
const bodyText = $("body").text();
const words = bodyText
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
return {
wordCount: words.length,
textLength: bodyText.length,
readability: calculateReadabilityScore(bodyText),
paragraphCount: $("p").length,
listCount: $("ul, ol").length,
};
}
// Perform comprehensive analysis
const analysis = {
url: finalUrl,
pageLoadTime: (Date.now() - startTime) / 1000,
title: analyzeTitle($),
meta: {
description: analyzeMetaDescription($),
robots: analyzeMetaRobots($),
viewport: analyzeViewport($),
charset: analyzeCharset($),
keywords: $('meta[name="keywords"]').attr("content") || null,
},
headings: analyzeHeadings($),
images: analyzeImages($),
links: analyzeLinks($, finalUrl),
content: analyzeContent($),
technical: {
canonical: analyzeCanonical($),
language: analyzeLanguage($),
schemaMarkup: analyzeSchemaMarkup($),
doctype: analyzeDoctype($),
},
social: {
openGraph: analyzeOpenGraph($),
twitterCard: analyzeTwitterCards($),
},
security: securityHeaders,
analyzedAt: new Date().toISOString(),
};
return NextResponse.json(analysis);
} catch (error) {
console.error("SEO analysis error:", error);
return NextResponse.json(
{
error: "Internal server error during analysis",
details:
process.env.NODE_ENV === "development" ? error.stack : undefined,
},
{ status: 500 }
);
}
}
// Analysis Functions
function analyzeTitle($) {
const title = $("title").first().text().trim();
return {
exists: title.length > 0,
text: title,
length: title.length,
status:
title.length >= 30 && title.length <= 60
? "optimal"
: title.length < 30
? "too_short"
: "too_long",
};
}
function analyzeMetaDescription($) {
const desc = $('meta[name="description"]').attr("content") || "";
return {
exists: desc.length > 0,
text: desc,
length: desc.length,
status:
desc.length >= 50 && desc.length <= 160
? "optimal"
: desc.length < 50
? "too_short"
: "too_long",
};
}
function analyzeMetaRobots($) {
const content = $('meta[name="robots"]').attr("content") || "";
return {
exists: content.length > 0,
content,
noindex: content.includes("noindex"),
nofollow: content.includes("nofollow"),
};
}
function analyzeViewport($) {
const viewport = $('meta[name="viewport"]').attr("content") || "";
return {
exists: viewport.length > 0,
content: viewport,
mobileFriendly: viewport.includes("width=device-width"),
};
}
function analyzeHeadings($) {
return {
h1: {
count: $("h1").length,
texts: $("h1")
.map((i, el) => $(el).text().trim())
.get(),
},
h2: {
count: $("h2").length,
texts: $("h2")
.map((i, el) => $(el).text().trim())
.get(),
},
h3: {
count: $("h3").length,
texts: $("h3")
.map((i, el) => $(el).text().trim())
.get(),
},
};
}
function analyzeImages($) {
const images = $("img");
const withAlt = images.filter((i, el) => {
const alt = $(el).attr("alt");
return alt && alt.trim() !== "";
}).length;
return {
total: images.length,
withAlt,
withoutAlt: images.length - withAlt,
percentageWithAlt:
images.length > 0 ? Math.round((withAlt / images.length) * 100) : 100,
};
}
function analyzeLinks($, baseUrl) {
const links = $("a[href]");
let internal = 0;
let external = 0;
let nofollow = 0;
try {
const baseDomain = new URL(baseUrl).hostname.replace("www.", "");
links.each((i, el) => {
const href = $(el).attr("href");
const rel = $(el).attr("rel") || "";
if (rel.includes("nofollow")) nofollow++;
try {
const url = new URL(href, baseUrl);
if (url.hostname.replace("www.", "") === baseDomain) {
internal++;
} else {
external++;
}
} catch {
internal++; // Relative links
}
});
} catch (e) {
console.error("Link analysis error:", e);
}
return {
total: links.length,
internal,
external,
nofollow,
nofollowPercentage:
links.length > 0 ? Math.round((nofollow / links.length) * 100) : 0,
};
}
function analyzeContent($) {
const bodyText = $("body").text();
const words = bodyText
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
return {
wordCount: words.length,
textLength: bodyText.length,
readability: calculateReadabilityScore(words), // Implement your own formula
};
}
function analyzeCanonical($) {
const canonical = $('link[rel="canonical"]').attr("href") || "";
return {
exists: canonical.length > 0,
url: canonical,
isSelf: canonical === $('meta[property="og:url"]').attr("content"),
};
}
function analyzeSchemaMarkup($) {
const schemas = $('script[type="application/ld+json"]');
const types = [];
schemas.each((i, el) => {
try {
const json = JSON.parse($(el).text());
if (json["@type"]) types.push(json["@type"]);
} catch (e) {
console.error("Schema parsing error:", e);
}
});
return {
count: schemas.length,
types: [...new Set(types)], // Unique types only
};
}
function analyzeOpenGraph($) {
return {
title: $('meta[property="og:title"]').attr("content") || "",
description: $('meta[property="og:description"]').attr("content") || "",
image: $('meta[property="og:image"]').attr("content") || "",
url: $('meta[property="og:url"]').attr("content") || "",
};
}
function analyzeTwitterCards($) {
return {
card: $('meta[name="twitter:card"]').attr("content") || "",
title: $('meta[name="twitter:title"]').attr("content") || "",
description: $('meta[name="twitter:description"]').attr("content") || "",
image: $('meta[name="twitter:image"]').attr("content") || "",
};
}
function analyzeLanguage($) {
return $("html").attr("lang") || null;
}
function analyzeDoctype($) {
const doctype = $("html")[0]?.prev?.data;
return doctype?.includes("<!DOCTYPE") ? doctype : null;
}
export const dynamic = "force-dynamic";