From 9687c4160dba4929fc5a770e2edb22d2507b0666 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 14 Sep 2026 04:22:47 -0300 Subject: [PATCH] =?UTF-8?q?fix(C160):=20normaliza=20cole=C3=A7=C3=B5es=20o?= =?UTF-8?q?bsoletas=20(J/vazia)=20do=20link=20legado=20para=20D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/ops/teqo-1313-deploy.md | 61 +++++++++++++++++++ .../acervo-abrir-fonte-pdf-oficial-impl.md | 6 ++ scripts/lib/camaraSpeeches.mjs | 25 +++++++- tests/unit/camaraSpeeches.unit.spec.ts | 24 ++++++++ 4 files changed, 115 insertions(+), 1 deletion(-) diff --git a/docs/ops/teqo-1313-deploy.md b/docs/ops/teqo-1313-deploy.md index 1b6c310f..98905c7d 100644 --- a/docs/ops/teqo-1313-deploy.md +++ b/docs/ops/teqo-1313-deploy.md @@ -499,6 +499,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/#page=`). É +script-only (nenhuma migration): resolve uma vez por publicação +(`/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 `). + +```bash +ssh homeserver +source ~/.nvm/nvm.sh +cd ~/teqo-backfill && git fetch origin && git checkout && 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 /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/-[-s].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 `) 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`) diff --git a/docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md b/docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md index 3e2e2a1a..2f3174a8 100644 --- a/docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md +++ b/docs/plans/acervo-abrir-fonte-pdf-oficial-impl.md @@ -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 | diff --git a/scripts/lib/camaraSpeeches.mjs b/scripts/lib/camaraSpeeches.mjs index 393ddb49..f22b530e 100644 --- a/scripts/lib/camaraSpeeches.mjs +++ b/scripts/lib/camaraSpeeches.mjs @@ -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')), diff --git a/tests/unit/camaraSpeeches.unit.spec.ts b/tests/unit/camaraSpeeches.unit.spec.ts index 97280120..181aeda2 100644 --- a/tests/unit/camaraSpeeches.unit.spec.ts +++ b/tests/unit/camaraSpeeches.unit.spec.ts @@ -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()