2025-07-08 21:07:23 +07:00

455 lines
12 KiB
JavaScript

// app/api/seo-check/route.js
import { NextResponse } from "next/server";
import puppeteer from "puppeteer-core";
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 }
);
}
// Launch Puppeteer browser
let browser;
try {
browser = await puppeteer.launch({
executablePath: process.env.NEXT_CHROMIUM_PATH,
headless: "new",
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
],
});
} catch (e) {
return NextResponse.json(
{ error: "Failed to initialize browser" },
{ status: 500 }
);
}
let page;
let finalUrl;
let html;
let securityHeaders = {};
let response;
try {
page = await browser.newPage();
// Set user agent and headers
await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
// Listen for response to capture headers
page.on("response", async (res) => {
if (res.url() === url || res.url() === finalUrl) {
response = res;
securityHeaders = {
https: res.url().startsWith("https://"),
xFrameOptions: res.headers()["x-frame-options"],
xXSSProtection: res.headers()["x-xss-protection"],
contentTypeOptions: res.headers()["x-content-type-options"],
strictTransportSecurity: res.headers()["strict-transport-security"],
};
}
});
// Navigate to the page with timeout
await page.goto(url, {
waitUntil: "networkidle2",
timeout: 15000,
});
// Get final URL after potential redirects
finalUrl = page.url();
// Get the full HTML content
html = await page.content();
} catch (e) {
await browser.close();
return NextResponse.json(
{
error:
e.name === "TimeoutError"
? "Page load timed out"
: `Failed to load page: ${e.message}`,
},
{ status: 400 }
);
}
// Use Puppeteer's DOM methods for analysis
const analysis = {
url: finalUrl,
pageLoadTime: (Date.now() - startTime) / 1000,
title: await analyzeTitle(page),
meta: {
description: await analyzeMetaDescription(page),
robots: await analyzeMetaRobots(page),
viewport: await analyzeViewport(page),
charset: await analyzeCharset(page),
keywords: await getMetaContent(page, "keywords"),
},
headings: await analyzeHeadings(page),
images: await analyzeImages(page),
links: await analyzeLinks(page, finalUrl),
content: await analyzeContent(page),
technical: {
canonical: await analyzeCanonical(page),
language: await analyzeLanguage(page),
schemaMarkup: await analyzeSchemaMarkup(page),
doctype: await analyzeDoctype(page),
},
social: {
openGraph: {
title: await getMetaContent(page, "og:title"),
description: await getMetaContent(page, "og:description"),
image: await getMetaContent(page, "og:image"),
url: await getMetaContent(page, "og:url"),
},
twitterCard: {
card: await getMetaContent(page, "twitter:card"),
title: await getMetaContent(page, "twitter:title"),
description: await getMetaContent(page, "twitter:description"),
image: await getMetaContent(page, "twitter:image"),
},
},
security: securityHeaders,
analyzedAt: new Date().toISOString(),
};
await browser.close();
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 }
);
}
}
// Helper function to get meta content
async function getMetaContent(page, nameOrProperty) {
try {
return await page.$eval(
`meta[name="${nameOrProperty}"], meta[property="${nameOrProperty}"]`,
(el) => (el ? el.getAttribute("content") : null)
);
} catch {
return null;
}
}
// Analysis functions using Puppeteer's DOM API
async function analyzeTitle(page) {
const title = await page.title();
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",
};
}
async function analyzeMetaDescription(page) {
const desc = (await getMetaContent(page, "description")) || "";
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",
};
}
async function analyzeMetaRobots(page) {
const content = (await getMetaContent(page, "robots")) || "";
return {
exists: content.length > 0,
content,
noindex: content.includes("noindex"),
nofollow: content.includes("nofollow"),
};
}
async function analyzeViewport(page) {
const viewport = (await getMetaContent(page, "viewport")) || "";
return {
exists: viewport.length > 0,
content: viewport,
mobileFriendly: viewport.includes("width=device-width"),
};
}
async function analyzeCharset(page) {
try {
// Check meta charset
const charsetMeta = await page.$eval("meta[charset]", (el) =>
el.getAttribute("charset")
);
if (charsetMeta) {
return {
exists: true,
value: charsetMeta.toUpperCase(),
declaredInMeta: true,
};
}
// Check http-equiv
const httpEquiv = await page.$eval(
'meta[http-equiv="Content-Type"]',
(el) => el.getAttribute("content")
);
if (httpEquiv) {
const charsetMatch = httpEquiv.match(/charset=([^;]+)/i);
if (charsetMatch) {
return {
exists: true,
value: charsetMatch[1].toUpperCase(),
declaredInMeta: true,
};
}
}
return {
exists: false,
value: null,
declaredInMeta: false,
};
} catch {
return {
exists: false,
value: null,
declaredInMeta: false,
};
}
}
async function analyzeHeadings(page) {
const getHeadingTexts = async (selector) => {
return page.$$eval(selector, (els) =>
els.map((el) => el.textContent.trim())
);
};
return {
h1: {
count: await page.$$eval("h1", (els) => els.length),
texts: await getHeadingTexts("h1"),
},
h2: {
count: await page.$$eval("h2", (els) => els.length),
texts: await getHeadingTexts("h2"),
},
h3: {
count: await page.$$eval("h3", (els) => els.length),
texts: await getHeadingTexts("h3"),
},
};
}
async function analyzeImages(page) {
const images = await page.$$("img");
const withAlt = await page.$$eval(
"img",
(imgs) => imgs.filter((img) => img.alt && img.alt.trim() !== "").length
);
return {
total: images.length,
withAlt,
withoutAlt: images.length - withAlt,
percentageWithAlt:
images.length > 0 ? Math.round((withAlt / images.length) * 100) : 100,
};
}
async function analyzeLinks(page, baseUrl) {
const links = await page.$$("a[href]");
let internal = 0;
let external = 0;
let nofollow = 0;
try {
const baseDomain = new URL(baseUrl).hostname.replace("www.", "");
for (const link of links) {
const href = await link.evaluate((el) => el.getAttribute("href"));
const rel = await link.evaluate((el) => el.getAttribute("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,
};
}
async function analyzeCanonical(page) {
const canonical =
(await page
.$eval('link[rel="canonical"]', (el) =>
el ? el.getAttribute("href") : null
)
.catch(() => null)) || "";
const ogUrl = (await getMetaContent(page, "og:url")) || "";
return {
exists: canonical.length > 0,
url: canonical,
isSelf: canonical === ogUrl,
};
}
async function analyzeSchemaMarkup(page) {
const schemas = await page.$$('script[type="application/ld+json"]');
const types = [];
for (const schema of schemas) {
try {
const jsonText = await schema.evaluate((el) => el.textContent);
const json = JSON.parse(jsonText);
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
};
}
async function analyzeLanguage(page) {
return page.$eval("html", (el) => el.getAttribute("lang")).catch(() => null);
}
async function analyzeDoctype(page) {
return page.evaluate(() => {
const doctype = document.doctype;
return doctype
? `<!DOCTYPE ${doctype.name}` +
(doctype.publicId ? ` PUBLIC "${doctype.publicId}"` : "") +
(doctype.systemId ? ` "${doctype.systemId}"` : "") +
">"
: null;
});
}
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);
}
async function analyzeContent(page) {
const bodyText = await page.$eval("body", (el) => el.textContent);
const words = bodyText
.trim()
.split(/\s+/)
.filter((word) => word.length > 0);
const paragraphs = await page.$$("p");
const lists = await page.$$("ul, ol");
return {
wordCount: words.length,
textLength: bodyText.length,
readability: calculateReadabilityScore(bodyText),
paragraphCount: paragraphs.length,
listCount: lists.length,
};
}
export const dynamic = "force-dynamic";