teqo-backup/scripts/build-city-report.mjs
Francisco Solla 321ac7b872
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
feat(relatorio-cidade): foca o relatório na mobilização do 2º turno
A federal foi decidida em 04/10; o documento de cidade passa a servir
inteiro à eleição de Lula. Saem conta eleitoral/cadeira de Solla,
concorrentes e pré-candidatos, emendas e entregas (sem fetch do Portal
da Transparência no builder), acervo de falas, panorama regional e
sinais/demandas; demografia, economia e transporte ficam como contexto
da visita.

A página 1 vira só mobilização: KPIs oficiais do artefato TSE 2026
(Lula/Δ, X₁/X₂, Solla), papel determinístico da cidade, quem mobiliza,
movimento local do 2º turno, riscos e o painel "O que pedir e falar"
(pedido Lula 13; guardrail de votos em jogo no lugar do defeso).

O checklist cai de 10 para 8 itens (emendas_web e quem_investe saem;
relacao_campo/disputa_local reancorados no 2º turno); listas
preCandidates/investments/emendasIndicators saem do contrato (arquivos
antigos seguem aceitos em replay). O snapshot enxuga para rede +
conjuntura + demografia (cityReportTerritory.mjs removido).

Prova: unit 5.232 verdes, int do snapshot sem PII e sem os grupos
aposentados, lint/format/typecheck/knip limpos e rebuild da fixture de
Cruz das Almas (9 seções, 16 páginas contra 21, página 1 fechando sem
fallback).
2026-10-10 16:57:20 -03:00

