mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
6d676609cd
commit
fa6933c1cd
9 changed files with 1220 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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/**
|
||||
|
|
|
|||
1
docs/changelog/2026-09-12-c152.md
Normal file
1
docs/changelog/2026-09-12-c152.md
Normal 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.
|
||||
127
docs/plans/piloto-fonte-videos-camara-impl.md
Normal file
127
docs/plans/piloto-fonte-videos-camara-impl.md
Normal 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/`.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
106
docs/research/piloto-fonte-videos-camara.md
Normal file
106
docs/research/piloto-fonte-videos-camara.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
239
scripts/lib/camaraSpeeches.mjs
Normal file
239
scripts/lib/camaraSpeeches.mjs
Normal 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=(\\d+)&t=(\\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,
|
||||
}
|
||||
}
|
||||
423
scripts/pilot-camara-speeches.mjs
Normal file
423
scripts/pilot-camara-speeches.mjs
Normal 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)))
|
||||
312
tests/unit/camaraSpeeches.unit.spec.ts
Normal file
312
tests/unit/camaraSpeeches.unit.spec.ts
Normal 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=${audioId}&t=${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=5&t=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' }])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue