Iniciativa Cidadã Independente · Transparência Parlamentar
TS400 linhas14.145 bytes

apps/worker/src/services/newsletter-data.ts

SHA-256

9cf3bebcfc43830b96c608da8ff0e794ed01e33899d91f39cde1fb0b32b00ad4

Somente leiturafonte-acf635076da2
/**
 * Coleta deltas semanais para a newsletter:
 *  - Top 10 atual + comparação com snapshot anterior
 *  - Top movers (subiram/desceram >= 5 posições)
 *  - Atividade legislativa da semana (autorias, votações)
 *  - Movimentações (afastados / retornados / suplentes assumindo)
 *  - Agenda de plenário e comissões da próxima semana
 *
 * Saída: NewsletterData estruturado para alimentar o prompt da IA.
 */

import type { Env, IdsScore } from '../types'
import { getLatestRanking } from './ranking'
import { getSenadorList, getAfastadosFull } from './legis'

export interface NewsletterTopItem {
  posicao: number
  posicaoAnterior?: number
  delta?: number
  senadorCod: string
  nome: string
  partido: string
  uf: string
  status?: string
  idsTotal: number
  idsAnterior?: number
  deltaIds?: number
  destaque?: string
  ceapTotalAno?: number
  pctDivulgacao?: number
}

export interface NewsletterCeap {
  fonteAno: number
  topGastadores: Array<{ nome: string; partido: string; uf: string; total: number }>
  topPctDivulgacao: Array<{ nome: string; partido: string; uf: string; pct: number; total: number }>
  mediaGastoAno: number
}

export interface NewsletterMovimentacao {
  tipo: 'entrou' | 'saiu' | 'afastou'
  nome: string
  partido?: string
  uf?: string
  motivo?: string
  data?: string
}

export interface NewsletterAtividade {
  totalProcessosNovos: number
  totalVotacoes: number
  totalDiscursos: number
  topAutores?: Array<{ nome: string; partido: string; uf: string; quantidade: number }>
}

export interface NewsletterAgendaItem {
  dataHora: string
  titulo: string
  local: string
  tipo: string
}

export interface NewsletterData {
  edicao: string                       // ex: "2026-W19"
  semana: { inicio: string; fim: string }
  semanaAnterior: { inicio: string; fim: string }
  totalSenadores: number
  panorama: {
    idsMedio: number
    idsMaior: number
    idsMenor: number
    confiancaAlta: number
    confiancaBaixa: number
  }
  topAtual: NewsletterTopItem[]
  bottomAtual: NewsletterTopItem[]
  topMovers: NewsletterTopItem[]       // mudanças relevantes (>= 5 posições ou >= 10% IDS)
  movimentacoes: NewsletterMovimentacao[]
  atividade: NewsletterAtividade
  agenda: NewsletterAgendaItem[]
  ceap: NewsletterCeap
  fontes: string[]
  geradoEm: string
}

