feat(C160): link oficial do acervo vira PDF direto do Diário (#988)

- puros: classify/parse da URL legada (dc_20b/dc_20, montaPdf), build do PDF
  direto com #page, cache key por publicação, plano de reparo e fallback
- camaraFetch: resolveOfficialPdfUrl segue o redirect via .leg.br (fail-closed)
  e probeLink generalizado (Range + contentType)
- import: normaliza officialTextUrl no write path (cache <out>/diario por
  publicação; falha preserva PDF direto anterior, senão null)
- novo modo --repair-links [--dry-run] com guard CAMARA_IMPORT_CONFIRM fora de
  alvo local, relatório de cobertura e idempotência
This commit is contained in:
Francisco Solla 2026-09-14 03:45:57 -03:00
parent c2bd982b8d
commit e7369839aa
7 changed files with 1126 additions and 24 deletions

View file

@ -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/<narquivo>` (`#page=<página>` 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<br/>urlTexto = dc_20b.asp"] --> B["classify/parse puro<br/>camaraSpeeches.mjs"]
B -->|legacy| C["resolveOfficialPdfUrl<br/>.leg.br → 302 montaPdf<br/>camaraFetch.mjs"]
C --> P["probeLink Range<br/>200/206 application/pdf"]
P --> D["ensureCachedDownload json<br/>&lt;out&gt;/diario/&lt;publicação&gt;.json"]
D --> E["buildOfficialPdfUrl<br/>#page=txPagina"]
B -->|direct / montaPdf| E
B -->|falha| F["null → fallback<br/>youtubeUrl / botão oculto"]
E --> G[("speech.officialTextUrl")]
end
subgraph REPAIR["Reparo one-off — --repair-links"]
H["scan speech<br/>officialTextUrl não-nulo"] --> I["planOfficialLinkRepairs (puro)<br/>agrupa por publicação"]
I --> J["resolve 1×/publicação<br/>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<br/>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: <out>/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 <n>`; 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/<archiveFileName>[#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 `<out>/diario`; falha → `null` preservando direto anterior; `report.officialLink`; falha `stage:'official-link'`); `runRepairLinks` (scan/plan/resolve/update/relatório `repair-links-<runAt>.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-<runAt>.json`, `<out>/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 <dia real> --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).

View file

@ -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 `<out>/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 <YYYY-MM-DD> (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 <YYYY-MM-DD> (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 <n>] [opções]
Uso: pnpm camara:import [--all | --legislature 54|55|56|57 | --date YYYY-MM-DD | --coverage | --verify-links <n> | --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 <n> 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 <out>/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 <n> 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 <out>/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 `<out>/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<string>}
*/
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)

View file

@ -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

View file

@ -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=<n>` 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=<n>`
* 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,

View file

@ -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,
}
}

View file

@ -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()
})

View file

@ -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,
})
})
})