180 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* City report builder (C163): snapshot + research → PDF A4 + `.md`.
*
* The report serves the 2º turno (Lula × Flávio): mobilization summary on page 1
* and the deep dive after it. Runs on the workstation: no database access, no
* persistence, no external API — the official 2º-turno numbers come from the
* committed artifact (`pnpm build:second-round`).
*
* Usage:
* NODE_OPTIONS="--no-deprecation --import=tsx/esm" node scripts/build-city-report.mjs \
* --snapshot=data/relatorios-cidade/<slug>.snapshot.json \
* --research=data/relatorios-cidade/<slug>.research.json \
* --out-dir=docs/research/relatorios-cidade
*/
import { chromium } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildCityReport } from './lib/cityReportBlocks.mjs'
import { renderReportHtml, renderReportMd } from './lib/cityReportRender.mjs'
import { normalizeResearchInput } from './lib/cityReportResearch.mjs'
import { dieWithLabel, isTruthyEnv, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-city-report'
const die = dieWithLabel(LABEL)
const DEFAULT_OUT_DIR = 'docs/research/relatorios-cidade'
const CACHE_DIR = 'data/relatorios-cidade'
const MM_TO_PX = 96 / 25.4
const PDF_MARGINS_MM = { top: 14, bottom: 16, left: 12, right: 12 }
/** Measure the summary at the real printable width — screen 1280px wraps nothing. */
const PRINTABLE_WIDTH_PX = Math.round((210 - PDF_MARGINS_MM.left - PDF_MARGINS_MM.right) * MM_TO_PX)
const PRINTABLE_HEIGHT_PX = Math.round(
(297 - PDF_MARGINS_MM.top - PDF_MARGINS_MM.bottom) * MM_TO_PX,
)
/** Rounding slack of the mm→px conversion; content must still fit the page. */
const PAGE_ONE_TOLERANCE_PX = 8
const PAGE_ONE_BUDGET_PX = PRINTABLE_HEIGHT_PX - PAGE_ONE_TOLERANCE_PX
loadCliEnv()
const { flags } = parseEqualsFlags(process.argv.slice(2))
const snapshotPath = typeof flags.snapshot === 'string' ? flags.snapshot : null
const researchPath = typeof flags.research === 'string' ? flags.research : null
const outDir = typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR
const generatedAt =
typeof flags['generated-at'] === 'string' ? new Date(flags['generated-at']) : new Date()
if (!snapshotPath || !researchPath) {
die('Uso: --snapshot=<json> --research=<json> [--out-dir=<dir>]')
}
if (Number.isNaN(generatedAt.getTime())) die(`--generated-at inválida: ${flags['generated-at']}`)
const readJson = async (relativePath, label) => {
try {
return JSON.parse(await readFile(resolve(ROOT, relativePath), 'utf8'))
} catch (error) {
die(
`Falha ao ler ${label} (${relativePath}): ${error instanceof Error ? error.message : error}`,
)
}
}
const snapshot = await readJson(snapshotPath, 'snapshot')
const research = normalizeResearchInput(await readJson(researchPath, 'pesquisa'), {
now: generatedAt,
})
const currentCodeSha = (() => {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: ROOT }).toString().trim()
} catch {
return null
}
})()
if (snapshot.meta?.codeSha && currentCodeSha && snapshot.meta.codeSha !== currentCodeSha) {
console.warn(
`[${LABEL}] AVISO: snapshot extraído no SHA ${snapshot.meta.codeSha}, builder no ${currentCodeSha} — ` +
'o par deve ser o mesmo; regenere o snapshot se o extrator mudou.',
)
}
if (snapshot.municipality?.slug !== research.municipalitySlug) {
die(
`Pesquisa de "${research.municipalitySlug}" não corresponde ao snapshot de "${snapshot.municipality?.slug}" — pare e regenere.`,
)
}
const baseName = `${snapshot.municipality.slug}-${generatedAt.toISOString().slice(0, 10)}`
const pdfFile = join(outDir, `${baseName}.pdf`)
/**
* Renders both artifacts for a given fit fallback. Page 1 never cuts with "…":
* pass 1 prints the researcher's `summary` (or the full answer); if it does not
* fit the one-page guard, pass 2 re-renders with `textFallback: 'pointer'` and
* the lines without a `summary` point to the deep dive (which keeps the
* integral). The guard stays fail-closed if even the pointer does not fit.
*/
const buildArtifacts = (textFallback) => {
const report = buildCityReport({ snapshot, research, generatedAt, textFallback })
return { report, html: renderReportHtml(report), markdown: renderReportMd(report) }
}
let artifacts = buildArtifacts('full')
const browser = await chromium.launch()
try {
const page = await browser.newPage()
await page.setViewportSize({ width: PRINTABLE_WIDTH_PX, height: PRINTABLE_HEIGHT_PX })
const measureSummary = async () => {
await page.setContent(artifacts.html, { waitUntil: 'load' })
await page.emulateMedia({ media: 'print' })
return page.evaluate(() => {
const element = document.querySelector('[data-page="summary"]')
return element ? element.scrollHeight : null
})
}
let summaryScrollHeight = await measureSummary()
if (summaryScrollHeight === null) die('Bloco de resumo ausente no HTML — renderer quebrado.')
if (summaryScrollHeight > PAGE_ONE_BUDGET_PX) {
console.log(
`[${LABEL}] página 1 com o texto integral não coube (${summaryScrollHeight}px) — ` +
're-renderizando com ponteiro para o aprofundamento (sem "…").',
)
artifacts = buildArtifacts('pointer')
summaryScrollHeight = await measureSummary()
if (summaryScrollHeight === null) die('Bloco de resumo ausente no HTML — renderer quebrado.')
if (summaryScrollHeight > PAGE_ONE_BUDGET_PX) {
die(
`A página 1 estourou até com o fallback de ponteiro (${summaryScrollHeight}px > ${PAGE_ONE_BUDGET_PX}px úteis). ` +
'Encurte os "summary" da pesquisa ou os itens de lista — o resumo TEM de caber em uma página.',
)
}
}
await page.pdf({
path: resolve(ROOT, pdfFile),
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
headerTemplate: '<div></div>',
footerTemplate: `<div style="width:100%;font-family:'Fira Sans','DejaVu Sans',sans-serif;font-size:7pt;color:#a1a1aa;padding:0 12mm;display:flex;justify-content:space-between;">
<span>${artifacts.report.meta.title} · ${snapshot.municipality.name} · documento interno de campanha</span>
<span>pág. <span class="pageNumber"></span>/<span class="totalPages"></span></span>
</div>`,
margin: { top: '14mm', bottom: '16mm', left: '12mm', right: '12mm' },
})
} finally {
await browser.close()
}
const { html, markdown } = artifacts
const htmlPath = join(CACHE_DIR, `${baseName}.html`)
await mkdir(resolve(ROOT, CACHE_DIR), { recursive: true })
await writeFile(resolve(ROOT, htmlPath), html)
console.log(`[${LABEL}] HTML intermediário → ${htmlPath}`)
await mkdir(resolve(ROOT, outDir), { recursive: true })
const mdFile = join(outDir, `${baseName}.md`)
await writeFile(resolve(ROOT, mdFile), markdown)
console.log(`[${LABEL}] companion → ${mdFile}`)
console.log(`[${LABEL}] PDF → ${pdfFile}`)
console.log(
`[${LABEL}] ${snapshot.municipality.name}: lacunas_pesquisa=${research.gaps.length} html_bytes=${Buffer.byteLength(html)}`,
)
if (isTruthyEnv(process.env.CITY_REPORT_STRICT) && research.gaps.length > 0) {
die(
`CITY_REPORT_STRICT=1 e ${research.gaps.length} lacuna(s) de pesquisa — o relatório saiu, mas o modo estrito falha.`,
)
}
process.exit(0)