TS296 linhas8.871 bytes
apps/worker/src/services/adm.ts
SHA-256
aa685211a417703dc2c0f9ed27476884e9a9a3fcd520b54af4bd1c3fe64ef74c
Somente leiturafonte-acf635076da2
import type { Env, CeapMes } from '../types'
const CACHE_TTL_SECONDS = 6 * 60 * 60
const LEG57_ANOS = [2023, 2024, 2025, 2026]
const TENTATIVAS = 3
/**
* Upstream do ADM fora do ar ou respondendo em formato inesperado.
*
* Existe para que uma indisponibilidade NUNCA seja confundida com "senador
* não teve despesas": em 2026-08-02 uma falha silenciosa zerou o CEAP dos 81
* senadores e o ranking persistiu esses zeros por uma semana inteira.
*/
export class AdmIndisponivelError extends Error {
constructor(url: string, motivo: string) {
super(`ADM indisponível (${url}): ${motivo}`)
this.name = 'AdmIndisponivelError'
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
/**
* Extrai a lista de registros. O ADM serve tanto array puro (despesas_ceaps)
* quanto o envelope `{ statusCode, msg, data: [...] }` (endpoints migrados em
* 2026). Qualquer outra forma devolve null e é tratada como falha.
*/
function extrairRegistros(payload: unknown): Record<string, unknown>[] | null {
if (Array.isArray(payload)) return payload as Record<string, unknown>[]
if (payload && typeof payload === 'object') {
const interno = (payload as { data?: unknown }).data
if (Array.isArray(interno)) return interno as Record<string, unknown>[]
}
return null
}
async function fetchAdm(url: string): Promise<Record<string, unknown>[]> {
let motivo = 'desconhecido'
for (let tentativa = 0; tentativa < TENTATIVAS; tentativa++) {
try {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(20_000),
})
if (!res.ok) {
motivo = `HTTP ${res.status}`
} else {
const registros = extrairRegistros(await res.json())
if (registros) return registros
motivo = 'resposta sem lista de registros'
}
} catch (err) {
motivo = (err as Error).message || String(err)
}
// adm.senado.gov.br dá ConnectTimeout esporádico; backoff 0.5s / 1s
if (tentativa < TENTATIVAS - 1) await sleep(500 * 2 ** tentativa)
}
throw new AdmIndisponivelError(url, `${motivo} (${TENTATIVAS} tentativas)`)
}
/**
* Sempre devolve uma lista de registros ou lança. Só grava no cache payload já
* validado, para não envenenar o cache com resposta degradada.
*/
async function cachedFetch(
env: Env,
cacheKey: string,
url: string,
): Promise<Record<string, unknown>[]> {
const cached = await env.SENADO_CACHE.get(cacheKey, 'json')
if (cached !== null) {
const registros = extrairRegistros(cached)
if (registros) return registros
}
const dados = await fetchAdm(url)
await env.SENADO_CACHE.put(cacheKey, JSON.stringify(dados), {
expirationTtl: CACHE_TTL_SECONDS,
})
return dados
}
async function getCeapByYear(
env: Env,
ano: number,
): Promise<Record<string, number>> {
const cacheKey = `adm:ceap:${ano}`
const data = await cachedFetch(
env,
cacheKey,
`${env.ADM_BASE_URL}/api/v1/senadores/despesas_ceaps/${ano}`,
)
const totais: Record<string, number> = {}
for (const reg of data) {
const cod = String(reg?.codSenador ?? '')
const valor = Number(reg?.valorReembolsado ?? 0)
if (cod && cod !== 'undefined') {
totais[cod] = (totais[cod] ?? 0) + valor
}
}
return totais
}
/**
* Retorna CEAP total acumulado de cada senador na legislatura 57 inteira.
*
* Lança se o resultado vier vazio: uma legislatura inteira sem nenhuma despesa
* é sempre falha de upstream, nunca dado real. Sem essa guarda o ranking
* persiste zeros e o site mostra "sem cota" para todos os senadores.
*/
export async function getCeapLeg57(
env: Env,
): Promise<Record<string, number>> {
const totais: Record<string, number> = {}
for (const ano of LEG57_ANOS) {
const ceapAno = await getCeapByYear(env, ano)
for (const [cod, valor] of Object.entries(ceapAno)) {
totais[cod] = (totais[cod] ?? 0) + valor
}
}
if (Object.keys(totais).length === 0) {
throw new AdmIndisponivelError(
`${env.ADM_BASE_URL}/api/v1/senadores/despesas_ceaps`,
`nenhuma despesa em ${LEG57_ANOS.join('/')} — upstream degradado`,
)
}
return totais
}
export async function getCeapMensal(
env: Env,
codigoParlamentar: string,
ano: number,
): Promise<CeapMes[]> {
const cacheKey = `adm:ceap:ano:${ano}`
const data = await cachedFetch(
env,
cacheKey,
`${env.ADM_BASE_URL}/api/v1/senadores/despesas_ceaps/${ano}`,
)
const meses: Record<number, number> = {}
for (const reg of data) {
const cod = String(reg?.codSenador ?? '')
if (cod !== codigoParlamentar) continue
const mes = Number(reg?.mes ?? 0)
const valor = Number(reg?.valorReembolsado ?? 0)
if (mes >= 1 && mes <= 12) {
meses[mes] = (meses[mes] ?? 0) + valor
}
}
return Object.entries(meses)
.map(([mes, valorTotal]) => ({ mes: parseInt(mes, 10), ano, valorTotal }))
.sort((a, b) => a.mes - b.mes)
}
/** ADM devolve flags como "S"/"N" — Boolean("N") seria `true`. */
function flagSN(valor: unknown): boolean {
if (typeof valor === 'boolean') return valor
return String(valor ?? '').trim().toUpperCase() === 'S'
}
/**
* Retorna mapa nomeNormalizado → { auxilioMoradia, imovelFuncional }
* (API não tem código do senador, usa nome — fazemos match por nome normalizado).
*/
export async function getAuxiliosMoradia(
env: Env,
): Promise<Record<string, { auxilioMoradia: boolean; imovelFuncional: boolean }>> {
const data = await cachedFetch(
env,
'adm:auxilios:moradia',
`${env.ADM_BASE_URL}/api/v1/senadores/auxilio-moradia`,
)
const result: Record<string, { auxilioMoradia: boolean; imovelFuncional: boolean }> = {}
for (const reg of data) {
const nome = normalizeNome(String(reg?.nomeParlamentar ?? ''))
if (!nome) continue
result[nome] = {
auxilioMoradia: flagSN(reg?.auxilioMoradia),
imovelFuncional: flagSN(reg?.imovelFuncional),
}
}
return result
}
/**
* Retorna mapa codSenador → { total, divulgacao, escritorio, locomocao, consultoria, outros }
* para a legislatura inteira (somando todos os anos).
*/
export async function getCeapBreakdownLeg57(
env: Env,
): Promise<
Record<
string,
{
total: number
divulgacao: number
escritorio: number
locomocao: number
consultoria: number
outros: number
}
>
> {
const out: Record<
string,
{
total: number
divulgacao: number
escritorio: number
locomocao: number
consultoria: number
outros: number
}
> = {}
for (const ano of LEG57_ANOS) {
const data = await cachedFetch(
env,
`adm:ceap:ano:${ano}`,
`${env.ADM_BASE_URL}/api/v1/senadores/despesas_ceaps/${ano}`,
)
for (const reg of data) {
const cod = String(reg?.codSenador ?? '')
if (!cod || cod === 'undefined') continue
const valor = Number(reg?.valorReembolsado ?? 0)
const tipo = String(reg?.tipoDespesa ?? '').toLowerCase()
if (!out[cod]) {
out[cod] = {
total: 0,
divulgacao: 0,
escritorio: 0,
locomocao: 0,
consultoria: 0,
outros: 0,
}
}
const b = out[cod]
b.total += valor
if (tipo.includes('divulga')) {
b.divulgacao += valor
} else if (tipo.includes('escritório') || tipo.includes('aluguel') || tipo.includes('material de consumo')) {
b.escritorio += valor
} else if (tipo.includes('passag') || tipo.includes('locomo') || tipo.includes('combust')) {
b.locomocao += valor
} else if (tipo.includes('consultor') || tipo.includes('assessoria') || tipo.includes('pesquisa')) {
b.consultoria += valor
} else {
b.outros += valor
}
}
}
return out
}
/**
* Retorna mapa nomeNormalizado → numero de escritórios de apoio.
* (API não expoe código do senador, usamos nome.)
*/
export async function getEscritoriosCount(
env: Env,
): Promise<Record<string, number>> {
// /escritorios responde 302 → /escritorios-apoio desde 2026-06; o nome do
// parlamentar passou a vir aninhado em `parlamentar.nome`.
const data = await cachedFetch(
env,
'adm:escritorios:lista',
`${env.ADM_BASE_URL}/api/v1/senadores/escritorios-apoio`,
)
const out: Record<string, number> = {}
for (const reg of data) {
const parlamentar = reg?.parlamentar as { nome?: unknown } | undefined
const nome = normalizeNome(String(parlamentar?.nome ?? reg?.nome ?? ''))
if (!nome) continue
out[nome] = (out[nome] ?? 0) + 1
}
return out
}
export function normalizeNome(nome: string): string {
return nome
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9 ]/g, '')
.replace(/\s+/g, ' ')
.trim()
}