Iniciativa Cidadã Independente · Transparência Parlamentar
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()]
}