683 lines
20 KiB
JavaScript
683 lines
20 KiB
JavaScript
#!/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 });
|
||
|
||
/** 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( |\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);
|
||
});
|