fix(C160): normaliza coleções obsoletas (J/vazia) do link legado para D

A varredura de produção (2026-09-14) achou 33 falas em selCodColecaoCsv=J e 1
com o parâmetro vazio: o endpoint devolve 'Documento não encontrado' e a mesma
data/página resolve na coleção D que a busca atual da Câmara gera. Aliases
J/vazio → D, puros e unit-testados; DCN e demais códigos ficam como estão.
This commit is contained in:
Francisco Solla 2026-09-14 04:22:47 -03:00
parent e7369839aa
commit 03d1b79629
4 changed files with 115 additions and 1 deletions

View file

@ -523,6 +523,67 @@ DELETE FROM "speech" WHERE "legislature" = '55';
Para desfazer o acervo inteiro: `DELETE FROM "speech_segment"; DELETE FROM "speech";`
(a cobertura `--coverage` volta a zero). Reexecutar `--all` reconstrói.
## C160 — reparo do link oficial do acervo (PDF direto do Diário)
Operação de dados do acervo de falas (C153/C154/C155): troca o
`officialTextUrl` legado (`dc_20b.asp`/`dc_20.asp`/`montaPdf.asp`, que pendura
no browser) pelo PDF direto do Diário
(`https://imagem.camara.leg.br/Imagem/d/pdf/<arquivo>#page=<página>`). É
script-only (nenhuma migration): resolve uma vez por publicação
(`<out>/diario/`), é idempotente e reporta cobertura (falas/datas atualizadas,
falhas e legado restante).
**Pré-requisitos:** o SHA do branch/commit com o C160 (a passada original rodou
de `~/teqo-backfill` no SHA `766e4ae9`, 2026-09-14); `~/stack/.env` +
`~/stack/teqo-1313.env`; `~/teqo-backfill` com `pnpm install` no SHA. O
`git fetch` do homeserver alcança o GitHub pelo bundle/com o remote disponível
(na passada original o clone apontava para repo local e usou-se `git fetch
~/c160.bundle <branch>`).
```bash
ssh homeserver
source ~/.nvm/nvm.sh
cd ~/teqo-backfill && git fetch origin && git checkout <SHA> && pnpm install
set -a; source ~/stack/.env; set +a
set -a; source ~/stack/teqo-1313.env; set +a
export DATABASE_URL="${DATABASE_URL/@postgres:5432/@127.0.0.1:5433}"
export CAMARA_IMPORT_CONFIRM=1 # guard C160: escrita em produção
# dry-run (read-only; aquece o cache por publicação em <out>/diario):
pnpm camara:import --repair-links --dry-run --out /srv/hdd/backups/teqo-camara
# escrita (idempotente; 2ª passada = 0 atualizações):
pnpm camara:import --repair-links --out /srv/hdd/backups/teqo-camara
# validação (0 legadas restantes):
pnpm camara:import --repair-links --dry-run --out /srv/hdd/backups/teqo-camara
```
O guard `CAMARA_IMPORT_CONFIRM=1` é exigido sempre que a escrita não é
provadamente local (`NODE_ENV=production`, host remoto ou `ALLOW_REMOTE_DB`);
`--dry-run` é read-only e não exige a flag. A escrita é confinada a
`speech.officialTextUrl`; VOD, transcrições e segmentos não são tocados.
**Comportamento medido da Câmara (2026-09-14):** o legado `.gov.br` responde
302 para `.leg.br` (hop lento, 25–135s) e o `.leg.br` responde 302 para
`montaPdf.asp?narquivo=…&npagina=…` (~5s); o PDF direto responde 200/206
`application/pdf` em <1s. O resolver reescreve `.gov.br`→`.leg.br` no lookup,
segue o redirect e parseia o `montaPdf`; a resolução é cacheada uma vez por
publicação (`diario/<data>-<coleção>[-s<suplemento>].json`) e cada PDF é
provado com GET Range antes de gravar. Falha de resolução deixa a fala
intocada, o run sai 1 e a próxima passada é idempotente.
**Resultado registrado (2026-09-14):** 965 falas legadas em 510 publicações
resolvidas; 0 legadas restantes na validação; 32 falas sem link (fallback
YouTube/oculto). Relatórios em
`/srv/hdd/backups/teqo-camara/reports/repair-links-*.json` e changelog
`docs/changelog/2026-09-14-c160.md`.
### Rollback
Não há migração; o reparo só reescreve `speech.officialTextUrl` e o valor
anterior de cada fala está em `updates[].previousUrl` no JSON do run. O import
(`pnpm camara:import --date <dia>`) reconstrói o link a partir da API (a fonte
é a URL legada devolvida pela Câmara); reexecutar o reparo é idempotente.
## Referências
- `scripts/deploy-homeserver.sh` — o script (fonte da verdade do fluxo; parametrizado por `TEQO_ENV`)

