chore(seo): auditor repetivel de SEO/AEO/GEO (scripts/seo-audit.mjs e seo-compare.mjs)
Le o sitemap, baixa cada URL como um robo (HTML sem JS) e confere titulo, descricao, canonical, Open Graph, JSON-LD, titulos de secao, resposta direta cedo, acesso de robos de busca e de IA e links internos. Gera JSON para comparar antes e depois. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
02ecba73f8
commit
7c03dece9b
2 files changed
+281
No files matched your search
@@ -0,0 +1,246 @@
|
|||||||
|
#!/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(/</g, "<").replace(/>/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}`);
|
||||||
|
}
|
||||||
|
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(", "));
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Compara duas execuções de scripts/seo-audit.mjs: node scripts/seo-compare.mjs antes.json depois.json */
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const [beforePath, afterPath] = process.argv.slice(2);
|
||||||
|
if (!beforePath || !afterPath) {
|
||||||
|
console.error("uso: node scripts/seo-compare.mjs antes.json depois.json");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const before = JSON.parse(readFileSync(beforePath, "utf8"));
|
||||||
|
const after = JSON.parse(readFileSync(afterPath, "utf8"));
|
||||||
|
const key = (c) => `${c.scope}::${c.id}`;
|
||||||
|
const prev = new Map(before.checks.map((c) => [key(c), c]));
|
||||||
|
const next = new Map(after.checks.map((c) => [key(c), c]));
|
||||||
|
|
||||||
|
console.log(`\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`);
|
||||||
|
console.log("grupo antes depois");
|
||||||
|
for (const g of new Set([...Object.keys(before.groups), ...Object.keys(after.groups)])) {
|
||||||
|
const a = before.groups[g] ?? { ok: 0, total: 0 };
|
||||||
|
const b = after.groups[g] ?? { ok: 0, total: 0 };
|
||||||
|
console.log(`${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`);
|
||||||
|
}
|
||||||
|
const fixed = [];
|
||||||
|
const regressed = [];
|
||||||
|
for (const [k, c] of next) {
|
||||||
|
const p = prev.get(k);
|
||||||
|
if (p && !p.ok && c.ok) fixed.push(c);
|
||||||
|
if (p && p.ok && !c.ok) regressed.push(c);
|
||||||
|
if (!p && !c.ok) regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` });
|
||||||
|
}
|
||||||
|
console.log(`\nCorrigidos (${fixed.length}):`);
|
||||||
|
for (const c of fixed) console.log(` + ${c.scope} ${c.id}`);
|
||||||
|
console.log(`\nPiorou (${regressed.length}):`);
|
||||||
|
for (const c of regressed) console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`);
|
||||||
|
process.exit(regressed.length ? 2 : 0);
|
||||||
Reference in new issue
Block a user