#!/usr/bin/env node /** * Auditoria repetível de SEO / AEO / GEO, sem dependências (Node >= 20). * * node scripts/seo-audit.mjs --base http://localhost:3000 --out audit-antes.json * node scripts/seo-compare.mjs audit-antes.json audit-depois.json * * Lê o sitemap, baixa cada URL como um robô (HTML sem JavaScript) e confere o que buscadores e sistemas de * resposta/IA realmente enxergam. Não inventa nota: cada verificação é passa/falha com a evidência. * O que depende de conta (Search Console, Bing, CrUX, citações em plataformas de IA) está em docs/seo-geo-operations.md. */ import { writeFileSync } from "node:fs"; const args = Object.fromEntries( process.argv .slice(2) .map((a, i, all) => (a.startsWith("--") ? [a.slice(2), all[i + 1]] : null)) .filter(Boolean), ); const BASE = (args.base ?? "https://evandro.dev.br").replace(/\/$/, ""); const SITE = "https://evandro.dev.br"; // origem canônica declarada no código const UA = "Mozilla/5.0 (compatible; seo-audit/1.0)"; const AI_BOTS = [ [ "Googlebot", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", ], [ "bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", ], [ "OAI-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot", ], [ "GPTBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)", ], [ "ChatGPT-User", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)", ], [ "Claude-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; +searchbot@anthropic.com)", ], [ "ClaudeBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)", ], [ "PerplexityBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)", ], ]; const local = (url) => url.replace(SITE, BASE); // permite auditar localhost com URLs canônicas de produção const get = async (url, ua = UA, init = {}) => { const t0 = performance.now(); const res = await fetch(url, { headers: { "user-agent": ua, "accept-encoding": "gzip, br" }, redirect: "manual", ...init, }); const body = init.method === "HEAD" ? "" : await res.text(); return { res, body, ms: Math.round(performance.now() - t0) }; }; const attrs = (tag) => { const out = {}; for (const m of tag.matchAll( /([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g, )) out[m[1].toLowerCase()] = m[2] ?? m[3] ?? ""; return out; }; const decode = (s) => s .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/</g, "<") .replace(/>/g, ">") .trim(); const strip = (html) => decode( html .replace(/<(script|style|noscript|template|svg)[\s\S]*?<\/\1>/gi, " ") .replace(//g, " ") .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " "), ); const words = (s) => (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length; function parsePage(html) { const metas = [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); const meta = (key, val = "name") => metas.find((m) => m[val]?.toLowerCase() === key)?.content; const links = [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); const headings = [ ...html.matchAll(/]*>([\s\S]*?)<\/h\1>/gi), ].map((m) => ({ level: +m[1], text: strip(m[2]) })); const imgs = [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); const anchors = [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); const ld = [ ...html.matchAll( /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi, ), ].map((m) => m[1]); const main = html.match(//i)?.[0] ?? html; const firstH1 = main.search(/= 0 ? main.slice(firstH1) : main; const firstP = [...afterH1.matchAll(/]*>([\s\S]*?)<\/p>/gi)] .map((m) => strip(m[1])) .find((t) => words(t) >= 8); return { title: decode(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""), description: meta("description"), robots: meta("robots"), viewport: meta("viewport"), lang: html.match(/]*\blang=["']([^"']+)/i)?.[1], canonical: links.find((l) => l.rel === "canonical")?.href, alternates: links .filter((l) => l.rel === "alternate" && l.hreflang) .map((l) => l.hreflang), og: Object.fromEntries( metas .filter((m) => m.property?.startsWith("og:")) .map((m) => [m.property, m.content]), ), tw: Object.fromEntries( metas .filter((m) => m.name?.startsWith("twitter:")) .map((m) => [m.name, m.content]), ), headings, imgs, anchors, ld, wordCount: words(strip(main)), firstAnswerWords: firstP ? words(firstP) : 0, }; } function jsonLdFacts(blocks) { const nodes = []; const errors = []; for (const raw of blocks) { try { const data = JSON.parse(raw); const list = data["@graph"] ?? (Array.isArray(data) ? data : [data]); nodes.push(...list); } catch (e) { errors.push(String(e.message).slice(0, 80)); } } const ids = new Set(nodes.map((n) => n["@id"]).filter(Boolean)); const types = nodes.flatMap((n) => [].concat(n["@type"] ?? [])); const dangling = []; const walk = (o) => { if (Array.isArray(o)) return o.forEach(walk); if (o && typeof o === "object") { const keys = Object.keys(o); if ( keys.length === 1 && keys[0] === "@id" && !ids.has(o["@id"]) && !String(o["@id"]).startsWith(SITE) ) dangling.push(o["@id"]); Object.values(o).forEach(walk); } }; walk(nodes); return { nodes, ids, types, errors, dangling }; } const checks = []; // {scope, id, group, ok, detail} const add = (scope, group, id, ok, detail = "") => checks.push({ scope, group, id, ok: Boolean(ok), detail }); /** Chaves de Open Graph que toda página precisa ter. */ const OG_KEYS = [ "og:title", "og:description", "og:url", "og:image", "og:type", "og:site_name", "og:locale", ]; /** Compressão do HTML e tempo até o primeiro byte. */ function checkTechnical(scope, res, ms) { const encoding = res.headers.get("content-encoding"); add( scope, "técnico", "html-comprimido", /br|gzip/.test(encoding ?? "") || BASE.startsWith("http://localhost"), encoding ?? "sem compressão", ); add( scope, "técnico", "ttfb-menor-800ms", ms < 800, `${ms} ms (HTML completo)`, ); } /** Idioma, viewport e título (tamanho e unicidade no site). */ function checkHead(scope, p, siteTitles) { add( scope, "on-page", "lang-pt-BR", p.lang?.toLowerCase() === "pt-br", p.lang ?? "ausente", ); add(scope, "on-page", "viewport", Boolean(p.viewport)); add( scope, "on-page", "title-30-65", p.title.length >= 30 && p.title.length <= 65, `${p.title.length} car.: ${p.title}`, ); add(scope, "on-page", "title-unico", !siteTitles.has(p.title), p.title); siteTitles.add(p.title); } /** Descrição (tamanho e unicidade), canônica própria e indexabilidade. */ function checkSnippet(scope, url, res, p, siteDescs) { add( scope, "on-page", "description-70-165", !!p.description && p.description.length >= 70 && p.description.length <= 165, `${p.description?.length ?? 0} car.`, ); add( scope, "on-page", "description-unica", !!p.description && !siteDescs.has(p.description), ); if (p.description) siteDescs.add(p.description); add( scope, "on-page", "canonical-propria", p.canonical === url || p.canonical === `${url}/`, p.canonical ?? "ausente", ); const noindex = (value) => /noindex/i.test(value ?? ""); add( scope, "on-page", "indexavel", !noindex(p.robots) && !noindex(res.headers.get("x-robots-tag")), ); } /** A hierarquia de títulos pula algum nível (de h1 direto para h3, por exemplo)? */ function skipsHeadingLevel(headings) { let previous = 0; for (const h of headings) { if (h.level - previous > 1) return true; previous = h.level; } return false; } /** Links internos (mesmo site, sem protocolo relativo) de uma lista de âncoras. */ const isInternalLink = (a) => a.href && (a.href.startsWith("/") || a.href.startsWith(SITE)) && !a.href.startsWith("//"); /** Links internos suficientes e links externos que abrem em outra aba com noopener. */ function checkLinks(scope, p) { const internal = p.anchors.filter(isInternalLink); const destinations = new Set(internal.map((a) => a.href.split("#")[0])).size; add( scope, "on-page", "links-internos-5+", destinations >= 5, `${destinations} destinos`, ); const external = p.anchors.filter((a) => a.target === "_blank"); // noreferrer também implica noopener const unsafe = external.filter( (a) => !/noopener|noreferrer/.test(a.rel ?? ""), ); add( scope, "on-page", "externos-com-noopener", unsafe.length === 0, `${external.length} links _blank; sem rel: ${unsafe .map((a) => a.href) .slice(0, 2) .join(" ")}`, ); } /** Títulos (um h1, hierarquia), imagens com alt e links. */ function checkStructure(scope, p) { const h1 = p.headings.filter((h) => h.level === 1); add(scope, "on-page", "um-h1", h1.length === 1, `${h1.length} h1`); add( scope, "on-page", "hierarquia-de-titulos", !skipsHeadingLevel(p.headings), ); add( scope, "on-page", "imagens-com-alt", p.imgs.every((i) => i.alt !== undefined), `${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`, ); checkLinks(scope, p); } /** A imagem de compartilhamento responde 200 e é uma imagem. */ async function checkOgImage(scope, src) { const img = await get(local(src), UA, { method: "GET" }).catch(() => null); add( scope, "social", "og-image-acessivel", img?.res.status === 200 && /^image\//.test(img.res.headers.get("content-type") ?? ""), `${img?.res.status} ${img?.res.headers.get("content-type")}`, ); } /** Open Graph completo, cartão do Twitter e a imagem de compartilhamento. */ async function checkSocial(scope, p) { const missingOg = OG_KEYS.filter((k) => !p.og[k]); add( scope, "social", "og-completo", missingOg.length === 0, missingOg.length ? `faltam ${missingOg.join(",")}` : "", ); add( scope, "social", "twitter-card", p.tw["twitter:card"] === "summary_large_image" && !!p.tw["twitter:title"] && !!p.tw["twitter:image"], ); if (p.og["og:image"]) await checkOgImage(scope, p.og["og:image"]); } /** JSON-LD válido, referências que resolvem, tipos proibidos, breadcrumb e Person com sameAs. Devolve os fatos. */ function checkStructuredData(scope, p) { const ld = jsonLdFacts(p.ld); add( scope, "dados-estruturados", "json-ld-valido", p.ld.length > 0 && ld.errors.length === 0, ld.errors.join("; ") || `${p.ld.length} blocos`, ); add( scope, "dados-estruturados", "referencias-resolvem", ld.dangling.length === 0, ld.dangling.join(", "), ); add( scope, "dados-estruturados", "sem-faqpage-nem-localbusiness", !ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)), ld.types.join(","), ); if (scope !== "/") add( scope, "dados-estruturados", "breadcrumb", ld.types.includes("BreadcrumbList"), ); add( scope, "dados-estruturados", "person-com-sameas", !ld.types.includes("Person") || ld.nodes.some( (n) => n["@type"] === "Person" && Array.isArray(n.sameAs) && // ao menos um perfil profissional no ar (o GitHub do autor foi suspenso; sobrou o LinkedIn) n.sameAs.length >= 1, ), ); return ld; } /** Conteúdo no HTML sem JavaScript, resposta direta cedo e autor consistente. */ function checkAeo(scope, p, ld) { add( scope, "aeo-geo", "conteudo-no-html-sem-js", p.wordCount >= 250, `${p.wordCount} palavras no
do HTML`, ); add( scope, "aeo-geo", "resposta-direta-cedo", p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70, `${p.firstAnswerWords} palavras no 1º parágrafo após o H1`, ); const names = new Set( ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name), ); add( scope, "aeo-geo", "entidade-autor-consistente", names.size <= 1 && (scope === "/" ? names.size === 1 : true), [...names].join(","), ); } async function auditUrl(url, siteTitles, siteDescs) { const { res, body, ms } = await get(local(url)); const scope = url.replace(SITE, "") || "/"; add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`); if (res.status !== 200) return null; checkTechnical(scope, res, ms); const p = parsePage(body); checkHead(scope, p, siteTitles); checkSnippet(scope, url, res, p, siteDescs); checkStructure(scope, p); await checkSocial(scope, p); const ld = checkStructuredData(scope, p); checkAeo(scope, p, ld); return { scope, p, ld }; } /** Sitemap e robots.txt do site; devolve as URLs do sitemap. */ async function checkSiteFiles() { const sm = await get(`${BASE}/sitemap.xml`); const urls = [...sm.body.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]); add( "site", "técnico", "sitemap-200", sm.res.status === 200 && urls.length > 0, `${urls.length} URLs`, ); const robots = await get(`${BASE}/robots.txt`); add( "site", "técnico", "robots-com-sitemap", /sitemap:\s*https?:\/\//i.test(robots.body), ); add( "site", "técnico", "robots-nao-bloqueia-tudo", !/disallow:\s*\/\s*$/im.test(robots.body), ); add("site", "técnico", "http-para-https-ou-local", true); return urls; } /** Acesso dos robôs de busca e de IA, e-mail visível para robôs e a política do llms.txt. */ async function checkBots() { for (const [name, ua] of AI_BOTS) { const r = await get(`${BASE}/`, ua); add( "site", "aeo-geo", `acesso-${name}`, r.res.status === 200, `HTTP ${r.res.status}`, ); } // A Cloudflare (Email Address Obfuscation) troca e-mails por /cdn-cgi/l/email-protection para quem é robô: buscadores e IA não veem o contato const botHome = await get(`${BASE}/`, AI_BOTS[0][1]); add( "site", "aeo-geo", "email-visivel-para-robos", !/email-protection|\[email( |\s)protected\]/.test(botHome.body), /email-protection/.test(botHome.body) ? "HTML servido a robôs tem e-mail ofuscado" : "", ); const llms = await get(`${BASE}/llms.txt`); // informativo: a política do projeto exclui llms.txt (nenhum sistema de IA o usa, segundo o Google); ausência é o esperado add( "site", "aeo-geo", "llms-txt-ausente-por-politica", llms.res.status === 404, `HTTP ${llms.res.status}`, ); } /** Audita cada URL do sitemap, uma depois da outra; páginas que não respondem 200 ficam como `null`. */ async function auditPages(urls) { const titles = new Set(); const descs = new Set(); const pages = []; for (const url of urls) pages.push(await auditUrl(url, titles, descs)); return pages; } /** Destinos internos únicos (caminhos) de todas as páginas auditadas. */ function internalTargets(pages) { const targets = new Set(); for (const pg of pages.filter(Boolean)) for (const a of pg.p.anchors) { const h = a.href?.split("#")[0]; if (h?.startsWith("/") && !h.startsWith("//")) targets.add(h); } return targets; } /** Destinos que respondem erro (ou não respondem), com o status. */ async function findBroken(targets) { const broken = []; for (const t of targets) { const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch( () => null, ); if (!r || r.res.status >= 400) broken.push(`${t} (${r?.res.status ?? "erro"})`); } return broken; } /** Páginas do sitemap (fora a home) que nenhuma página audita linka. */ function findOrphans(urls, targets) { const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, ""))); const linked = [...targets].map((t) => t.replace(/\/$/, "")); return [...inSitemap].filter((u) => { const path = u.replace(SITE, "") || "/"; return path !== "/" && !linked.includes(path); }); } /** Links internos quebrados (amostra: todos os destinos únicos das páginas auditadas) e páginas órfãs. */ async function checkInternalLinks(urls, pages) { const targets = internalTargets(pages); const brokenList = await findBroken(targets); add( "site", "técnico", "sem-links-internos-quebrados", brokenList.length === 0, `${brokenList.length}/${targets.size} quebrados: ${brokenList.join(", ")}`, ); const orphanCandidates = findOrphans(urls, targets); add( "site", "técnico", "sem-paginas-orfas-no-sitemap", orphanCandidates.length === 0, orphanCandidates.join(", "), ); } /** Links únicos para os repositórios do autor encontrados nas páginas auditadas. */ function githubLinks(pages) { const links = new Set(); for (const pg of pages.filter(Boolean)) for (const a of pg.p.anchors) if ( /^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "") ) links.add(a.href.replace(/\/$/, "")); return links; } /** Repositórios que dão 404 (ou não respondem) para visitantes. */ async function findBrokenRepos(links) { const broken = []; for (const url of links) { const r = await get(url, UA, { method: "HEAD" }).catch(() => null); if (!r || r.res.status === 404) broken.push(url.replace("https://github.com/evandrodevbr/", "")); } return broken; } /** Links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado). */ async function checkGithubLinks(pages) { const ghLinks = githubLinks(pages); const ghBroken = await findBrokenRepos(ghLinks); add( "site", "técnico", "links-github-acessiveis", ghBroken.length === 0, ghBroken.length ? `404 para visitantes: ${ghBroken.join(", ")}` : `${ghLinks.size} repositórios`, ); } /** Quantas verificações passaram em cada grupo. */ function summarizeGroups() { const groups = {}; for (const c of checks) { groups[c.group] ??= { ok: 0, total: 0 }; const g = groups[c.group]; g.total++; if (c.ok) g.ok++; } return groups; } /** As falhas agrupadas pelo id da verificação. */ function failuresById() { const byId = {}; for (const f of checks.filter((c) => !c.ok)) { byId[f.id] ??= []; byId[f.id].push(f); } return byId; } function printReport(urls, groups) { console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`); for (const [g, v] of Object.entries(groups)) console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`); console.log(`\nFalhas (${checks.filter((c) => !c.ok).length}):`); for (const [id, list] of Object.entries(failuresById())) console.log( ` - ${id} (${list.length}): ${list .slice(0, 4) .map( (f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`, ) .join(" | ")}`, ); } async function main() { const urls = await checkSiteFiles(); await checkBots(); const pages = await auditPages(urls); await checkInternalLinks(urls, pages); await checkGithubLinks(pages); const groups = summarizeGroups(); const out = { base: BASE, at: new Date().toISOString(), urls: urls.length, groups, checks, }; if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2)); printReport(urls, groups); } main().catch((e) => { console.error(e); process.exit(1); });