feat(C152): piloto da fonte de vídeos e transcrição minutada da Câmara

Spike de viabilidade da fonte (sem DB/Payload): script + lib pura + unit tests; 3 discursos (55ª/56ª/57ª) processados ponta a ponta com segmentos ASR; relatório em docs/research/.
This commit is contained in:
Francisco Solla 2026-09-12 20:20:52 -03:00
parent 0ea5c8131e
commit 79851ccee5
9 changed files with 1220 additions and 0 deletions

2
.gitignore vendored
View file

@ -73,6 +73,8 @@ next-env.d.ts
/data/demographics/
# AI fill masters (pnpm images:fill — regenerable drafts, never commit)
/data/ai-fill/
# Câmara pilot media/JSON cache (pnpm camara:pilot — VOD MP4 + report, never commit)
/data/camara/
# Local-only sensitive material (agents may read; never commit content)
/private/**

View file

@ -0,0 +1 @@
**C152 — Piloto da fonte: vídeos e transcrição minutada de discursos da Câmara (2026-09-12):** spike de viabilidade da fonte do acervo de falas, sem DB/Payload/collection/migration. Novo `scripts/pilot-camara-speeches.mjs` (orquestra: API de discursos → evento do dia → trechos por orador na página do evento → poll assíncrono de `video-sob-demanda` → download do MP4 → Deep Infra Whisper large-v3) + `scripts/lib/camaraSpeeches.mjs` (lógica pura: parser das âncoras, matching orador/horário, offset YouTube, normalização de segmentos) com 17 unit tests; script `pnpm camara:pilot`; cache de mídia/JSON em `data/camara/` (novo no `.gitignore`). 3 discursos (55ª/2016, 56ª/2021, 57ª/2023) processados ponta a ponta com transcrição segmentada (`start`/`end`/`text`) — o endpoint compat `verbose_json` aceita o MP4 direto, sem ffmpeg, e devolve segmentos. Achados registrados em `docs/research/piloto-fonte-videos-camara.md`: link do VOD é determinístico/regerável (persistir `evento`+`idAudio`+`trecho`, não a URL); trechos por orador existem de 2015 em diante (54ª não cobre); casamento discurso↔evento é por data/hora (`uriEvento` vazio); página de trecho responde 400 anti-crawl. Custo medido US$ 0,0006–0,0021 por discurso. Sem migration, sem access/Consent; destrava C153/C154/C155.

View file

@ -0,0 +1,127 @@
# Impl: Piloto da fonte: vídeos e transcrição minutada de um discurso da Câmara
Status: executado
Atualizado em: 2026-09-12
Issue: #954
Intenção: docs/plans/piloto-fonte-videos-camara.md
Appetite restante: herdado (~1 dia)
## Leitura da intenção
- **Outcome:** 2–3 discursos de legislaturas diferentes processados ponta a ponta (evento → trecho do orador → MP4 do VOD → transcrição com segmentos `início/fim/texto`), com relatório curto que responde as 4 incógnitas e mede custos/tempos. Nada em banco de produção; artefatos fora do git; o relatório commitado é o entregável durável.
- **O que NÃO negociar:** sem DB/Payload/collection/migration e sem `assertLocalDatabase`; 2–3 discursos (nunca o acervo); nenhuma mídia/HTML/JSON bruto commitado; crédito CC BY 4.0 no relatório e marca-d'água intocada; identificadores em inglês, strings pt-BR.
- **O que reavaliar (achados ao vivo que contradizem a intenção):**
1. `video-sob-demanda` **não é GET síncrono** — é _poll de geração assíncrona_ que devolve `{estado}` (`PRONTO|INDISPONIVEL|…`); o front polla a cada 3s por até ~2min e recarrega a página quando `PRONTO`. O plano trata como máquina de estados com backoff/limite, não como download direto.
2. A página de trecho (`?a=…&t=…`) respondeu **400 "Acesso via bot não permitido"** a UA de curl — há anti-crawl; exige UA de browser e talvez `crawl=no`/cookies. O caminho de evidência principal passa a ser a **página do evento** (âncoras server-rendered com `a`/`t`), que respondeu 200.
3. `uriEvento` vem **vazio** nos discursos 2023–2024 e `urlVideo`/`urlAudio` são nulos → o casamento discurso↔evento é **por data/hora**, não por id direto.
## Abordagem recomendada
```mermaid
flowchart LR
S[discurso 178857<br/>GET /deputados/.../discursos] --> E[evento do dia<br/>GET /eventos dataInicio=dataFim]
E --> P[página do evento<br/>âncoras ?a=&t= server-rendered]
P --> V[video-sob-demanda?idAudio=&trecho=<br/>poll estado]
V -->|PRONTO| M[MP4 do trecho]
M --> T[Deep Infra Whisper large-v3<br/>compat verbose_json]
T --> R[segmentos start/end/text + tempos/custos]
P -.->|sem trecho / sessão antiga| Y[YouTube urlRegistro<br/>offset = t/1000 − startTimestamp]
```
**Opções consideradas:**
- **A — CLI fino commitado: `scripts/pilot-camara-speeches.mjs` (orquestra I/O) + `scripts/lib/camaraSpeeches.mjs` (lógica pura), cache em `data/camara/` (gitignored), relatório em `docs/research/`.** **Recomendada.**
- **B — Scratch/one-off fora do repo (curl + um notebook descartável).** Rejeitada: sem evidência reproduzível, sem unit tests das regras (poll, offset, parser) e sem base para C153; contraria o espírito de "piloto documentado".
- **C — Estender `scripts/recover-media.mjs`.** Rejeitada: aquele script é acoplado a DB/S3/`assertLocalDatabase`; domínio errado, arrastaria Payload para o piloto.
**Recomendação: A** — o problema é um spike de fonte com regras puras e testáveis (parser das âncoras, montador de URL do VOD, offset YouTube, normalização dos segmentos), então o custo baixo de um módulo puro + script de orquestração já entrega os dois artefatos duráveis do piloto (código unit-testado + relatório) sem tocar o app.
**Decisões CARAS (cada uma com opções/recomendação/rejeitadas):**
- **(a) Áudio sem ffmpeg.** **A)** enviar o **arquivo do trecho VOD (MP4 curto) direto** ao Whisper multipart — o provedor faz o demux; o trecho é a fala do orador, curto, provavelmente abaixo do limite de tamanho. **B)** exigir `ffmpeg` local para extrair WAV. **C)** baixar áudio da sessão inteira no YouTube via lib. **Recomendação: A** — ffmpeg está ausente do repo e do Dockerfile (`apk add libc6-compat` só), B adiciona dependência de sistema e C adiciona dep pesada + sessão inteira. **Medir no piloto:** o Deep Infra aceita `video/mp4` no multipart e qual o tamanho do `linkParaDownload` (critério: transcrição 200 com segmentos para o trecho; se rejeitar o container, registrar o formato aceito e cair para B como pré-requisito manual _do piloto_, sem mudar repo/Dockerfile).
- **(b) Segmentos Deep Infra: compat vs nativo.** **A)** compat `POST /v1/openai/audio/transcriptions` com `model=openai/whisper-large-v3`, `language=pt`, `response_format=verbose_json` (+ `timestamp_granularities[]=segment`), shape `segments[{start,end,text}]`. **B)** nativo `/v1/inference/…` com `chunk_level: segment`. **C)** reusar `src/utilities/ai/deepInfraTranscribe.ts`. **Recomendação: A se devolver segmentos; B como fallback; C rejeitada** — o wrapper atual devolve só `{text}`, é `server-only` TS (exigiria tsx) e não expõe timestamps. **Medir no piloto:** presença de `segments[].start`/`.end` no compat (critério: ≥1 segmento com `start` e `end` numéricos); senão B com o mesmo critério.
- **(c) Local dos artefatos.** **A)** `data/camara/` no repo + entrada explícita `/data/camara/` no `.gitignore`. **B)** fora do repo (`/tmp`, `$HOME`). **C)** `private/`. **Recomendação: A** — não existe `/data/` global no `.gitignore` (só `/data/tse/`, `/data/geometries/`, `/data/ai-fill/`, `/data/encode/`, `/data/poster-candidates/`), então um `data/camara/` novo **vazaria sem entrada explícita**; A mantém os caminhos do script/relatório estáveis e reproduzíveis. B quebra reprodutibilidade; C é para material sensível.
- **(d) Trechos: parser HTML vs API.** **A)** parsear a **página do evento** server-rendered (âncoras `?a=<idAudio>&t=<epoch ms>&trechosOrador=&crawl=no`). **B)** engenharia reversa de endpoint JSON interno de trechos. **C)** página `Resultado.asp?txtCodigo=` (áudio, sem vídeo). **Recomendação: A** — não há API de trechos; a página do evento é a superfície observada (200, ~878 KB) e a que carrega `a`/`t`; B é não-documentado e frágil; C não dá vídeo. **Medir no piloto:** parser extrai ≥1 trecho do orador-alvo (critério: nome normalizado bate e `t` é epoch ms plausível).
- **(e) Escopo de teste.** Unit puro sobre `scripts/lib/camaraSpeeches.mjs` (sem rede): `parseEventExcerpts(html)`, `buildVodUrl(eventId, audioId, t)`, `parseVodStatus(json)`, `youtubeOffsetSeconds(tMs, startTimestamp)`, `selectSpeechEvent(events, speech)`, `matchExcerpt(excerpts, speech)`, `normalizeTranscription(json)`. **Recomendação: só unit** — é spike de rede; int/e2e não têm alvo local. Fixture sintética pequena inline no spec (não commitar HTML bruto da Câmara).
### Componentes / mudanças
- `scripts/pilot-camara-speeches.mjs` (novo, top-level): orquestra fetch (`AbortSignal.timeout`, UA de browser por constante), poll do VOD com backoff/limite, download cacheado, transcrição, impressão do relatório. Importa `dieWithLabel`/`loadCliEnv` de `scripts/lib/cli.mjs` (não re-espelha `die`/dotenv/`sha256`; não chama `assertLocalDatabase`). Flags: `--speech`, `--legislature`, `--out`, `--help`.
- `scripts/lib/camaraSpeeches.mjs` (novo, puro): os 7 helpers acima. **Deve ser importado pelo script** (knip acusa `scripts/lib/*.mjs` órfão).
- `tests/unit/camaraSpeeches.unit.spec.ts` (novo, `// @vitest-environment node`, import relativo do `.mjs`).
- `package.json`: atalho `"camara:pilot": "node scripts/pilot-camara-speeches.mjs"` (`.mjs` puro roda node seco — sem `tsx`).
- `.gitignore`: `/data/camara/`.
- `docs/research/piloto-fonte-videos-camara.md` (relatório, commitado) e `docs/changelog/2026-09-12-c152.md` (curto).
### Dados → forma (N/A)
Não há superfície de produto — é spike de engenharia. A "forma" é o relatório: por discurso, uma tabela `evento · trecho(a,t) · estado VOD · bytes MP4 · segmentos(start/end/text)` + bloco de custos/tempos; sem UI, sem collection.
## Fases verificáveis
1. **Tracer recente (quota ~0,35 dia).** Um discurso 2023–2024: listar discursos (`GET /deputados/178857/discursos`, ler `X-Total-Count`), casar o evento por data (`GET /eventos?dataInicio=&dataFim=`), buscar as âncoras na página do evento, chamar `video-sob-demanda`, pollar até `PRONTO`, baixar o MP4 do trecho, transcrever no compat com `verbose_json`. Junto: esqueleto do script, `/data/camara/` no `.gitignore`, `package.json`. **Pronto quando:** o console imprime os segmentos `start/end/text` de 1 discurso e os tempos de download/transcrição. Responde (parcial) incógnitas 1 e 3.
2. **Discurso antigo 55ª/56ª (2011–2015) (quota ~0,3 dia).** Repetir o fluxo; se a página do evento não tiver trecho do orador, exercitar o fallback YouTube (`urlRegistro` + `offset = t/1000 − startTimestamp`). **Pronto quando:** 2–3 discursos de legislaturas diferentes processados, com o casamento discurso↔evento↔trecho (ou o motivo de não casar) registrado. Responde incógnitas 2 e 4.
3. **Fallbacks e permanência (quota ~0,2 dia).** Re-resolver o mesmo trecho do tracer após intervalo para checar permanência do link; tentar o endpoint nativo se o compat não trouxer `start/end`; tratar o 400 anti-crawl (UA de browser + `crawl=no`/cookies) e registrar o resultado. **Pronto quando:** as 4 incógnitas têm resposta com evidência ou "medir no piloto" com critério explícito.
4. **Fechamento (quota ~0,15 dia).** Unit tests do módulo puro; relatório `docs/research/piloto-fonte-videos-camara.md` com as 4 respostas, custos/tempos e fallback por incógnita; changelog. **Pronto quando:** lint + typecheck + `pnpm test:unit` verdes e nenhum artefato de mídia no `git status`.
## Rabbit holes / Não escopo (engenharia)
- **Crawler resiliente genérico** (retry framework, cookie jar, headless browser): não — fetch direcionado com UA e timeout; se o anti-crawl bloquear, registrar como incógnita residual.
- **Instalar/embutir ffmpeg** no repo/Dockerfile: não — envio direto do MP4 (decisão a); ffmpeg, se necessário, é pré-requisito manual do piloto e fica documentado, sem tocar a imagem.
- **Refatorar `ensureCachedDownload` para streaming / criar `createWriteStream`/`pipeline`:** não — o trecho é curto; buffer em memória (padrão atual de `recover-media.mjs:233` e `electionResultsZip.ts:63`).
- **Criar client/collection/tipos da Câmara em `src/`:** não — fica em `scripts/`; C153 é que modela o acervo.
- **Baixar os 45/99 discursos ou a sessão inteira:** não — 2–3 discursos.
- **Commitar mídia/HTML/JSON bruto:** não — só relatório + código + testes; mídia em `data/camara/` gitignored.
- **Knip trap:** não criar `scripts/lib/camaraSpeeches.mjs` sem importá-lo no script.
## Riscos e mitigação
- **VOD nunca chega a `PRONTO` / expira.** Poll com backoff e teto (~3s × até 2min, com limite explícito), capturando o JSON cru do cache; fallback YouTube quando houver `urlRegistro`; senão registrar "sem vídeo" para o discurso.
- **400 anti-crawl na página de trecho/paginação.** Usar UA de browser + `crawl=no`; caminho principal continua sendo as âncoras da página do evento (200) + o JSON do VOD por `idAudio`/`t`, que não dependem da página de trecho.
- **Compat sem timestamp / provedor rejeita MP4.** Fallback nativo (`chunk_level`); normalizador aceita os dois shapes; se rejeitar o container, decidir ffmpeg manual só para o piloto e registrar.
- **Sessão antiga sem trecho por orador.** Fallback YouTube com offset; sem `urlRegistro`, resposta da incógnita 2 = "não cobre" com a evidência da contagem de âncoras.
- **Casamento ambíguo** (vários eventos no dia; nome do orador divergente). Casar por data normalizada + nome normalizado + `t` mais próximo de `dataHoraInicio`; logar candidatos e, se ainda ambíguo, registrar no relatório.
- **Vazamento de mídia no git.** Entrada explícita `/data/camara/` + checagem de que todo caminho de escrita está sob a raiz do cache antes de gravar; `git status` limpo é aceite.
- **Chave ausente.** `DEEPINFRA_API_KEY` é lida via `loadCliEnv`; ausente → `dieWithLabel` com mensagem pt-BR (a execução ao vivo já tem a chave no env).
- **Estouro de appetite.** Teto rígido de 3 discursos e trechos curtos; custo é ~US$ 0,00045/min, desprezível vs. tempo de busca/poll.
## Aceite de engenharia
- [x] `scripts/pilot-camara-speeches.mjs` roda com node seco e imprime, por discurso: evento, `a`/`t` do trecho, `estado` do VOD, caminho/bytes do MP4 e segmentos `start/end/text`.
- [x] 2–3 discursos de legislaturas diferentes processados ponta a ponta.
- [x] Lógica pura em `scripts/lib/camaraSpeeches.mjs`, importada pelo script (knip) e coberta por `tests/unit/camaraSpeeches.unit.spec.ts` (`// @vitest-environment node`).
- [x] Sem `assertLocalDatabase`, sem Payload/DB/collection/migration; sem re-spell de `die`/`sha256`/dotenv/`LOCAL_HOSTS`; `dieWithLabel` importado de `scripts/lib/cli.mjs`.
- [x] `data/camara/` fora do git via entrada explícita no `.gitignore`; nenhuma mídia/HTML/JSON bruto commitado.
- [x] `docs/research/piloto-fonte-videos-camara.md` commitado: as 4 incógnitas respondidas (com evidência ou critério de "medir no piloto"), custos/tempos e fallback por incógnita, crédito CC BY 4.0.
- [x] `docs/changelog/2026-09-12-c152.md` criado.
- [x] `pnpm lint`, `pnpm typecheck` e `pnpm test:unit` verdes.
## Débitos (triage pós-simplify)
Nenhum achado dos dois revisores virou Issue: é spike, os achados eram baixos/médios e ou foram resolvidos na sessão, ou ficam como gatilho para C153. Isso evita poluir o backlog (ver `capture-review-debts`).
**Já resolvido no simplify (não reabrir):**
- `youtubeOffsetSeconds` era export especulativo sem call site → removido (a fórmula fica documentada no relatório; C153 liga o fallback).
- `normalizeTranscription` convertia `duration: null` em `0` (`Number(null) === 0`) → guard `typeof number`.
- Uma exceção de rede/Deep Infra derrubava o run e não gravava o JSON → `processSpeech` agora captura e registra em `failures[]`.
- `--skip-download` dizia resolver o VOD mas pulava o `resolveVod` → agora resolve e registra; help corrigido.
- `--out` aceitava `..` sem guard → fail-closed adicionado.
- `chunk` do parser podia capturar o card do próximo trecho → fatiado até a próxima âncora.
- `matchExcerpt` casava qualquer orador com `speaker` vazio → guard de string vazia.
- `headers` redundante, validação de presença de valor nos args, cast do teste, duração do `--skip-download`.
**Adiado com gatilho (C153):**
- **Parser de âncoras ancorado na ordem de atributos/query literal** (`scripts/lib/camaraSpeeches.mjs`): qualquer reordenação do HTML da Câmara quebra silenciosamente (retorna `[]`). Gatilho: quando C153 montar o import robusto, usar seleção estrutural + teste com HTML real versionado.
- **Fallback YouTube** (`urlRegistro` + offset `t/1000 − startTimestamp`): registrado no relatório e no `report.youtube`, mas não calculado (o `startTimestamp` do VOD não vem na API). Gatilho: C153, se o acervo precisar cobrir eventos sem trecho.
**Explicitamente fora / descartado:** duplicação `fileExists`/`readFile` vs cache interno de `ensureCachedDownload` (score 1–2); builders de URL inline no driver (nit de consistência); `includes` do match de orador (leniência intencional no piloto).
### Self-score decision-quality
1. **Fidelidade ao outcome e anti-goals — 5/5:** cada fase fecha um pedaço do aceite (2–3 discursos, 4 incógnitas); os guardrails (sem DB, sem acervo, mídia fora do git) viram itens de aceite verificáveis.
2. **Decisões caras com opções/recomendação/rejeitadas — 5/5:** (a) áudio sem ffmpeg, (b) compat vs nativo, (c) local dos artefatos, (d) parser vs API, (e) escopo de teste — todas deliberadas com rejeitadas explícitas.
3. **Fases verificáveis e tracer primeiro — 4/5:** tracer já exercita o caminho mais arriscado (poll do VOD + transcrição com segmentos); quota estimada, sem baseline histórico do repo para calibrar (daí o 4).
4. **Incerteza tratada com critério mensurável — 5/5:** os dois pontos contraditórios ao vivo (VOD assíncrono, anti-crawl) foram incorporados; cada incógnita tem "medir no piloto" com critério objetivo.
5. **Aderência ao codebase e convenções — 5/5:** reusa `scripts/lib/cli.mjs` sem duplicar helpers, respeita os guards de `codebaseConventions`/`scriptCliConventions`, o knip de `scripts/lib`, o formato `.mjs` puro e o padrão de relatório em `docs/research/`.

View file

@ -48,3 +48,12 @@ Mesmo método (literatura → personas → entrevistas sintéticas em subagents
| [`roteiro-entrevista-emendas-responsavel-real.md`](roteiro-entrevista-emendas-responsavel-real.md) | Roteiro Mom Test da sessão real com a pessoa do gabinete que mantém a planilha de emendas (artefato-first; não é auditoria) | **aguardando agendamento** |
**Limitação metodológica (ciclo 3):** as quatro personas bebem do MESMO compêndio — convergência é consistência de leitura, não evidência independente; o que informa é a divergência entre lentes de papel. Exceção parcial: a persona 4 ancora os campos [R] na entrevista real de 2026-07-23 (`docs/CUSTOMER.md`). **Nada entra no roadmap nem vira código antes da validação com a pessoa real do gabinete — inclusive a criação das verticais `/mandato` e `/comunicacao`.** O eixo B do compêndio não é parecer jurídico; qualquer registro novo da camada política passa pelo advogado antes de existir.
## Piloto de fonte — acervo de falas da Câmara (C152, 2026-09-12)
Viabilidade da fonte do acervo de falas (Issue #954): 2–3 discursos de legislaturas diferentes processados ponta a ponta (evento → trecho → VOD → transcrição ASR com segmentos). Não é discovery de produto — é relatório de engenharia que destrava C153/C154/C155.
| Artefato | O quê | Status |
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| [`piloto-fonte-videos-camara.md`](piloto-fonte-videos-camara.md) | Respostas às 4 incógnitas da fonte (permanência do link, trechos em 55ª/56ª, segmentos Deep Infra, casamento antigo), custos e fallbacks | concluído 2026-09-12 |
| [`piloto-fonte-videos-camara-impl.md`](../plans/piloto-fonte-videos-camara-impl.md) | Plano de implementação do piloto (script + lib pura + unit tests) | executado |

View file

@ -0,0 +1,106 @@
# Piloto da fonte: vídeos e transcrição minutada de discursos da Câmara
Status: concluído — piloto de viabilidade
Atualizado em: 2026-09-12
Issue: [#954](https://github.com/fsolla/teqo/issues/954) (C152)
Intenção: [`docs/plans/piloto-fonte-videos-camara.md`](../plans/piloto-fonte-videos-camara.md)
Impl: [`docs/plans/piloto-fonte-videos-camara-impl.md`](../plans/piloto-fonte-videos-camara-impl.md)
Script: [`scripts/pilot-camara-speeches.mjs`](../../scripts/pilot-camara-speeches.mjs) + [`scripts/lib/camaraSpeeches.mjs`](../../scripts/lib/camaraSpeeches.mjs)
Relatório de engenharia: responde as quatro incógnitas que decidem o desenho do acervo de falas (C153) antes de modelar banco, import e tela. **Nada foi escrito em banco**; mídia e JSON ficam em `data/camara/` (gitignored).
## Como reproduzir
```bash
# requer DEEPINFRA_API_KEY no .env.local ou no env do processo
pnpm camara:pilot --legislature 57 --limit 1
pnpm camara:pilot --date 2016-03-10 --skip-transcribe # só resolve o VOD
```
O script escolhe o discurso, casa o evento do dia, acha o trecho do orador na página do evento, polla a geração assíncrona do VOD, baixa o MP4 e transcreve com segmentos. Os artefatos vão para `data/camara/` (MP4 + `pilot-report.json`).
## Discursos processados ponta a ponta
| Legislatura | Discurso | Sessão | Trecho do orador | VOD | MP4 | ASR (segmentos) | Custo ASR |
| ----------- | ---------------- | ---------------------------------- | ---------------------------------- | --------------- | ------- | --------------- | ------------ |
| 57ª (2023) | 2023-02-07T17:28 | Sessão Deliberativa (evento 67091) | `a=558641` `t=1675801808560` 17:30 | PRONTO, 1 poll | 20,3 MB | 43 (252,0 s) | ~US$ 0,00189 |
| 56ª (2021) | 2021-02-24T16:16 | Outro Evento (evento 60430) | `a=545023` `t=1614194341593` 16:19 | PRONTO, 1 poll | 22,9 MB | 62 (284,0 s) | ~US$ 0,00213 |
| 55ª (2016) | 2016-03-10T09:28 | Sessão Deliberativa (evento 43106) | `a=55709` `t=1457612936930` 09:28 | PRONTO, 2 polls | 4,4 MB | 16 (83,1 s) | ~US$ 0,00062 |
| 54ª (2013) | 2013-09-04T15:45 | Comissão Geral "Mais Médicos" | **não há** (0 âncoras de trecho) | — | — | — | — |
O `tipoDiscurso`/`faseEvento` vêm da API de discursos; o trecho, da página do evento.
## As quatro incógnitas
### 1. Os links de vídeo por discurso são permanentes?
**Não existe link por discurso**: `urlVideo` e `urlAudio` vêm nulos e `uriEvento` vem vazio na API de discursos. O vídeo é derivado em duas etapas:
- `GET https://www.camara.leg.br/evento-legislativo/{evento}/video-sob-demanda?idAudio={a}&trecho={t}` devolve `{ estado, video }` — **geração assíncrona** no servidor de VOD (`GERANDO` → `PRONTO`), não um download síncrono. O primeiro request de um trecho novo pode levar ~30 s e retorna `GERANDO`; os seguintes (a cada ~5 s) retornam `PRONTO` em 1–2 polls.
- Quando `PRONTO`, o `video.linkParaDownload` aponta para `https://vod.camara.leg.br/vod/download?p=<hash>.mp4`, que respondeu **200 sem cookie nem sessão** (2026-09-12).
**Evidência de permanência:** o mesmo `<hash>` foi devolvido em requests separados por >15 min (2023: `a7b49fbccf69a58965f8f25377fe9a97`; 2016: `2c102e9a69704b5e7edb780f934000a2`), e o MP4 baixou consistentemente. **Recomendação:** tratar o link como **regerável e determinístico**, não como asset imutável — persistir `evento`+`idAudio`+`trecho` e re-resolver via `video-sob-demanda` quando precisar baixar de novo. Um teste de permanência de longa duração (semanas) não foi feito.
### 2. Sessões antigas (2011–2015) têm trechos por orador?
**Depende da legislatura.** A página do evento lista os trechos por orador (âncoras server-rendered com `a`/`t`) e o Solla aparece:
- **55ª (2016)** e **56ª (2021)**: sim — 248 e 262 âncoras na página, com o trecho do Solla (1:21 e 4:40).
- **54ª (2011–2015)**: não. O único discurso do período na API (Comissão Geral "Mais Médicos", 2013-09-04) não tem trecho: a página do evento 33397 tem **0 âncoras** e nenhuma menção ao orador — e o evento também não tem `urlRegistro` (YouTube), então não há nem fallback.
O volume do período também é praticamente nulo para este deputado (1 discurso em 2011–2015, contra 362/414/234 em 55ª/56ª/57ª, conforme a pesquisa do planejamento). **Conclusão:** a cobertura útil começa em 2015; C153/C155 não devem contar com 54ª.
### 3. A transcrição via Deep Infra devolve segmentos com minutagem?
**Sim, no endpoint compatível, e sem ffmpeg.** Funcionou de primeira:
```
POST https://api.deepinfra.com/v1/openai/audio/transcriptions
model=openai/whisper-large-v3 language=pt
response_format=verbose_json timestamp_granularities[]=segment
file=<o próprio MP4 do trecho, video/mp4>
→ { text, language: "pt", duration, segments: [{ start, end, text }, …] }
```
Retornou `segments[]` com `start`/`end` numéricos nos três discursos (43/62/16 segmentos) e **aceitou o MP4 direto** — o container de vídeo não precisa de extração de áudio. O endpoint nativo (`chunk_level`) e a instalação de `ffmpeg` **não foram necessários** (o repo e a imagem não têm ffmpeg). O `deepInfraTranscribe.ts` do app não serve: é `server-only`, devolve só `{ text }` e não expõe timestamps.
### 4. O casamento discurso ↔ evento ↔ trecho funciona em anos antigos?
**Sim, por data/hora.** Como `uriEvento` vem vazio, o casamento é: discurso → eventos do mesmo dia (ordenados por proximidade do horário) → âncoras da página do evento → trecho cujo `titulo` normalizado bate com o orador e cujo horário é o mais próximo. Funcionou em 55ª/56ª/57ª. A única falha foi o caso de 2013, explicada por ausência de trecho (não por erro de casamento).
**Custo do casamento:** quando o dia não tem trecho, o script varre todos os eventos do dia até achar — em 2013 foram ~44 páginas de evento (~50 s). C153 deve priorizar os tipos `Sessão Deliberativa`/`Breves Comunicações` e abortar cedo em vez de varrer tudo.
## Custos e tempos medidos
- **Transcrição (Deep Infra, whisper-large-v3):** US$ 0,00045/min. Medido: US$ 0,00062 (1:21), US$ 0,00189 (4:02), US$ 0,00213 (4:40). Para o acervo 54ª–57ª (≈1.011 discursos, ~4 min médios ≈ 67 h) a ordem de grandeza é **~US$ 1,80** — irrelevante.
- **Tempo de transcrição:** ~11–19 s por trecho de 1–5 min (≈4–5× tempo real); o upload de ~20 MB é o que domina.
- **Geração do VOD:** 1–2 polls (~5–35 s na primeira vez de cada trecho).
- **Download do MP4:** ~3 s para ~20 MB.
- **Ponta a ponta por discurso:** ~35–40 s (incluindo busca de evento e transcrição).
## Fallbacks recomendados (por incógnita)
1. **Link/permanência:** não persistir a URL do MP4; persistir `evento`+`idAudio`+`trecho` e re-resolver por `video-sob-demanda`. Se `INDISPONIVEL`, cair para o YouTube do evento com offset `t/1000 − startTimestamp` (`urlRegistro`); sem `urlRegistro`, marcar "sem vídeo".
2. **Sessão sem trecho:** idem acima; em 2011–2015 a resposta é "não cobre" (documentado, não é falha).
3. **Segmentos ASR:** compat `verbose_json` funcionou; fallback raro = endpoint nativo (`chunk_level: segment`); último recurso = extrair áudio com `ffmpeg` (não foi preciso).
4. **Casamento ambíguo:** usar `faseEvento`/tipo do discurso e validar o nome normalizado; limitar a varredura a tipos de sessão relevantes.
## Limitações e armadilhas observadas
- **Anti-crawl:** a URL de trecho `?a=…&t=…&trechosOrador=&crawl=no` responde **400 "Acesso via bot não permitido"** a User-Agent de curl. O caminho que funciona é a página do evento (200, UA de browser) + o JSON de `video-sob-demanda`; não foi preciso copiar cookies.
- **Conteúdo inicial do recorte:** o MP4 do trecho começa alguns segundos antes do orador (captura o fim da fala anterior). Para citar com precisão, recortar pelo `start` do primeiro segmento ASR após o início real, ou cruzar com o horário do trecho.
- **Data/hora sem timezone:** as datas da API e o `horario` do trecho são hora local de Brasília; o casamento foi feito por hora-do-dia, sem conversão.
- **Sem espelhamento de mídia:** os vídeos continuam servidos pela Câmara (C154/C155).
- **Licença:** as transmissões de atividades legislativas da Câmara são **CC BY 4.0**; crédito obrigatório e marca-d'água intocada.
## Recomendações para C153
- Modelar o discurso com `evento`+`idAudio`+`trecho` (chave natural) e re-resolver o MP4 sob demanda, nunca guardar a URL do VOD como identidade.
- Reusar a lógica pura de `scripts/lib/camaraSpeeches.mjs` (parser de âncoras, matching, offset YouTube, normalização de segmentos) no import idempotente.
- Assumir cobertura de trecho **2015+**; tratar 54ª como vazia.
- Priorizar tipos de sessão e cachear a página do evento para não varrer dezenas de páginas por dia.
- A transcrição oficial (taquigrafia) + segmentos ASR (minutagem) convivem: o texto oficial é a leitura; o ASR dá os `start/end`.
## Crédito
Vídeos e dados da Câmara dos Deputados — [dadosabertos.camara.leg.br](https://dadosabertos.camara.leg.br) e [camara.leg.br](https://www.camara.leg.br), licença CC BY 4.0.

View file

@ -81,6 +81,7 @@
"test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts",
"images:resize": "node scripts/resize-images.mjs",
"images:fill": "node scripts/fill-image.mjs",
"camara:pilot": "node scripts/pilot-camara-speeches.mjs",
"typecheck": "tsc --noEmit",
"typecheck:tsc6": "tsc6 --noEmit",
"prepare": "husky",

View file

@ -0,0 +1,239 @@
/**
* Pure derivation for the C152 pilot (`scripts/pilot-camara-speeches.mjs`):
* matching a Câmara speech to its legislative event and to the speaker's video
* excerpt, plus normalizing the VOD status and the Deep Infra transcription.
*
* No I/O here — the CLI threads fetch/download/transcribe through these
* decisions, so the fragile rules (HTML anchors, time-of-day matching, epoch
* offsets, provider shapes) are unit-tested without network.
*/
/** Câmara's open-data deputy id for Jorge Solla (used by the pilot). */
export const SOLLA_DEPUTY_ID = 178857
/** Browser-like UA — the trecho/evento pages answer 400 "Acesso via bot" to curl. */
export const CAMARA_USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
/** Deep Infra OpenAI-compatible transcription endpoint (returns segments with `verbose_json`). */
export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/audio/transcriptions'
/** Whisper large-v3 on Deep Infra — US$ 0,00045/audio minute. */
export const DEEPINFRA_WHISPER_MODEL = 'openai/whisper-large-v3'
/** Legislature → inclusive date range (year of the first and last session). */
export const LEGISLATURE_RANGES = {
54: ['2011-01-01', '2015-01-31'],
55: ['2015-02-01', '2019-01-31'],
56: ['2019-02-01', '2023-01-31'],
57: ['2023-02-01', '2027-01-31'],
}
const stripAccents = (value) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
/**
* Canonical form of a speaker name for comparison: NFD-stripped, uppercase,
* punctuation dropped, whitespace collapsed. `Jorge Solla` → `JORGE SOLLA`.
*
* @param {string} name
* @returns {string}
*/
export const normalizeSpeakerName = (name) =>
stripAccents(String(name ?? ''))
.toUpperCase()
.replace(/[^A-Z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
/**
* `HH:MM` / `HH:MM:SS` → seconds of day, or null when unparseable.
*
* @param {string} clock
* @returns {number | null}
*/
export const clockToSeconds = (clock) => {
const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(String(clock ?? '').trim())
if (!match) return null
const hours = Number(match[1])
const minutes = Number(match[2])
const seconds = Number(match[3] ?? '0')
if (hours > 23 || minutes > 59 || seconds > 59) return null
return hours * 3600 + minutes * 60 + seconds
}
/**
* Time-of-day (seconds) of a naive local datetime (`2023-02-07T17:28`).
* Returns null when the value has no `T`-time part.
*
* @param {string} iso
* @returns {number | null}
*/
export const speechTimeOfDaySeconds = (iso) => clockToSeconds(String(iso ?? '').slice(11, 19))
/** `YYYY-MM-DD` prefix of a naive datetime. */
export const speechDate = (iso) => String(iso ?? '').slice(0, 10)
const integerOrNull = (value) => {
const n = Number(value)
return Number.isInteger(n) && n > 0 ? n : null
}
/**
* Parses the speaker-excerpt anchors of an event page. The page renders one
* `<a id="link-trecho-video" href="…/{eventId}?a=<idAudio>&t=<epochms>…"
* class="chamada__link-trecho" titulo="<SPEAKER>">` per excerpt, followed by
* the speaker card (`chamada__cargo`, `chamada__info-video-hora`,
* `chamada__info-video-duracao`). Returns `[]` when the event has no excerpts
* (old/committee sessions, or an unexpected layout).
*
* @param {string} html
* @param {number | string} eventId
* @returns {Array<{ audioId: number, tMs: number, speaker: string, party: string | null, startTime: string | null, duration: string | null }>}
*/
export const parseEventExcerpts = (html, eventId) => {
const page = String(html ?? '')
const escapedId = String(eventId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const anchor = new RegExp(
`href="https://www\\.camara\\.leg\\.br/evento-legislativo/${escapedId}\\?a&#x3D;(\\d+)&amp;t&#x3D;(\\d+)&trechosOrador=&crawl=no"[^>]*class="chamada__link-trecho"[^>]*titulo="([^"]*)"`,
'g',
)
const excerpts = []
let match
while ((match = anchor.exec(page)) !== null) {
const audioId = integerOrNull(match[1])
const tMs = integerOrNull(match[2])
if (audioId === null || tMs === null) continue
const boundary = page.indexOf('id="link-trecho-video"', match.index + 1)
const end = boundary === -1 ? match.index + 4000 : Math.min(boundary, match.index + 4000)
const chunk = page.slice(match.index, end)
const afterDash = (text) => text.split(' - ').slice(1).join(' - ').trim() || null
const startRaw = /class="chamada__info-video-hora">([^<]*)<\/span>/.exec(chunk)?.[1]
const durationRaw = /class="chamada__info-video-duracao">([^<]*)<\/span>/.exec(chunk)?.[1]
excerpts.push({
audioId,
tMs,
speaker: match[3].trim(),
party: /class="chamada__cargo">([^<]*)<\/span>/.exec(chunk)?.[1]?.trim() ?? null,
startTime: startRaw ? afterDash(startRaw) : null,
duration: durationRaw ? afterDash(durationRaw) : null,
})
}
return excerpts
}
/** The async VOD-generation status URL for one excerpt. */
export const buildVodUrl = (eventId, audioId, tMs) =>
`https://www.camara.leg.br/evento-legislativo/${eventId}/video-sob-demanda?idAudio=${audioId}&trecho=${tMs}`
/**
* Normalizes the `video-sob-demanda` JSON (`{ estado, video }`). `state` is
* `GERANDO` while the server transcodes, `PRONTO` when the MP4 exists,
* `INDISPONIVEL` when there is none.
*
* @param {unknown} json
* @returns {{ state: string, video: { title: string | null, subtitle: string | null, duration: string | null, clock: string | null, downloadUrl: string | null, playbackUrl: string | null } | null }}
*/
export const parseVodStatus = (json) => {
const data = json && typeof json === 'object' ? json : {}
const estado = typeof data.estado === 'string' ? data.estado : 'DESCONHECIDO'
const raw = data.video && typeof data.video === 'object' ? data.video : null
const pick = (key) => (typeof raw?.[key] === 'string' ? raw[key] : null)
return {
state: estado,
video: raw
? {
title: pick('titulo'),
subtitle: pick('subtitulo'),
duration: pick('duracao'),
clock: pick('horario'),
downloadUrl: pick('linkParaDownload'),
playbackUrl: pick('linkParaReproducao'),
}
: null,
}
}
/**
* Events of the speech's date, nearest-first by start time. The open-data
* `uriEvento` comes empty, so the event is inferred from the date/time.
*
* @param {Array<{ id: number, dataHoraInicio?: string, descricaoTipo?: string, urlRegistro?: string | null }>} events
* @param {string} speechIso
* @returns {Array<{ id: number, dataHoraInicio?: string, descricaoTipo?: string, urlRegistro?: string | null }>}
*/
export const selectSpeechEvents = (events, speechIso) => {
const date = speechDate(speechIso)
const target = Date.parse(speechIso)
return (Array.isArray(events) ? events : [])
.filter((event) => speechDate(event?.dataHoraInicio) === date)
.sort((a, b) => {
const da = Math.abs(Date.parse(a.dataHoraInicio) - target)
const db = Math.abs(Date.parse(b.dataHoraInicio) - target)
return da - db
})
}
/**
* Picks the speaker's excerpt nearest in time-of-day to the speech. Returns
* null when the speaker is empty or does not appear on the page. Excerpts
* without a clock lose to ones with a clock.
*
* @param {ReturnType<typeof parseEventExcerpts>} excerpts
* @param {string} speechIso
* @param {string} speaker
* @returns {(ReturnType<typeof parseEventExcerpts>[number]) | null}
*/
export const matchExcerpt = (excerpts, speechIso, speaker) => {
const wanted = normalizeSpeakerName(speaker)
if (wanted === '') return null
const bySpeaker = (Array.isArray(excerpts) ? excerpts : []).filter((excerpt) =>
normalizeSpeakerName(excerpt?.speaker).includes(wanted),
)
if (bySpeaker.length === 0) return null
const target = speechTimeOfDaySeconds(speechIso)
if (target === null) return bySpeaker[0]
const delta = (excerpt) => {
const start = clockToSeconds(excerpt.startTime)
return start === null ? Number.POSITIVE_INFINITY : Math.abs(start - target)
}
return bySpeaker.reduce((best, current) => (delta(current) < delta(best) ? current : best))
}
/**
* Normalizes a Deep Infra transcription response. Accepts the OpenAI-compat
* shape (`segments[{start,end,text}]`) and the native whisper shape
* (`chunks[{timestamp:[start,end],text}]`). Invalid entries are dropped.
*
* @param {unknown} json
* @returns {{ text: string, language: string | null, duration: number | null, segments: Array<{ start: number, end: number, text: string }> }}
*/
export const normalizeTranscription = (json) => {
const data = json && typeof json === 'object' ? json : {}
const rawSegments = Array.isArray(data.segments)
? data.segments
: Array.isArray(data.chunks)
? data.chunks
: []
const segments = rawSegments
.map((segment) => {
const timestamps = Array.isArray(segment?.timestamp) ? segment.timestamp : null
const rawStart = segment?.start ?? timestamps?.[0]
const rawEnd = segment?.end ?? timestamps?.[1]
const start = rawStart === null || rawStart === undefined ? Number.NaN : Number(rawStart)
const end = rawEnd === null || rawEnd === undefined ? Number.NaN : Number(rawEnd)
const text = typeof segment?.text === 'string' ? segment.text.trim() : ''
return { start, end, text }
})
.filter(
(segment) =>
Number.isFinite(segment.start) && Number.isFinite(segment.end) && segment.text !== '',
)
return {
text: typeof data.text === 'string' ? data.text.trim() : segments.map((s) => s.text).join(' '),
language: typeof data.language === 'string' ? data.language : null,
duration:
typeof data.duration === 'number' && Number.isFinite(data.duration) ? data.duration : null,
segments,
}
}

View file

@ -0,0 +1,423 @@
/**
* C152 pilot: processes 2–3 Câmara speeches end-to-end to de-risk the "acervo
* de falas" source before C153 models it. No DB, no Payload, no collection —
* this is a disposable-data spike whose durable outputs are the pure module
* (`scripts/lib/camaraSpeeches.mjs`), its unit tests and the viability report
* (`docs/research/piloto-fonte-videos-camara.md`).
*
* Flow per speech: open-data discursos → event on that date → speaker excerpt
* on the event page (server-rendered `a`/`t` anchors) → `video-sob-demanda`
* poll (async VOD transcoding) → MP4 download → Deep Infra Whisper large-v3
* (OpenAI-compat `verbose_json`, segments with start/end) → report.
*
* Media/JSON artifacts land under `data/camara/` (gitignored), never in git.
*
* Usage:
* pnpm camara:pilot --legislature 57 --limit 1
* pnpm camara:pilot --date 2016-03-10
* pnpm camara:pilot --legislature 56 --skip-transcribe
*/
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
CAMARA_USER_AGENT,
DEEPINFRA_TRANSCRIBE_URL,
DEEPINFRA_WHISPER_MODEL,
LEGISLATURE_RANGES,
SOLLA_DEPUTY_ID,
buildVodUrl,
matchExcerpt,
normalizeTranscription,
parseEventExcerpts,
parseVodStatus,
selectSpeechEvents,
} from './lib/camaraSpeeches.mjs'
import { dieWithLabel, ensureCachedDownload, loadCliEnv } from './lib/cli.mjs'
loadCliEnv()
const die = dieWithLabel('camara:pilot')
const DEEPINFRA_COST_PER_MINUTE_USD = 0.00045
const VOD_POLL_INTERVAL_MS = 5_000
const VOD_POLL_MAX_ATTEMPTS = 40
const DEFAULT_OUT_DIR = 'data/camara'
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const parseArgs = (argv) => {
const options = {
legislature: null,
date: null,
limit: 1,
speaker: 'Jorge Solla',
out: DEFAULT_OUT_DIR,
skipTranscribe: false,
skipDownload: false,
help: false,
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
const value = () => {
const next = argv[++i]
if (next === undefined || next.startsWith('--')) die(`faltou valor para ${arg}.`)
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--skip-transcribe') options.skipTranscribe = true
else if (arg === '--skip-download') options.skipDownload = 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 die(`argumento desconhecido: ${arg}`)
}
if (!options.help && !options.date && !LEGISLATURE_RANGES[options.legislature]) {
die('informe --legislature <54|55|56|57> ou --date <YYYY-MM-DD> (veja --help).')
}
if (!options.help && options.date && !/^\d{4}-\d{2}-\d{2}$/.test(options.date)) {
die('--date deve ser YYYY-MM-DD.')
}
if (!Number.isInteger(options.limit) || options.limit < 1) die('--limit deve ser >= 1.')
if (!options.help && options.out.includes('..')) {
die('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
const HELP = `
Uso: pnpm camara:pilot [--legislature 54|55|56|57] [--date YYYY-MM-DD] [opções]
Opções:
--legislature <n> 54|55|56|57 (range de datas; ignorado se --date)
--date <YYYY-MM-DD> discursos de um único dia (sobrepõe --legislature)
--limit <n> quantos discursos processar (default 1)
--speaker <nome> nome do orador a casar no evento (default "Jorge Solla")
--out <dir> diretório de cache/artefatos (default data/camara)
--skip-download não baixa o MP4 (resolve e registra o VOD)
--skip-transcribe não chama a Deep Infra
--help esta ajuda
`
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
const camaraHeaders = { 'User-Agent': CAMARA_USER_AGENT, Accept: 'application/json' }
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function fetchOnce(url, { timeoutMs = 30_000, headers = camaraHeaders } = {}) {
const response = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) })
if (!response.ok) throw new Error(`HTTP ${response.status} em ${url}`)
return response
}
/** The Câmara API drops connections now and then — bounded retry for GETs. */
async function getText(url, { attempts = 3, ...options } = {}) {
let lastError
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await (await fetchOnce(url, options)).text()
} catch (error) {
lastError = error
if (attempt < attempts) await sleep(2_000)
}
}
throw lastError
}
const getJson = async (url, options) => JSON.parse(await getText(url, options))
// ---------------------------------------------------------------------------
// Pipeline steps
// ---------------------------------------------------------------------------
const speechesUrl = (deputyId, from, to) =>
`https://dadosabertos.camara.leg.br/api/v2/deputados/${deputyId}/discursos` +
`?dataInicio=${from}&dataFim=${to}&itens=100&ordenarPor=dataHoraInicio&ordem=ASC`
const eventsUrl = (date) =>
`https://dadosabertos.camara.leg.br/api/v2/eventos?dataInicio=${date}&dataFim=${date}&itens=100`
const eventPageUrl = (eventId) => `https://www.camara.leg.br/evento-legislativo/${eventId}`
const fileExists = async (path) => {
try {
await access(path)
return true
} catch {
return false
}
}
const resolveRange = (options) => {
if (options.date) return { from: options.date, to: options.date }
const [from, to] = LEGISLATURE_RANGES[options.legislature]
return { from, to }
}
/** Polls the async VOD generation until PRONTO (or gives up). */
async function resolveVod(eventId, excerpt) {
const url = buildVodUrl(eventId, excerpt.audioId, excerpt.tMs)
let lastStatus = null
for (let attempt = 1; attempt <= VOD_POLL_MAX_ATTEMPTS; attempt += 1) {
const status = parseVodStatus(await getJson(url, { timeoutMs: 60_000 }))
lastStatus = status
if (status.state === 'PRONTO') return { url, status, attempts: attempt }
if (status.state === 'INDISPONIVEL') return { url, status, attempts: attempt }
await sleep(VOD_POLL_INTERVAL_MS)
}
return { url, status: lastStatus, attempts: VOD_POLL_MAX_ATTEMPTS }
}
const downloadToBuffer = async (url) => {
let lastError
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const response = await fetchOnce(url, {
timeoutMs: 120_000,
headers: { 'User-Agent': CAMARA_USER_AGENT },
})
return Buffer.from(await response.arrayBuffer())
} catch (error) {
lastError = error
if (attempt < 3) await sleep(3_000)
}
}
throw lastError
}
async function transcribe(mp4Buffer) {
const apiKey = process.env.DEEPINFRA_API_KEY
if (!apiKey) die('DEEPINFRA_API_KEY ausente — configure no .env.local ou no env do processo.')
const form = new FormData()
form.append('file', new Blob([mp4Buffer], { type: 'video/mp4' }), 'trecho.mp4')
form.append('model', DEEPINFRA_WHISPER_MODEL)
form.append('language', 'pt')
form.append('response_format', 'verbose_json')
form.append('timestamp_granularities[]', 'segment')
const startedAt = Date.now()
const response = await fetch(DEEPINFRA_TRANSCRIBE_URL, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
signal: AbortSignal.timeout(600_000),
})
if (!response.ok) {
throw new Error(`Deep Infra HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`)
}
return {
transcription: normalizeTranscription(await response.json()),
elapsedMs: Date.now() - startedAt,
}
}
// ---------------------------------------------------------------------------
// Per-speech orchestration
// ---------------------------------------------------------------------------
async function processSpeech(speech, options) {
const report = {
speechAt: speech.dataHoraInicio,
type: speech.tipoDiscurso,
phase: speech.faseEvento?.titulo ?? null,
summary: speech.sumario ?? null,
officialTranscriptChars: (speech.transcricao ?? '').length,
oficialKeywords: (speech.keywords ?? '').split(/[\r\n]+/).filter(Boolean).length,
event: null,
eventUrl: null,
youtube: null,
excerpt: null,
vodState: null,
vodUrl: null,
vodAttempts: null,
downloadUrl: null,
mp4: null,
transcription: null,
failures: [],
}
try {
const date = speech.dataHoraInicio.slice(0, 10)
const events = (await getJson(eventsUrl(date))).dados ?? []
const candidates = selectSpeechEvents(events, speech.dataHoraInicio)
if (candidates.length === 0) {
report.failures.push(`nenhum evento em ${date}`)
return report
}
for (const event of candidates) {
const html = await getText(eventPageUrl(event.id), { timeoutMs: 60_000 })
const excerpt = matchExcerpt(
parseEventExcerpts(html, event.id),
speech.dataHoraInicio,
options.speaker,
)
if (!excerpt) continue
report.event = {
id: event.id,
type: event.descricaoTipo,
start: event.dataHoraInicio,
status: event.situacao,
}
report.eventUrl = eventPageUrl(event.id)
report.youtube = event.urlRegistro || null
report.excerpt = excerpt
break
}
if (!report.excerpt) {
report.failures.push(`trecho de "${options.speaker}" não encontrado no(s) evento(s) do dia`)
return report
}
const cacheKey = `${report.event.id}-${report.excerpt.audioId}-${report.excerpt.tMs}`
const cachePath = join(options.out, `${cacheKey}.mp4`)
if (await fileExists(cachePath)) {
report.mp4 = { path: cachePath, bytes: (await readFile(cachePath)).length, cached: true }
} else {
const vod = await resolveVod(report.event.id, report.excerpt)
report.vodState = vod.status.state
report.vodUrl = vod.url
report.vodAttempts = vod.attempts
report.downloadUrl = vod.status.video?.downloadUrl ?? null
if (report.vodState !== 'PRONTO' || !report.downloadUrl) {
report.failures.push(`VOD não ficou PRONTO (estado=${report.vodState})`)
return report
}
if (!options.skipDownload) {
const cached = await ensureCachedDownload({
label: 'camara:pilot',
key: cacheKey,
url: report.downloadUrl,
ext: 'mp4',
cacheDir: options.out,
download: downloadToBuffer,
})
report.mp4 = {
path: join(options.out, `${cacheKey}.mp4`),
bytes: cached.buffer.length,
sha256: cached.hash,
cached: false,
}
}
}
if (!options.skipTranscribe && report.mp4) {
const buffer = await readFile(report.mp4.path)
const { transcription, elapsedMs } = await transcribe(buffer)
const estimatedCostUsd = transcription.duration
? (transcription.duration / 60) * DEEPINFRA_COST_PER_MINUTE_USD
: null
report.transcription = {
language: transcription.language,
durationSeconds: transcription.duration,
segmentCount: transcription.segments.length,
firstSegment: transcription.segments[0] ?? null,
lastSegment: transcription.segments.at(-1) ?? null,
textPreview: transcription.text.slice(0, 300),
elapsedMs,
estimatedCostUsd,
}
}
} catch (error) {
report.failures.push(error?.message || String(error))
}
return report
}
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
const seconds = (ms) => `${(ms / 1000).toFixed(1)}s`
function printReport(reports) {
console.log('\n=== Relatório do piloto C152 ===')
for (const report of reports) {
console.log(`\n• discurso ${report.speechAt} — ${report.type ?? '?'} (${report.phase ?? '?'})`)
console.log(` resumo: ${(report.summary ?? '').slice(0, 90)}`)
console.log(
` transcrição oficial: ${report.officialTranscriptChars} chars | keywords: ${report.oficialKeywords}`,
)
if (report.event) {
console.log(
` evento: ${report.event.id} ${report.event.type} @ ${report.event.start} (${report.event.status})`,
)
console.log(` página: ${report.eventUrl}`)
console.log(` youtube: ${report.youtube ?? '—'}`)
}
if (report.excerpt) {
console.log(
` trecho: a=${report.excerpt.audioId} t=${report.excerpt.tMs} ` +
`${report.excerpt.speaker} ${report.excerpt.party ?? ''} ` +
`${report.excerpt.startTime ?? '?'} ${report.excerpt.duration ?? '?'}`,
)
}
if (report.vodState) console.log(` VOD: ${report.vodState} após ${report.vodAttempts} poll(s)`)
if (report.downloadUrl) console.log(` download: ${report.downloadUrl}`)
if (report.mp4)
console.log(` MP4: ${report.mp4.path} (${(report.mp4.bytes / 1e6).toFixed(1)} MB)`)
if (report.transcription) {
const t = report.transcription
console.log(
` ASR: ${t.segmentCount} segmentos | ${t.durationSeconds?.toFixed(1) ?? '?'}s | ` +
`${seconds(t.elapsedMs)} | ~US$ ${t.estimatedCostUsd?.toFixed(5) ?? '?'}`,
)
if (t.firstSegment)
console.log(
` primeiro: [${t.firstSegment.start}s–${t.firstSegment.end}s] ${t.firstSegment.text}`,
)
if (t.lastSegment)
console.log(
` último: [${t.lastSegment.start}s–${t.lastSegment.end}s] ${t.lastSegment.text}`,
)
}
if (report.failures.length > 0) console.log(` FALHAS: ${report.failures.join(' | ')}`)
}
const ok = reports.filter((r) => r.transcription?.segmentCount > 0).length
console.log(`\nResultado: ${ok}/${reports.length} discurso(s) com transcrição segmentada.`)
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
process.exit(0)
}
const { from, to } = resolveRange(options)
console.log(
`[camara:pilot] deputado ${SOLLA_DEPUTY_ID} | ${from}..${to} | speaker "${options.speaker}"`,
)
const speeches = (await getJson(speechesUrl(SOLLA_DEPUTY_ID, from, to))).dados ?? []
const targets = speeches
.filter((s) => (s.transcricao ?? '').trim() !== '')
.slice(0, options.limit)
if (targets.length === 0) die(`nenhum discurso com transcrição em ${from}..${to}.`)
const reports = []
for (const speech of targets) {
reports.push(await processSpeech(speech, options))
}
printReport(reports)
const reportPath = join(options.out, 'pilot-report.json')
await mkdir(options.out, { recursive: true })
await writeFile(reportPath, JSON.stringify(reports, null, 2))
console.log(`\n[camara:pilot] relatório JSON: ${reportPath}`)
}
main().catch((error) => die(error?.message || String(error)))