View file

@ -165,6 +165,12 @@ Sem migration, sem schema, sem access, sem UI, sem package.json (`pnpm camara:im
- Refatorar `parseArgs` inteiro ou promover o resolver a utility `src/` — **não**; dono é `scripts/`.
- Commitar cache/relatórios — **não** (`data/camara/` gitignored).
### Adiado com gatilho (triage do simplify)
- **Conversão `montaPdf` duplicada** (2 call sites: `resolveOfficialTextUrl` e `planOfficialLinkRepairs`): extrair um helper puro no lib **quando um 3º call site aparecer** ou a regra mudar.
- **Wiring do fallback do import** (preservar PDF direto anterior em falha de resolução) coberto pelo unit de `officialTextUrlFallback`, não pela closure do script: extrair o resolvedor com deps injetadas **se o caminho de falha ganhar um modo novo** ou o fallback for modificado.
- **Sanitize da cache key** (`publicationCacheKey`) funde códigos de coleção com caracteres não previstos (`_`): revisitar **se aparecer coleção real fora de `[A-Za-z0-9]`**.
## Riscos e mitigação
| Risco | Mitigação |

View file

@ -506,6 +506,22 @@ const queryValue = (url, ...names) => {
return null
}
/**
* The open-data API still emits stale `selCodColecaoCsv` codes for speeches
* whose Diário is the Câmara collection: `J` and empty return "Documento não
* encontrado" (measured 2026-09-14) while the same date/page resolves under the
* current `D` code the Câmara search itself generates. Any other code (e.g.
* `DCN`) is kept as-is.
*/
const LEGACY_COLLECTION_ALIASES = { '': 'D', J: 'D' }
const normalizeLegacyCollection = (raw) => {
const value = String(raw ?? '')
.trim()
.toUpperCase()
return LEGACY_COLLECTION_ALIASES[value] ?? value
}
const positiveIntegerOrNull = (value) => {
const number = Number(value)
return Number.isInteger(number) && number > 0 ? number : null
@ -565,13 +581,20 @@ export const parseLegacyOfficialUrl = (raw) => {
}
const date = parseDiarioDate(queryValue(url, 'Datain'))
if (date === null) return null
const rawCollection = queryValue(url, 'selCodColecaoCsv', 'selCodColecao')
const collection = normalizeLegacyCollection(rawCollection)
const lookupUrl = new URL(url)
if (lookupUrl.hostname === 'imagem.camara.gov.br') lookupUrl.hostname = 'imagem.camara.leg.br'
if (collection !== rawCollection) {
lookupUrl.searchParams.delete('selCodColecaoCsv')
lookupUrl.searchParams.delete('selCodColecao')
lookupUrl.searchParams.set('selCodColecaoCsv', collection)
}
return {
lookupUrl: lookupUrl.toString(),
publication: {
date,
collection: queryValue(url, 'selCodColecaoCsv', 'selCodColecao'),
collection,
supplement: queryValue(url, 'txSuplemento'),
},
page: positiveIntegerOrNull(queryValue(url, 'txPagina')),

View file

@ -737,6 +737,30 @@ describe('parseLegacyOfficialUrl', () => {
expect(parsed?.page).toBeNull()
})
it('normalizes the stale J and empty collection codes to D', () => {
const staleJ =
'https://imagem.camara.gov.br/dc_20b.asp?largura=&altura=&tipoForm=diarios' +
'&selCodColecaoCsv=J&Datain=10%2F10%2F2019&txPagina=147&txSuplemento=&enviar=Pesquisar'
const parsedJ = parseLegacyOfficialUrl(staleJ)
expect(parsedJ?.publication).toEqual({ date: '2019-10-10', collection: 'D', supplement: null })
expect(parsedJ?.lookupUrl).toContain('selCodColecaoCsv=D')
expect(parsedJ?.lookupUrl).not.toContain('selCodColecaoCsv=J')
const parsedEmpty = parseLegacyOfficialUrl(
staleJ.replace('selCodColecaoCsv=J', 'selCodColecaoCsv='),
)
expect(parsedEmpty?.publication.collection).toBe('D')
expect(parsedEmpty?.lookupUrl).toContain('selCodColecaoCsv=D')
})
it('keeps a collection that is not a known stale alias', () => {
const parsed = parseLegacyOfficialUrl(
legacyGovUrl.replace('selCodColecaoCsv=D', 'selCodColecaoCsv=S'),
)
expect(parsed?.publication.collection).toBe('S')
expect(parsed?.lookupUrl).toContain('selCodColecaoCsv=S')
})
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()