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:
1 parent
9e2d65a926
commit
204ebd5fa5
49 files changed
+2197
-606
No files matched your search
+278
-205
@@ -18,6 +18,9 @@
|
||||
* --ready-wait 1500 (ms de espera depois de o texto aparecer, para a animação de entrada do título acabar antes de o
|
||||
* vídeo começar), --hide-text "Qual lingerie" (esconde na gravação a seção cujo título tem esse texto: para um bloco que o cliente ainda
|
||||
* não preencheu, como uma grade de categorias com marcadores de imagem vazios, que não deve aparecer no vídeo),
|
||||
* --color-scheme dark (esquema de cores que o site recebe, `light` ou `dark`: um site que muda de tema por
|
||||
* prefers-color-scheme, mas escreve texto branco sobre um fundo que só escurece no tema escuro, sai com seções
|
||||
* "vazias" no tema claro; sem a opção o navegador usa o padrão dele),
|
||||
* --dry 1 (só mede os trechos em branco, sem gravar).
|
||||
*
|
||||
* Antes de gravar a página é percorrida devagar, com as imagens preguiçosas forçadas e decodificadas e a rede
|
||||
@@ -75,6 +78,7 @@ const HOLD_MS = Number(args.hold ?? 1000);
|
||||
const READY_TEXT = args["ready-text"];
|
||||
const HIDE_TEXT = args["hide-text"];
|
||||
const READY_WAIT_MS = Number(args["ready-wait"] ?? 0);
|
||||
const COLOR_SCHEME = args["color-scheme"];
|
||||
|
||||
const VIEWPORTS = {
|
||||
desktop: { width: 1440, height: 900, isMobile: false, hasTouch: false },
|
||||
@@ -99,37 +103,39 @@ function ff(argv) {
|
||||
|
||||
async function dismissPopups(page) {
|
||||
return page.evaluate(() => {
|
||||
const done = [];
|
||||
const roots = document.querySelectorAll(
|
||||
'[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[class*="popup" i],[class*="newsletter" i]',
|
||||
);
|
||||
for (const root of roots) {
|
||||
const SELECTOR =
|
||||
'[id*="cookie" i],[class*="cookie" i],[id*="consent" i],[class*="consent" i],[role="dialog"],[role="alertdialog"],[aria-modal="true"],[class*="popup" i],[class*="newsletter" i]';
|
||||
const text = (b) =>
|
||||
(b.innerText || b.getAttribute("aria-label") || "").trim();
|
||||
// visível, com tamanho de aviso e fixo na janela (cookies, newsletter, pop-ups)
|
||||
const isPopup = (root) => {
|
||||
const rect = root.getBoundingClientRect();
|
||||
const style = getComputedStyle(root);
|
||||
if (rect.width < 80 || rect.height < 24) continue;
|
||||
if (
|
||||
const hidden =
|
||||
style.visibility === "hidden" ||
|
||||
style.display === "none" ||
|
||||
Number(style.opacity) === 0
|
||||
)
|
||||
continue;
|
||||
if (!["fixed", "sticky"].includes(style.position)) continue;
|
||||
Number(style.opacity) === 0;
|
||||
const small = rect.width < 80 || rect.height < 24;
|
||||
return !small && !hidden && ["fixed", "sticky"].includes(style.position);
|
||||
};
|
||||
// recusar primeiro; senão, qualquer botão que feche o aviso
|
||||
const pickButton = (root) => {
|
||||
const buttons = [
|
||||
...root.querySelectorAll('button,a[role="button"],[role="button"]'),
|
||||
];
|
||||
const text = (b) =>
|
||||
(b.innerText || b.getAttribute("aria-label") || "").trim();
|
||||
const pick =
|
||||
buttons.find((b) =>
|
||||
/^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i.test(
|
||||
text(b),
|
||||
),
|
||||
) ||
|
||||
buttons.find((b) =>
|
||||
/(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i.test(
|
||||
text(b),
|
||||
),
|
||||
);
|
||||
const refuse =
|
||||
/^(recusar|rejeitar|negar|apenas (os )?necess|somente (os )?necess)/i;
|
||||
const close =
|
||||
/(fechar|close|dispensar|entendi|^ok$|continuar|aceitar|accept|×|✕)/i;
|
||||
return (
|
||||
buttons.find((b) => refuse.test(text(b))) ||
|
||||
buttons.find((b) => close.test(text(b)))
|
||||
);
|
||||
};
|
||||
const done = [];
|
||||
for (const root of document.querySelectorAll(SELECTOR)) {
|
||||
if (!isPopup(root)) continue;
|
||||
const pick = pickButton(root);
|
||||
if (pick) {
|
||||
done.push(text(pick));
|
||||
pick.click();
|
||||
@@ -365,12 +371,8 @@ async function measureBlank(page) {
|
||||
return { step, maxScroll, ratios, factors };
|
||||
}
|
||||
|
||||
async function recordOne(browser, vpName) {
|
||||
const vp = VIEWPORTS[vpName];
|
||||
const rawDir = join(rawRoot, vpName);
|
||||
rmSync(rawDir, { recursive: true, force: true });
|
||||
mkdirSync(rawDir, { recursive: true });
|
||||
|
||||
/** O contexto do Chromium que grava o vídeo, com a página aberta e os erros do console recolhidos em `logs`. */
|
||||
async function openRecording(browser, vp, rawDir) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: vp.width, height: vp.height },
|
||||
deviceScaleFactor: 1,
|
||||
@@ -380,6 +382,7 @@ async function recordOne(browser, vpName) {
|
||||
locale: "pt-BR",
|
||||
timezoneId: "America/Sao_Paulo",
|
||||
reducedMotion: "no-preference",
|
||||
colorScheme: COLOR_SCHEME,
|
||||
recordVideo: { dir: rawDir, size: { width: vp.width, height: vp.height } },
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
@@ -400,40 +403,39 @@ async function recordOne(browser, vpName) {
|
||||
m.type() === "error" &&
|
||||
logs.push(`console.error: ${m.text().slice(0, 160)}`),
|
||||
);
|
||||
const tStart = Date.now();
|
||||
return { context, page, logs };
|
||||
}
|
||||
|
||||
/** Abre a página e espera a rede ociosa, as fontes e os pré-carregadores e animações iniciais. */
|
||||
async function openPage(page) {
|
||||
const response = await page.goto(url, { waitUntil: "load", timeout: 90000 });
|
||||
await page
|
||||
.waitForLoadState("networkidle", { timeout: 20000 })
|
||||
.catch(() => {});
|
||||
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
||||
await sleep(SETTLE_MS); // pré-carregadores e animações iniciais
|
||||
const popups1 = await dismissPopups(page);
|
||||
await sleep(SETTLE_MS);
|
||||
return response;
|
||||
}
|
||||
|
||||
// passe de aquecimento: percorre a página devagar e deixa todas as imagens carregadas e decodificadas, com a rede
|
||||
// ociosa; depois volta ao topo (essa parte é cortada do vídeo)
|
||||
await preloadPage(page);
|
||||
await waitImages(page);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await sleep(600);
|
||||
const popups2 = await dismissPopups(page);
|
||||
const images = await imageStatus(page);
|
||||
// imagem que não carrega de jeito nenhum (hospedagem de terceiros fora do ar, por exemplo) aparece como ícone de
|
||||
// imagem quebrada com o texto alternativo: não vai para o vídeo
|
||||
const hiddenSections = HIDE_TEXT
|
||||
? await page.evaluate((text) => {
|
||||
const hidden = [];
|
||||
for (const heading of document.querySelectorAll("h1, h2, h3")) {
|
||||
if (!heading.textContent.includes(text)) continue;
|
||||
const section = heading.closest("section") ?? heading.parentElement;
|
||||
if (!section) continue;
|
||||
section.style.display = "none";
|
||||
hidden.push(heading.textContent.trim());
|
||||
}
|
||||
return hidden;
|
||||
}, HIDE_TEXT)
|
||||
: [];
|
||||
const hiddenBrokenImages = await page.evaluate(() => {
|
||||
/** Esconde as seções (h1 a h3) cujo título tem `text`; devolve os títulos escondidos. */
|
||||
const hideSections = (page, text) =>
|
||||
page.evaluate((needle) => {
|
||||
const hidden = [];
|
||||
for (const heading of document.querySelectorAll("h1, h2, h3")) {
|
||||
const section = heading.closest("section") ?? heading.parentElement;
|
||||
if (!heading.textContent.includes(needle) || !section) continue;
|
||||
section.style.display = "none";
|
||||
hidden.push(heading.textContent.trim());
|
||||
}
|
||||
return hidden;
|
||||
}, text);
|
||||
|
||||
/**
|
||||
* Esconde as imagens que não carregam de jeito nenhum (hospedagem de terceiros fora do ar, por exemplo): aparecem como
|
||||
* ícone de imagem quebrada com o texto alternativo e não vão para o vídeo. Devolve as URLs.
|
||||
*/
|
||||
const hideBrokenImages = (page) =>
|
||||
page.evaluate(() => {
|
||||
const broken = [...document.images].filter(
|
||||
(img) => img.complete && img.naturalWidth === 0 && img.currentSrc,
|
||||
);
|
||||
@@ -441,21 +443,30 @@ async function recordOne(browser, vpName) {
|
||||
return broken.map((img) => img.currentSrc);
|
||||
});
|
||||
|
||||
const blank = await measureBlank(page);
|
||||
/**
|
||||
* Passe de aquecimento: percorre a página devagar e deixa todas as imagens carregadas e decodificadas, com a rede
|
||||
* ociosa, fecha avisos e esconde o que não deve aparecer; depois volta ao topo (essa parte é cortada do vídeo).
|
||||
*/
|
||||
async function warmUp(page) {
|
||||
const popups1 = await dismissPopups(page);
|
||||
await preloadPage(page);
|
||||
await waitImages(page);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await sleep(600);
|
||||
if (args.dry === "1") {
|
||||
await context.close();
|
||||
return {
|
||||
vp: vpName,
|
||||
images,
|
||||
hiddenBrokenImages,
|
||||
step: blank.step,
|
||||
inkRatios: blank.ratios.map((r) => +r.toFixed(3)),
|
||||
factors: blank.factors.map((f) => +f.toFixed(1)),
|
||||
};
|
||||
}
|
||||
const popups2 = await dismissPopups(page);
|
||||
const images = await imageStatus(page);
|
||||
const hiddenSections = HIDE_TEXT ? await hideSections(page, HIDE_TEXT) : [];
|
||||
const hiddenBrokenImages = await hideBrokenImages(page);
|
||||
return {
|
||||
popupsClosed: [...popups1, ...popups2],
|
||||
images,
|
||||
hiddenSections,
|
||||
hiddenBrokenImages,
|
||||
};
|
||||
}
|
||||
|
||||
const metrics = await page.evaluate(() => ({
|
||||
const pageMetrics = (page) =>
|
||||
page.evaluate(() => ({
|
||||
scrollY: window.scrollY,
|
||||
height: document.documentElement.scrollHeight,
|
||||
view: window.innerHeight,
|
||||
@@ -463,41 +474,43 @@ async function recordOne(browser, vpName) {
|
||||
hasHorizontalOverflow:
|
||||
document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
}));
|
||||
// tempo de rolagem a 1 px/s: cada trecho custa passo / fator; a velocidade base cabe o total em MAX_SECONDS - 2
|
||||
|
||||
/** Velocidade da rolagem (px/s): o tempo de cada trecho custa passo / fator, e o total cabe em MAX_SECONDS - 2. */
|
||||
function scrollPlan(blank) {
|
||||
const unitSeconds = blank.factors.reduce(
|
||||
(sum, factor) => sum + blank.step / factor,
|
||||
0,
|
||||
);
|
||||
const speed = Math.max(SPEED, unitSeconds / (MAX_SECONDS - 2));
|
||||
const expectedScrollSeconds = unitSeconds / speed;
|
||||
const warm = metrics.height;
|
||||
return { speed, expectedScrollSeconds: unitSeconds / speed };
|
||||
}
|
||||
|
||||
if (READY_TEXT) {
|
||||
// o começo do slide, não um ponto qualquer dele: primeiro espera o texto sair (se já estiver lá), depois entrar
|
||||
await page
|
||||
.waitForFunction(
|
||||
(text) =>
|
||||
!(document.querySelector("h1")?.textContent ?? "").includes(text),
|
||||
READY_TEXT,
|
||||
{ timeout: 20000, polling: 100 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await page
|
||||
.waitForFunction(
|
||||
(text) =>
|
||||
(document.querySelector("h1")?.textContent ?? "").includes(text),
|
||||
READY_TEXT,
|
||||
{ timeout: 40000, polling: 100 },
|
||||
)
|
||||
.catch(() => logs.push(`ready-text "${READY_TEXT}" não apareceu no h1`));
|
||||
if (READY_WAIT_MS) await sleep(READY_WAIT_MS);
|
||||
}
|
||||
/** Com `--ready-text`: só segue quando o primeiro <h1> acaba de ganhar esse texto (o começo do slide). */
|
||||
async function waitReadyText(page, logs) {
|
||||
if (!READY_TEXT) return;
|
||||
// primeiro espera o texto sair (se já estiver lá), depois entrar
|
||||
await page
|
||||
.waitForFunction(
|
||||
(text) =>
|
||||
!(document.querySelector("h1")?.textContent ?? "").includes(text),
|
||||
READY_TEXT,
|
||||
{ timeout: 20000, polling: 100 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await page
|
||||
.waitForFunction(
|
||||
(text) =>
|
||||
(document.querySelector("h1")?.textContent ?? "").includes(text),
|
||||
READY_TEXT,
|
||||
{ timeout: 40000, polling: 100 },
|
||||
)
|
||||
.catch(() => logs.push(`ready-text "${READY_TEXT}" não apareceu no h1`));
|
||||
if (READY_WAIT_MS) await sleep(READY_WAIT_MS);
|
||||
}
|
||||
|
||||
// marcadores visuais (magenta no início, ciano no fim): o relógio do vídeo do Playwright não é confiável, então o corte é feito pelos quadros
|
||||
await flash(page, "rgb(255,0,255)");
|
||||
const t0 = Date.now() - tStart;
|
||||
await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster
|
||||
const result = await page.evaluate(
|
||||
/** Rola a página até o fim, guiada por tempo, na velocidade de cada trecho; devolve onde e em quanto tempo acabou. */
|
||||
const scrollThrough = (page, plan) =>
|
||||
page.evaluate(
|
||||
({ speedPxS, step, factors }) =>
|
||||
new Promise((resolve) => {
|
||||
// fator de velocidade na posição y: interpolado entre as medidas de cada `step` px
|
||||
@@ -525,27 +538,36 @@ async function recordOne(browser, vpName) {
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}),
|
||||
{ speedPxS: speed, step: blank.step, factors: blank.factors },
|
||||
plan,
|
||||
);
|
||||
|
||||
/** A rolagem gravada entre os marcadores magenta (início) e ciano (fim); devolve a rolagem e o tempo de relógio. */
|
||||
async function recordScroll(page, blank, speed) {
|
||||
await flash(page, "rgb(255,0,255)");
|
||||
const startedAt = Date.now();
|
||||
await sleep(HOLD_MS); // a página parada no topo: de onde sai o poster
|
||||
const result = await scrollThrough(page, {
|
||||
speedPxS: speed,
|
||||
step: blank.step,
|
||||
factors: blank.factors,
|
||||
});
|
||||
await sleep(1000);
|
||||
const t1 = Date.now() - tStart;
|
||||
const wallSeconds = (Date.now() - startedAt) / 1000;
|
||||
await flash(page, "rgb(0,255,255)");
|
||||
await context.close();
|
||||
return { result, wallSeconds };
|
||||
}
|
||||
|
||||
/** Corta o vídeo cru entre os marcadores (intermediário de alta qualidade, só para os cortes); devolve o arquivo e a duração. */
|
||||
function trimRecording(rawDir) {
|
||||
const rawFile = readdirSync(rawDir).find((f) => f.endsWith(".webm"));
|
||||
const rawPath = join(rawDir, rawFile);
|
||||
const marks = findMarkers(rawPath);
|
||||
const startAt = marks.scrollStart.toFixed(3);
|
||||
const marks = findMarkers(join(rawDir, rawFile));
|
||||
const dur = (marks.scrollEnd - marks.scrollStart).toFixed(3);
|
||||
const wallSeconds = (t1 - t0) / 1000;
|
||||
|
||||
const trimmed = join(rawDir, "trimmed.mp4");
|
||||
// intermediário de alta qualidade, só para os cortes
|
||||
ff([
|
||||
"-ss",
|
||||
startAt,
|
||||
marks.scrollStart.toFixed(3),
|
||||
"-i",
|
||||
rawPath,
|
||||
join(rawDir, rawFile),
|
||||
"-t",
|
||||
dur,
|
||||
"-an",
|
||||
@@ -561,68 +583,70 @@ async function recordOne(browser, vpName) {
|
||||
"yuv420p",
|
||||
trimmed,
|
||||
]);
|
||||
return { trimmed, dur };
|
||||
}
|
||||
|
||||
const sizes = {};
|
||||
const encode = (kind, scale) => {
|
||||
const out = join(outDir, `${vpName}.${kind}`);
|
||||
const vf = scale
|
||||
? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`]
|
||||
: [];
|
||||
const crfs = kind === "mp4" ? [26, 30, 34, 38] : [33, 37, 41, 46];
|
||||
for (const crf of crfs) {
|
||||
if (kind === "mp4") {
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
...vf,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"slow",
|
||||
"-profile:v",
|
||||
"main",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
out,
|
||||
]);
|
||||
} else {
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
...vf,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-b:v",
|
||||
"0",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-row-mt",
|
||||
"1",
|
||||
"-deadline",
|
||||
"good",
|
||||
"-cpu-used",
|
||||
"2",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
out,
|
||||
]);
|
||||
}
|
||||
sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size };
|
||||
if (sizes[kind].bytes <= MAX_BYTES) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const kind of ["mp4", "webm"]) {
|
||||
if (!encode(kind, null)) {
|
||||
for (const scale of [0.85, 0.7]) if (encode(kind, scale)) break;
|
||||
}
|
||||
const ENCODER_ARGS = {
|
||||
mp4: (crf) => [
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"slow",
|
||||
"-profile:v",
|
||||
"main",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
],
|
||||
webm: (crf) => [
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libvpx-vp9",
|
||||
"-b:v",
|
||||
"0",
|
||||
"-crf",
|
||||
String(crf),
|
||||
"-row-mt",
|
||||
"1",
|
||||
"-deadline",
|
||||
"good",
|
||||
"-cpu-used",
|
||||
"2",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
],
|
||||
};
|
||||
const ENCODER_CRFS = { mp4: [26, 30, 34, 38], webm: [33, 37, 41, 46] };
|
||||
|
||||
/** Codifica `kind` com qualidade cada vez menor até caber em MAX_BYTES; devolve se coube e guarda o último em `sizes`. */
|
||||
function encodeKind(trimmed, out, kind, scale, sizes) {
|
||||
const vf = scale
|
||||
? ["-vf", `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2`]
|
||||
: [];
|
||||
for (const crf of ENCODER_CRFS[kind]) {
|
||||
ff(["-i", trimmed, ...vf, ...ENCODER_ARGS[kind](crf), out]);
|
||||
sizes[kind] = { crf, scale: scale ?? 1, bytes: statSync(out).size };
|
||||
if (sizes[kind].bytes <= MAX_BYTES) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** MP4 e WebM de até MAX_BYTES cada: se a qualidade mínima não basta, reduz a escala. */
|
||||
function encodeVideos(trimmed, vpName) {
|
||||
const sizes = {};
|
||||
for (const kind of ["mp4", "webm"]) {
|
||||
const out = join(outDir, `${vpName}.${kind}`);
|
||||
if (!encodeKind(trimmed, out, kind, null, sizes))
|
||||
[0.85, 0.7].some((scale) => encodeKind(trimmed, out, kind, scale, sizes));
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
function makePoster(vpName) {
|
||||
ff([
|
||||
"-ss",
|
||||
String(POSTER_AT),
|
||||
@@ -636,69 +660,118 @@ async function recordOne(browser, vpName) {
|
||||
"80",
|
||||
join(outDir, `${vpName}.webp`),
|
||||
]);
|
||||
}
|
||||
|
||||
// 6 quadros do vídeo pronto, igualmente espaçados: nenhum pode estar quase todo em branco
|
||||
const frames = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
/** O quadro do MP4 pronto no segundo `at`, como PNG. */
|
||||
const frameAt = (vpName, at) =>
|
||||
spawnSync(
|
||||
ffmpegPath,
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
at.toFixed(2),
|
||||
"-i",
|
||||
join(outDir, `${vpName}.mp4`),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-c:v",
|
||||
"png",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024 * 16 },
|
||||
).stdout;
|
||||
|
||||
/** 6 quadros do vídeo pronto, igualmente espaçados: nenhum pode estar quase todo em branco. */
|
||||
function sampleFrames(vpName, dur) {
|
||||
return Array.from({ length: 6 }, (_, i) => {
|
||||
const at = ((i + 0.5) / 6) * Number(dur);
|
||||
const png = spawnSync(
|
||||
ffmpegPath,
|
||||
[
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
at.toFixed(2),
|
||||
"-i",
|
||||
join(outDir, `${vpName}.mp4`),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-c:v",
|
||||
"png",
|
||||
"-",
|
||||
],
|
||||
{ maxBuffer: 1024 * 1024 * 16 },
|
||||
).stdout;
|
||||
const { ratio } = inkRatio(png);
|
||||
frames.push({
|
||||
const { ratio } = inkRatio(frameAt(vpName, at));
|
||||
return {
|
||||
at: +at.toFixed(1),
|
||||
ink: +ratio.toFixed(3),
|
||||
blank: ratio < BLANK_LO,
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// folha de contato para revisão visual (fora da pasta de saída)
|
||||
/** Folha de contato para revisão visual (fora da pasta de saída). */
|
||||
function contactSheet(trimmed, vpName, dur) {
|
||||
const mobile = vpName === "mobile";
|
||||
ff([
|
||||
"-i",
|
||||
trimmed,
|
||||
"-vf",
|
||||
`fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${vpName === "mobile" ? 195 : 480}:-1,tile=${vpName === "mobile" ? 8 : 4}x2`,
|
||||
`fps=1/${Math.max(3, Math.round(Number(dur) / 8))},scale=${mobile ? 195 : 480}:-1,tile=${mobile ? 8 : 4}x2`,
|
||||
"-frames:v",
|
||||
"1",
|
||||
join(rawRoot, `${vpName}-contact.png`),
|
||||
]);
|
||||
}
|
||||
|
||||
/** O que `--dry` devolve: só a medição dos trechos em branco. */
|
||||
const dryReport = (vpName, warm, blank) => ({
|
||||
vp: vpName,
|
||||
images: warm.images,
|
||||
hiddenBrokenImages: warm.hiddenBrokenImages,
|
||||
step: blank.step,
|
||||
inkRatios: blank.ratios.map((r) => +r.toFixed(3)),
|
||||
factors: blank.factors.map((f) => +f.toFixed(1)),
|
||||
});
|
||||
|
||||
async function recordOne(browser, vpName) {
|
||||
const rawDir = join(rawRoot, vpName);
|
||||
rmSync(rawDir, { recursive: true, force: true });
|
||||
mkdirSync(rawDir, { recursive: true });
|
||||
|
||||
const { context, page, logs } = await openRecording(
|
||||
browser,
|
||||
VIEWPORTS[vpName],
|
||||
rawDir,
|
||||
);
|
||||
const response = await openPage(page);
|
||||
const warm = await warmUp(page);
|
||||
const blank = await measureBlank(page);
|
||||
await sleep(600);
|
||||
if (args.dry === "1") {
|
||||
await context.close();
|
||||
return dryReport(vpName, warm, blank);
|
||||
}
|
||||
|
||||
const metrics = await pageMetrics(page);
|
||||
const plan = scrollPlan(blank);
|
||||
await waitReadyText(page, logs);
|
||||
const { result, wallSeconds } = await recordScroll(page, blank, plan.speed);
|
||||
await context.close();
|
||||
|
||||
const { trimmed, dur } = trimRecording(rawDir);
|
||||
const sizes = encodeVideos(trimmed, vpName);
|
||||
makePoster(vpName);
|
||||
const frames = sampleFrames(vpName, dur);
|
||||
contactSheet(trimmed, vpName, dur);
|
||||
|
||||
return {
|
||||
vp: vpName,
|
||||
status: response?.status(),
|
||||
title: metrics.title,
|
||||
pageHeight: metrics.height,
|
||||
warmHeight: warm,
|
||||
speedPxS: Math.round(speed),
|
||||
warmHeight: metrics.height,
|
||||
speedPxS: Math.round(plan.speed),
|
||||
scrollSeconds: +(result.ms / 1000).toFixed(1),
|
||||
expectedScrollSeconds: +expectedScrollSeconds.toFixed(1),
|
||||
expectedScrollSeconds: +plan.expectedScrollSeconds.toFixed(1),
|
||||
endY: Math.round(result.endY),
|
||||
maxNow: Math.round(result.maxNow),
|
||||
durationS: +dur,
|
||||
wallSeconds: +wallSeconds.toFixed(1),
|
||||
videoSecondsPerWallSecond: +(Number(dur) / wallSeconds).toFixed(3),
|
||||
popupsClosed: [...popups1, ...popups2],
|
||||
popupsClosed: warm.popupsClosed,
|
||||
horizontalOverflow: metrics.hasHorizontalOverflow,
|
||||
images,
|
||||
hiddenBrokenImages,
|
||||
hiddenSections,
|
||||
images: warm.images,
|
||||
hiddenBrokenImages: warm.hiddenBrokenImages,
|
||||
hiddenSections: warm.hiddenSections,
|
||||
frames,
|
||||
sizesKB: Object.fromEntries(
|
||||
Object.entries(sizes).map(([k, v]) => [
|
||||
|
||||
+220
-109
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user