fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve

Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:48 -03:00
1 parent 9e2d65a926
commit 204ebd5fa5
49 files changed
+2197 -606

No files matched your search

+220 -109
View File
@@ -180,19 +180,26 @@ 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;
/** 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(res.headers.get("content-encoding") ?? "") ||
BASE.startsWith("http://localhost"),
res.headers.get("content-encoding") ?? "sem compressão",
/br|gzip/.test(encoding ?? "") || BASE.startsWith("http://localhost"),
encoding ?? "sem compressão",
);
add(
scope,
@@ -201,8 +208,10 @@ async function auditUrl(url, siteTitles, siteDescs) {
ms < 800,
`${ms} ms (HTML completo)`,
);
const p = parsePage(body);
}
/** Idioma, viewport e título (tamanho e unicidade no site). */
function checkHead(scope, p, siteTitles) {
add(
scope,
"on-page",
@@ -220,6 +229,10 @@ async function auditUrl(url, siteTitles, siteDescs) {
);
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",
@@ -243,21 +256,69 @@ async function auditUrl(url, siteTitles, siteDescs) {
p.canonical === url || p.canonical === `${url}/`,
p.canonical ?? "ausente",
);
const noindex = (value) => /noindex/i.test(value ?? "");
add(
scope,
"on-page",
"indexavel",
!/noindex/i.test(p.robots ?? "") &&
!/noindex/i.test(res.headers.get("x-robots-tag") ?? ""),
!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`);
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",
"hierarquia-de-titulos",
!skipsHeadingLevel(p.headings),
);
add(
scope,
"on-page",
@@ -265,44 +326,25 @@ async function auditUrl(url, siteTitles, siteDescs) {
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(" ")}`,
);
checkLinks(scope, p);
}
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]);
/** 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",
@@ -314,24 +356,15 @@ async function auditUrl(url, siteTitles, siteDescs) {
scope,
"social",
"twitter-card",
tw["twitter:card"] === "summary_large_image" &&
!!tw["twitter:title"] &&
!!tw["twitter:image"],
p.tw["twitter:card"] === "summary_large_image" &&
!!p.tw["twitter:title"] &&
!!p.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")}`,
);
}
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,
@@ -373,7 +406,11 @@ async function auditUrl(url, siteTitles, siteDescs) {
n.sameAs.length >= 2,
),
);
return ld;
}
/** Conteúdo no HTML sem JavaScript, resposta direta cedo e autor consistente. */
function checkAeo(scope, p, ld) {
add(
scope,
"aeo-geo",
@@ -398,10 +435,26 @@ async function auditUrl(url, siteTitles, siteDescs) {
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 };
}
async function main() {
/** 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(
@@ -426,7 +479,11 @@ async function main() {
!/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(
@@ -457,44 +514,63 @@ async function main() {
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;
}
// links internos quebrados (amostra: todos os destinos únicos das páginas auditadas)
/** 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);
}
let broken = 0;
const brokenList = [];
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++;
brokenList.push(`${t} (${r?.res.status ?? "erro"})`);
}
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",
broken === 0,
`${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`,
brokenList.length === 0,
`${brokenList.length}/${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)
);
});
const orphanCandidates = findOrphans(urls, targets);
add(
"site",
"técnico",
@@ -502,21 +578,35 @@ async function main() {
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();
/** 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 ?? "")
)
ghLinks.add(a.href.replace(/\/$/, ""));
const ghBroken = [];
for (const url of ghLinks) {
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)
ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
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",
@@ -526,7 +616,10 @@ async function main() {
? `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 };
@@ -534,26 +627,25 @@ async function main() {
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));
return 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}`);
const failing = checks.filter((c) => !c.ok);
console.log(`\nFalhas (${failing.length}):`);
/** As falhas agrupadas pelo id da verificação. */
function failuresById() {
const byId = {};
for (const f of failing) {
for (const f of checks.filter((c) => !c.ok)) {
byId[f.id] ??= [];
byId[f.id].push(f);
}
for (const [id, list] of Object.entries(byId))
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)
@@ -564,6 +656,25 @@ async function main() {
);
}
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);