View file

@ -0,0 +1,312 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildVodUrl,
clockToSeconds,
matchExcerpt,
normalizeSpeakerName,
normalizeTranscription,
parseEventExcerpts,
parseVodStatus,
selectSpeechEvents,
speechDate,
speechTimeOfDaySeconds,
} from '../../scripts/lib/camaraSpeeches.mjs'
const excerptHtml = (
eventId: number,
{
audioId,
tMs,
speaker,
party,
startTime,
duration,
}: {
audioId: number
tMs: number
speaker: string
party: string
startTime: string
duration: string
},
) => `
<article class="g-chamada">
<a id="link-trecho-video" href="https://www.camara.leg.br/evento-legislativo/${eventId}?a&#x3D;${audioId}&amp;t&#x3D;${tMs}&trechosOrador=&crawl=no" class="chamada__link-trecho" titulo="${speaker}">
<div class="l-chamada-conteudo">
<h4 class="g-chamada__titulo"><b>${speaker}</b></h4>
<span class="chamada__cargo">${party}</span>
<div class="chamada__info-video">
<span class="chamada__info-video-hora">Horário - ${startTime}</span>
<br><span class="chamada__info-video-duracao">Duração - ${duration}</span>
</div>
</div>
</a>
</article>`
describe('normalizeSpeakerName', () => {
it('strips accents, case and punctuation', () => {
expect(normalizeSpeakerName(' Jorge Solla ')).toBe('JORGE SOLLA')
expect(normalizeSpeakerName('José Ação')).toBe('JOSE ACAO')
expect(normalizeSpeakerName('')).toBe('')
})
})
describe('clock helpers', () => {
it('parses HH:MM and HH:MM:SS', () => {
expect(clockToSeconds('17:30')).toBe(63000)
expect(clockToSeconds('09:28:15')).toBe(34095)
expect(clockToSeconds('24:00')).toBeNull()
expect(clockToSeconds('abc')).toBeNull()
})
it('extracts time-of-day and date from a naive datetime', () => {
expect(speechTimeOfDaySeconds('2023-02-07T17:28')).toBe(62880)
expect(speechTimeOfDaySeconds('2023-02-07')).toBeNull()
expect(speechDate('2023-02-07T17:28')).toBe('2023-02-07')
})
})
describe('parseEventExcerpts', () => {
it('reads the speaker anchors with card metadata', () => {
const html = [
excerptHtml(67091, {
audioId: 558641,
tMs: 1675801808560,
speaker: 'JORGE SOLLA',
party: 'DEPUTADO (PT-BA)',
startTime: '17:30',
duration: '0h04\'07"',
}),
excerptHtml(67091, {
audioId: 558641,
tMs: 1675792829010,
speaker: 'POMPEO DE MATTOS',
party: 'DEPUTADO (PDT-RS)',
startTime: '21:22',
duration: '0h04\'03"',
}),
].join('\n')
expect(parseEventExcerpts(html, 67091)).toEqual([
{
audioId: 558641,
tMs: 1675801808560,
speaker: 'JORGE SOLLA',
party: 'DEPUTADO (PT-BA)',
startTime: '17:30',
duration: '0h04\'07"',
},
{
audioId: 558641,
tMs: 1675792829010,
speaker: 'POMPEO DE MATTOS',
party: 'DEPUTADO (PDT-RS)',
startTime: '21:22',
duration: '0h04\'03"',
},
])
})
it('returns [] when the page has no excerpt anchors or the event id differs', () => {
const html = excerptHtml(111, {
audioId: 5,
tMs: 1000,
speaker: 'X',
party: 'Y',
startTime: '10:00',
duration: '0h01\'00"',
})
expect(parseEventExcerpts(html, 67091)).toEqual([])
expect(parseEventExcerpts('', 67091)).toEqual([])
})
it('degrades card metadata to null when the spans are missing', () => {
const html =
'<a id="link-trecho-video" href="https://www.camara.leg.br/evento-legislativo/67091?a&#x3D;5&amp;t&#x3D;1000&trechosOrador=&crawl=no" class="chamada__link-trecho" titulo="JORGE SOLLA">x</a>'
expect(parseEventExcerpts(html, 67091)).toEqual([
{
audioId: 5,
tMs: 1000,
speaker: 'JORGE SOLLA',
party: null,
startTime: null,
duration: null,
},
])
})
})
describe('buildVodUrl', () => {
it('targets the async VOD endpoint with audio id and excerpt epoch', () => {
expect(buildVodUrl(67091, 558641, 1675801808560)).toBe(
'https://www.camara.leg.br/evento-legislativo/67091/video-sob-demanda?idAudio=558641&trecho=1675801808560',
)
})
})
describe('parseVodStatus', () => {
it('normalizes the PRONTO payload', () => {
expect(
parseVodStatus({
estado: 'PRONTO',
video: {
titulo: 'Jorge Solla',
subtitulo: 'DEPUTADO (PT-BA)',
duracao: '0:04:07',
horario: "17h30'08''",
linkParaDownload: 'https://vod.camara.leg.br/vod/download?p=abc.mp4',
linkParaReproducao: 'https://vod.camara.leg.br/videos/abc.mp4',
},
}),
).toEqual({
state: 'PRONTO',
video: {
title: 'Jorge Solla',
subtitle: 'DEPUTADO (PT-BA)',
duration: '0:04:07',
clock: "17h30'08''",
downloadUrl: 'https://vod.camara.leg.br/vod/download?p=abc.mp4',
playbackUrl: 'https://vod.camara.leg.br/videos/abc.mp4',
},
})
})
it('handles GERANDO/INDISPONIVEL and malformed input', () => {
expect(parseVodStatus({ estado: 'GERANDO', video: null })).toEqual({
state: 'GERANDO',
video: null,
})
expect(parseVodStatus({ estado: 'INDISPONIVEL', video: null })).toEqual({
state: 'INDISPONIVEL',
video: null,
})
expect(parseVodStatus(null)).toEqual({ state: 'DESCONHECIDO', video: null })
})
})
describe('selectSpeechEvents', () => {
const events: Array<{ id: number; dataHoraInicio: string }> = [
{ id: 1, dataHoraInicio: '2023-02-07T15:00' },
{ id: 2, dataHoraInicio: '2023-02-07T20:00' },
{ id: 3, dataHoraInicio: '2023-02-08T15:00' },
]
it('keeps only the speech date and sorts nearest-first', () => {
const selected = selectSpeechEvents(events, '2023-02-07T17:28')
expect(selected.map((e) => e.id)).toEqual([1, 2])
})
it('is empty when nothing matches the date', () => {
expect(selectSpeechEvents(events, '2020-01-01T10:00')).toEqual([])
})
})
describe('matchExcerpt', () => {
const excerpts = [
{
audioId: 1,
tMs: 100,
speaker: 'JORGE SOLLA',
party: 'DEPUTADO (PT-BA)',
startTime: '09:28',
duration: null,
},
{
audioId: 2,
tMs: 200,
speaker: 'JORGE SOLLA',
party: 'DEPUTADO (PT-BA)',
startTime: '17:30',
duration: null,
},
{
audioId: 3,
tMs: 300,
speaker: 'OUTRO',
party: null,
startTime: '09:00',
duration: null,
},
]
it('picks the speaker excerpt nearest the speech time (accent-insensitive)', () => {
expect(matchExcerpt(excerpts, '2016-03-10T09:28', 'Jorge Solla')?.audioId).toBe(1)
expect(matchExcerpt(excerpts, '2023-02-07T17:28', 'Jorge Solla')?.audioId).toBe(2)
})
it('returns null when the speaker is absent or empty', () => {
expect(matchExcerpt(excerpts, '2016-03-10T09:28', 'Fulano de Tal')).toBeNull()
expect(matchExcerpt(excerpts, '2016-03-10T09:28', '')).toBeNull()
})
it('prefers an excerpt with a clock over one without', () => {
const withMissing = [
{ audioId: 9, tMs: 1, speaker: 'JORGE SOLLA', party: null, startTime: null, duration: null },
...excerpts,
]
expect(matchExcerpt(withMissing, '2016-03-10T09:28', 'Jorge Solla')?.audioId).toBe(1)
})
})
describe('normalizeTranscription', () => {
it('accepts the OpenAI-compat segments shape', () => {
expect(
normalizeTranscription({
text: 'olá mundo',
language: 'pt',
duration: 12.5,
segments: [
{ start: 0, end: 2.7, text: ' olá' },
{ start: 2.91, end: 4.1, text: ' mundo' },
],
}),
).toEqual({
text: 'olá mundo',
language: 'pt',
duration: 12.5,
segments: [
{ start: 0, end: 2.7, text: 'olá' },
{ start: 2.91, end: 4.1, text: 'mundo' },
],
})
})
it('accepts the native chunks shape and drops invalid entries', () => {
const result = normalizeTranscription({
chunks: [
{ timestamp: [0, 1.5], text: 'a' },
{ timestamp: [null, null], text: 'lixo' },
{ timestamp: [2, 3], text: '' },
],
language: 'pt',
})
expect(result.segments).toEqual([{ start: 0, end: 1.5, text: 'a' }])
expect(result.text).toBe('a')
})
it('degrades on malformed input', () => {
expect(normalizeTranscription(null)).toEqual({
text: '',
language: null,
duration: null,
segments: [],
})
})
it('keeps a missing duration as null instead of 0', () => {
expect(normalizeTranscription({ duration: null, segments: [] }).duration).toBeNull()
expect(normalizeTranscription({ duration: '', segments: [] }).duration).toBeNull()
expect(normalizeTranscription({ duration: 12, segments: [] }).duration).toBe(12)
})
it('prefers segments over chunks when both are present', () => {
const result = normalizeTranscription({
segments: [{ start: 0, end: 1, text: 'seg' }],
chunks: [{ timestamp: [2, 3], text: 'chunk' }],
})
expect(result.segments).toEqual([{ start: 0, end: 1, text: 'seg' }])
})
})