TS145 linhas7.266 bytes
apps/worker/src/services/processos.ts
SHA-256
bfb64b95a0a32e7a1f574c0b96e459e9999c97d98f47bd0bf5820944a8e0671a
Somente leiturafonte-0531042d9f3e
import type { Env } from '../types'
import type { Periodo } from './legis'
import { classificarTramitacao, ehNormativa, PROCESSOS_CACHE_PREFIX } from './proposicoes'
import type { AutoriaAvaliada } from './proposicoes'
const INICIO = '2023-02-01'
const FIM = '2027-01-31'
const TTL = 6 * 3600
type Registro = Record<string, unknown>
const pendentes = new WeakMap<object, Map<string, Promise<unknown>>>()
let ativos = 0
const fila: Array<() => void> = []
let proximaRequisicao = 0
let pausaAte = 0
async function aguardarFonte(): Promise<void> {
// Limite global inclusive entre senadores: no máximo quatro inícios/s.
for (;;) {
const espera = Math.max(proximaRequisicao, pausaAte) - Date.now()
if (espera <= 0) {
proximaRequisicao = Date.now() + 250
return
}
await new Promise((resolve) => setTimeout(resolve, espera))
}
}
export function esperaRetryAfter(valor: string | null, agora = Date.now()): number {
const segundos = valor && /^\d+(?:\.\d+)?$/.test(valor) ? Number(valor) : NaN
const atraso = Number.isFinite(segundos) ? segundos * 1000 : Date.parse(valor ?? '') - agora
return Number.isFinite(atraso) ? Math.max(1000, atraso) : 60_000
}
async function limitado<T>(fn: () => Promise<T>): Promise<T> {
if (ativos >= 6) await new Promise<void>((resolve) => fila.push(resolve))
else ativos++
try { return await fn() } finally {
const proximo = fila.shift()
if (proximo) proximo()
else ativos--
}
}
async function buscar(env: Env, chave: string, url: string): Promise<unknown> {
let mapa = pendentes.get(env.SENADO_CACHE)
if (!mapa) { mapa = new Map(); pendentes.set(env.SENADO_CACHE, mapa) }
const anterior = mapa.get(chave)
if (anterior) return anterior
const promessa = (async () => {
const cache = await env.SENADO_CACHE.get(chave, 'json')
if (cache !== null) return cache
return limitado(async () => {
let erro: unknown
for (const atraso of [0, 1000, 3000, 5000]) {
if (atraso) await new Promise((resolve) => setTimeout(resolve, atraso))
try {
await aguardarFonte()
const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(30_000) })
if (res.status === 429) {
pausaAte = Math.max(pausaAte, Date.now() + esperaRetryAfter(res.headers.get('Retry-After')))
}
if (!res.ok) throw new Error(`processos: HTTP ${res.status}`)
const data: unknown = await res.json()
// Erros de formato nunca viram listas vazias nem resultados cacheados.
const valido = chave.includes(':autor:') ? Array.isArray(data) :
data && typeof data === 'object' && !Array.isArray(data) &&
Number((data as Registro).id) > 0 && (data as Registro).documento && Array.isArray((data as Registro).autuacoes)
if (!valido) throw new Error(`processos: formato de resposta inválido em ${url}`)
// O detalhe contém documentos e anexos volumosos. Guardar só a
// evidência usada pelo indicador e pela listagem pública de autorias.
const registro = data as Registro
const compacto = chave.includes(':autor:') ? data : {
id: registro.id, codigoMateria: registro.codigoMateria,
identificacao: registro.identificacao, sigla: registro.sigla,
idProcessoCasaInicial: registro.idProcessoCasaInicial,
documento: { dataApresentacao: (registro.documento as Registro).dataApresentacao },
situacaoAtual: registro.situacaoAtual, siglaSituacaoAtual: registro.siglaSituacaoAtual,
dataSituacaoAtual: registro.dataSituacaoAtual,
deliberacao: registro.deliberacao, normaGerada: registro.normaGerada,
autuacoes: (registro.autuacoes as Registro[]).filter((a) => Number(a.numero) === 1).map((a) => ({
numero: a.numero,
situacoes: a.situacoes,
informesLegislativos: ((a.informesLegislativos ?? []) as Registro[])
.filter((i) => i.siglaSituacaoIniciada === 'APRVPAR')
.map((i) => ({ data: i.data, siglaSituacaoIniciada: i.siglaSituacaoIniciada, descricao: i.descricao })),
})),
}
await env.SENADO_CACHE.put(chave, JSON.stringify(compacto), { expirationTtl: TTL })
return compacto
} catch (err) { erro = err }
}
throw erro
})
})()
mapa.set(chave, promessa)
try { return await promessa } finally { mapa.delete(chave) }
}
export function corteProposicoes(periodo?: Periodo, hoje = new Date().toISOString().slice(0, 10)): string {
return [periodo?.fim ?? FIM, FIM, hoje].sort()[0]
}
/** Janela fixa: `tramitouLegislaturaAtual` mudaria silenciosamente na Leg. 58. */
export async function listarProcessosAutor(env: Env, codigo: string): Promise<Registro[]> {
const url = `${env.LEGIS_BASE_URL}/processo?codigoParlamentarAutor=${encodeURIComponent(codigo)}&dataInicioApresentacao=${INICIO}&dataFimApresentacao=${FIM}`
const data = await buscar(env, `${PROCESSOS_CACHE_PREFIX}:autor:${codigo}`, url)
if (!Array.isArray(data) || data.some((p) => !p || typeof p !== 'object' || !Number.isSafeInteger(Number(p.id)) || Number(p.id) <= 0 || !/^\d{4}-\d{2}-\d{2}$/.test(String(p.dataApresentacao)))) {
throw new Error('processos: lista de autorias inválida')
}
return data as Registro[]
}
export async function avaliarAutorias(env: Env, codigo: string, periodo?: Periodo): Promise<AutoriaAvaliada[]> {
const corte = corteProposicoes(periodo)
const inicio = periodo?.inicio ?? INICIO
const lista = await listarProcessosAutor(env, codigo)
const unicos = new Map<string, Registro>()
for (const p of lista) {
const dt = String(p.dataApresentacao)
if (dt >= inicio && dt <= corte) unicos.set(String(p.id), p)
}
const resultados = await Promise.allSettled([...unicos.values()].map(async (p) => {
let processo = p
if (ehNormativa(p)) {
const detalhe = await buscar(env, `${PROCESSOS_CACHE_PREFIX}:detalhe:${p.id}`, `${env.LEGIS_BASE_URL}/processo/${p.id}`) as Registro
if (Number(detalhe.id) !== Number(p.id)) throw new Error('processos: detalhe não corresponde à autoria')
processo = { ...p, ...detalhe, dataApresentacao: p.dataApresentacao }
}
return { processo, tramitacao: classificarTramitacao(processo, corte) }
}))
const erro = resultados.find((r): r is PromiseRejectedResult => r.status === 'rejected')
if (erro) throw erro.reason
const avaliadas = resultados.map((r) => (r as PromiseFulfilledResult<AutoriaAvaliada>).value)
// Renumerações/autuações de um mesmo projeto não geram novas contribuições.
const porProjeto = new Map<string, AutoriaAvaliada>()
avaliadas.sort((a, b) => String(a.processo.dataApresentacao).localeCompare(String(b.processo.dataApresentacao)) || Number(a.processo.id) - Number(b.processo.id))
for (const a of avaliadas) {
const id = String(a.processo.idProcessoCasaInicial || a.processo.codigoMateria || a.processo.id)
const anterior = porProjeto.get(id)
if (!anterior) porProjeto.set(id, a)
else if (a.tramitacao.pontos > anterior.tramitacao.pontos) anterior.tramitacao = a.tramitacao
}
return [...porProjeto.values()]
}