// 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("