Files
portifolio-evandrodevbr/scripts/seo-audit.mjs
T

261 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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; [email protected])"],
["ClaudeBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; [email protected])"],
["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(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim();
const strip = (html) =>
decode(
html
.replace(/<(script|style|noscript|template|svg)[\s\S]*?<\/\1>/gi, " ")
.replace(/<!--[\s\S]*?-->/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(/<meta\b[^>]*>/gi)].map((m) => attrs(m[0]));
const meta = (key, val = "name") => metas.find((m) => m[val]?.toLowerCase() === key)?.content;
const links = [...html.matchAll(/<link\b[^>]*>/gi)].map((m) => attrs(m[0]));
const headings = [...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)].map((m) => ({ level: +m[1], text: strip(m[2]) }));
const imgs = [...html.matchAll(/<img\b[^>]*>/gi)].map((m) => attrs(m[0]));
const anchors = [...html.matchAll(/<a\b[^>]*>/gi)].map((m) => attrs(m[0]));
const ld = [...html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
const main = html.match(/<main\b[\s\S]*?<\/main>/i)?.[0] ?? html;
const firstH1 = main.search(/<h1\b/i);
const afterH1 = firstH1 >= 0 ? main.slice(firstH1) : main;
const firstP = [...afterH1.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)].map((m) => strip(m[1])).find((t) => words(t) >= 8);
return {
title: decode(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""),
description: meta("description"),
robots: meta("robots"),
viewport: meta("viewport"),
lang: html.match(/<html[^>]*\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 });
async function auditUrl(url, siteTitles, siteDescs) {
const target = local(url);
const { res, body, ms } = await get(target);
const scope = url.replace(SITE, "") || "/";
add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`);
if (res.status !== 200) return null;
add(scope, "técnico", "html-comprimido", /br|gzip/.test(res.headers.get("content-encoding") ?? "") || BASE.startsWith("http://localhost"), res.headers.get("content-encoding") ?? "sem compressão");
add(scope, "técnico", "ttfb-menor-800ms", ms < 800, `${ms} ms (HTML completo)`);
const p = parsePage(body);
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);
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");
add(scope, "on-page", "indexavel", !/noindex/i.test(p.robots ?? "") && !/noindex/i.test(res.headers.get("x-robots-tag") ?? ""));
const h1 = p.headings.filter((h) => h.level === 1);
add(scope, "on-page", "um-h1", h1.length === 1, `${h1.length} h1`);
let skipped = false;
p.headings.reduce((prev, h) => {
if (h.level - prev > 1) skipped = true;
return h.level;
}, 0);
add(scope, "on-page", "hierarquia-de-titulos", !skipped);
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`);
const internal = p.anchors.filter((a) => a.href && (a.href.startsWith("/") || a.href.startsWith(SITE)) && !a.href.startsWith("//"));
add(scope, "on-page", "links-internos-5+", new Set(internal.map((a) => a.href.split("#")[0])).size >= 5, `${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`);
const external = p.anchors.filter((a) => a.target === "_blank");
// noreferrer também implica noopener
add(scope, "on-page", "externos-com-noopener", external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")), `${external.length} links _blank; sem rel: ${external.filter((a) => !/noopener|noreferrer/.test(a.rel ?? "")).map((a) => a.href).slice(0, 2).join(" ")}`);
const og = p.og, tw = p.tw;
const missingOg = ["og:title", "og:description", "og:url", "og:image", "og:type", "og:site_name", "og:locale"].filter((k) => !og[k]);
add(scope, "social", "og-completo", missingOg.length === 0, missingOg.length ? `faltam ${missingOg.join(",")}` : "");
add(scope, "social", "twitter-card", tw["twitter:card"] === "summary_large_image" && !!tw["twitter:title"] && !!tw["twitter:image"]);
if (og["og:image"]) {
const img = await get(local(og["og:image"]), 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")}`);
}
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) && n.sameAs.length >= 2));
add(scope, "aeo-geo", "conteudo-no-html-sem-js", p.wordCount >= 250, `${p.wordCount} palavras no <main> 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(","));
return { scope, p, ld };
}
async function main() {
const sm = await get(`${BASE}/sitemap.xml`);
const urls = [...sm.body.matchAll(/<loc>([^<]+)<\/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));
const home = await get(`${BASE}/`);
add("site", "técnico", "http-para-https-ou-local", true);
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(&#160;|\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}`);
const titles = new Set();
const descs = new Set();
const pages = [];
for (const url of urls) pages.push(await auditUrl(url, titles, descs));
// links internos quebrados (amostra: todos os destinos únicos das páginas auditadas)
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 && (h.startsWith("/") && !h.startsWith("//"))) targets.add(h);
}
let broken = 0;
const brokenList = [];
for (const t of targets) {
const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(() => null);
if (!r || r.res.status >= 400) {
broken++;
brokenList.push(`${t} (${r?.res.status ?? "erro"})`);
}
}
add("site", "técnico", "sem-links-internos-quebrados", broken === 0, `${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`);
const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, "")));
const orphanCandidates = [...inSitemap].filter((u) => {
const path = u.replace(SITE, "") || "/";
return path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path);
});
add("site", "técnico", "sem-paginas-orfas-no-sitemap", orphanCandidates.length === 0, orphanCandidates.join(", "));
// links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado)
const ghLinks = 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 ?? "")) ghLinks.add(a.href.replace(/\/$/, ""));
const ghBroken = [];
for (const url of ghLinks) {
const r = await get(url, UA, { method: "HEAD" }).catch(() => null);
if (!r || r.res.status === 404) ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
}
add("site", "técnico", "links-github-acessiveis", ghBroken.length === 0, ghBroken.length ? `404 para visitantes: ${ghBroken.join(", ")}` : `${ghLinks.size} repositórios`);
const groups = {};
for (const c of checks) {
const g = (groups[c.group] ??= { ok: 0, total: 0 });
g.total++;
if (c.ok) g.ok++;
}
const out = { base: BASE, at: new Date().toISOString(), urls: urls.length, groups, checks };
if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2));
console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`);
for (const [g, v] of Object.entries(groups)) console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
const failing = checks.filter((c) => !c.ok);
console.log(`\nFalhas (${failing.length}):`);
const byId = {};
for (const f of failing) (byId[f.id] ??= []).push(f);
for (const [id, list] of Object.entries(byId)) console.log(` - ${id} (${list.length}): ${list.slice(0, 4).map((f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`).join(" | ")}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});