teqo-backup/scripts/lib/camaraFetch.mjs
Francisco Solla 6c0261fd0f feat(C153): catálogo de falas do Solla — schema, access, import idempotente e busca
Collections speech/speechSegment no grupo Comunicação, papel communicator
(leitura restrita) e índice trigram normalizado para a busca por segmento.
Import idempotente da Câmara (discurso→evento→trecho→VOD→ASR) com facetas
gazetteer+LLM, proveniência e preservação de curadoria manual; parser de
quem presidia; reusa a lógica pura do piloto C152 via scripts/lib.

Related #955
2026-09-12 21:39:11 -03:00

119 lines
4.3 KiB
JavaScript

/**
* HTTP surface shared by the Câmara speech scripts (C152 pilot + C153 import):
* the open-data endpoints, the anti-crawl browser UA, bounded retry, the async
* VOD poll and the Deep Infra transcription call. Kept apart from
* `camaraSpeeches.mjs` (pure) so the fragile rules stay unit-testable without
* network.
*/
import {
buildVodUrl,
CAMARA_USER_AGENT,
DEEPINFRA_TRANSCRIBE_URL,
DEEPINFRA_WHISPER_MODEL,
normalizeTranscription,
parseVodStatus,
} from './camaraSpeeches.mjs'
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. */
export 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
}
export const getJson = async (url, options) => JSON.parse(await getText(url, options))
export const downloadToBuffer = async (url, { attempts = 3, timeoutMs = 120_000 } = {}) => {
let lastError
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetchOnce(url, {
timeoutMs,
headers: { 'User-Agent': CAMARA_USER_AGENT },
})
return Buffer.from(await response.arrayBuffer())
} catch (error) {
lastError = error
if (attempt < attempts) await sleep(3_000)
}
}
throw lastError
}
export 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`
export const eventsUrl = (date) =>
`https://dadosabertos.camara.leg.br/api/v2/eventos?dataInicio=${date}&dataFim=${date}&itens=100`
export const eventPageUrl = (eventId) => `https://www.camara.leg.br/evento-legislativo/${eventId}`
const VOD_POLL_INTERVAL_MS = 5_000
const VOD_POLL_MAX_ATTEMPTS = 40
/** Polls the async VOD generation until PRONTO (or gives up). */
export 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' || status.state === 'INDISPONIVEL') {
return { url, status, attempts: attempt }
}
await sleep(VOD_POLL_INTERVAL_MS)
}
return { url, status: lastStatus, attempts: VOD_POLL_MAX_ATTEMPTS }
}
export const DEEPINFRA_COST_PER_MINUTE_USD = 0.00045
/**
* Sends the excerpt MP4 (video/mp4 is accepted; no ffmpeg) to Deep Infra's
* OpenAI-compatible endpoint and returns the normalized transcription.
* Throws on missing key or provider error — the caller owns the failure policy.
*/
export async function transcribeSpeechAudio(buffer) {
const apiKey = process.env.DEEPINFRA_API_KEY
if (!apiKey) {
throw new Error('DEEPINFRA_API_KEY ausente — configure no .env.local ou no env do processo.')
}
const form = new FormData()
form.append('file', new Blob([buffer], { 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,
}
}