function isoWeek(d: Date): string {
  const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
  const day = t.getUTCDay() || 7
  t.setUTCDate(t.getUTCDate() + 4 - day)
  const yearStart = new Date(Date.UTC(t.getUTCFullYear(), 0, 1))
  const weekNum = Math.ceil(((t.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
  return `${t.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`
}

function rangeSemana(now = new Date()): { inicio: string; fim: string } {
  // Semana ISO: segunda → domingo. Para a edição que sai segunda 09:00 BRT, a
  // "semana referência" é a anterior fechada (segunda 7d atrás → domingo 1d atrás).
  const dia = now.getUTCDay() || 7  // 1..7 (segunda=1)
  const fimSemanaAnterior = new Date(now)
  fimSemanaAnterior.setUTCDate(now.getUTCDate() - dia)  // domingo
  const inicio = new Date(fimSemanaAnterior)
  inicio.setUTCDate(fimSemanaAnterior.getUTCDate() - 6)  // segunda
  return {
    inicio: inicio.toISOString().slice(0, 10),
    fim: fimSemanaAnterior.toISOString().slice(0, 10),
  }
}

function shiftSemana(s: { inicio: string; fim: string }): { inicio: string; fim: string } {
  const [yi, mi, di] = s.inicio.split('-').map(Number)
  const [yf, mf, df] = s.fim.split('-').map(Number)
  const i = new Date(Date.UTC(yi, mi - 1, di - 7))
  const f = new Date(Date.UTC(yf, mf - 1, df - 7))
  return {
    inicio: i.toISOString().slice(0, 10),
    fim: f.toISOString().slice(0, 10),
  }
}

async function snapshotEm(env: Env, dataLimite: string): Promise<IdsScore[]> {
  const row = await env.SENADO_DB.prepare(
    `SELECT MAX(computed_at) as ts FROM ranking_snapshots WHERE computed_at <= ?`,
  ).bind(dataLimite + 'T23:59:59Z').first<{ ts: string }>()
  if (!row?.ts) return []
  const result = await env.SENADO_DB.prepare(
    `SELECT * FROM ranking_snapshots WHERE computed_at = ? ORDER BY ids_total DESC`,
  ).bind(row.ts).all<Record<string, unknown>>()
  return (result.results ?? []).map((r) => ({
    senadorCod: String(r.senador_cod),
    nome: String(r.nome),
    partido: String(r.partido ?? ''),
    uf: String(r.uf ?? ''),
    bloco: String(r.bloco ?? ''),
    fotoUrl: String(r.foto_url ?? ''),
    idsTotal: Number(r.ids_total),
    dimProdutividade: Number(r.dim_produtividade),
    dimEfetividade: Number(r.dim_efetividade),
    dimParticipacao: Number(r.dim_participacao),
    dimFiscalizacao: Number(r.dim_fiscalizacao),
    dimCeap: Number(r.dim_ceap),
    dimTransparencia: Number(r.dim_transparencia),
    autoriasTotal: Number(r.autorias_total ?? 0),
    autoriasAprovadas: Number(r.autorias_aprovadas ?? 0),
    votacaoPresentes: Number(r.votacoes_presentes ?? 0),
    votacoesTotal: Number(r.votacoes_total ?? 0),
    relatoriasTotal: Number(r.relatorias_total ?? 0),
    discursosTotal: Number(r.discursos_total ?? 0),
    apartesTotal: Number(r.apartes_total ?? 0),
    ceapTotalAno: Number(r.ceap_total_ano ?? 0),
    pctDivulgacao: Number(r.pct_divulgacao ?? 0),
    mesesAtivos: Number(r.meses_ativos ?? 0),
    status: (r.status ?? undefined) as IdsScore['status'],
    confianca: (r.confianca ?? undefined) as IdsScore['confianca'],
  }))
}

async function fetchAtividadeSemanal(
  env: Env,
  inicio: string,
  fim: string,
): Promise<NewsletterAtividade> {
  // /processo aceita data de apresentação; usamos para contar matérias novas.
  const urlProc = `${env.LEGIS_BASE_URL}/processo?dataInicioApresentacao=${inicio}&dataFimApresentacao=${fim}`
  const urlVot = `${env.LEGIS_BASE_URL}/votacao?dataInicio=${inicio}&dataFim=${fim}`

  const fetchJson = async (url: string): Promise<unknown[]> => {
    try {
      const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(30_000) })
      if (!res.ok) return []
      const j = (await res.json()) as unknown
      return Array.isArray(j) ? j : []
    } catch {
      return []
    }
  }

  const [processos, votacoes] = await Promise.all([fetchJson(urlProc), fetchJson(urlVot)])

  // Top autores na semana (estima por contagem em "autoria" string — heurística)
  const autorCount = new Map<string, number>()
  for (const p of processos) {
    const obj = p as Record<string, unknown>
    const a = String(obj.autoria ?? '')
    const matches = a.match(/Senador[a]?\s+([^(]+)\s*\([A-Z]+\/[A-Z]{2}\)/g) ?? []
    for (const m of matches) {
      const trimmed = m.trim()
      autorCount.set(trimmed, (autorCount.get(trimmed) ?? 0) + 1)
    }
  }
  const topAutores = [...autorCount.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
    .map(([raw, quantidade]) => {
      const m = raw.match(/Senador[a]?\s+([^(]+)\(([A-Z]+)\/([A-Z]{2})\)/)
      return {
        nome: (m?.[1] ?? raw).trim(),
        partido: m?.[2] ?? '',
        uf: m?.[3] ?? '',
        quantidade,
      }
    })

  // Discursos da semana (fonte global é cara — pulamos por enquanto)
  return {
    totalProcessosNovos: processos.length,
    totalVotacoes: votacoes.length,
    totalDiscursos: 0,
    topAutores,
  }
}

async function fetchAgenda(env: Env): Promise<NewsletterAgendaItem[]> {
  const url = `${env.LEGIS_BASE_URL}/plenario/agenda/atual/iCal`
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(20_000) })
    if (!res.ok) return []
    const ics = await res.text()
    return parseICalAgenda(ics).slice(0, 8)
  } catch {
    return []
  }
}

function parseICalAgenda(ics: string): NewsletterAgendaItem[] {
  const events: NewsletterAgendaItem[] = []
  const blocks = ics.split('BEGIN:VEVENT').slice(1)
  for (const b of blocks) {
    const get = (k: string) => {
      const m = b.match(new RegExp(`${k}[^:]*:([^\\r\\n]+)`))
      return m ? m[1].trim() : ''
    }
    const dtstart = get('DTSTART')
    const summary = get('SUMMARY').replace(/\\n/g, ' ').replace(/\\,/g, ',')
    const location = get('LOCATION')
    if (!dtstart || !summary) continue
    const dataHora = formatIcsDate(dtstart)
    if (!dataHora) continue
    events.push({
      dataHora,
      titulo: summary,
      local: location,
      tipo: summary.toLowerCase().includes('comissão') ? 'comissão' : 'plenário',
    })
  }
  // Filtrar pra próximos 8 dias
  const hoje = new Date().toISOString().slice(0, 10)
  return events.filter((e) => e.dataHora.slice(0, 10) >= hoje).sort((a, b) => a.dataHora.localeCompare(b.dataHora))
}

function formatIcsDate(s: string): string {
  // ex: 20260512T140000Z ou 20260512
  const m = s.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2}))?/)
  if (!m) return ''
  const [, y, mo, d, h, mi] = m
  if (h && mi) return `${y}-${mo}-${d}T${h}:${mi}:00Z`
  return `${y}-${mo}-${d}T00:00:00Z`
}

