teqo-backup/scripts/lib/dossieCamara.mjs
Francisco Solla 4b00a8778a feat(C188): relatórios e dossiês sem truncamento mecânico
Dono único de orçamento de texto em reportText.mjs (capList,
excerptDeepDive, joinWithRemainder, summaryText/summarySurfaceText,
summaryListText), reusado por C163 e C186. Contrato summary por item nos
dois arquivos de pesquisa; superfície de resumo nunca corta com "…" e
aponta o aprofundamento; toda lista capada mostra contador; builders com
2 passadas guard-gated (integral → ponteiro → die fail-closed).

Closes #1136
2026-09-17 19:59:02 -03:00

148 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Câmara activity for the dossiê's Era C (owner): proposições and discursos of
* the deputy, always with an explicit date window (the API defaults to 7 days)
* and a per-item source URL. Network failures degrade to an explicit gap — the
* dossiê never prints a silent zero.
*/
import { getJsonWithBackoff, speechesUrl } from './camaraFetch.mjs'
import { isNonEmptyString } from './cityReportResearch.mjs'
import { SOLLA_DEPUTY_ID } from './dossieCareer.mjs'
import { excerptDeepDive } from './reportText.mjs'
const CAMARA_API = 'https://dadosabertos.camara.leg.br/api/v2'
export const CAMARA_DEFAULT_FROM = '2015-01-01'
const DEFAULT_MAX_PAGES = 2
const PAGE_SIZE = 100
const ITEMS_PER_LIST = 20
export const propositionsUrl = (deputyId, page) =>
`${CAMARA_API}/proposicoes?idDeputadoAutor=${deputyId}&itens=${PAGE_SIZE}&ordem=DESC&ordenarPor=id&pagina=${page}`
const propositionSourceUrl = (id, deputyId) =>
id
? `https://www.camara.leg.br/proposicoesWeb/fichadetramitacao?idProposicao=${id}`
: `https://www.camara.leg.br/deputados/${deputyId}`
export const normalizeProposition = (row, deputyId = SOLLA_DEPUTY_ID) => {
const sigla = isNonEmptyString(row?.siglaTipo) ? row.siglaTipo.trim() : null
const number = row?.numero ?? null
const year = row?.ano ?? null
const ementa = isNonEmptyString(row?.ementa) ? row.ementa.trim() : null
if (!sigla && !ementa) return null
const identifier = [sigla, number && year ? `${number}/${year}` : number ? String(number) : null]
.filter(Boolean)
.join(' ')
return {
title: identifier || 'Proposição de autoria do deputado',
detail: ementa,
year: year ? String(year) : null,
sourceUrl: propositionSourceUrl(row?.id, deputyId),
}
}
export const normalizeSpeech = (row, deputyId) => {
const sumario = isNonEmptyString(row?.sumario) ? row.sumario.trim() : null
const transcricao = isNonEmptyString(row?.transcricao) ? row.transcricao.trim() : null
// Deep-dive surface: an explicit "…" is honest; a silent cut would not be (C188).
const detail = excerptDeepDive(sumario ?? transcricao ?? '', 280) || null
if (!detail && !isNonEmptyString(row?.dataHoraInicio)) return null
const date = isNonEmptyString(row?.dataHoraInicio) ? row.dataHoraInicio.slice(0, 10) : null
const tipo = isNonEmptyString(row?.tipoDiscurso) ? row.tipoDiscurso.trim() : 'Pronunciamento'
return {
title: `${tipo} em ${date ?? 'data não informada'}`,
detail,
year: date ? date.slice(0, 4) : null,
sourceUrl: `${CAMARA_API}/deputados/${deputyId}/discursos`,
}
}
const fetchPaged = async ({ url, page, fetchImpl, label }) => {
const payload = await getJsonWithBackoff(`${url(page)}`, { fetchImpl, label, attempts: 2 })
return Array.isArray(payload?.dados) ? payload.dados : []
}
/**
* The Câmara `/discursos` endpoint rejects a window wider than 4 years
* ("A data de início não pode ser maior que 4 anos em relação à data final"),
* so the mandate is walked in ≤4-year windows. Pure and unit-tested.
*/
export const speechWindows = (from, to, spanYears = 4) => {
const startYear = Number(from.slice(0, 4))
const endYear = Number(to.slice(0, 4))
const windows = []
for (let year = startYear; year <= endYear; year += spanYears) {
const windowStart = `${String(year).padStart(4, '0')}-01-01`
const windowEndYear = Math.min(year + spanYears - 1, endYear)
const lastDay = new Date(Date.UTC(windowEndYear, 12, 0)).toISOString().slice(0, 10)
const windowEnd = windowEndYear === endYear ? to : lastDay
windows.push({ from: windowStart, to: windowEnd })
}
return windows
}
/**
* @param {{
* deputyId?: number,
* from?: string,
* to?: string,
* fetchImpl?: typeof fetch,
* maxPages?: number,
* label?: string,
* }} [options]
* @returns {Promise<{ status: 'ok'|'gap', items: Array<{title: string, detail: string|null, year: string|null, sourceUrl: string}>, reason: string|null, sourceUrl: string }>}
*/
export const fetchCamaraActivity = async ({
deputyId = SOLLA_DEPUTY_ID,
from = CAMARA_DEFAULT_FROM,
to = new Date().toISOString().slice(0, 10),
fetchImpl = fetch,
maxPages = DEFAULT_MAX_PAGES,
label = 'dossie-camara',
} = {}) => {
const sourceUrl = `${CAMARA_API}/deputados/${deputyId}/discursos`
try {
const propositions = []
const speeches = []
for (let page = 1; page <= maxPages; page += 1) {
const rows = await fetchPaged({
url: (p) => propositionsUrl(deputyId, p),
page,
fetchImpl,
label,
})
propositions.push(...rows.map((row) => normalizeProposition(row, deputyId)).filter(Boolean))
if (rows.length < PAGE_SIZE) break
}
for (const window of speechWindows(from, to)) {
if (speeches.length >= ITEMS_PER_LIST) break
const payload = await getJsonWithBackoff(
`${speechesUrl(deputyId, window.from, window.to)}&pagina=1`,
{
fetchImpl,
label,
attempts: 2,
},
)
const rows = Array.isArray(payload?.dados) ? payload.dados : []
speeches.push(...rows.map((row) => normalizeSpeech(row, deputyId)).filter(Boolean))
}
const items = [...propositions.slice(0, ITEMS_PER_LIST), ...speeches.slice(0, ITEMS_PER_LIST)]
if (items.length === 0) {
return {
status: 'gap',
items: [],
reason: `Sem proposições ou discursos localizados na janela ${from}–${to}.`,
sourceUrl,
}
}
return { status: 'ok', items, reason: null, sourceUrl }
} catch (error) {
return {
status: 'gap',
items: [],
reason: `Falha ao consultar a API da Câmara: ${error instanceof Error ? error.message : error}`,
sourceUrl,
}
}
}