489 lines
12 KiB
JavaScript
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";
|