fix(ci): lint do Biome, Next 16.3.8 (RCE em next/og) e transicoes que se remedem
CI / verify (push) Failing after 5m43s

- 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]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Opus 5.5 committed 2026-10-05 01:38:41 -03:00
1 parent 6db10ffa67
commit 4c826e938f
17 files changed
+816 -253

No files matched your search

+377 -67
View File
@@ -21,30 +21,68 @@ 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)"],
[
"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 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] ?? "";
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 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
@@ -57,16 +95,25 @@ 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 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 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 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);
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"),
@@ -74,9 +121,19 @@ function parsePage(html) {
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])),
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,
@@ -105,7 +162,13 @@ function jsonLdFacts(blocks) {
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"]);
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);
}
};
@@ -114,7 +177,8 @@ function jsonLdFacts(blocks) {
}
const checks = []; // {scope, id, group, ok, detail}
const add = (scope, group, id, ok, detail = "") => checks.push({ scope, group, id, ok: Boolean(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);
@@ -122,20 +186,70 @@ async function auditUrl(url, siteTitles, siteDescs) {
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)`);
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",
"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-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));
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") ?? ""));
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;
@@ -144,57 +258,205 @@ async function auditUrl(url, siteTitles, siteDescs) {
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`);
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(" ")}`);
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"]);
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 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,
"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(","));
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`);
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",
"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}`);
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" : "");
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}`);
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();
@@ -206,52 +468,100 @@ async function main() {
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);
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);
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(", ")}`);
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);
return (
path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path)
);
});
add("site", "técnico", "sem-paginas-orfas-no-sitemap", orphanCandidates.length === 0, orphanCandidates.join(", "));
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(/\/$/, ""));
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/", ""));
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`);
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 });
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 };
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}`);
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(" | ")}`);
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) => {