/** * 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 * `` 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} excerpts * @param {string} speechIso * @param {string} speaker * @returns {(ReturnType[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, } }