mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
c2bd982b8d
commit
e7369839aa
7 changed files with 1126 additions and 24 deletions
203
docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md
Normal file
203
docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md
Normal 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/><out>/diario/<publicação>.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).
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue