diff --git a/docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md b/docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md new file mode 100644 index 00000000..3e2e2a1a --- /dev/null +++ b/docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md @@ -0,0 +1,203 @@ +# Impl: Acervo de falas: "Abrir fonte" abre o PDF oficial do Diário (fim do visualizador legado) + +Status: aprovado +Atualizado em: 2026-09-14 +Issue: #988 +Intenção: docs/plans/acervo-abrir-fonte-pdf-oficial.md +Appetite restante: herdado (~1 dia eng + uma passada de máquina de resolução; sem migration) + +## Leitura da intenção + +- **Outcome:** toda fala do acervo entrega "Abrir fonte" apontando para o PDF oficial do Diário em `https://imagem.camara.leg.br/Imagem/d/pdf/` (`#page=` quando a URL legada tinha `txPagina`); reimport/backfill não reintroduz `dc_20b.asp`/`dc_20.asp`/`montaPdf.asp`; o reparo dos discursos já importados roda com guard de escrita, é idempotente e reporta cobertura (falas/datas atualizadas, falhas). A página do acervo nunca depende da Câmara para renderizar. +- **O que NÃO negociar:** + - Destino é o **PDF direto** (não o visualizador legado, não o `montaPdf` intermediário, não YouTube como padrão); resolver **offline/cacheado**, uma resolução por publicação compartilhada pelas falas daquela data. + - Falha de resolução **não entrega link morto**: cai no fallback existente (`youtubeUrl`; sem ele, botão oculto) e o relatório lista a data como pendência. + - Reparo com **guard de escrita** (`CAMARA_IMPORT_CONFIRM=1` via `requiresWriteConfirm`), read-only isento (`--coverage`/`--verify-links`; `--dry-run` novo), reexecutável; cache/report em `data/camara/` (gitignored desde C152). + - Sem reimportar ASR/LLM, sem tocar VOD (`vodPlaybackUrl`/`vodDownloadUrl`), sem novo campo/segundo link, sem mudar crédito CC BY nem URL pública do acervo. +- **O que reavaliar (hipóteses da "Direção no codebase"):** + - _Consumidores do link_ (`SpeechResultCard.tsx:125-130`, detalhe `[id]/page.tsx:63,121-128`): **nenhuma mudança de UI** — o href é direto (`sourceUrl = officialTextUrl ?? youtubeUrl` em `speechViewModels.ts:221`/`:249`). Os arquivos são referência de leitura, não de escrita. + - _Estender `--verify-links`/`--coverage`_: reavaliado para um corte menor — o **`--repair-links --dry-run` é o verificador read-only** (scan + classificação + plano, sem rede quando tudo é direto); a resolução do reparo já verifica cada PDF com Range antes de gravar (D3/D5). + - _Quando rodar o reparo_ (intenção recomendou após o merge, assumido): reavaliado para **pré-merge no SHA do branch**, precedente C155/OPS79 — a reparação é script-only (não depende do deploy) e assim o PR carrega a prova viva; o runbook §C160 executa igual se o operador preferir pós-merge (D8). + - _Falha → `null`_: vale para o **write path do import**; no reparo, escrever `null` destruiria a única pista da pendência e tiraria a linha do scanner — o reparo deixa a linha intocada e sai vermelho (D4). + +**Fatos de código e medição (2026-09-14):** + +| Verificação | Resultado | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Gravação atual | `scripts/import-camara-speeches.mjs:468` — `officialTextUrl: speech.urlTexto ?? null` (bundle :458-484; upsert :487). Nenhum outro writer; único ponto do link | +| Consumers | `speechViewModels.ts:39/78/221/249`; `SpeechResultCard.tsx:125-130`; `[id]/page.tsx:63,121-128` | +| Legado no repo | nenhuma referência a `dc_20`/`montaPdf`/`narquivo` em `src/` ou `tests/`; `camaraSpeeches.mjs` não tem nada de URL oficial | +| Infra reusável | `ensureCachedDownload` (`cli.mjs:190-228`; `ext` json devolve parseado); `probeVodLink` (`camaraFetch.mjs:129`); guard `assertWriteAllowed` (:870-878) + `assertLocalDatabase` (:894); `writeReport` (:614); precedente `recover-media.mjs:79-85` | +| e2e | `tests/e2e/campaignSpeechAcervo.e2e.spec.ts:44` usa URL fictícia e asserta "Abrir fonte" — **não muda** (manifest cobre `src/utilities/speech`, `scripts/lib/e2e-affected-manifest.mjs:279-285`) | +| URL legada real | `https://imagem.camara.gov.br/dc_20b.asp?largura=&altura=&tipoForm=diarios&selCodColecaoCsv=D&Datain=8%2F2%2F2023&txPagina=73&txSuplemento=&enviar=Pesquisar` | +| Hop 1 `.gov.br` → `.leg.br` | 302 com a mesma query; ~135s no teste frio (intenção mediu 25–85s) | +| Hop 2 `.leg.br` → `montaPdf` | 302 `montaPdf.asp?narquivo=DCD0020230208000210000.PDF&npagina=73`; ~5,4s; `set-cookie` ASP | +| PDF direto | `https://imagem.camara.leg.br/Imagem/d/pdf/DCD0020230208000210000.PDF` → 206 `application/pdf` em 0,4s com Range `0-1023` (~43,6 MB) | +| Fragmento | `#page=73` = `txPagina` legado / `npagina` do redirect | +| Exemplos resolvidos | 25/06/2020 → `DCD0020200625001020000.PDF#page=129`; 28/09/2011 → `DCD28SET2011.pdf` (ano antigo, nome curto); 10/03/2026 → `DCD0020260310000250000.PDF` | +| `narquivo` | **não derivável da data** (número do Diário fora da URL); só seguindo o redirect — por isso a resolução é cacheada por publicação | + +## Abordagem recomendada + +Evoluir o **dono** do acervo (C153/C155) com (1) normalização do link no write path do import e (2) um modo de reparo one-off no mesmo CLI, com resolução cacheada por publicação e verificação do PDF antes de gravar. + +```mermaid +flowchart LR + subgraph IMPORT["Import (dono) — grava sempre PDF direto"] + A["API discursos
urlTexto = dc_20b.asp"] --> B["classify/parse puro
camaraSpeeches.mjs"] + B -->|legacy| C["resolveOfficialPdfUrl
.leg.br → 302 montaPdf
camaraFetch.mjs"] + C --> P["probeLink Range
200/206 application/pdf"] + P --> D["ensureCachedDownload json
<out>/diario/<publicação>.json"] + D --> E["buildOfficialPdfUrl
#page=txPagina"] + B -->|direct / montaPdf| E + B -->|falha| F["null → fallback
youtubeUrl / botão oculto"] + E --> G[("speech.officialTextUrl")] + end + subgraph REPAIR["Reparo one-off — --repair-links"] + H["scan speech
officialTextUrl não-nulo"] --> I["planOfficialLinkRepairs (puro)
agrupa por publicação"] + I --> J["resolve 1×/publicação
cache + probe"] + J -->|"--dry-run (read-only)"| K["relatório, zero escrita"] + J -->|"write + CAMARA_IMPORT_CONFIRM=1"| G + end + G --> L["UI: Abrir fonte
href direto — sem mudança"] + F --> L +``` + +**Opções consideradas:** A) estender o dono (`import-camara-speeches.mjs` + `camaraSpeeches.mjs`/`camaraFetch.mjs`) com normalização no `processSpeech` e modo `--repair-links [--dry-run]`; B) script separado `repair-camara-links.mjs` + normalização só no import; C) re-importar tudo com `--all` para corrigir o campo; D) resolver no render/clique. +**Recomendação:** A — um CLI, um cache, um guard, um relatório; o reparo reusa bootstrap/`loadCliEnv`/`writeReport`/`ensureCachedDownload` e o import reusa a mesma resolução. +**Rejeitadas:** B — twin que duplica bootstrap, guard, cache e a sabedoria da Câmara (dois lugares para o mesmo bug); C — custo de ASR/LLM e risco de regressão em dados bons (o escopo é só o link); D — latência de 5–135s ou pendura no request do usuário e acopla o render à Câmara. + +### Decisões de engenharia + +#### D1 — Dono único: normalização no import + modo de reparo no mesmo CLI (barato; a decisão que importa é não criar twin) + +- **Opções:** A) `--repair-links` no `import-camara-speeches.mjs` + normalização em `processSpeech`; B) script novo de reparo; C) reparo via `--all` re-importando tudo; D) SQL manual no runbook. +- **Recomendação:** A — o import é o dono do campo desde a C153; o reparo é "o mesmo pipeline sem ASR/LLM": varre `speech` com `officialTextUrl` não-nulo (`overrideAccess: true`, `pagination: false`, `select` mínimo), agrupa por publicação, resolve uma vez por grupo, atualiza só quem muda. Nenhuma escrita fora de `speech.officialTextUrl`. +- **Rejeitadas:** B — segundo CLI para o mesmo conhecimento (flags, guard, cache, host rewrite) = drift; C — reexecuta página de evento/VOD/ASR (custo e risco) para corrigir um campo; D — sem idempotência, sem relatório e sem guard. + +#### D2 — Resolução pela cadeia de redirect, com host reescrito (caro: é a única fonte do `narquivo`) + +- **Opções:** A) seguir o redirect HTTP com `fetch` (`redirect: 'follow'`; `response.url` final = `montaPdf.asp?narquivo=…&npagina=…`) e parsear `narquivo`/`npagina`; reescrever `.gov.br` → `.leg.br` antes do lookup (fallback para a URL original); B) derivar o nome do PDF da data; C) raspar o HTML/meta-refresh do `montaPdf`; D) pedir a URL pronta à API. +- **Recomendação:** A — medido: o `narquivo` só aparece depois do 302, e `response.url` entrega o `montaPdf` sem parsear HTML; o rewrite pula o hop de ~135s (medido) e o fallback cobre a exceção. Validação de host na URL final (`imagem.camara.leg.br`) para não seguir redirect fora da Câmara (fail-closed, precedente de origem restrita do `recover-media`). +- **Rejeitadas:** B — provado impossível (o número do Diário não está na URL); C — frágil (meta-refresh muda) e desnecessário com `response.url`; D — não existe endpoint para isso na API open-data. + +#### D3 — Resolução cacheada e verificada (barato, semântica de cache importa) + +- **Opções:** A) `ensureCachedDownload` (ext `json`) com `cacheDir: /diario`, chave por publicação (`date + collection + supplement`), `download` injetado que segue o redirect **e** prova o PDF com Range (`200/206 application/pdf`) antes de devolver o buffer — falha lança, então **nada é cacheado**; cache só guarda sucesso; B) cache próprio em memória; C) sem cache (uma resolução por fala); D) cachear também falhas. +- **Recomendação:** A — padrão explícito do dono (`ensureCachedDownload` em `cli.mjs:190`), uma chamada de rede por publicação/ever, sobrevive a reexecuções e o `--dry-run` aquece o cache do write. Generalizar `probeVodLink` → `probeLink(url, …)` com `contentType` no retorno (um helper HTTP de probe para VOD e PDF; o único call site existente é o `--verify-links`). +- **Rejeitadas:** B — re-resolve a cada run (a reparação seria repetida ~700×); C — uma chamada por fala (997×) e o import de um dia pagaria o mesmo hop de novo; D — veneno de cache: uma falha transitória bloquearia o reparo para sempre. +- **Forma do JSON cacheado:** `{ archiveFileName, page, finalUrl, checkedAt }` (auditoria; identifiers em inglês, o `narquivo` da Câmara vira `archiveFileName`); o fragmento `#page` é montado por fala, nunca cacheado. + +#### D4 — Semântica de falha nos dois write paths (caro: dado real) + +- **Opções:** A) import: falha → `officialTextUrl = null` (fallback existente), **preservando** `existing.officialTextUrl` se já for direto; reparo: falha → **não escreve** a linha, reporta a publicação e o run termina vermelho, reexecutável; B) `null` nos dois; C) manter o legado nos dois; D) falha silenciosa. +- **Recomendação:** A — no import (execução autônoma) o aceite manda nada de link morto → fallback (`youtubeUrl`; sem ele, botão oculto), com falha `stage: 'official-link'` no relatório; no reparo (sessão de operador) escrever `null` destruiria a única pista da pendência e removeria a linha do scanner — a linha fica intocada, o run sai 1 e a próxima passada é idempotente. Preservar o direto anterior em falha evita que um reimport com rede ruim apague um reparo bom. +- **Rejeitadas:** B no reparo — perde a pista e a capacidade de retry; C — viola o aceite "reimport não reintroduz o legado"; D — inaceitável (o relatório é o produto da operação). + +#### D5 — Verificação read-only pós-reparo: o menor corte (barato) + +- **Opções:** A) `--repair-links --dry-run` como aceite (conta `diretas`/`legadas`/`desconhecidas`, lista datas pendentes; sem rede quando tudo é direto) + a verificação de PDF embutida na resolução (D3); B) estender `--verify-links` para probe-ar `officialTextUrl`; C) modo novo `--verify-official `; D) coluna `legacyTextUrl` no `--coverage`. +- **Recomendação:** A — determinístico, sem rede, sem guard, e já é o veículo do relatório; a reachability do PDF é provada na própria resolução (`probeLink` antes de cachear/gravar), então um probe de amostra não adiciona garantia nova (o PDF é estático; o VOD, que é regenerável, continua coberto pelo `--verify-links`). +- **Rejeitadas:** B — muda a semântica da amostra de VOD (rows sem VOD e com oficial ficariam de fora) e duplica o que a resolução já prova; C — cerimônia; D — redundante com o dry-run (revisitar se um dia houver série histórica de saúde do link). + +#### D6 — Guard do modo de reparo (caro: fail-closed) + +- **Opções:** A) `--repair-links --dry-run` = read-only (exige só `DATABASE_URL`); `--repair-links` = write, passa por `assertWriteAllowed` (`CAMARA_IMPORT_CONFIRM=1` quando `NODE_ENV=production`/host não-local/`ALLOW_REMOTE_DB`) e por `assertLocalDatabase`; B) read-only por padrão + `--apply` para escrever; C) escrever sempre que `--repair-links` for passado. +- **Recomendação:** A — vocabulário existente do CLI (o modo é a ação; a flag de ambiente é a intenção), precedente literal de `recover-media` e do próprio import; `--dry-run` sem `--repair-links` morre no parse; `--repair-links` não combina com `--all/--date/--legislature/--coverage/--verify-links` nem com `--limit`/`--skip-transcribe`/`--reclassify`. +- **Rejeitadas:** B — diverge do CLI atual (`--all` já escreve com guard) sem ganho de segurança; C — perde o dry-run barato e o planejamento. + +#### D7 — Testes proporcionais (barato) + +- **Opções:** A) unit dos puros novos com as URLs reais medidas + int do estado do import; B) e2e/CLI com rede; C) nenhum teste novo; D) extrair `parseArgs` para teste. +- **Recomendação:** A — os puros (`classifyOfficialTextUrl`, `parseLegacyOfficialUrl`, `parseMontaPdfUrl`, `buildOfficialPdfUrl`, `publicationCacheKey`, `planOfficialLinkRepairs`) são exatamente onde a regra frágil mora e falham em silêncio sem teste; o int do `speechImport` ganha a asserção do `officialTextUrl` no `findSpeechImportState`; o e2e `campaignSpeechAcervo` existente **não muda** (a URL fictícia e o assert "Abrir fonte" continuam) e roda selecionado pelo manifest (`src/utilities/speech`). +- **Rejeitadas:** B — flaky, depende da Câmara e não prova a regra; C — o classifier/grouper são a parte que erra; D — desproporcional (fill-in futuro se a CLI crescer). + +#### D8 — Onde e quando o reparo roda (caro: dado real; ordem documentada) + +- **Opções:** A) pré-merge no homeserver, checkout `~/teqo-backfill` no SHA do branch, dry-run → write → dry-run (precedente C155/OPS79), com o resultado no PR; B) pós-merge/deploy no SHA de main (recomendação assumida da intenção); C) junto do próximo import. +- **Recomendação:** A — o reparo **não depende do deploy** (script-only; nenhuma migration), então rodar antes do merge entrega a prova viva no PR e fecha a Issue com produção já reparada; o guard é o mesmo e a passada é idempotente. O runbook §C160 documenta a mesma sequência para reexecução pós-merge, se preferirem. +- **Rejeitadas:** B — deixa a janela "mergeado e Issue fechada com o legado no ar" dependente de um operador não agendado, sem prova viva no PR; C — acopla um defeito de UI à janela do próximo import. + +### Componentes / mudanças + +| Arquivo | Mudança | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `scripts/lib/camaraSpeeches.mjs` (puro) | `classifyOfficialTextUrl(raw)` → `none\|direct\|legacy\|montaPdf\|unknown`; `isLegacyDiarioUrl(raw)` (shape legado mesmo malformado); `parseLegacyOfficialUrl(raw)` → `{ lookupUrl (.gov.br→.leg.br), publication {date ISO, collection, supplement}, page }`; `parseMontaPdfUrl(raw)` → `{ archiveFileName, page }`; `buildOfficialPdfUrl(archiveFileName, page)` → `/Imagem/d/pdf/[#page=]`; `officialTextUrlFallback(existingUrl)` (preserva só PDF direto); `publicationCacheKey(publication)` (filesystem-safe); `planOfficialLinkRepairs(rows)` → `{ groups, directUpdates, alreadyDirect, unknown, unresolvable, none }` | +| `scripts/lib/camaraFetch.mjs` (HTTP) | `resolveOfficialPdfUrl(legacyUrl, { timeoutMs: 180_000, attempts: 2 })` — parse estrito (fail-closed se não for link legado), rewrite do host, `redirect: 'follow'`, lê `response.url`, valida host Câmara, parseia `montaPdf`/PDF direto, retorna `{ archiveFileName, page, pdfUrl, finalUrl }`, lança em falha; renomear `probeVodLink` → `probeLink` (mesmo comportamento + campo `contentType`; único call site em `--verify-links`) | +| `scripts/import-camara-speeches.mjs` (dono) | `--repair-links` + `--dry-run` no `parseArgs`/`HELP`/`modeLabel`; normalização em `processSpeech` (classifica/parseia/resolve com `ensureCachedDownload` em `/diario`; falha → `null` preservando direto anterior; `report.officialLink`; falha `stage:'official-link'`); `runRepairLinks` (scan/plan/resolve/update/relatório `repair-links-.json`); hint do guard aponta §C155/§C160 | +| `src/utilities/speech/speechImport.ts` | `SpeechImportState.officialTextUrl` (select + retorno) para a preservação em falha; nenhuma outra semântica muda | +| `tests/unit/camaraSpeeches.unit.spec.ts` | Unit dos 6 puros com as URLs reais (legada `.gov.br`, `.leg.br`, `montaPdf`, PDF direto, ano curto, `null`/desconhecida; datas `2023-02-08`/`2020-06-25`; páginas 73/129) | +| `tests/int/speechImport.int.spec.ts` | Asserção de `officialTextUrl` no `findSpeechImportState` (bundle com URL direta) | +| `docs/ops/teqo-1313-deploy.md` | Nova `## C160 — reparo do link oficial do acervo (PDF direto do Diário)`: pré-requisitos, dry-run → write → dry-run, esperado, rollback, comportamento medido da Câmara | +| `docs/changelog/2026-09-14-c160.md` (novo) | Entrada curta com os números medidos na passada de produção | + +Sem migration, sem schema, sem access, sem UI, sem package.json (`pnpm camara:import` existe), sem Consent. `data/camara/` já é gitignored (`.gitignore:77`); o cache novo é `data/camara/diario/`. + +### Dados → forma (relatórios) + +- **Import (delta no relatório C153/C155):** por fala, `officialLink: 'none'|'direct'|'resolved'|'failed'|'unknown'` (fill-in de nome); falha entra em `failures[]` com `stage: 'official-link'` e mensagem. O `speechLine`/stdout pode mostrar `link oficial resolvido`/`link oficial falhou` — sem contadores novos obrigatórios. +- **Reparo (`repair-links-.json`, `/reports/`):** + - `scanned` (falas com `officialTextUrl` não-nulo), `withoutLink` (total `speech` − scanned), `alreadyDirect`, `unknown[]` (id + url, intocados); + - `publications[]`: `{ key, lookupUrl, archiveFileName, state: 'resolved'|'failed', rows, pages[], error? }`; + - `unresolvable[]`: legados malformados (ficam intocados e o write sai 1 — nenhum link legado pode permanecer); + - `updates[]`: `{ id, speechAt, legislature, previousUrl, nextUrl }` (planejado no dry-run; aplicado no write); + - `totals`: `{ legacy, updated, failedPublications, failedRows, remainingLegacy }`; `dryRun`, `runAt`, `elapsedMs`, `options`. +- **Stdout:** `falas: N com link (D diretas, L legadas em P publicações, U desconhecidas); M sem link` → `publicações: R resolvidas, F falhas` → `dry-run: L seriam atualizadas` / `atualizadas: L; legadas restantes: 0`. Falhas listadas com `key`/data/erro. +- **Destino:** JSONs no homeserver (`/srv/hdd/backups/teqo-camara/reports/`), nada commitado; resumo no corpo do PR, no changelog e no runbook. + +## Fases verificáveis + +1. **Puros + unit (≈0,25 dia).** Implementar os 6 helpers em `camaraSpeeches.mjs` e os unit com as URLs reais medidas. _Prova:_ `pnpm test:unit` verde (casos novos), `pnpm lint`, `pnpm typecheck`. _Quota:_ ~150 linhas no lib + ~120 no spec. +2. **Rede + import (≈0,25 dia).** `resolveOfficialPdfUrl` + `probeLink`; normalização em `processSpeech`; `SpeechImportState.officialTextUrl`. _Prova local (`teqo_wt160`):_ `pnpm camara:import --date --limit 1` → linha com PDF direto (`#page`), sem falha `official-link`; reexecução = `cache hit` e mesmo valor. Se a rede workstation→Câmara estiver instável, o smoke real vira a Fase 4 (homeserver). _Quota:_ ~60 linhas em `camaraFetch` + ~60 no import + ~4 no `speechImport`. +3. **Reparo (≈0,25 dia).** Modo `--repair-links`/`--dry-run`, `planOfficialLinkRepairs` no caminho, relatório e guards. _Prova local:_ fixture com a URL legada real → dry-run não escreve (valores intactos); write → linha vira PDF direto; 2ª passada = 0 updates/`remainingLegacy: 0`; URL desconhecida vira `unknown` sem escrita; falha de publicação força exit 1 no write (e 0 no dry-run). _Quota:_ ~120 linhas + ~40 no int. +4. **Ops em produção (pré-merge; ≈1h preparo + ~1h de resolução + ~10min).** Após push do branch: `~/teqo-backfill` no SHA do branch, `pnpm install`, envs de `~/stack/.env` + `~/stack/teqo-1313.env`, `DATABASE_URL` reescrito para o proxy `127.0.0.1:5433`, `--out /srv/hdd/backups/teqo-camara`. Passos: `--repair-links --dry-run` (mede falas/legadas/publicações; 0 falhas esperadas; aquece o cache) → `CAMARA_IMPORT_CONFIRM=1 pnpm camara:import --repair-links` → `--repair-links --dry-run` (**0 legadas**) → `pnpm camara:import --coverage` (acervo íntegro). _Prova:_ JSONs + números no PR. +5. **Fechamento (≈0,5h).** Runbook §C160 + changelog com os números medidos; `pnpm gate:fast`, `pnpm test:int`, e2e `campaignSpeechAcervo` (selecionado pelo manifest de `src/utilities/speech` — preservado, sem mudanças); `pnpm push`; PR `Closes #988` + auto-merge. _Quota:_ 1 PR; prova: check `checks` verde. + +## Rabbit holes / Não escopo (engenharia) + +- Espelhar PDFs no Garage, baixar o Diário, gerar clipes — **não** (intenção corta). +- Resolver no `render`/clique ou cache HTTP na página — **não**; resolução offline por publicação. +- Nova migration/campo/segundo link, UI, Consent, access — **não**; o `officialTextUrl` é o mesmo campo. +- Estender `--verify-links`/`--coverage` para link oficial, modo `--verify-official`, probe periódico — **não** (D5). +- Fórmula para o `narquivo`, crawler do Diário, fallback de meta-refresh — **não**; o redirect é a fonte. +- Generalizar o resolver para o Senado/comissões/outras publicações — **não**. +- Parallelizar resoluções, otimizar wall clock — **não**; uma por publicação, com cache e pausa educada. +- Refatorar `parseArgs` inteiro ou promover o resolver a utility `src/` — **não**; dono é `scripts/`. +- Commitar cache/relatórios — **não** (`data/camara/` gitignored). + +## Riscos e mitigação + +| Risco | Mitigação | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fonte lenta/instável (`.gov.br` 135s frio; hangs) | Rewrite `.leg.br` no lookup; timeout 180s + 2 tentativas; fallback para a URL original; cache por publicação; falha vira pendência, nunca link morto | +| Shape do redirect mudar (`montaPdf` renomeado/host novo) | Resolver valida host e formato e falha fechado; unit pina os shapes com URL real; report lista as datas; gatilho de revisitação no runbook | +| Reimport regride um link já reparado | Normalização no write path; preservar `existing` direto em falha; aceite pós-passada = `--repair-links --dry-run` com 0 legadas | +| Cache envenenado por falha transitória | `download` lança em falha ⇒ `ensureCachedDownload` não grava; cache só de sucesso; próxima passada tenta de novo | +| Falha parcial no reparo | Linhas falhas ficam intocadas, run sai 1, relatório lista as datas; reexecução idempotente (só o que falta é re-resolvido) | +| ~700 publicações × ~5s = wall clock alto | Cache por publicação; dry-run aquece o cache do write (write ≈ só updates no DB); pausa de 250ms; medir no dry-run | +| `narquivo` errado/PDF 404 ou HTML anti-bot | `probeLink` com Range antes de cachear/gravar: exige 200/206 `application/pdf`; sem probe ok, não escreve | +| Escrita concorrente com o app em produção | Update de um único campo em `speech`, sequencial, sem locks de outras tabelas; janela de baixa atividade | +| Rollback | Não há schema a desfazer; `previousUrl` de cada linha no JSON do run; reexecução do import/reparo sempre reconstrói o valor direto | + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção coberto: nenhuma fala entrega `dc_20b.asp`/`dc_20.asp`/`montaPdf.asp`; clique abre o PDF direto (com `#page` quando havia `txPagina`); render do acervo não depende da Câmara. +- [ ] Import grava sempre PDF direto (ou `null` no fallback); reimport não reintroduz legado; falha reportada com `stage: 'official-link'`. +- [ ] `--repair-links --dry-run` read-only (sem guard, sem escrita); `--repair-links` com `CAMARA_IMPORT_CONFIRM=1` + `assertLocalDatabase`; não combina com outros modos; `--dry-run` exige `--repair-links`. +- [ ] Reparo idempotente e com cobertura: 2ª passada = 0 updates/0 legadas; report com falas/datas atualizadas, falhas e `remainingLegacy`; produção com 0 legadas após a passada. +- [ ] Sem migration/schema/UI/Consent; VOD e transcrições intocados; crédito "Fonte: Câmara dos Deputados · CC BY 4.0" mantido; `data/camara/` fora do git. +- [ ] Invariantes AGENTS/engineering-standards: copy pt-BR/identificadores em inglês; `overrideAccess: true` com comentário de bypass (CLI sem sessão); escrita em uma única collection (sem transação multi-collection); sem `Contact`/collection paralela. +- [ ] Testes: unit novos verdes (puros com URLs reais); int do `speechImport` atualizado; e2e `campaignSpeechAcervo` preservado e verde (selecionado via manifest); `pnpm gate:fast` + CI verdes. +- [ ] Runbook `§C160` e `docs/changelog/2026-09-14-c160.md` com os números medidos; PR `Closes #988` com auto-merge. + +## Self-score decision-quality + +| Critério | Nota | Justificativa | +| ----------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. Decisões caras têm rejeitadas | 5 | D1 (dono único vs twin/script novo), D2 (redirect vs fórmula/raspagem), D4 (falha nos dois write paths), D6 (guard), D8 (timing em produção) com opções, recomendação e rejeitadas justificadas; o barato (nomes de campos, `n` de probe, formato do stdout) ficou como fill-in. | +| 2. Abordagem cabe no appetite | 5 | ~1 dia eng; sem migration/schema/UI; reusa `ensureCachedDownload`, `probeLink`, guards, `writeReport` e os puros do dono; a passada de máquina é cacheada por publicação (não 997 resoluções). | +| 3. Rabbit holes nomeados | 5 | Espelhar PDFs, resolver no render, schema/segundo link, extensão de `--verify-links`/`--coverage`, generalização para outras casas, paralelização e commit de artefatos explicitamente cortados. | +| 4. Depth check (reusa donos/shells) | 5 | Nenhum módulo/collection/rota nova: o dono (`import-camara-speeches.mjs`), os puros (`camaraSpeeches.mjs`), o HTTP (`camaraFetch.mjs`), o cache (`ensureCachedDownload`), o guard (`requiresWriteConfirm`/`assertLocalDatabase`), o relatório (`writeReport`) e o estado (`findSpeechImportState`) são estendidos no lugar; sem twin. | +| 5. Intenção (aceite) preservada | 4 | Todo o aceite mapeado; dois desvios conscientes documentados — timing do reparo (pré-merge com precedente C155/OPS79, em vez do "após o merge" assumido) e falha no reparo deixar a linha intocada + exit 1 (em vez de `null`), ambos com rejeitadas e alternativa no runbook. | + +Média: **4,8** (≥4). diff --git a/scripts/import-camara-speeches.mjs b/scripts/import-camara-speeches.mjs index d59cb3f9..be9b90b2 100644 --- a/scripts/import-camara-speeches.mjs +++ b/scripts/import-camara-speeches.mjs @@ -16,6 +16,11 @@ * write run targeting production (or a remote/override DB) additionally * requires `CAMARA_IMPORT_CONFIRM=1`. * + * C160 writes `officialTextUrl` as the direct Diário PDF: the import normalizes + * the legacy `urlTexto` (`dc_20b.asp`/`dc_20.asp`) by following its redirect, + * cached once per publication under `/diario/`, and `--repair-links + * [--dry-run]` fixes the already-imported speeches without reimporting. + * * Usage: * pnpm camara:import --legislature 57 * pnpm camara:import --date 2023-02-07 --limit 3 @@ -23,6 +28,8 @@ * pnpm camara:import --all --limit 2 # smoke * pnpm camara:import --coverage * pnpm camara:import --verify-links 3 + * pnpm camara:import --repair-links --dry-run + * CAMARA_IMPORT_CONFIRM=1 pnpm camara:import --repair-links */ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' @@ -38,7 +45,8 @@ import { getJson, getJsonWithBackoff, getText, - probeVodLink, + probeLink, + resolveOfficialPdfUrl, resolveVod, speechesUrl, transcribeSpeechAudio, @@ -47,13 +55,21 @@ import { LEGISLATURE_RANGES, SOLLA_DEPUTY_ID, aggregateBackfillRuns, + buildOfficialPdfUrl, + classifyOfficialTextUrl, dateRangeChunks, + isLegacyDiarioUrl, legislatureForDate, matchExcerpt, + officialTextUrlFallback, parseDurationToSeconds, parseEventExcerpts, + parseLegacyOfficialUrl, + parseMontaPdfUrl, parseOfficialKeywords, parsePresidingOfficerTransitions, + planOfficialLinkRepairs, + publicationCacheKey, resolvePresidingOfficer, selectLinkSample, selectSpeechEvents, @@ -103,11 +119,14 @@ const parseArgs = (argv) => { date: null, limit: null, speaker: 'Jorge Solla', + speakerProvided: false, out: DEFAULT_OUT_DIR, skipTranscribe: false, reclassify: false, coverage: false, verifyLinks: null, + repairLinks: false, + dryRun: false, help: false, } for (let index = 0; index < argv.length; index += 1) { @@ -121,13 +140,17 @@ const parseArgs = (argv) => { else if (arg === '--all') options.all = true else if (arg === '--coverage') options.coverage = true else if (arg === '--verify-links') options.verifyLinks = Number(value()) + else if (arg === '--repair-links') options.repairLinks = true + else if (arg === '--dry-run') options.dryRun = true else if (arg === '--skip-transcribe') options.skipTranscribe = true else if (arg === '--reclassify') options.reclassify = true else if (arg === '--legislature') options.legislature = Number(value()) else if (arg === '--date') options.date = value() else if (arg === '--limit') options.limit = Number(value()) - else if (arg === '--speaker') options.speaker = value() - else if (arg === '--out') options.out = value() + else if (arg === '--speaker') { + options.speaker = value() + options.speakerProvided = true + } else if (arg === '--out') options.out = value() else die(`argumento desconhecido: ${arg}`) } if (options.help) return options @@ -136,6 +159,22 @@ const parseArgs = (argv) => { const importModes = [options.all, options.date !== null, options.legislature !== null].filter( Boolean, ).length + if (options.dryRun && !options.repairLinks) die('--dry-run só vale com --repair-links.') + if (options.repairLinks && readOnlyModes > 0) { + die('--repair-links não combina com --coverage/--verify-links.') + } + if (options.repairLinks && importModes > 0) { + die('--repair-links não combina com --all/--date/--legislature.') + } + if ( + options.repairLinks && + (options.limit !== null || + options.skipTranscribe || + options.reclassify || + options.speakerProvided) + ) { + die('--limit/--skip-transcribe/--reclassify/--speaker só valem para os modos de import.') + } if (readOnlyModes > 1) die('--coverage e --verify-links são modos separados.') if (readOnlyModes > 0 && importModes > 0) { die('--coverage/--verify-links não combinam com --all/--date/--legislature.') @@ -156,9 +195,11 @@ const parseArgs = (argv) => { ) { die('--verify-links exige um inteiro >= 1.') } - if (importModes === 0 && readOnlyModes === 0) options.legislature = 57 - if (options.legislature !== null && !LEGISLATURE_RANGES[options.legislature]) { - die('informe --legislature <54|55|56|57> ou --date (veja --help).') + if (!options.repairLinks) { + if (importModes === 0 && readOnlyModes === 0) options.legislature = 57 + if (options.legislature !== null && !LEGISLATURE_RANGES[options.legislature]) { + die('informe --legislature <54|55|56|57> ou --date (veja --help).') + } } if (options.date !== null && !/^\d{4}-\d{2}-\d{2}$/.test(options.date)) { die('--date deve ser YYYY-MM-DD.') @@ -173,7 +214,7 @@ const parseArgs = (argv) => { } const HELP = ` -Uso: pnpm camara:import [--all | --legislature 54|55|56|57 | --date YYYY-MM-DD | --coverage | --verify-links ] [opções] +Uso: pnpm camara:import [--all | --legislature 54|55|56|57 | --date YYYY-MM-DD | --coverage | --verify-links | --repair-links [--dry-run]] [opções] Modos de import (escrevem no acervo): --all processa as 54ª–57ª em sequência (relatório por legislatura) @@ -182,6 +223,14 @@ Modos de import (escrevem no acervo): Modos read-only: --coverage imprime a cobertura do banco por legislatura (sem rede/escrita) --verify-links amostra n discursos por legislatura e testa os links do VOD + --repair-links --dry-run + planeja o reparo do link oficial (PDF direto do Diário) sem escrever + +Reparo do link oficial (C160): + --repair-links troca officialTextUrl legado (dc_20b/dc_20.asp/montaPdf) pelo PDF + direto do Diário; resolve uma vez por publicação (cache em /diario) + e exige CAMARA_IMPORT_CONFIRM=1 fora de alvo local (runbook §C160) + --dry-run só com --repair-links: relata o plano sem escrever Opções de import: --limit quantos discursos processar por legislatura (default: todos) @@ -192,7 +241,7 @@ Opções de import: --help esta ajuda Escrita em alvo não-local ou com NODE_ENV=production exige CAMARA_IMPORT_CONFIRM=1 -(runbook: docs/ops/teqo-1313-deploy.md §C155). +(runbook: docs/ops/teqo-1313-deploy.md §C155/§C160). A página do evento é cacheada em /events; apague-a para reler a Câmara. ` @@ -340,6 +389,99 @@ async function transcribeExcerpt(eventId, excerpt, options) { } } +// --------------------------------------------------------------------------- +// Official Diário link (C160) +// --------------------------------------------------------------------------- + +const DIARIO_CACHE_DIR = 'diario' +const OFFICIAL_LINK_FAILURE_STAGE = 'official-link' + +/** + * One publication, one network resolution, ever: the cached PDF archive file + * name of a `(date, collection, supplement)` in `/diario/`. The injected + * download follows the legacy redirect and probes the direct PDF with a Range + * GET, so a failure throws before anything is cached. + * + * @param {{ date: string, collection: string | null, supplement: string | null }} publication + * @param {string} lookupUrl + * @param {{ out: string }} options + * @returns {Promise} + */ +async function resolvePublicationPdf(publication, lookupUrl, options) { + const key = publicationCacheKey(publication) + if (key === null) throw new Error('publicação sem data — link oficial não resolvível') + const cached = await ensureCachedDownload({ + label: 'camara:import', + key, + url: lookupUrl, + ext: 'json', + cacheDir: join(options.out, DIARIO_CACHE_DIR), + download: async () => { + const resolved = await resolveOfficialPdfUrl(lookupUrl) + const probe = await probeLink(resolved.pdfUrl, { timeoutMs: 60_000 }) + if (!probe.ok || !String(probe.contentType ?? '').includes('application/pdf')) { + throw new Error(`PDF oficial inacessível (${probe.note}) em ${resolved.pdfUrl}`) + } + return Buffer.from( + JSON.stringify({ + archiveFileName: resolved.archiveFileName, + page: resolved.page, + finalUrl: resolved.finalUrl, + checkedAt: new Date().toISOString(), + }), + ) + }, + }) + const archiveFileName = String(cached.json?.archiveFileName ?? '').trim() + if (archiveFileName === '') throw new Error(`cache sem archiveFileName para ${key}`) + return archiveFileName +} + +/** + * Resolves one raw `urlTexto` to the direct Diário PDF. Never returns the + * legacy visualizer or the `montaPdf` hop: on failure it preserves a previously + * stored direct URL (null otherwise, so the UI falls back to YouTube/the hidden + * button) and reports. + * + * @param {unknown} rawUrl + * @param {unknown} existingUrl + * @param {{ out: string }} options + * @returns {Promise<{ url: string | null, state: 'none' | 'direct' | 'resolved' | 'failed' | 'unknown', error?: string }>} + */ +async function resolveOfficialTextUrl(rawUrl, existingUrl, options) { + const kind = classifyOfficialTextUrl(rawUrl) + if (kind === 'none') return { url: null, state: 'none' } + if (kind === 'direct') return { url: String(rawUrl).trim(), state: 'direct' } + if (kind === 'montaPdf') { + const parsed = parseMontaPdfUrl(rawUrl) + return { url: buildOfficialPdfUrl(parsed.archiveFileName, parsed.page), state: 'resolved' } + } + if (kind === 'unknown') { + if (!isLegacyDiarioUrl(rawUrl)) return { url: String(rawUrl).trim(), state: 'unknown' } + return { + url: officialTextUrlFallback(existingUrl), + state: 'failed', + error: `link legado não resolvível: ${String(rawUrl).slice(0, 160)}`, + } + } + + const parsed = parseLegacyOfficialUrl(rawUrl) + try { + const archiveFileName = await resolvePublicationPdf( + parsed.publication, + parsed.lookupUrl, + options, + ) + return { url: buildOfficialPdfUrl(archiveFileName, parsed.page), state: 'resolved' } + } catch (error) { + return { + url: officialTextUrlFallback(existingUrl), + state: 'failed', + error: error?.message ?? String(error), + } + } +} + async function processSpeech(payload, speech, options, run) { // Identity: the API triple is the key; a genuinely distinct speech sharing // the triple (seen 2026-06-17T17:16) gets a content-hash suffix instead of @@ -365,6 +507,7 @@ async function processSpeech(payload, speech, options, run) { segmentCount: 0, created: false, manualFacetsPreserved: false, + officialLink: null, failures: [], } @@ -455,6 +598,17 @@ async function processSpeech(payload, speech, options, run) { media.durationSeconds ?? (preserveStoredMedia ? (existing?.durationSeconds ?? null) : null) ?? parseDurationToSeconds(excerpt?.duration) + + const official = await resolveOfficialTextUrl( + speech.urlTexto ?? null, + existing?.officialTextUrl ?? null, + options, + ) + report.officialLink = official.state + if (official.error) { + report.failures.push({ stage: OFFICIAL_LINK_FAILURE_STAGE, message: official.error }) + } + const bundle = { sourceKey, speechAt: String(speech.dataHoraInicio ?? '').trim(), @@ -465,7 +619,7 @@ async function processSpeech(payload, speech, options, run) { durationSeconds, summary: speech.sumario ?? null, officialTranscript: speech.transcricao ?? null, - officialTextUrl: speech.urlTexto ?? null, + officialTextUrl: official.url, keywords, eventId: event?.id ?? null, eventType: event?.descricaoTipo ?? null, @@ -599,6 +753,8 @@ const speechLine = (report) => { if (report.segmentCount > 0) parts.push(`${report.segmentCount} segmentos`) if (report.presidingOfficer) parts.push(`presidiu ${report.presidingOfficer}`) if (report.classifiedBy) parts.push(`facetas ${report.classifiedBy}`) + if (report.officialLink === 'resolved') parts.push('link oficial resolvido') + if (report.officialLink === 'failed') parts.push('link oficial falhou') if (report.failures.length > 0) parts.push(`${report.failures.length} falha(s)`) return parts.join(' · ') } @@ -810,7 +966,7 @@ const runVerifyLinks = async (payload, options) => { ] for (const [kind, url] of links) { if (!url) continue - const probe = await probeVodLink(url) + const probe = await probeLink(url) checked.push({ legislature: row.legislature, speechId: row.id, kind, ...probe }) console.log( `[camara:import] ${row.legislature ?? '—'} #${row.id} ${kind}: ` + @@ -846,6 +1002,153 @@ const runVerifyLinks = async (payload, options) => { // Main // --------------------------------------------------------------------------- +/** Resolves every publication of the repair plan once (cache + probe). */ +async function resolveRepairPublications(plan, options) { + const publications = [] + const archiveNames = new Map() + let failedRows = 0 + for (const group of plan.groups) { + try { + const archiveFileName = await resolvePublicationPdf( + group.publication, + group.lookupUrl, + options, + ) + archiveNames.set(group.key, archiveFileName) + publications.push({ + key: group.key, + lookupUrl: group.lookupUrl, + archiveFileName, + state: 'resolved', + rows: group.rows.length, + pages: group.rows.map((row) => row.page), + }) + } catch (error) { + failedRows += group.rows.length + publications.push({ + key: group.key, + lookupUrl: group.lookupUrl, + archiveFileName: null, + state: 'failed', + rows: group.rows.length, + error: error?.message ?? String(error), + }) + console.log(`[camara:import] ✗ ${group.key}: ${error?.message ?? error}`) + } + } + return { publications, archiveNames, failedRows } +} + +/** Offline `montaPdf` conversions + the rows of every resolved publication. */ +const buildRepairUpdates = (plan, archiveNames) => [ + ...plan.directUpdates, + ...plan.groups.flatMap((group) => { + const archiveFileName = archiveNames.get(group.key) + if (!archiveFileName) return [] + return group.rows.map((row) => ({ + id: row.id, + previousUrl: row.previousUrl, + nextUrl: buildOfficialPdfUrl(archiveFileName, row.page), + speechAt: row.speechAt, + legislature: row.legislature, + })) + }), +] + +async function applyRepairUpdates(payload, updates) { + let updated = 0 + const failures = [] + for (const update of updates) { + try { + await payload.update({ + collection: 'speech', + id: update.id, + data: { officialTextUrl: update.nextUrl }, + depth: 0, + // Intentional bypass: the import CLI is a trusted actor with no session. + overrideAccess: true, + }) + updated += 1 + } catch (error) { + failures.push({ id: update.id, message: error?.message ?? String(error) }) + } + } + return { updated, failures } +} + +const runRepairLinks = async (payload, options) => { + const startedAt = Date.now() + const found = await payload.find({ + collection: 'speech', + where: { officialTextUrl: { exists: true } }, + pagination: false, + depth: 0, + select: { legislature: true, speechAt: true, officialTextUrl: true }, + // Intentional bypass: the import CLI is a trusted actor with no session. + overrideAccess: true, + }) + const total = await payload.count({ + collection: 'speech', + // Intentional bypass: the import CLI is a trusted actor with no session. + overrideAccess: true, + }) + const plan = planOfficialLinkRepairs(found.docs) + const legacyRows = plan.groups.reduce((sum, group) => sum + group.rows.length, 0) + const totalLegacy = legacyRows + plan.directUpdates.length + console.log( + `[camara:import] falas: ${found.docs.length} com link ` + + `(${plan.alreadyDirect} diretas, ${legacyRows} legadas em ${plan.groups.length} publicações, ` + + `${plan.directUpdates.length} montaPdf, ${plan.unresolvable.length} malformadas, ` + + `${plan.unknown.length} desconhecidas); ` + + `${total.totalDocs - found.docs.length} sem link`, + ) + + const { publications, archiveNames, failedRows } = await resolveRepairPublications(plan, options) + const updates = buildRepairUpdates(plan, archiveNames) + const { updated, failures } = options.dryRun + ? { updated: 0, failures: [] } + : await applyRepairUpdates(payload, updates) + + const failedPublications = publications.filter((entry) => entry.state === 'failed').length + const unresolved = plan.unresolvable.length + const report = { + runAt: new Date().toISOString(), + mode: 'repair-links', + dryRun: options.dryRun, + options: runOptions(options), + scanned: found.docs.length, + withoutLink: total.totalDocs - found.docs.length, + alreadyDirect: plan.alreadyDirect, + unknown: plan.unknown, + unresolvable: plan.unresolvable, + publications, + updates, + failures, + totals: { + legacy: totalLegacy, + unresolved, + updated, + failedPublications, + failedRows, + failedUpdates: failures.length, + remainingLegacy: Math.max(0, totalLegacy + unresolved - updated), + }, + elapsedMs: Date.now() - startedAt, + } + + console.log( + options.dryRun + ? `[camara:import] dry-run: ${updates.length} falas seriam atualizadas, ` + + `${failedRows + unresolved} pendente(s) em ${failedPublications} publicação(ões)` + : `[camara:import] atualizadas: ${updated}; legadas restantes: ${report.totals.remainingLegacy}`, + ) + const reportPath = await writeReport(options, `repair-links-${stamp(report.runAt)}.json`, report) + console.log(`\n[camara:import] relatório JSON: ${reportPath}`) + if (!options.dryRun && (failedPublications > 0 || failures.length > 0 || unresolved > 0)) { + die('reparo incompleto — publicações/falas pendentes listadas no relatório.') + } +} + const databaseTarget = () => { const url = process.env.DATABASE_URL const host = databaseHostname(url) @@ -857,6 +1160,9 @@ const databaseTarget = () => { const modeLabel = (options) => { if (options.coverage) return 'coverage (read-only)' if (options.verifyLinks !== null) return `verify-links n=${options.verifyLinks} (read-only)` + if (options.repairLinks) { + return options.dryRun ? 'repair-links dry-run (read-only)' : 'repair-links' + } if (options.all) return 'backfill 54ª–57ª' if (options.date) return `date ${options.date}` return `legislature ${options.legislature}` @@ -873,7 +1179,7 @@ const assertWriteAllowed = () => { die( `alvo de escrita não-local/produção detectado (${databaseTarget()}).\n` + `Confirme a intenção com:\n ${WRITE_CONFIRM_FLAG}=1 pnpm camara:import …\n` + - `Runbook: docs/ops/teqo-1313-deploy.md §C155.`, + `Runbook: docs/ops/teqo-1313-deploy.md §C155/§C160.`, ) } @@ -884,7 +1190,8 @@ async function main() { process.exit(0) } - const readOnly = options.coverage || options.verifyLinks !== null + const readOnly = + options.coverage || options.verifyLinks !== null || (options.repairLinks && options.dryRun) if (readOnly) { if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.') } else { @@ -893,7 +1200,7 @@ async function main() { assertWriteAllowed() assertLocalDatabase( 'camara:import', - 'O import escreve no acervo; produção exige CAMARA_IMPORT_CONFIRM=1 (runbook §C155).', + 'O import escreve no acervo; produção exige CAMARA_IMPORT_CONFIRM=1 (runbook §C155/§C160).', ) } console.log(`[camara:import] alvo: ${databaseTarget()} | modo: ${modeLabel(options)}`) @@ -901,6 +1208,7 @@ async function main() { const payload = await getPayload({ config }) if (options.coverage) await runCoverage(payload, options) else if (options.verifyLinks !== null) await runVerifyLinks(payload, options) + else if (options.repairLinks) await runRepairLinks(payload, options) else if (options.all) await runBackfill(payload, options) else await runSingleRange(payload, options) process.exit(0) diff --git a/scripts/lib/camaraFetch.mjs b/scripts/lib/camaraFetch.mjs index 8bc8ce2d..97a6cf97 100644 --- a/scripts/lib/camaraFetch.mjs +++ b/scripts/lib/camaraFetch.mjs @@ -6,11 +6,15 @@ * network. */ import { + buildOfficialPdfUrl, buildVodUrl, CAMARA_USER_AGENT, DEEPINFRA_TRANSCRIBE_URL, DEEPINFRA_WHISPER_MODEL, normalizeTranscription, + parseDirectOfficialPdfUrl, + parseLegacyOfficialUrl, + parseMontaPdfUrl, parseVodStatus, } from './camaraSpeeches.mjs' @@ -116,18 +120,19 @@ export async function resolveVod(eventId, excerpt) { } /** - * Lightweight reachability probe of one stored VOD link (C155 sample check): - * GET with a 1 KiB `Range` so the MP4 body never streams, falling back to HEAD - * when the server refuses the ranged GET (405/501). One retry absorbs the - * CDN's transient connection resets; it never throws — a network error becomes - * an `ok: false` result so one dead link cannot abort the report. + * Lightweight reachability probe of one stored Câmara link (C155 VOD sample + * check + C160 official-PDF check): GET with a 1 KiB `Range` so the body never + * streams, falling back to HEAD when the server refuses the ranged GET + * (405/501). One retry absorbs the CDN's transient connection resets; it never + * throws — a network error becomes an `ok: false` result so one dead link + * cannot abort the report. * * @param {string | null | undefined} url * @param {{ timeoutMs?: number, attempts?: number }} [options] - * @returns {Promise<{ url: string | null, status: number | null, ok: boolean, note: string }>} + * @returns {Promise<{ url: string | null, status: number | null, contentType: string | null, ok: boolean, note: string }>} */ -export async function probeVodLink(url, { timeoutMs = 20_000, attempts = 2 } = {}) { - if (!url) return { url: null, status: null, ok: false, note: 'sem link' } +export async function probeLink(url, { timeoutMs = 20_000, attempts = 2 } = {}) { + if (!url) return { url: null, status: null, contentType: null, ok: false, note: 'sem link' } const request = async (method) => { const response = await fetch(url, { @@ -141,7 +146,7 @@ export async function probeVodLink(url, { timeoutMs = 20_000, attempts = 2 } = { const contentType = response.headers.get('content-type') const note = `HTTP ${response.status}${contentType ? ` ${contentType}` : ''}` await response.body?.cancel().catch(() => undefined) - return { url, status: response.status, ok: response.ok, note } + return { url, status: response.status, contentType, ok: response.ok, note } } let lastError @@ -155,7 +160,57 @@ export async function probeVodLink(url, { timeoutMs = 20_000, attempts = 2 } = { if (attempt < attempts) await sleep(1_000) } } - return { url, status: null, ok: false, note: lastError?.message ?? String(lastError) } + return { + url, + status: null, + contentType: null, + ok: false, + note: lastError?.message ?? String(lastError), + } +} + +/** + * Follows the legacy Diário visualizer redirect to the direct PDF (C160). The + * chain measured on 2026-09-14: `dc_20b.asp` 302 → `montaPdf.asp?narquivo=…` + * (~5s on `.leg.br`; the `.gov.br` host adds a slow ~135s hop, so the lookup + * starts at `.leg.br`), and `response.url` is the `montaPdf` URL carrying + * `narquivo`/`npagina` — no HTML parsing. Fails closed when the chain does not + * land on a Câmara `montaPdf`/PDF URL. + * + * @param {string} legacyUrl + * @param {{ timeoutMs?: number, attempts?: number }} [options] + * @returns {Promise<{ archiveFileName: string, page: number | null, pdfUrl: string, finalUrl: string }>} + */ +export async function resolveOfficialPdfUrl(legacyUrl, { timeoutMs = 180_000, attempts = 2 } = {}) { + const parsedLookup = parseLegacyOfficialUrl(legacyUrl) + if (!parsedLookup) throw new Error(`link oficial não reconhecido: ${String(legacyUrl ?? '')}`) + const lookupUrl = parsedLookup.lookupUrl + + let lastError + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const response = await fetch(lookupUrl, { + redirect: 'follow', + headers: { 'User-Agent': CAMARA_USER_AGENT }, + signal: AbortSignal.timeout(timeoutMs), + }) + const finalUrl = response.url + await response.body?.cancel().catch(() => undefined) + if (!response.ok) throw new Error(`HTTP ${response.status} em ${lookupUrl}`) + + const parsed = parseMontaPdfUrl(finalUrl) ?? parseDirectOfficialPdfUrl(finalUrl) + if (!parsed) throw new Error(`redirect final inesperado em ${finalUrl}`) + return { + ...parsed, + pdfUrl: buildOfficialPdfUrl(parsed.archiveFileName, parsed.page), + finalUrl, + } + } catch (error) { + lastError = error + if (attempt < attempts) await sleep(3_000) + } + } + throw lastError } export const DEEPINFRA_COST_PER_MINUTE_USD = 0.00045 diff --git a/scripts/lib/camaraSpeeches.mjs b/scripts/lib/camaraSpeeches.mjs index 7605d9a6..393ddb49 100644 --- a/scripts/lib/camaraSpeeches.mjs +++ b/scripts/lib/camaraSpeeches.mjs @@ -477,6 +477,289 @@ export const aggregateBackfillRuns = (runs) => { } } +// --------------------------------------------------------------------------- +// Official Diário link (C160) +// --------------------------------------------------------------------------- + +const CAMARA_IMAGEM_HOSTS = new Set(['imagem.camara.leg.br', 'imagem.camara.gov.br']) +const LEGACY_DIARIO_PATHS = new Set(['/dc_20b.asp', '/dc_20.asp']) +const MONTA_PDF_PATH = '/montaPdf.asp' +const DIRECT_PDF_PATH = /^\/Imagem\/d\/pdf\/([^/]+\.pdf)$/i + +/** Absolute or Câmara-relative URL of a Diário link; null when unparseable. */ +const parseDiarioUrl = (raw) => { + const value = String(raw ?? '').trim() + if (value === '') return null + try { + const url = new URL(value, 'https://imagem.camara.leg.br/') + return url.protocol === 'https:' || url.protocol === 'http:' ? url : null + } catch { + return null + } +} + +const queryValue = (url, ...names) => { + for (const name of names) { + const value = url.searchParams.get(name) + if (value !== null && value.trim() !== '') return value.trim() + } + return null +} + +const positiveIntegerOrNull = (value) => { + const number = Number(value) + return Number.isInteger(number) && number > 0 ? number : null +} + +/** + * `Datain` spelling (`25/6/2020`, URL-decoded) → `2020-06-25`; null when the + * day/month/year is not a real date. + * + * @param {unknown} value + * @returns {string | null} + */ +export const parseDiarioDate = (value) => { + const match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})/.exec(String(value ?? '').trim()) + if (!match) return null + const day = Number(match[1]) + const month = Number(match[2]) + const year = Number(match[3]) + if (day < 1 || month < 1 || month > 12) return null + const parsed = new Date(Date.UTC(year, month - 1, day)) + if (parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) return null + return `${match[3]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` +} + +/** + * True when the URL is one of the legacy Diário endpoints + * (`dc_20b.asp`/`dc_20.asp`/`montaPdf.asp` on the Câmara image host), even when + * its `Datain`/`narquivo` is malformed. The write paths must never store one of + * these. + * + * @param {unknown} raw + * @returns {boolean} + */ +export const isLegacyDiarioUrl = (raw) => { + const url = parseDiarioUrl(raw) + return Boolean( + url && + CAMARA_IMAGEM_HOSTS.has(url.hostname) && + (LEGACY_DIARIO_PATHS.has(url.pathname) || url.pathname === MONTA_PDF_PATH), + ) +} + +/** + * Parses the legacy Diário visualizer URL (`dc_20b.asp`/`dc_20.asp`) into what + * the resolver needs: the `.leg.br` lookup URL (the `.gov.br` hop alone measured + * ~135s on 2026-09-14), the publication identity that keys the cache and the + * speech page. Null when the URL is not a legacy Diário link with a parseable + * `Datain`. + * + * @param {unknown} raw + * @returns {{ lookupUrl: string, publication: { date: string, collection: string | null, supplement: string | null }, page: number | null } | null} + */ +export const parseLegacyOfficialUrl = (raw) => { + const url = parseDiarioUrl(raw) + if (!url || !CAMARA_IMAGEM_HOSTS.has(url.hostname) || !LEGACY_DIARIO_PATHS.has(url.pathname)) { + return null + } + const date = parseDiarioDate(queryValue(url, 'Datain')) + if (date === null) return null + const lookupUrl = new URL(url) + if (lookupUrl.hostname === 'imagem.camara.gov.br') lookupUrl.hostname = 'imagem.camara.leg.br' + return { + lookupUrl: lookupUrl.toString(), + publication: { + date, + collection: queryValue(url, 'selCodColecaoCsv', 'selCodColecao'), + supplement: queryValue(url, 'txSuplemento'), + }, + page: positiveIntegerOrNull(queryValue(url, 'txPagina')), + } +} + +/** + * Parses the intermediate redirect target + * (`montaPdf.asp?narquivo=DCD….PDF&npagina=73`) into the PDF archive file name + * and the page. Null when the URL is not a `montaPdf` link or the `narquivo` is + * not a safe file name (that value goes into a URL path, never into HTML). + * + * @param {unknown} raw + * @returns {{ archiveFileName: string, page: number | null } | null} + */ +export const parseMontaPdfUrl = (raw) => { + const url = parseDiarioUrl(raw) + if (!url || !CAMARA_IMAGEM_HOSTS.has(url.hostname) || url.pathname !== MONTA_PDF_PATH) { + return null + } + const archiveFileName = queryValue(url, 'narquivo') + if (archiveFileName === null || !/^[A-Za-z0-9._-]+$/.test(archiveFileName)) return null + return { archiveFileName, page: positiveIntegerOrNull(queryValue(url, 'npagina')) } +} + +/** + * The direct official PDF URL, with `#page=` when the page is known; null + * when the archive file name is empty. + * + * @param {unknown} archiveFileName + * @param {unknown} [page] + * @returns {string | null} + */ +export const buildOfficialPdfUrl = (archiveFileName, page = null) => { + const name = String(archiveFileName ?? '').trim() + if (name === '') return null + const base = `https://imagem.camara.leg.br/Imagem/d/pdf/${encodeURIComponent(name)}` + const pageNumber = positiveIntegerOrNull(page) + return pageNumber === null ? base : `${base}#page=${pageNumber}` +} + +/** + * Parses an already-direct official PDF URL (the format the import writes) + * back into `{ archiveFileName, page }`. The page comes from the `#page=` + * fragment; null when the URL is not a Câmara PDF. + * + * @param {unknown} raw + * @returns {{ archiveFileName: string, page: number | null } | null} + */ +export const parseDirectOfficialPdfUrl = (raw) => { + const url = parseDiarioUrl(raw) + if (!url || !CAMARA_IMAGEM_HOSTS.has(url.hostname)) return null + const match = DIRECT_PDF_PATH.exec(url.pathname) + if (!match) return null + const pageFromHash = /^#page=(\d+)$/.exec(url.hash) + return { archiveFileName: match[1], page: positiveIntegerOrNull(pageFromHash?.[1]) } +} + +/** + * `none` (no link), `direct` (already the PDF), `legacy` (`dc_20b`/`dc_20` with + * a parseable date), `montaPdf` (the intermediate redirect) or `unknown` + * (anything else). Only `legacy` needs the network; `montaPdf` is converted + * offline. + * + * @param {unknown} raw + * @returns {'none' | 'direct' | 'legacy' | 'montaPdf' | 'unknown'} + */ +export const classifyOfficialTextUrl = (raw) => { + if (String(raw ?? '').trim() === '') return 'none' + const url = parseDiarioUrl(raw) + if (!url || !CAMARA_IMAGEM_HOSTS.has(url.hostname)) return 'unknown' + if (LEGACY_DIARIO_PATHS.has(url.pathname)) { + return parseLegacyOfficialUrl(raw) ? 'legacy' : 'unknown' + } + if (url.pathname === MONTA_PDF_PATH) return parseMontaPdfUrl(raw) ? 'montaPdf' : 'unknown' + if (parseDirectOfficialPdfUrl(raw)) return 'direct' + return 'unknown' +} + +/** + * What the write paths keep when resolution fails: a previously stored direct + * PDF (never a legacy link), or null so the UI falls back to YouTube/hides the + * button. + * + * @param {unknown} existingUrl + * @returns {string | null} + */ +export const officialTextUrlFallback = (existingUrl) => + classifyOfficialTextUrl(existingUrl) === 'direct' ? String(existingUrl).trim() : null + +/** + * Filesystem-safe cache key of one Diário publication: date + collection + * (`D`, `S`, …) + supplement. One network resolution per key, ever. + * + * @param {{ date?: string, collection?: string | null, supplement?: string | null } | null | undefined} publication + * @returns {string | null} + */ +export const publicationCacheKey = (publication) => { + const date = String(publication?.date ?? '').trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null + const collection = String(publication?.collection ?? '').trim() + const supplement = String(publication?.supplement ?? '').trim() + const key = [ + date, + ...(collection === '' ? [] : [collection]), + ...(supplement === '' ? [] : [`s${supplement}`]), + ].join('-') + return key.replace(/[^A-Za-z0-9._-]+/g, '_') +} + +/** + * Splits scanned speeches into the repair work plan: `groups` are legacy URLs + * sharing one publication (one resolution each), `directUpdates` are + * `montaPdf` links convertible offline, `alreadyDirect`/`none` are untouched, + * `unresolvable` are malformed legacy links that must not stay (they fail the + * write run) and `unknown` is reported. Pure — the caller owns resolution and + * writes. + * + * @param {Array<{ id: number, officialTextUrl?: string | null, speechAt?: string | null, legislature?: string | null }> | null | undefined} rows + * @returns {{ + * groups: Array<{ key: string, publication: { date: string, collection: string | null, supplement: string | null }, lookupUrl: string, rows: Array<{ id: number, page: number | null, previousUrl: string, speechAt: string | null, legislature: string | null }> }>, + * directUpdates: Array<{ id: number, previousUrl: string, nextUrl: string, speechAt: string | null, legislature: string | null }>, + * alreadyDirect: number, + * unknown: Array<{ id: number, previousUrl: string, speechAt: string | null, legislature: string | null }>, + * unresolvable: Array<{ id: number, previousUrl: string, speechAt: string | null, legislature: string | null }>, + * none: number, + * }} + */ +export const planOfficialLinkRepairs = (rows) => { + const groups = new Map() + const directUpdates = [] + const unknown = [] + const unresolvable = [] + let alreadyDirect = 0 + let none = 0 + + for (const row of Array.isArray(rows) ? rows : []) { + const raw = row?.officialTextUrl + const kind = classifyOfficialTextUrl(raw) + const meta = { + id: row?.id, + previousUrl: String(raw ?? '').trim(), + speechAt: row?.speechAt ?? null, + legislature: row?.legislature ?? null, + } + if (kind === 'none') { + none += 1 + continue + } + if (kind === 'direct') { + alreadyDirect += 1 + continue + } + if (kind === 'montaPdf') { + const parsed = parseMontaPdfUrl(raw) + directUpdates.push({ + ...meta, + nextUrl: buildOfficialPdfUrl(parsed.archiveFileName, parsed.page), + }) + continue + } + if (kind === 'legacy') { + const parsed = parseLegacyOfficialUrl(raw) + const key = publicationCacheKey(parsed.publication) + const group = groups.get(key) ?? { + key, + publication: parsed.publication, + lookupUrl: parsed.lookupUrl, + rows: [], + } + group.rows.push({ ...meta, page: parsed.page }) + groups.set(key, group) + continue + } + if (isLegacyDiarioUrl(raw)) unresolvable.push(meta) + else unknown.push(meta) + } + + return { + groups: [...groups.values()].sort((left, right) => left.key.localeCompare(right.key)), + directUpdates, + alreadyDirect, + unknown, + unresolvable, + none, + } +} + /** * Deterministic VOD-link sample for the `--verify-links` mode: up to * `perLegislature` speeches per legislature that actually carry a VOD link, diff --git a/src/utilities/speech/speechImport.ts b/src/utilities/speech/speechImport.ts index a8d61961..c5a00aa2 100644 --- a/src/utilities/speech/speechImport.ts +++ b/src/utilities/speech/speechImport.ts @@ -59,6 +59,7 @@ export type SpeechImportState = { vodDownloadUrl: string | null summary: string | null officialTranscript: string | null + officialTextUrl: string | null } export type SpeechUpsertCounts = { @@ -87,6 +88,7 @@ export const findSpeechImportState = async ( vodDownloadUrl: true, summary: true, officialTranscript: true, + officialTextUrl: true, }, // Intentional bypass: the import CLI is a trusted actor with no session. overrideAccess: true, @@ -112,6 +114,7 @@ export const findSpeechImportState = async ( vodDownloadUrl: speech.vodDownloadUrl ?? null, summary: speech.summary ?? null, officialTranscript: speech.officialTranscript ?? null, + officialTextUrl: speech.officialTextUrl ?? null, } } diff --git a/tests/int/speechImport.int.spec.ts b/tests/int/speechImport.int.spec.ts index e2d3373f..46df52e9 100644 --- a/tests/int/speechImport.int.spec.ts +++ b/tests/int/speechImport.int.spec.ts @@ -168,7 +168,9 @@ describe('upsertSpeechBundle (C153)', () => { }) it('reports the stored import state for skip decisions', async () => { - const bundle = baseBundle() + const officialTextUrl = + 'https://imagem.camara.leg.br/Imagem/d/pdf/DCD0020230208000210000.PDF#page=73' + const bundle = baseBundle({ officialTextUrl }) await upsertSpeechBundle(payload, bundle) const state = await findSpeechImportState(payload, bundle.sourceKey) @@ -177,6 +179,7 @@ describe('upsertSpeechBundle (C153)', () => { excerptTMs: 1675801808560, segmentCount: 1, classifiedBy: 'gazetteer', + officialTextUrl, }) expect(await findSpeechImportState(payload, 'test-import-missing')).toBeNull() }) diff --git a/tests/unit/camaraSpeeches.unit.spec.ts b/tests/unit/camaraSpeeches.unit.spec.ts index 1f04c7e6..97280120 100644 --- a/tests/unit/camaraSpeeches.unit.spec.ts +++ b/tests/unit/camaraSpeeches.unit.spec.ts @@ -4,18 +4,28 @@ import { describe, expect, it } from 'vitest' import { aggregateBackfillRuns, + buildOfficialPdfUrl, buildVodUrl, + classifyOfficialTextUrl, clockToSeconds, dateRangeChunks, + isLegacyDiarioUrl, legislatureForDate, matchExcerpt, normalizeSpeakerName, normalizeTranscription, + officialTextUrlFallback, + parseDiarioDate, + parseDirectOfficialPdfUrl, parseDurationToSeconds, parseEventExcerpts, + parseLegacyOfficialUrl, + parseMontaPdfUrl, parseOfficialKeywords, parsePresidingOfficerTransitions, parseVodStatus, + planOfficialLinkRepairs, + publicationCacheKey, resolvePresidingOfficer, selectLinkSample, selectSpeechEvents, @@ -668,3 +678,240 @@ describe('dateRangeChunks', () => { ]) }) }) + +// Real URLs measured on 2026-09-14 (see docs/plans/acervo-abrir-fonte-pdf-oficial.md). +const legacyGovUrl = + 'https://imagem.camara.gov.br/dc_20b.asp?largura=&altura=&tipoForm=diarios' + + '&selCodColecaoCsv=D&Datain=8%2F2%2F2023&txPagina=73&txSuplemento=&enviar=Pesquisar' +const legacyGovUrl75 = legacyGovUrl.replace('txPagina=73', 'txPagina=75') +const montaPdfUrl = + 'https://imagem.camara.leg.br/montaPdf.asp?narquivo=DCD0020230208000210000.PDF&npagina=73' +const directPdfUrl = 'https://imagem.camara.leg.br/Imagem/d/pdf/DCD0020230208000210000.PDF' + +describe('parseDiarioDate', () => { + it('parses the Datain spelling into ISO', () => { + expect(parseDiarioDate('25/6/2020')).toBe('2020-06-25') + expect(parseDiarioDate('8/2/2023')).toBe('2023-02-08') + expect(parseDiarioDate('28/09/2011')).toBe('2011-09-28') + }) + + it('rejects what is not a real date', () => { + expect(parseDiarioDate('2023-02-08')).toBeNull() + expect(parseDiarioDate('32/1/2023')).toBeNull() + expect(parseDiarioDate('31/2/2023')).toBeNull() + expect(parseDiarioDate('1/13/2023')).toBeNull() + expect(parseDiarioDate('')).toBeNull() + }) +}) + +describe('isLegacyDiarioUrl', () => { + it('recognizes the legacy endpoints even with malformed params', () => { + expect(isLegacyDiarioUrl(legacyGovUrl)).toBe(true) + expect(isLegacyDiarioUrl(montaPdfUrl)).toBe(true) + expect(isLegacyDiarioUrl('https://imagem.camara.leg.br/dc_20b.asp?Datain=xx')).toBe(true) + }) + + it('rejects direct PDFs and foreign URLs', () => { + expect(isLegacyDiarioUrl(directPdfUrl)).toBe(false) + expect(isLegacyDiarioUrl('https://example.com/dc_20b.asp?Datain=8/2/2023')).toBe(false) + expect(isLegacyDiarioUrl(null)).toBe(false) + }) +}) + +describe('parseLegacyOfficialUrl', () => { + it('extracts publication, page and a .leg.br lookup URL from the legacy link', () => { + expect(parseLegacyOfficialUrl(legacyGovUrl)).toEqual({ + lookupUrl: + 'https://imagem.camara.leg.br/dc_20b.asp?largura=&altura=&tipoForm=diarios' + + '&selCodColecaoCsv=D&Datain=8%2F2%2F2023&txPagina=73&txSuplemento=&enviar=Pesquisar', + publication: { date: '2023-02-08', collection: 'D', supplement: null }, + page: 73, + }) + }) + + it('keeps a .leg.br host and reports a missing page', () => { + const parsed = parseLegacyOfficialUrl( + 'https://imagem.camara.leg.br/dc_20.asp?Datain=1/3/2023&txPagina=', + ) + expect(parsed?.lookupUrl).toContain('imagem.camara.leg.br') + expect(parsed?.page).toBeNull() + }) + + it('returns null for non-legacy or dateless URLs', () => { + expect(parseLegacyOfficialUrl(directPdfUrl)).toBeNull() + expect(parseLegacyOfficialUrl('https://imagem.camara.leg.br/dc_20b.asp?Datain=xx')).toBeNull() + expect(parseLegacyOfficialUrl(null)).toBeNull() + }) +}) + +describe('parseMontaPdfUrl', () => { + it('parses the relative redirect target and the absolute one', () => { + expect(parseMontaPdfUrl('montaPdf.asp?narquivo=DCD0020230208000210000.PDF&npagina=73')).toEqual( + { + archiveFileName: 'DCD0020230208000210000.PDF', + page: 73, + }, + ) + expect(parseMontaPdfUrl(montaPdfUrl)).toEqual({ + archiveFileName: 'DCD0020230208000210000.PDF', + page: 73, + }) + expect(parseMontaPdfUrl('montaPdf.asp?narquivo=DCD0020230208000210000.PDF')?.page).toBeNull() + }) + + it('rejects an unsafe archive name and non-montaPdf URLs', () => { + expect(parseMontaPdfUrl('montaPdf.asp?narquivo=../../etc/passwd&npagina=1')).toBeNull() + expect(parseMontaPdfUrl(directPdfUrl)).toBeNull() + }) +}) + +describe('buildOfficialPdfUrl', () => { + it('builds the direct PDF URL with the page fragment when known', () => { + expect(buildOfficialPdfUrl('DCD0020230208000210000.PDF', 73)).toBe( + 'https://imagem.camara.leg.br/Imagem/d/pdf/DCD0020230208000210000.PDF#page=73', + ) + expect(buildOfficialPdfUrl('DCD28SET2011.pdf')).toBe( + 'https://imagem.camara.leg.br/Imagem/d/pdf/DCD28SET2011.pdf', + ) + }) + + it('omits an invalid page and refuses an empty archive name', () => { + expect(buildOfficialPdfUrl('DCD0020230208000210000.PDF', 0)).toBe(directPdfUrl) + expect(buildOfficialPdfUrl('', 10)).toBeNull() + }) +}) + +describe('parseDirectOfficialPdfUrl', () => { + it('parses the archive name and the page fragment', () => { + expect(parseDirectOfficialPdfUrl(`${directPdfUrl}#page=73`)).toEqual({ + archiveFileName: 'DCD0020230208000210000.PDF', + page: 73, + }) + expect(parseDirectOfficialPdfUrl(directPdfUrl)).toEqual({ + archiveFileName: 'DCD0020230208000210000.PDF', + page: null, + }) + expect( + parseDirectOfficialPdfUrl('https://imagem.camara.leg.br/Imagem/d/pdf/DCD28SET2011.pdf'), + ).toEqual({ archiveFileName: 'DCD28SET2011.pdf', page: null }) + }) + + it('returns null outside the Câmara PDF path', () => { + expect(parseDirectOfficialPdfUrl(legacyGovUrl)).toBeNull() + expect(parseDirectOfficialPdfUrl('https://example.com/Imagem/d/pdf/x.PDF')).toBeNull() + }) +}) + +describe('classifyOfficialTextUrl', () => { + it('classifies none, direct, legacy and montaPdf', () => { + expect(classifyOfficialTextUrl(null)).toBe('none') + expect(classifyOfficialTextUrl(' ')).toBe('none') + expect(classifyOfficialTextUrl(directPdfUrl)).toBe('direct') + expect(classifyOfficialTextUrl(`${directPdfUrl}#page=129`)).toBe('direct') + expect(classifyOfficialTextUrl(legacyGovUrl)).toBe('legacy') + expect(classifyOfficialTextUrl(montaPdfUrl)).toBe('montaPdf') + }) + + it('classifies everything else as unknown', () => { + expect(classifyOfficialTextUrl('https://example.com/discurso')).toBe('unknown') + expect(classifyOfficialTextUrl('https://imagem.camara.leg.br/outro.asp?x=1')).toBe('unknown') + expect(classifyOfficialTextUrl('https://imagem.camara.leg.br/dc_20b.asp?Datain=xx')).toBe( + 'unknown', + ) + expect(classifyOfficialTextUrl('lixo')).toBe('unknown') + }) +}) + +describe('officialTextUrlFallback', () => { + it('preserves a stored direct PDF and nulls everything else', () => { + expect(officialTextUrlFallback(`${directPdfUrl}#page=73`)).toBe(`${directPdfUrl}#page=73`) + expect(officialTextUrlFallback(legacyGovUrl)).toBeNull() + expect(officialTextUrlFallback(montaPdfUrl)).toBeNull() + expect(officialTextUrlFallback(null)).toBeNull() + }) +}) + +describe('publicationCacheKey', () => { + it('keys by date, collection and supplement', () => { + expect(publicationCacheKey({ date: '2023-02-08', collection: 'D', supplement: null })).toBe( + '2023-02-08-D', + ) + expect(publicationCacheKey({ date: '2020-06-25', collection: 'D', supplement: '1' })).toBe( + '2020-06-25-D-s1', + ) + expect(publicationCacheKey({ date: '2023-02-08', collection: null, supplement: '' })).toBe( + '2023-02-08', + ) + }) + + it('returns null without a valid date', () => { + expect(publicationCacheKey({ date: '08/02/2023', collection: 'D' })).toBeNull() + expect(publicationCacheKey(null)).toBeNull() + }) +}) + +describe('planOfficialLinkRepairs', () => { + it('groups legacy rows by publication and separates the other classes', () => { + const malformedLegacyUrl = 'https://imagem.camara.leg.br/dc_20b.asp?Datain=xx' + const plan = planOfficialLinkRepairs([ + { + id: 1, + officialTextUrl: legacyGovUrl, + speechAt: '2023-02-08T10:00', + legislature: '57', + }, + { + id: 2, + officialTextUrl: legacyGovUrl75, + speechAt: '2023-02-08T11:00', + legislature: '57', + }, + { id: 3, officialTextUrl: directPdfUrl, speechAt: null, legislature: null }, + { id: 4, officialTextUrl: null, speechAt: null, legislature: null }, + { id: 5, officialTextUrl: montaPdfUrl, speechAt: null, legislature: null }, + { id: 6, officialTextUrl: 'https://example.com/discurso', speechAt: null, legislature: null }, + { id: 7, officialTextUrl: malformedLegacyUrl }, + ]) + + expect(plan.groups).toHaveLength(1) + expect(plan.groups[0].key).toBe('2023-02-08-D') + expect(plan.groups[0].publication).toEqual({ + date: '2023-02-08', + collection: 'D', + supplement: null, + }) + expect(plan.groups[0].rows.map((row: { id: number }) => row.id)).toEqual([1, 2]) + expect(plan.groups[0].rows.map((row: { page: number | null }) => row.page)).toEqual([73, 75]) + expect(plan.directUpdates).toEqual([ + { + id: 5, + previousUrl: montaPdfUrl, + nextUrl: `${directPdfUrl}#page=73`, + speechAt: null, + legislature: null, + }, + ]) + expect(plan.alreadyDirect).toBe(1) + expect(plan.none).toBe(1) + expect(plan.unknown.map((row: { id: number }) => row.id)).toEqual([6]) + expect(plan.unresolvable).toEqual([ + { + id: 7, + previousUrl: malformedLegacyUrl, + speechAt: null, + legislature: null, + }, + ]) + }) + + it('tolerates an empty scan', () => { + expect(planOfficialLinkRepairs(null)).toEqual({ + groups: [], + directUpdates: [], + alreadyDirect: 0, + unknown: [], + unresolvable: [], + none: 0, + }) + }) +})