// app/api/seo-check/route.js import { NextResponse } from "next/server"; import puppeteer from "puppeteer"; 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({ headless: true, }); } 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 ? `" : 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";