export async function coletarDadosSemanais(env: Env): Promise<NewsletterData> {
  const now = new Date()
  const semana = rangeSemana(now)
  const semanaAnterior = shiftSemana(semana)
  const edicao = isoWeek(now)

  const [atual, anterior, lista, afastados, atividade, agenda] = await Promise.all([
    getLatestRanking(env),
    snapshotEm(env, semanaAnterior.fim),
    getSenadorList(env),
    getAfastadosFull(env),
    fetchAtividadeSemanal(env, semana.inicio, semana.fim),
    fetchAgenda(env),
  ])

  const posAnteriorMap = new Map<string, { pos: number; ids: number }>()
  anterior.forEach((s, i) => posAnteriorMap.set(s.senadorCod, { pos: i + 1, ids: s.idsTotal }))

  const enriched: NewsletterTopItem[] = atual.map((s, i) => {
    const prev = posAnteriorMap.get(s.senadorCod)
    return {
      posicao: i + 1,
      posicaoAnterior: prev?.pos,
      delta: prev ? prev.pos - (i + 1) : undefined,  // delta>0 = subiu
      senadorCod: s.senadorCod,
      nome: s.nome,
      partido: s.partido,
      uf: s.uf,
      status: s.status,
      idsTotal: s.idsTotal,
      idsAnterior: prev?.ids,
      deltaIds: prev ? Math.round((s.idsTotal - prev.ids) * 10) / 10 : undefined,
      ceapTotalAno: s.ceapTotalAno,
      pctDivulgacao: s.pctDivulgacao,
    }
  })

  const topAtual = enriched.slice(0, 10)
  const bottomAtual = enriched.slice(-5).reverse()

  const topMovers = enriched
    .filter((e) => e.delta !== undefined && (Math.abs(e.delta) >= 5 || (e.deltaIds !== undefined && Math.abs(e.deltaIds) >= 5)))
    .sort((a, b) => Math.abs(b.delta ?? 0) - Math.abs(a.delta ?? 0))
    .slice(0, 6)

  // Movimentações: quem entrou ou saiu do EXERCÍCIO na semana.
  //
  // A partir do IDS v4 ninguém sai mais do ranking — quem encerra o mandato
  // permanece avaliado para o prêmio da legislatura. Comparar presença nas
  // listas, portanto, deixaria de detectar saídas e reportaria os encerrados
  // históricos como "entrou" na primeira execução. O sinal correto é a flag
  // de exercício, não a presença.
  const emExercicioAgora = new Set(
    lista.filter((s) => s.emExercicio !== false).map((s) => s.codigo),
  )
  const emExercicioAntes = new Set(
    anterior.filter((s) => s.emExercicio !== false).map((s) => s.senadorCod),
  )
  const movimentacoes: NewsletterMovimentacao[] = []
  for (const s of lista) {
    if (emExercicioAgora.has(s.codigo) && !emExercicioAntes.has(s.codigo)) {
      movimentacoes.push({ tipo: 'entrou', nome: s.nome, partido: s.partido, uf: s.uf })
    }
  }
  for (const a of anterior) {
    if (emExercicioAntes.has(a.senadorCod) && !emExercicioAgora.has(a.senadorCod)) {
      const af = afastados.find((x) => x.codigo === a.senadorCod)
      movimentacoes.push({
        tipo: af ? 'afastou' : 'saiu',
        nome: a.nome,
        partido: a.partido,
        uf: a.uf,
        motivo: af?.motivo,
        data: af?.dataInicio,
      })
    }
  }

  // Panorama
  const idsValores = atual.map((s) => s.idsTotal)
  const panorama = {
    idsMedio: Math.round((idsValores.reduce((a, b) => a + b, 0) / Math.max(idsValores.length, 1)) * 10) / 10,
    idsMaior: idsValores.length ? Math.round(Math.max(...idsValores) * 10) / 10 : 0,
    idsMenor: idsValores.length ? Math.round(Math.min(...idsValores) * 10) / 10 : 0,
    confiancaAlta: atual.filter((s) => s.confianca === 'alta').length,
    confiancaBaixa: atual.filter((s) => s.confianca === 'baixa').length,
  }

  // CEAP — agregados a partir do snapshot atual.
  // OBS: ceap_total_ano e pct_divulgacao são valores acumulados do ano corrente
  // (não dá pra atribuir SOMENTE à semana — a fonte ADM é mensal/anual).
  const anoCorrente = new Date().getUTCFullYear()
  const comCeap = atual.filter((s) => (s.ceapTotalAno ?? 0) > 0)
  const totalCeap = comCeap.reduce((acc, s) => acc + (s.ceapTotalAno ?? 0), 0)
  const ceap: NewsletterCeap = {
    fonteAno: anoCorrente,
    topGastadores: [...comCeap]
      .sort((a, b) => (b.ceapTotalAno ?? 0) - (a.ceapTotalAno ?? 0))
      .slice(0, 5)
      .map((s) => ({
        nome: s.nome,
        partido: s.partido,
        uf: s.uf,
        total: Math.round((s.ceapTotalAno ?? 0) * 100) / 100,
      })),
    topPctDivulgacao: [...comCeap]
      .filter((s) => (s.pctDivulgacao ?? 0) > 0)
      .sort((a, b) => (b.pctDivulgacao ?? 0) - (a.pctDivulgacao ?? 0))
      .slice(0, 5)
      .map((s) => ({
        nome: s.nome,
        partido: s.partido,
        uf: s.uf,
        pct: Math.round((s.pctDivulgacao ?? 0) * 10) / 10,
        total: Math.round((s.ceapTotalAno ?? 0) * 100) / 100,
      })),
    mediaGastoAno: comCeap.length ? Math.round((totalCeap / comCeap.length) * 100) / 100 : 0,
  }

  return {
    edicao,
    semana,
    semanaAnterior,
    totalSenadores: atual.length,
    panorama,
    topAtual,
    bottomAtual,
    topMovers,
    movimentacoes: movimentacoes.slice(0, 10),
    atividade,
    agenda,
    ceap,
    fontes: [
      'https://legis.senado.leg.br/dadosabertos/processo',
      'https://legis.senado.leg.br/dadosabertos/votacao',
      'https://legis.senado.leg.br/dadosabertos/plenario/agenda/atual/iCal',
      'https://observasenado.org',
    ],
    geradoEm: now.toISOString(),
  }
}