Files

683 lines
20 KiB
JavaScript
Raw Permalink 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(/&/g, "&")
.replace(/"/g, '"')
.replace(/'|'/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 });
/** 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 <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(","),
);
}
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>([^<]+)<\/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(&#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}`,
);
}
/** 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);
});