Files
portifolio-evandrodevbr/scripts/seo-audit.mjs
Evandro Fonseca JuniorandClaude Opus 5.5 4c826e938f
CI / verify (push) Failing after 5m43s
fix(ci): lint do Biome, Next 16.3.8 (RCE em next/og) e transicoes que se remedem
- Biome: formatacao, ordem de imports, max-width duplicado no cabecalho de
  secao, supressao justificada para nomes de view-transition, ajustes nos
  scripts de auditoria de SEO
- next 16.3.3 -> 16.3.8: GHSA-vcvr-r3jv-pc5j (execucao remota de codigo em
  next/og ImageResponse, usado nas imagens de compartilhamento)
- overrides de brace-expansion (GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p,
  GHSA-q2hr-2g5m-vwhr), dependencia de desenvolvimento via jest
- visibilidade das cenas le as posicoes a cada quadro e o GSAP remede as
  passagens quando a pagina muda de altura (fontes, imagens, Experiencia)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:38:41 -03:00

571 lines
16 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(/&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),
);
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?.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) {
groups[c.group] ??= { ok: 0, total: 0 };
const g = groups[c.group];
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] ??= [];
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);
});