senado-br-mcp 3.18.5 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -565,7 +565,7 @@ Fields of the `concise` block (contract v1.3 since 3.18.5; per response — one
565
565
  | `citation` | Ready-to-use citation string (human-readable) |
566
566
  | `license` | Source terms (Dados Abertos do Senado Federal) |
567
567
  | `field_sources` | *Optional.* Per-field sub-sources, each with its own extraction instant — only on responses that merge reads (v1.2, since 3.17.0; see below) |
568
- | `derived` / `derivation_note` | *Optional.* `true` + a fixed note when the server computed the values — the `estatisticas` modes (distribution, aggregates, rankings) of `senado_remuneracoes_servidores`, `senado_horas_extras`, `senado_ceaps`, `senado_execucao_orcamentaria` and `senado_suprimento_fundos`; the individual values they cite remain the Senate's own |
568
+ | `derived` / `derivation_note` | *Optional.* `true` + a fixed pt-BR note naming the calculation whenever the server computed values instead of copying them (since 3.19.0, the whole class): the `estatisticas` modes (distribution, aggregates, rankings); group sums, counts and averages — CEAPS `valorTotal` and its per-senator/type/month/supplier modes, the budget execution aggregates (`por-ano`, the default, included), the payroll `resumo`, the overtime `valorTotal`, the `relatoria` distribution, seated senators per party/UF, the historical e-Cidadania national totals; the payroll gross (`bruto`, sum of seven components); servants assigned to an org subtree (`subordinadasA`); a vote tally counted from nominal votes when the source publishes none (plenary open roll calls, committee votes without totals); e-Cidadania percentages, consensus/polarisation rankings and the estimated idea creation date; the current legislature's period. Absent when every number is the source's own (raw lists, `detalhe` of the budget, published tallies). The individual values cited remain the Senate's |
569
569
  | `revision` | Whether the figure may still change at the source: `status` is `current` for every Senate source (the version in force at extraction; none is claimed `final`), and `note` says, for the administrative and budget sources, that the Senate may correct published data later |
570
570
 
571
571
  No Senate source publishes notices alongside the data, so `notices` (also part of v1.3) does not appear. The text footer gains a line only for exceptions — here, the computed-values line on the `estatisticas` modes.
@@ -688,7 +688,7 @@ The frozen NDJSON is **not** committed (built from the sovereign D1 corpus on de
688
688
  | `senado_ecidadania_obter_consulta` | Detalhe de uma consulta: votos, autor, relator, comentários |
689
689
  | `senado_ecidadania_consultas_analise` | Analisa o conjunto completo de consultas **abertas** via `modo` (consenso/polarizada); `status` padrão `aberta` |
690
690
  | `senado_ecidadania_listar_ideias` | Ideias legislativas de cidadãos; ranking das mais apoiadas via `ordenarPor: apoios` |
691
- | `senado_ecidadania_obter_ideia` | Detalhe de uma ideia: texto, apoios, status de conversão em projeto |
691
+ | `senado_ecidadania_obter_ideia` | Detalhe de uma ideia: texto, apoios, data em que foi proposta, linha do tempo (meta, Sugestão, decisão da CDH, encerramento) e proposições em que virou |
692
692
  | `senado_ecidadania_listar_eventos` | Eventos interativos (audiências, sabatinas, lives); ranking dos mais comentados via `ordenarPor` |
693
693
  | `senado_ecidadania_obter_evento` | Detalhe de um evento: pauta, convidados, link de vídeo |
694
694
  | `senado_ecidadania_sugerir_tema_enquete` | Sugere temas para enquete mensal a partir de critérios configuráveis |
@@ -167,8 +167,8 @@ const ideiasSchema = {
167
167
  type: "string",
168
168
  description: "Título da ideia legislativa.",
169
169
  sourceEndpoint: EP_IDEIAS_LISTING,
170
- sourceField: "article.resumo-ideia > section > a (texto da âncora)",
171
- operationalization: "Texto da âncora da seção, com entidades HTML decodificadas.",
170
+ sourceField: "li.resumo-ideia > a > h4 (até 11/10/2026: article.resumo-ideia > section > a)",
171
+ operationalization: "Texto do <h4> do bloco (antes de 12/10/2026, da âncora da seção), com entidades HTML decodificadas.",
172
172
  },
173
173
  {
174
174
  name: "apoios",
@@ -176,8 +176,8 @@ const ideiasSchema = {
176
176
  unit: "apoios",
177
177
  description: "Número de apoios recebidos pela ideia.",
178
178
  sourceEndpoint: EP_IDEIAS_LISTING,
179
- sourceField: "article.resumo-ideia > figure.grafico-ideia-legislativa > footer > span[1] (\"N apoios\")",
180
- operationalization: "Primeiro <span> do footer do figure.grafico-ideia-legislativa (\"253.804 apoios\"); número BR via parseBrNum. O span de meta (limiar) é ignorado.",
179
+ sourceField: "li.resumo-ideia > a > figure.componente-grafico-ideia > figcaption > span.contabilizacao (\"N apoios confirmados\"; até 11/10/2026: article.resumo-ideia > figure.grafico-ideia-legislativa > footer > span[1])",
180
+ operationalization: "Número do span.contabilizacao (\"253.804 apoios confirmados\"; antes de 12/10/2026, primeiro <span> do footer do figure.grafico-ideia-legislativa); número BR via parseBrNum. O span de meta (\"Meta de 20.000 apoios\") é ignorado.",
181
181
  },
182
182
  {
183
183
  name: "status",
@@ -185,7 +185,7 @@ const ideiasSchema = {
185
185
  description: "Situação da ideia: \"aberta\" | \"encerrada\" | \"convertida\" | \"removida\" (retirada do portal; demais campos = último estado conhecido).",
186
186
  sourceEndpoint: EP_IDEIAS_LISTING,
187
187
  sourceField: "(derivado) parâmetro GET situacao da listagem varrida",
188
- operationalization: "A listagem é varrida por situacao=N; o status é o mapa SITUACAO_STATUS ({5,6,8}→aberta, {7,9}→encerrada, 10→convertida) do valor usado no crawl. Ideia que sai das listas vivas tem a página de detalhe lida e recebe o status do texto de situação (mesmo mapa); \"removida\" quando o detalhe responde HTTP 410 (desde 06/10/2026).",
188
+ operationalization: "A listagem é varrida por situacao=N; o status é o mapa SITUACAO_STATUS ({5,6,8}→aberta, {7,9}→encerrada, 10→convertida) do valor usado no crawl. Ideia que sai das listas vivas tem a página de detalhe lida: até 12/10/2026 recebia o status do texto de situação (mesmo mapa); a página redesenhada não publica mais a situação, e desde a 3.20.0 o status vem da categoria da listagem quando a ideia aparece na fatia da situacao 7 da mesma rodada e, senão, é classificado pela linha do tempo do detalhe (regra que reproduz a categoria do portal em 335 de 336 ideias medidas em 17/10/2026). \"removida\" quando o detalhe responde HTTP 410 (desde 06/10/2026).",
189
189
  derived: true,
190
190
  },
191
191
  {
@@ -193,7 +193,7 @@ const ideiasSchema = {
193
193
  type: "date",
194
194
  description: "Data limite para atingir os 20.000 apoios (única âncora temporal upstream da ideia). NÃO é a data de criação: o nome é histórico, mantido pela compatibilidade do esquema.",
195
195
  sourceEndpoint: EP_IDEIAS_DETALHE,
196
- sourceField: "página de detalhe: bloco \"Data limite para receber 20.000 apoios\" (DD/MM/AAAA)",
196
+ sourceField: "página de detalhe: até 12/10/2026 o bloco \"Data limite para receber 20.000 apoios\"; depois, a linha do tempo \"Essa ideia pode receber apoios até DD/MM/AAAA\"",
197
197
  operationalization: "extractDate sobre a data-limite exibida no detalhe (visualizacaoideia) → ISO YYYY-MM-DD; lido no crawl de detalhe. Reaberto na v2 (era null por design na v1). Data provável de criação = esta data − 120 dias (regra dos 4 meses do portal; medida exata em 4 ideias convertidas de 2017-2020 contra a base de Pereira, UFPE 2024) — derivação do leitor, não variável do dataset.",
198
198
  caveat: "Em ideia aberta esta data está no futuro (é o fim do prazo de apoios). Detail-only (v2): null nas ideias ainda não enriquecidas pelo backfill de detalhe (censura à esquerda residual nesses campos até o backfill completar).",
199
199
  },
@@ -202,7 +202,7 @@ const ideiasSchema = {
202
202
  type: "string",
203
203
  description: "UF do cidadão autor da ideia (SEM nome — conteúdo de cidadão).",
204
204
  sourceEndpoint: EP_IDEIAS_DETALHE,
205
- sourceField: "página de detalhe: \"Ideia proposta por … (UF)\" — apenas o \"(UF)\"",
205
+ sourceField: "página de detalhe: \"Ideia proposta por … (UF)\" (na linha do tempo desde 12/10/2026) — apenas o \"(UF)\"",
206
206
  operationalization: "Extrai só a sigla de UF entre parênteses; o NOME do cidadão é descartado NA ORIGEM (nunca gravado no corpus). Postura de privacidade: conteúdo de cidadão → só UF.",
207
207
  caveat: "Detail-only (v2): null nas ideias ainda não enriquecidas. Nome do autor deliberadamente não coletado.",
208
208
  },
@@ -220,8 +220,8 @@ const ideiasSchema = {
220
220
  type: "string",
221
221
  description: "Proposição gerada pela ideia (ex.: \"SUG 12/2024\"), quando convertida.",
222
222
  sourceEndpoint: EP_IDEIAS_DETALHE,
223
- sourceField: "página de detalhe: identificação da proposição (SUG/PL nº)",
224
- operationalization: "Regex de identificação de proposição no detalhe; null quando a ideia não gerou proposição.",
223
+ sourceField: "página de detalhe: identificação da proposição (SUG/PL nº); desde a 3.20.0, o acontecimento \"Essa ideia gerou a Sugestão nº N, de AAAA\" da linha do tempo",
224
+ operationalization: "Até a 3.19: regex de identificação de proposição no detalhe. Desde a 3.20.0 (página redesenhada): a Sugestão gerada, \"SUG N/AAAA\"; as proposições em que a ideia virou (pela CDH ou adotada por parlamentar) saem só no detalhe ao vivo (senado_ecidadania_obter_ideia). Leitura sem Sugestão não apaga valor já gravado.",
225
225
  caveat: "Detail-only (v2): null nas ideias ainda não enriquecidas ou não convertidas.",
226
226
  },
227
227
  {
@@ -13,6 +13,8 @@ import { UpstreamError as PkgUpstreamError } from "@sbissoli/mcp-upstream";
13
13
  import { UPSTREAM_TIMEOUT_MS } from "../types.js";
14
14
  import { USER_AGENT } from "../version.js";
15
15
  import { CorpoForaDoPrazoError, PrazoDoCorpo, upstreamCall } from "../throttle/upstream.js";
16
+ import { lerBrNum, mesclarCampo } from "./mesclar.js";
17
+ import { lerIdeiaDetalhe } from "./ideia-detalhe.js";
16
18
  export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
17
19
  /**
18
20
  * Error carrying a `retryable` flag, mirroring UpstreamError so the tool layer
@@ -123,7 +125,7 @@ async function idaEcidadania(path, headers, rotulo) {
123
125
  throw e;
124
126
  }
125
127
  }
126
- /** Parse Brazilian number: 1.234.567 -> 1234567 */
128
+ /** Parse Brazilian number: 1.234.567 -> 1234567. Ausente/lixo → 0: NÃO use em escritor do corpus (ver `lerBrNum`). */
127
129
  export function parseBrNum(s) {
128
130
  return parseInt(s.replace(/\./g, ""), 10) || 0;
129
131
  }
@@ -206,21 +208,34 @@ export async function obterConsultaDetalheCorpus(id) {
206
208
  const html = await fetchPage(`/visualizacaomateria?id=${id}`);
207
209
  return parseConsultaDetalheCorpus(html);
208
210
  }
211
+ export async function lerDestaquesConsultas() {
212
+ const data = await fetchEcidadaniaJson("/restcolecaomaismateria");
213
+ return data.map((item) => ({
214
+ id: item.id,
215
+ materia: item.identificacaoBasica || "",
216
+ ementa: item.ementa || "",
217
+ votosSim: lerBrNum(item.votosFavor),
218
+ votosNao: lerBrNum(item.votosContra),
219
+ totalVotos: lerBrNum(item.totalVotos),
220
+ }));
221
+ }
209
222
  export async function listarConsultasInternal(params) {
210
223
  const { limite = 20 } = params;
211
- const data = await fetchEcidadaniaJson("/restcolecaomaismateria");
224
+ const data = await lerDestaquesConsultas();
212
225
  // status is hardcoded "aberta" here because this path serves only the REST "highlight"
213
226
  // collection (/restcolecaomais*), which lists active consultations by construction. Real
214
227
  // per-item status for the full set is derived from /processo (tramitando) in the ingestion
215
228
  // job, which is the source of truth. Edge case: a just-closed highlight may briefly show
216
229
  // "aberta" until the next corpus run corrects it — acceptable on this cold-start/fallback path.
230
+ // Contagem ausente vira 0 só AQUI, na saída ao vivo (comportamento histórico da tool); o splice
231
+ // do corpus lê `lerDestaquesConsultas` e não grava esse 0.
217
232
  return data.slice(0, limite).map((item) => buildConsultaResumo({
218
233
  id: item.id,
219
- materia: item.identificacaoBasica || "",
220
- ementa: item.ementa || "",
221
- votosSim: parseBrNum(String(item.votosFavor || "0")),
222
- votosNao: parseBrNum(String(item.votosContra || "0")),
223
- totalVotos: parseBrNum(String(item.totalVotos || "0")),
234
+ materia: item.materia,
235
+ ementa: item.ementa,
236
+ votosSim: item.votosSim ?? 0,
237
+ votosNao: item.votosNao ?? 0,
238
+ totalVotos: item.totalVotos ?? 0,
224
239
  status: "aberta",
225
240
  }));
226
241
  }
@@ -284,9 +299,17 @@ export const IDEIA_PRAZO_APOIOS_DIAS = 120;
284
299
  * da tool: a descrição é lida uma vez, o dado é lido a cada resposta, e a data limite de uma ideia
285
300
  * aberta cai no futuro — sem o aviso o leitor conclui que o servidor errou.
286
301
  */
287
- export const AVISO_DATAS_IDEIA = "dataPublicacao é a data limite para receber os 20.000 apoios, a única data que o portal publica (o nome do campo é histórico); " +
302
+ export const AVISO_DATAS_IDEIA = "dataPublicacao é a data limite para receber os 20.000 apoios, a única data que o corpus guarda (o nome do campo é histórico); " +
288
303
  "por isso cai no futuro em ideia aberta. dataCriacaoEstimada = data limite menos 120 dias, calculada pelo servidor " +
289
- "(regra dos 4 meses do portal, medida em 4 ideias de 2017-2020): é estimativa, não dado da fonte.";
304
+ "(regra dos 4 meses do portal, medida em 4 ideias de 2017-2020): é estimativa, não dado da fonte. " +
305
+ "A data real em que a ideia foi proposta sai em senado_ecidadania_obter_ideia (dataProposta).";
306
+ /**
307
+ * Aviso de datas do DETALHE: desde o redesenho de 12/10/2026 a página publica a data de proposição
308
+ * na linha do tempo. `dataCriacaoEstimada` fica por compatibilidade.
309
+ */
310
+ export const AVISO_DATAS_IDEIA_DETALHE = "dataProposta é a data em que a ideia foi proposta, publicada pela fonte — use esta. dataPublicacao (= prazoApoios) é a " +
311
+ "data limite para receber os 20.000 apoios (o nome do campo é histórico). dataCriacaoEstimada (data limite menos 120 dias, " +
312
+ "calculada pelo servidor) é estimativa mantida por compatibilidade.";
290
313
  /**
291
314
  * Data provável de criação da ideia: data limite (ISO) − `IDEIA_PRAZO_APOIOS_DIAS`. Aritmética em UTC
292
315
  * sobre o dia civil (sem fuso, sem horário de verão). Data ausente ou inválida → null, nunca uma data
@@ -329,124 +352,37 @@ export function buildIdeiaResumo(fields) {
329
352
  url: fields.url || `${ECIDADANIA_BASE}/visualizacaoideia?id=${fields.id}`,
330
353
  };
331
354
  }
332
- /**
333
- * Texto da situação na página de detalhe (`<em>` em section[title="Situação da Ideia"]) → vocabulário
334
- * de status do corpus (aberta | encerrada | convertida), o MESMO que `SITUACAO_STATUS` atribui por
335
- * bucket da listagem. Uma função só, usada pela tool ao vivo e pelo crawl de transições — antes
336
- * cada um tinha o seu mapeamento, e o da tool lia "Não acatada" (bucket 9, encerrada) como aberta.
337
- *
338
- * Textos REAIS medidos em 06/10/2026, uma ideia por bucket:
339
- * s5 "Aberta" · s6 "Na comissão" · s8 "Aguardando envio à CDH" → aberta
340
- * s7 "Encerrada - Sem apoio suficiente" · s9 "Não acatada" → encerrada
341
- * s10 "Convertida em Proposição" → convertida
342
- *
343
- * Comparação EXATA (sem caixa e sem acento), decisão do dono em 06/10/2026: texto novo do portal vira
344
- * null e aparece no log, em vez de ser gravado no histórico com um status adivinhado por palavra-chave.
345
- * Se o log mostrar um texto novo, acrescente-o aqui e no teste dos textos medidos.
346
- */
347
355
  /**
348
356
  * Status de corpus de uma ideia que o portal REMOVEU (detalhe responde HTTP 410). Não vem de texto de
349
357
  * situação — o portal não publica nada sobre ela —, só da resposta 410. Decisão do dono (06/10/2026):
350
358
  * manter o registro e o histórico no banco com este status, em vez de apagar ou deixar "aberta".
351
359
  */
352
360
  export const STATUS_IDEIA_REMOVIDA = "removida";
353
- export function statusFromSituacaoIdeia(texto) {
354
- const t = texto.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
355
- if (t === "aberta" || t === "na comissao" || t === "aguardando envio a cdh")
356
- return "aberta";
357
- if (t === "encerrada - sem apoio suficiente" || t === "nao acatada")
358
- return "encerrada";
359
- if (t === "convertida em proposicao")
360
- return "convertida";
361
- return null;
362
- }
363
- /** Texto cru da situação da ideia na página de detalhe, ou null se a seção não existe. */
364
- export function extractSituacaoIdeia(html) {
365
- const m = html.match(/title="Situa[çc][ãa]o da Ideia"[\s\S]*?<em>([^<]+)<\/em>/i);
366
- // O portal manda o texto em UTF-8 literal ("Não acatada", medido 06/10/2026) — sem entidades.
367
- return m ? m[1].replace(/\s+/g, " ").trim() : null;
368
- }
369
- /**
370
- * Pure parser of an idea detail page (`visualizacaoideia?id=`) for the CORPUS — extracts only the
371
- * four detail fields, keeping ONLY the UF of the citizen author (name discarded at source, never
372
- * returned). Distinct from `obterIdeiaInternal` (the live tool), which still surfaces the portal's
373
- * semi-anonymized author string; this corpus path never touches the name.
374
- */
375
- export function parseIdeiaDetalheCorpus(html) {
376
- const text = stripHtml(html);
377
- // "Ideia proposta por <span>NOME</span> <span>(UF)</span>" — capturamos SÓ a UF.
378
- const autorMatch = html.match(/Ideia proposta por<\/div>\s*<div[^>]*>\s*<span>[^<]+<\/span>\s*<span>\s*\(([A-Z]{2})\)<\/span>/);
379
- const autorUf = autorMatch ? autorMatch[1] : null;
380
- const dataMatch = html.match(/Data limite[\s\S]*?<div[^>]*>\s*(\d{2}\/\d{2}\/\d{4})\s*<\/div>/i);
381
- const dataPublicacao = dataMatch ? extractDate(dataMatch[1]) : null;
382
- const descMatch = html.match(/<b><div[^>]*>[^<]+<\/div><\/b>\s*<div[^>]*>([\s\S]*?)<\/div>/);
383
- const descricao = descMatch ? stripHtml(descMatch[1]).substring(0, 2000) || null : null;
384
- const plMatch = text.match(/(SUGEST[ÃA]O|PEC|PL|PLP)\s*n?º?\s*(\d+)\s*(?:de\s*)?(\d{4})/i);
385
- const plConvertido = plMatch
386
- ? `${plMatch[1].toUpperCase().includes("SUGEST") ? "SUG" : plMatch[1].toUpperCase()} ${plMatch[2]}/${plMatch[3]}`
387
- : null;
388
- const apoiosMatch = html.match(/class="contabilizacao"[^>]*>([^<]+)</);
389
- const apoios = apoiosMatch ? parseBrNum(apoiosMatch[1]) : null;
390
- const situacao = extractSituacaoIdeia(html);
391
- const status = situacao === null ? null : statusFromSituacaoIdeia(situacao);
392
- return { dataPublicacao, autorUf, descricao, plConvertido, apoios, status };
393
- }
394
- /** Fetch + parse the corpus-facing (UF-only) idea detail. */
395
- export async function obterIdeiaDetalheCorpus(id) {
396
- const html = await fetchPage(`/visualizacaoideia?id=${id}`);
397
- return parseIdeiaDetalheCorpus(html);
361
+ export async function lerDestaquesIdeias() {
362
+ const data = await fetchEcidadaniaJson("/restcolecaomaisideia");
363
+ return data.map((item) => ({ id: item.id, titulo: item.titulo || "", apoios: lerBrNum(item.apoiamentos) }));
398
364
  }
399
365
  export async function listarIdeiasInternal(params) {
400
366
  const { limite = 20 } = params;
401
- const data = await fetchEcidadaniaJson("/restcolecaomaisideia");
367
+ const data = await lerDestaquesIdeias();
402
368
  // status is hardcoded "aberta" (via the builder default) because this REST "highlight" collection
403
369
  // lists active ideas by construction; real per-idea status for the full set comes from the
404
370
  // per-situacao corpus crawl (the source of truth). See buildConsultaResumo for the same rationale.
405
- let ideias = data.map((item) => buildIdeiaResumo({
406
- id: item.id,
407
- titulo: item.titulo || "",
408
- apoios: parseBrNum(String(item.apoiamentos || "0")),
409
- }));
371
+ // apoios ausente vira 0 só na saída ao vivo (histórico da tool); o splice não grava esse 0.
372
+ let ideias = data.map((item) => buildIdeiaResumo({ id: item.id, titulo: item.titulo, apoios: item.apoios ?? 0 }));
410
373
  if (params.ordenarPor === "apoios") {
411
374
  ideias.sort((a, b) => params.ordem === "asc" ? a.apoios - b.apoios : b.apoios - a.apoios);
412
375
  }
413
376
  return ideias.slice(0, limite);
414
377
  }
378
+ /**
379
+ * Detalhe de uma ideia ao vivo (`senado_ecidadania_obter_ideia`), pelo leitor da página redesenhada
380
+ * em 12/10/2026 (`lerIdeiaDetalhe`). Devolve a leitura como o portal publica; a situação NÃO sai daqui
381
+ * (a página não a publica) — a tool a toma do corpus ou a deriva da linha do tempo. Página fora do
382
+ * molde lança `IdeiaDetalheForaDoMoldeError` (não retentável); 410 lança antes, em `fetchPage`.
383
+ */
415
384
  export async function obterIdeiaInternal(id) {
416
- const html = await fetchPage(`/visualizacaoideia?id=${id}`);
417
- const text = stripHtml(html);
418
- // Title: <b><div style="font-size:24px;...">TITLE</div></b> inside article#ideia-legislativa
419
- const tituloMatch = html.match(/id="ideia-legislativa"[\s\S]*?<b><div[^>]*>([^<]+)<\/div><\/b>/);
420
- const titulo = tituloMatch ? stripHtml(tituloMatch[1]) : "";
421
- // Description: first <div style="margin-bottom:15px;"> after the title that has content
422
- const descMatch = html.match(/<b><div[^>]*>[^<]+<\/div><\/b>\s*<div[^>]*>([\s\S]*?)<\/div>/);
423
- const descricao = descMatch ? stripHtml(descMatch[1]).substring(0, 2000) : "";
424
- // Support count: <span class="contabilizacao">17.978</span>
425
- const apoiosMatch = html.match(/class="contabilizacao"[^>]*>([^<]+)</);
426
- const apoios = apoiosMatch ? parseBrNum(apoiosMatch[1]) : 0;
427
- // Status: o mesmo mapeamento do corpus (statusFromSituacaoIdeia). Sem seção ou texto não
428
- // reconhecido, mantém o "aberta" histórico desta tool — o default antigo, agora só para o
429
- // desconhecido, não mais para "Não acatada".
430
- const situacao = extractSituacaoIdeia(html);
431
- const status = (situacao !== null ? statusFromSituacaoIdeia(situacao) : null) ?? "aberta";
432
- // Author: after "Ideia proposta por"
433
- const autorMatch = html.match(/Ideia proposta por<\/div>\s*<div[^>]*>\s*<span>([^<]+)<\/span>\s*<span>\s*\(([A-Z]{2})\)<\/span>/);
434
- // Date limit
435
- const dataMatch = html.match(/Data limite[\s\S]*?<div[^>]*>\s*(\d{2}\/\d{2}\/\d{4})\s*<\/div>/i);
436
- const dataPublicacao = dataMatch ? extractDate(dataMatch[1]) : extractDate(text);
437
- const comentarioMatch = text.match(/(\d+)\s*coment[aá]rio/i);
438
- const plMatch = text.match(/(SUGEST[ÃA]O|PEC|PL|PLP)\s*n?º?\s*(\d+)\s*(?:de\s*)?(\d{4})/i);
439
- return {
440
- id, titulo, descricao, apoios, dataPublicacao, status,
441
- autor: autorMatch ? `${autorMatch[1].trim()} (${autorMatch[2]})` : null,
442
- url: `${ECIDADANIA_BASE}/visualizacaoideia?id=${id}`,
443
- problema: null, solucao: null,
444
- comentarios: comentarioMatch ? parseInt(comentarioMatch[1]) : 0,
445
- dataEncerramento: null,
446
- plConvertido: plMatch
447
- ? `${plMatch[1].toUpperCase().includes("SUGEST") ? "SUG" : plMatch[1].toUpperCase()} ${plMatch[2]}/${plMatch[3]}`
448
- : null,
449
- };
385
+ return lerIdeiaDetalhe(await fetchPage(`/visualizacaoideia?id=${id}`));
450
386
  }
451
387
  /**
452
388
  * Canonical `EventoResumo` builder — single source of the object's field order, so the JSON payload
@@ -709,19 +645,21 @@ export async function contarComentariosAudiencia(id) {
709
645
  */
710
646
  export function buildEventoResumoEnriquecido(fields) {
711
647
  const d = fields.detalhe ?? null;
648
+ const g = fields.guardado;
712
649
  return buildEventoResumo({
713
650
  id: fields.id,
714
651
  titulo: d?.titulo || fields.titulo,
715
- data: d?.data ?? fields.dataListagem ?? null,
716
- hora: d?.hora ?? fields.horaListagem ?? null,
717
- comissao: fields.comissao ?? d?.comissao ?? null,
718
- comissaoNomeCompleto: d?.comissaoNomeCompleto ?? null,
719
- local: d?.local ?? null,
720
- descricao: d?.descricao ?? null,
721
- pauta: d?.pauta ?? [],
722
- convidados: d?.convidados ?? [],
723
- videoUrl: d?.videoUrl ?? null,
724
- comentarios: fields.comentariosCanon ?? fields.comentariosListagem ?? 0,
652
+ data: mesclarCampo(d?.data, g?.data) ?? fields.dataListagem ?? null,
653
+ hora: mesclarCampo(d?.hora, g?.hora) ?? fields.horaListagem ?? null,
654
+ comissao: fields.comissao ?? mesclarCampo(d?.comissao, g?.comissao),
655
+ comissaoNomeCompleto: mesclarCampo(d?.comissaoNomeCompleto, g?.comissaoNomeCompleto),
656
+ local: mesclarCampo(d?.local, g?.local),
657
+ descricao: mesclarCampo(d?.descricao, g?.descricao),
658
+ pauta: mesclarCampo(d?.pauta, g?.pauta) ?? [],
659
+ convidados: mesclarCampo(d?.convidados, g?.convidados) ?? [],
660
+ videoUrl: mesclarCampo(d?.videoUrl, g?.videoUrl),
661
+ // A contagem da listagem é a degradada (0 espúrio em 82% dos eventos, estudo A3): só por último.
662
+ comentarios: mesclarCampo(fields.comentariosCanon, g?.comentarios) ?? fields.comentariosListagem ?? 0,
725
663
  status: fields.status ?? d?.status,
726
664
  });
727
665
  }