senado-br-mcp 3.19.0 → 3.20.1

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
@@ -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,18 +193,18 @@ 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
  },
200
200
  {
201
201
  name: "autorUf",
202
202
  type: "string",
203
- description: "UF do cidadão autor da ideia (SEM nome — conteúdo de cidadão).",
203
+ description: "Localidade do cidadão autor da ideia como o portal a publica: a sigla da UF ou \"Exterior\" (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)\"",
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
- caveat: "Detail-only (v2): null nas ideias ainda não enriquecidas. Nome do autor deliberadamente não coletado.",
205
+ sourceField: "página de detalhe: \"Ideia proposta por … (UF)\" (na linha do tempo desde 12/10/2026) — apenas o parêntese",
206
+ operationalization: "Guarda o parêntese depois do nome tal como publicado: uma das 27 siglas de UF ou \"Exterior\" (os únicos valores medidos em 18/10/2026); parêntese vazio → null. O NOME do cidadão é descartado NA ORIGEM (nunca gravado no corpus).",
207
+ caveat: "Não é sempre uma UF: \"Exterior\" marca autor fora do país (antes de 18/10/2026 esse caso era gravado como null). null = ideia ainda não enriquecida pelo detalhe, ou portal sem localidade publicada. Nome do autor deliberadamente não coletado.",
208
208
  },
209
209
  {
210
210
  name: "descricao",
@@ -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
  }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Leitor da página de detalhe de uma ideia legislativa (`visualizacaoideia?id=`) na forma redesenhada
3
+ * pelo portal em 12/10/2026: a seção "Situação da Ideia" sumiu e virou uma "Linha do Tempo" de
4
+ * acontecimentos datados (`<ol class="timeline">`, mais recente no topo), e a UF do autor e a data
5
+ * limite mudaram de marcação. Fase A1 do ROADMAP ("Detalhe das ideias redesenhado…").
6
+ *
7
+ * Vocabulário FECHADO, medido em 17/10/2026 sobre 340 ideias de 2013 a 2026 em todas as situações
8
+ * (`docs/_local/ideias-linha-do-tempo-2026-10-17/`): cada item da linha do tempo é de um dos tipos de
9
+ * `TipoAcontecimento`; o que não casar com nenhum vira `naoReconhecido`, é copiado verbatim e ganha um
10
+ * aviso — nunca é encaixado no tipo mais parecido. Um item que casa com um tipo mas não entrega os
11
+ * campos do tipo (deriva parcial), ou um acontecimento de valor único que aparece duas vezes (duas
12
+ * Sugestões, duas decisões da CDH…), LANÇA `IdeiaDetalheForaDoMoldeError`: escolher um dos dois seria
13
+ * adivinhar, e quem chama (crawler, tool) decide o que fazer com a página — nunca gravar meio registro.
14
+ *
15
+ * Tudo aqui é COPIADO do que o portal publica: nenhuma situação é inferida (a situação do portal não é
16
+ * o último acontecimento — medido —, ela vem do bucket da listagem). As únicas padronizações são de
17
+ * forma, sem perda: datas para ISO, "Senadora"/"Deputada" para `transformadaPor` + `sexo`, número de
18
+ * proposição em partes. O nome do autor é registrado como o portal publica (abreviado), decisão do
19
+ * dono de 17/10/2026; os nomes de parlamentares são de agentes públicos.
20
+ *
21
+ * Puro: sem rede, sem banco. A página 410 (ideia removida) nunca chega aqui — `fetchPage` a trata pelo
22
+ * status HTTP.
23
+ */
24
+ export class IdeiaDetalheForaDoMoldeError extends Error {
25
+ /** Repetir não muda a página; quem conserta é o leitor (telemetria: `defeito`, ver `anexarClasse`). */
26
+ retryable = false;
27
+ classe = "defeito";
28
+ constructor(message) {
29
+ super(`detalhe da ideia fora do molde medido: ${message}`);
30
+ this.name = "IdeiaDetalheForaDoMoldeError";
31
+ }
32
+ }
33
+ // ── utilidades de texto ───────────────────────────────────────────────────
34
+ function decodificar(s) {
35
+ return s
36
+ .replace(/&nbsp;/g, " ")
37
+ .replace(/&quot;/g, '"')
38
+ .replace(/&#39;/g, "'")
39
+ .replace(/&lt;/g, "<")
40
+ .replace(/&gt;/g, ">")
41
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
42
+ .replace(/&amp;/g, "&");
43
+ }
44
+ /**
45
+ * Texto sem tags. Tags de bloco viram espaço; tags de linha somem sem espaço — o portal parte palavras
46
+ * em `<span>` ("pel<span>o</span>"), e trocar toda tag por espaço daria "pel o Deputado".
47
+ */
48
+ export function textoDoHtml(html) {
49
+ return decodificar(html
50
+ .replace(/<!--[\s\S]*?-->/g, " ")
51
+ .replace(/<\/?(?:p|div|li|ul|ol|h\d|br|section|figure|figcaption)\b[^>]*>/gi, " ")
52
+ .replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
53
+ }
54
+ /** dd/mm/aaaa → aaaa-mm-dd (validado: 31/02 é recusado, não "corrigido"). */
55
+ function isoDeDataBr(s, onde) {
56
+ const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(s.trim());
57
+ if (!m)
58
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: data "${s}" não é dd/mm/aaaa`);
59
+ return isoValida(Number(m[3]), Number(m[2]), Number(m[1]), onde);
60
+ }
61
+ const MESES = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"];
62
+ /** "26 de junho de 2018" → 2018-06-26. */
63
+ function isoDeDataExtenso(s, onde) {
64
+ const m = /^(\d{1,2}) de (\p{L}+) de (\d{4})$/u.exec(s.trim());
65
+ const mes = m ? MESES.indexOf(m[2].toLowerCase()) + 1 : 0;
66
+ if (!m || mes === 0)
67
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: data "${s}" não é "d de mês de aaaa"`);
68
+ return isoValida(Number(m[3]), mes, Number(m[1]), onde);
69
+ }
70
+ function isoValida(ano, mes, dia, onde) {
71
+ const d = new Date(Date.UTC(ano, mes - 1, dia));
72
+ if (d.getUTCFullYear() !== ano || d.getUTCMonth() !== mes - 1 || d.getUTCDate() !== dia) {
73
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: data inexistente ${dia}/${mes}/${ano}`);
74
+ }
75
+ return d.toISOString().slice(0, 10);
76
+ }
77
+ function inteiroBr(s) {
78
+ return parseInt(s.replace(/\./g, ""), 10);
79
+ }
80
+ /** "Projeto de Lei nº 3630, de 2021 (Substitutivo-CD)" → partes; null se não casar. */
81
+ export function partesDaProposicao(rotulo) {
82
+ const m = /^(.+?) nº ([\d.]+), de (\d{4})(?: \(([^)]+)\))?$/.exec(rotulo.trim());
83
+ return m ? { tipoNome: m[1], numero: inteiroBr(m[2]), ano: Number(m[3]), complemento: m[4] ?? null } : null;
84
+ }
85
+ function referenciaSugestao(rotulo, onde) {
86
+ const p = partesDaProposicao(rotulo);
87
+ if (!p || p.tipoNome !== "Sugestão" || p.complemento) {
88
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: "${rotulo}" não é "Sugestão nº N, de AAAA"`);
89
+ }
90
+ return { numero: p.numero, ano: p.ano };
91
+ }
92
+ /** Links `<a href>` do item, com o texto já limpo. */
93
+ function links(html) {
94
+ return [...html.matchAll(/<a\b[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => ({ href: decodificar(m[1]), texto: textoDoHtml(m[2]) }));
95
+ }
96
+ /**
97
+ * Link de uma proposição: o primeiro "… nº N, de AAAA" que não é a Sugestão (a decisão da CDH cita as
98
+ * duas) nem a Consulta Pública do próprio portal.
99
+ */
100
+ function linkDaProposicao(html, onde) {
101
+ const l = links(html).find((x) => !/^\/?ecidadania\//.test(x.href) && !x.texto.startsWith("Sugestão") && partesDaProposicao(x.texto));
102
+ if (!l)
103
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: proposição sem link "… nº N, de AAAA"`);
104
+ return l;
105
+ }
106
+ /**
107
+ * Construtor canônico: ordem de campos FIXA em todos os caminhos (o corpus compara payloads por hash,
108
+ * e ordem diferente faria a mesma proposição parecer mudada — ver `buildIdeiaResumo`).
109
+ */
110
+ function novaProposicao(p) {
111
+ return {
112
+ ordem: p.ordem, data: p.data, transformadaPor: p.transformadaPor, sexo: p.sexo,
113
+ parlamentar: p.parlamentar, parlamentarUf: p.parlamentarUf, tipoSigla: p.tipoSigla,
114
+ tipoNome: p.tipoNome, numero: p.numero, ano: p.ano, complemento: p.complemento, url: p.url,
115
+ };
116
+ }
117
+ // ── leitura de um item ────────────────────────────────────────────────────
118
+ const CARGOS = {
119
+ Senador: { por: "Senador", sexo: "masculino" },
120
+ Senadora: { por: "Senador", sexo: "feminino" },
121
+ Deputado: { por: "Deputado", sexo: "masculino" },
122
+ Deputada: { por: "Deputado", sexo: "feminino" },
123
+ };
124
+ /** Campo de valor único: a segunda ocorrência é molde novo, nunca sobrescrita. */
125
+ function unico(c, campo, valor) {
126
+ if (c.ideia[campo] !== null)
127
+ throw new IdeiaDetalheForaDoMoldeError(`${c.onde}: segundo acontecimento para "${String(campo)}"`);
128
+ c.ideia[campo] = valor;
129
+ }
130
+ /**
131
+ * Valores do parêntese do autor medidos em 18/10/2026 nas 2.968 ideias gravadas sem UF: as 27 siglas
132
+ * e "Exterior". O valor é guardado COMO PUBLICADO (classificação da fonte), e o vazio é null. Qualquer
133
+ * outro texto é página fora do molde — o vigia o vê, em vez de um valor desconhecido entrar no corpus.
134
+ */
135
+ const LOCALIDADES_DO_AUTOR = new Set([
136
+ "AC", "AL", "AM", "AP", "BA", "CE", "DF", "ES", "GO", "MA", "MG", "MS", "MT", "PA", "PB", "PE", "PI", "PR",
137
+ "RJ", "RN", "RO", "RR", "RS", "SC", "SE", "SP", "TO", "Exterior",
138
+ ]);
139
+ function localidadeDoAutor(span, onde) {
140
+ const s = decodificar(span ?? "").trim();
141
+ if (s === "")
142
+ return null;
143
+ const p = /^\((.*)\)$/.exec(s)?.[1]?.trim();
144
+ if (p === undefined || !LOCALIDADES_DO_AUTOR.has(p)) {
145
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: parêntese do autor fora dos valores medidos: ${JSON.stringify(s.slice(0, 40))}`);
146
+ }
147
+ return p;
148
+ }
149
+ /** Tenta cada tipo em ordem; devolve o tipo reconhecido (e preenche a ideia) ou null. */
150
+ function lerItem(c) {
151
+ const { html, item, onde } = c;
152
+ const t = item.texto;
153
+ if (/^Ideia proposta por /.test(t)) {
154
+ // O segundo <span> leva o parêntese da localidade SEM tags dentro: assim o nome (preguiçoso) para no
155
+ // primeiro </span> em todos os casos. Com o parêntese restrito a duas letras, "(Exterior)" caía fora
156
+ // do grupo opcional e o nome se esticava até engoli-lo — a UF saía null, calada (A8, 18/10/2026).
157
+ const m = /Ideia proposta por\s*<strong>\s*<span>([\s\S]*?)<\/span>\s*(?:<span>([^<]*)<\/span>)?\s*<\/strong>/.exec(html);
158
+ if (!m)
159
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: proposta sem "<strong><span>NOME</span><span> (UF)</span>"`);
160
+ unico(c, "autorNome", textoDoHtml(m[1]) || null);
161
+ c.ideia.autorUf = localidadeDoAutor(m[2], onde);
162
+ unico(c, "dataProposta", item.data);
163
+ return "proposta";
164
+ }
165
+ if (/^O que pode acontecer com essa ideia\?/.test(t)) {
166
+ const m = /pode receber apoios até\s*<strong>\s*([^<]+?)\s*<\/strong>/.exec(html);
167
+ if (!m)
168
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: regras sem "pode receber apoios até <strong>data</strong>"`);
169
+ unico(c, "prazoApoios", isoDeDataBr(m[1], onde));
170
+ return "prazo";
171
+ }
172
+ if (/class="oficina_legislativa"/.test(html)) {
173
+ const nivel = /realizada com participantes de nível\s*<span>([^<]+)<\/span>/.exec(html);
174
+ const cad = /Ideia cadastrada pelo usuário em\s*<span>(\d{2}\/\d{2}\/\d{4}) às (\d{2}:\d{2}:\d{2})<\/span>/.exec(html);
175
+ if (!nivel || !cad)
176
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: oficina sem nível ou sem data de cadastro`);
177
+ // Hora local do portal, sem fuso: o portal não publica o fuso (e houve horário de verão até 2019).
178
+ unico(c, "oficina", { nivel: nivel[1].trim(), dataCadastro: `${isoDeDataBr(cad[1], onde)}T${cad[2]}` });
179
+ return "oficina";
180
+ }
181
+ const meta = /^Ideia alcançou ([\d.]+) apoios$/.exec(t);
182
+ if (meta) {
183
+ unico(c, "dataMeta", item.data);
184
+ c.ideia.meta = inteiroBr(meta[1]);
185
+ return "meta";
186
+ }
187
+ if (/^Período de apoio encerrado /.test(t)) {
188
+ const m = /^Período de apoio encerrado Esta ideia recebeu (\d+) apoios e não alcançou a meta dentro do prazo\. O período terminou em (.+?)\.$/.exec(t);
189
+ if (!m)
190
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: encerramento sem "recebeu N apoios … terminou em data"`);
191
+ unico(c, "encerramento", { data: item.data, apoios: Number(m[1]), terminouEm: isoDeDataExtenso(m[2], onde) });
192
+ return "prazoEncerrado";
193
+ }
194
+ if (/^Essa ideia gerou a Sugestão /.test(t)) {
195
+ const l = links(html).find((x) => x.texto.startsWith("Sugestão"));
196
+ if (!l)
197
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: "gerou a Sugestão" sem o link da Sugestão`);
198
+ unico(c, "sugestao", { ...referenciaSugestao(l.texto, onde), data: item.data, url: l.href });
199
+ return "sugestao";
200
+ }
201
+ if (/^A CDH do Senado debateu e decidiu não transformar a Sugestão /.test(t)) {
202
+ const l = links(html).find((x) => x.texto.startsWith("Sugestão"));
203
+ if (!l)
204
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: decisão da CDH sem o link da Sugestão`);
205
+ unico(c, "decisaoCdh", { data: item.data, resultado: "não transformou", sugestao: referenciaSugestao(l.texto, onde) });
206
+ return "cdhNaoTransformou";
207
+ }
208
+ const titulo = /<h3>\s*Ideia transformada em\s*<span>([^<]+)<\/span>\s*(pela|por)\s*<span>([^<]+)<\/span>\s*<\/h3>/.exec(html);
209
+ if (titulo) {
210
+ const [, sigla, , quemBruto] = titulo;
211
+ const quem = quemBruto.trim();
212
+ // Autor fora do vocabulário medido (Comissão, Senador(a), Deputado(a)) é acontecimento novo:
213
+ // fica verbatim com aviso, em vez de virar um "por" adivinhado.
214
+ if (quem !== "Comissão" && !CARGOS[quem])
215
+ return null;
216
+ const prop = linkDaProposicao(html, onde);
217
+ const partes = partesDaProposicao(prop.texto);
218
+ const base = { ordem: item.ordem, data: item.data, tipoSigla: sigla.trim(), ...partes, url: prop.href };
219
+ if (quem === "Comissão") {
220
+ const sug = links(html).find((x) => x.texto.startsWith("Sugestão"));
221
+ if (!sug || !/A CDH do Senado debateu a Sugestão .+ e decidiu transformá-la no\(a\) /.test(t)) {
222
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: "transformada pela Comissão" sem "a CDH debateu a Sugestão … e decidiu transformá-la"`);
223
+ }
224
+ unico(c, "decisaoCdh", { data: item.data, resultado: "transformou", sugestao: referenciaSugestao(sug.texto, onde) });
225
+ c.ideia.proposicoes.push(novaProposicao({ ...base, transformadaPor: "Comissão", sexo: null, parlamentar: null, parlamentarUf: null }));
226
+ return "cdhTransformou";
227
+ }
228
+ const cargo = CARGOS[quem];
229
+ const autor = /A ideia foi adotada pel<span>[oa]<\/span>\s*<span>\s*(Senador|Senadora|Deputado|Deputada) (.+?) \(([A-Z]{2})\)\s*<\/span>/.exec(html);
230
+ if (!autor || autor[1] !== quem) {
231
+ throw new IdeiaDetalheForaDoMoldeError(`${onde}: "transformada por ${quem}" sem "adotada pelo(a) ${quem} NOME (UF)" correspondente`);
232
+ }
233
+ c.ideia.proposicoes.push(novaProposicao({ ...base, transformadaPor: cargo.por, sexo: cargo.sexo, parlamentar: autor[2].trim(), parlamentarUf: autor[3] }));
234
+ return "adocao";
235
+ }
236
+ if (/^Ideia transformada em /.test(t)) {
237
+ const prop = linkDaProposicao(html, onde);
238
+ c.ideia.proposicoes.push(novaProposicao({
239
+ ordem: item.ordem, data: item.data, transformadaPor: null, sexo: null, parlamentar: null, parlamentarUf: null,
240
+ tipoSigla: null, ...partesDaProposicao(prop.texto), url: prop.href,
241
+ }));
242
+ return "transformadaEm";
243
+ }
244
+ return null;
245
+ }
246
+ // ── a página ──────────────────────────────────────────────────────────────
247
+ /** Lê a página de detalhe inteira (ou o recorte `#container-ideia-legislativa`). */
248
+ export function lerIdeiaDetalhe(html) {
249
+ if (/class="object-not-found"/.test(html)) {
250
+ throw new IdeiaDetalheForaDoMoldeError('a página diz "Ideia Legislativa não encontrada!" (object-not-found)');
251
+ }
252
+ // Título em <b><div>, seguido da descrição no primeiro <div> (o mesmo par que o leitor antigo lia).
253
+ const cabeca = /id="ideia-legislativa"[\s\S]*?<b><div[^>]*>([\s\S]*?)<\/div><\/b>(?:\s*<div[^>]*>([\s\S]*?)<\/div>)?/.exec(html);
254
+ const titulo = cabeca ? textoDoHtml(cabeca[1]) : "";
255
+ if (!titulo)
256
+ throw new IdeiaDetalheForaDoMoldeError("sem título em #ideia-legislativa");
257
+ const descricao = cabeca?.[2] !== undefined ? textoDoHtml(cabeca[2]) || null : null;
258
+ const apoiosM = /<span class="contabilizacao">\s*([\d.]+)\s*<\/span>\s*apoios confirmados/.exec(html);
259
+ const linha = /<ol class="timeline">([\s\S]*?)<\/ol>\s*<\/section>/.exec(html);
260
+ if (!linha)
261
+ throw new IdeiaDetalheForaDoMoldeError("sem a Linha do Tempo (<ol class=\"timeline\">)");
262
+ const itens = linha[1].split(/(?=<li class="timeline-item)/).slice(1);
263
+ if (itens.length === 0)
264
+ throw new IdeiaDetalheForaDoMoldeError("Linha do Tempo vazia");
265
+ const ideia = {
266
+ titulo, descricao, apoios: apoiosM ? inteiroBr(apoiosM[1]) : null,
267
+ autorNome: null, autorUf: null, dataProposta: null, prazoApoios: null, dataMeta: null, meta: null,
268
+ sugestao: null, decisaoCdh: null, encerramento: null, oficina: null,
269
+ proposicoes: [], linhaDoTempo: [], avisos: [],
270
+ };
271
+ itens.forEach((li, ordem) => {
272
+ const corpo = li.replace(/<time\b[\s\S]*?<\/time>/, "").replace(/<a\b[^>]*href="declaracaoideia[^"]*"[\s\S]*?<\/a>/, "");
273
+ const item = {
274
+ ordem,
275
+ data: /<time datetime="(\d{4}-\d{2}-\d{2})"/.exec(li)?.[1] ?? null,
276
+ ativo: /^<li class="timeline-item active"/.test(li),
277
+ tipo: "naoReconhecido",
278
+ texto: textoDoHtml(corpo),
279
+ };
280
+ const tipo = lerItem({ ideia, item, html: corpo, onde: `item ${ordem}` });
281
+ if (tipo)
282
+ item.tipo = tipo;
283
+ else
284
+ ideia.avisos.push(`acontecimento não reconhecido no item ${ordem} (${item.data ?? "sem data"}): "${item.texto.slice(0, 120)}"`);
285
+ ideia.linhaDoTempo.push(item);
286
+ });
287
+ if (ideia.dataProposta === null)
288
+ throw new IdeiaDetalheForaDoMoldeError('Linha do Tempo sem "Ideia proposta por"');
289
+ return ideia;
290
+ }
291
+ /**
292
+ * Status DERIVADO da linha do tempo, para quando a categoria da listagem não está à mão. É
293
+ * classificação NOSSA — a categoria do portal (bucket da listagem) sempre prevalece sobre ele, e quem
294
+ * o usa marca a resposta como derivada.
295
+ *
296
+ * Regra medida em 17/10/2026 sobre a amostra de 336 ideias com bucket conhecido
297
+ * (`docs/_local/ideias-linha-do-tempo-2026-10-17/`): acerta 335. A divergência é irredutível — 103929
298
+ * (s10) e 136304 (s9) têm a MESMA linha do tempo (adoção por parlamentar e, depois, a CDH decidindo
299
+ * não transformar a Sugestão); o portal as classificou diferente.
300
+ * - decisão da CDH transformando, ou "transformada em" sem autor → convertida (s10)
301
+ * - CDH decidiu não transformar → encerrada (s9)
302
+ * - Sugestão sem decisão da CDH (mesmo adotada por parlamentar) → aberta (s6, na comissão)
303
+ * - adotada por parlamentar fora da Sugestão → convertida (s10)
304
+ * - período de apoio encerrado → encerrada (s7)
305
+ * - o resto (só proposta/prazo/oficina/meta) → aberta (s5, s8)
306
+ */
307
+ export function statusDaLinhaDoTempo(d) {
308
+ const tem = (t) => d.linhaDoTempo.some((e) => e.tipo === t);
309
+ if (tem("cdhTransformou") || tem("transformadaEm"))
310
+ return "convertida";
311
+ if (tem("cdhNaoTransformou"))
312
+ return "encerrada";
313
+ if (tem("sugestao"))
314
+ return "aberta";
315
+ if (tem("adocao"))
316
+ return "convertida";
317
+ if (tem("prazoEncerrado"))
318
+ return "encerrada";
319
+ return "aberta";
320
+ }
321
+ /** Limite histórico da descrição no corpus (o leitor antigo cortava aqui; mantém o hash das linhas). */
322
+ const DESCRICAO_CORPUS_MAX = 2000;
323
+ /** `IdeiaDetalhe` → campos do corpus. UF-only: o nome do autor cidadão não vai para o corpus. */
324
+ export function detalheParaCorpus(d) {
325
+ return {
326
+ dataPublicacao: d.prazoApoios,
327
+ autorUf: d.autorUf,
328
+ descricao: d.descricao ? d.descricao.substring(0, DESCRICAO_CORPUS_MAX) : null,
329
+ plConvertido: d.sugestao ? `SUG ${d.sugestao.numero}/${d.sugestao.ano}` : null,
330
+ };
331
+ }
332
+ /**
333
+ * Apoios publicados: o contador da página, ou o número do acontecimento "Período de apoio encerrado"
334
+ * (os dois são da fonte). Null se nenhum dos dois aparece — nunca 0 inventado.
335
+ */
336
+ export function apoiosPublicados(d) {
337
+ return d.apoios ?? d.encerramento?.apoios ?? null;
338
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Regra da classe dos escritores do corpus e-Cidadania: LEITURA VAZIA NUNCA APAGA VALOR GUARDADO.
3
+ *
4
+ * Origem (17/10/2026): o portal redesenhou a página de detalhe da ideia em 12/10 e o leitor antigo
5
+ * passou a devolver `autorUf`/`dataPublicacao` null em páginas lidas com sucesso. O backfill de
6
+ * detalhe gravava `detail ? detail.x : prev.x` — leitura bem-sucedida com campo vazio substituía o
7
+ * guardado — e apagou 21.265 UFs e 21.535 datas em cinco dias, com o workflow VERDE. Medido no
8
+ * `ecidadania_history`: de 01/09 a 12/10, zero campos preenchidos viraram null; de 13/10 a 17/10,
9
+ * 98–99% dos preenchidos de cada rodada.
10
+ *
11
+ * Duas defesas, as duas aqui:
12
+ * 1. `mesclarCampo` — todo escritor passa cada campo por ela: vazio lido mantém o guardado; apagar
13
+ * exige `apagarComMotivo(motivo)`, explícito e com motivo que o chamador registra.
14
+ * 2. `vigiarCampos` — a mesclagem esconde a deriva (o valor velho fica), então cada rodada mede, por
15
+ * campo, quantos valores guardados a leitura desta rodada ainda enxerga. Queda brusca reprova.
16
+ *
17
+ * Puro e sem dependência de Workers: roda no Cron de 2 h (src/) e nos jobs off-Worker (scripts/).
18
+ */
19
+ /** Pedido explícito de apagar um campo guardado. Sem motivo não existe. */
20
+ export class Apagado {
21
+ motivo;
22
+ constructor(motivo) {
23
+ if (!motivo.trim())
24
+ throw new Error("apagar campo guardado exige motivo");
25
+ this.motivo = motivo;
26
+ }
27
+ }
28
+ export function apagarComMotivo(motivo) {
29
+ return new Apagado(motivo);
30
+ }
31
+ /**
32
+ * Vazio = o leitor não achou o campo: null, undefined, string só de espaço, lista vazia, NaN.
33
+ * `0` NÃO é vazio — é contagem lida (apoios, votos, comentários). Quem não leu a contagem devolve null,
34
+ * nunca 0 (ver `lerBrNum`).
35
+ */
36
+ export function ehVazio(v) {
37
+ if (v === null || v === undefined)
38
+ return true;
39
+ if (typeof v === "string")
40
+ return v.trim() === "";
41
+ if (typeof v === "number")
42
+ return Number.isNaN(v);
43
+ if (Array.isArray(v))
44
+ return v.length === 0;
45
+ return false;
46
+ }
47
+ /**
48
+ * Valor a gravar para UM campo: o lido, se a leitura trouxe algo; senão o guardado; `null` só quando
49
+ * os dois são vazios ou quando o chamador pede `apagarComMotivo`.
50
+ */
51
+ export function mesclarCampo(lido, guardado) {
52
+ if (lido instanceof Apagado)
53
+ return null;
54
+ if (!ehVazio(lido))
55
+ return lido;
56
+ if (!ehVazio(guardado))
57
+ return guardado;
58
+ return null;
59
+ }
60
+ /**
61
+ * Número no formato brasileiro ("1.234.567") ou null quando não há número para ler. Par do
62
+ * `parseBrNum` (que devolve 0 para ausente/lixo e por isso não serve a escritor do corpus: ausência
63
+ * vira medida, e o 0 inventado sobrescreve a contagem guardada).
64
+ */
65
+ export function lerBrNum(v) {
66
+ if (typeof v === "number")
67
+ return Number.isFinite(v) ? v : null;
68
+ if (typeof v !== "string")
69
+ return null;
70
+ const s = v.trim().replace(/\./g, "");
71
+ if (!/^\d+$/.test(s))
72
+ return null;
73
+ return Number(s);
74
+ }
75
+ // ── vigia de taxa de leitura por campo ─────────────────────────────────────────
76
+ /**
77
+ * Limiar do vigia, escolhido pela medição de 17/10/2026 no `ecidadania_history` (ideias): no regime
78
+ * são, a retenção foi 100% (0 perdas em ~13 mil versões de 01/09 a 12/10); na quebra, 1–2%
79
+ * (UF 1.984 de 2.017 perdidas no 1º dia). 90% fica longe dos dois; abaixo disso é deriva de forma,
80
+ * não ruído.
81
+ */
82
+ export const RETENCAO_MINIMA = 0.9;
83
+ /**
84
+ * Com poucos guardados a taxa não diz nada (o splice de 2 h lê ~5 ideias; um id esquisito seria 20%).
85
+ * 50 mantém o vigia mudo ali e ativo nos jobs diários, que leem centenas a milhares.
86
+ */
87
+ export const N_MINIMO = 50;
88
+ /**
89
+ * Para cada campo: entre os ids lidos nesta rodada que já tinham o campo preenchido no corpus, que
90
+ * fração a leitura desta rodada ainda trouxe preenchida. Compara a leitura com os MESMOS ids guardados
91
+ * — não com a taxa do acervo inteiro, que muda com a fatia lida.
92
+ *
93
+ * `pares` recebe a LEITURA CRUA (antes da mesclagem): é ela que deriva; o mesclado nunca cairia.
94
+ */
95
+ export function vigiarCampos(pares, campos, opts = {}) {
96
+ const minimo = opts.retencaoMinima ?? RETENCAO_MINIMA;
97
+ const nMin = opts.nMinimo ?? N_MINIMO;
98
+ const cont = new Map(campos.map((c) => [c, { guardados: 0, lidos: 0 }]));
99
+ for (const { lido, guardado } of pares) {
100
+ if (!guardado)
101
+ continue;
102
+ for (const c of campos) {
103
+ if (ehVazio(guardado[c]))
104
+ continue;
105
+ const k = cont.get(c);
106
+ k.guardados++;
107
+ if (!ehVazio(lido[c]))
108
+ k.lidos++;
109
+ }
110
+ }
111
+ const resultado = campos.map((campo) => {
112
+ const { guardados, lidos } = cont.get(campo);
113
+ return { campo, guardados, lidos, retencao: guardados > 0 ? lidos / guardados : 1 };
114
+ });
115
+ const quedas = resultado.filter((r) => r.guardados >= nMin && r.retencao < minimo);
116
+ return { ok: quedas.length === 0, campos: resultado, quedas };
117
+ }
118
+ /** Linha de log do veredito: `autorUf 12/2017 (0.6%)`. */
119
+ export function descreverVigia(v) {
120
+ return v.campos
121
+ .map((c) => `${c.campo} ${c.lidos}/${c.guardados} (${(c.retencao * 100).toFixed(1)}%)`)
122
+ .join(" · ");
123
+ }
@@ -11,7 +11,8 @@
11
11
  * — the last good state is preserved; only a scrape_runs row is written so the failure is visible.
12
12
  */
13
13
  import { classifyRun } from "./anomaly.js";
14
- import { buildConsultaResumo, buildEventoResumo, buildIdeiaResumo, listarConsultasInternal, listarIdeiasInternal, listarEventosInternal, } from "./ecidadania.js";
14
+ import { buildConsultaResumo, buildEventoResumo, buildIdeiaResumo, lerDestaquesConsultas, lerDestaquesIdeias, listarEventosInternal, } from "./ecidadania.js";
15
+ import { mesclarCampo } from "./mesclar.js";
15
16
  /** FNV-1a 32-bit + length suffix — fast, synchronous, good enough for change detection. */
16
17
  export function contentHash(s) {
17
18
  let h = 0x811c9dc5;
@@ -82,7 +83,9 @@ async function refreshHighlights(db, now, cfg) {
82
83
  .prepare(SQL.currentRow)
83
84
  .bind(cfg.entidade, hl.id)
84
85
  .first();
85
- const item = existing ? cfg.splice(JSON.parse(existing.payload_json), hl) : hl;
86
+ const item = cfg.splice(existing ? JSON.parse(existing.payload_json) : undefined, hl);
87
+ if (!item)
88
+ continue;
86
89
  const payloadJson = JSON.stringify(item);
87
90
  const hash = contentHash(payloadJson);
88
91
  if (existing && existing.content_hash === hash)
@@ -95,27 +98,44 @@ async function refreshHighlights(db, now, cfg) {
95
98
  await db.batch(stmts);
96
99
  return { entidade: cfg.entidade, status: "ok-metrica", rowsScraped: highlights.length, rowsChanged };
97
100
  }
98
- const consultasHighlightCfg = {
101
+ export const consultasHighlightCfg = {
99
102
  entidade: "consultas",
100
- scrape: () => listarConsultasInternal({ limite: 100 }),
103
+ scrape: () => lerDestaquesConsultas(),
101
104
  metrica: (c) => c.totalVotos,
102
105
  comissao: () => null,
103
106
  // Preserves the corpus-authoritative detail fields (autoria/relator, v2) so the 2h metric splice
104
- // never wipes them; only the volatile vote counts come from the fresh REST highlight.
105
- splice: (corpus, fresh) => buildConsultaResumo({
106
- id: fresh.id,
107
- materia: corpus.materia,
108
- ementa: corpus.ementa,
109
- votosSim: fresh.votosSim,
110
- votosNao: fresh.votosNao,
111
- totalVotos: fresh.totalVotos,
112
- autoria: corpus.autoria,
113
- relator: corpus.relator,
114
- status: corpus.status,
115
- url: corpus.url,
116
- }),
107
+ // never wipes them; only the volatile vote counts come from the fresh REST highlight — and only
108
+ // the ones it actually carried.
109
+ splice: (corpus, fresh) => {
110
+ if (!corpus) {
111
+ if (fresh.votosSim === null || fresh.votosNao === null)
112
+ return null;
113
+ return buildConsultaResumo({
114
+ id: fresh.id, materia: fresh.materia, ementa: fresh.ementa,
115
+ votosSim: fresh.votosSim, votosNao: fresh.votosNao, totalVotos: fresh.totalVotos ?? undefined,
116
+ status: "aberta",
117
+ });
118
+ }
119
+ const votosSim = mesclarCampo(fresh.votosSim, corpus.votosSim) ?? 0;
120
+ const votosNao = mesclarCampo(fresh.votosNao, corpus.votosNao) ?? 0;
121
+ return buildConsultaResumo({
122
+ id: fresh.id,
123
+ materia: corpus.materia,
124
+ ementa: corpus.ementa,
125
+ votosSim,
126
+ votosNao,
127
+ // Total do REST quando veio; senão o guardado só se os votos também são os guardados (nada
128
+ // lido); com votos novos e total ausente, o builder deriva votosSim + votosNao.
129
+ totalVotos: fresh.totalVotos ??
130
+ (fresh.votosSim === null && fresh.votosNao === null ? corpus.totalVotos : undefined),
131
+ autoria: corpus.autoria,
132
+ relator: corpus.relator,
133
+ status: corpus.status,
134
+ url: corpus.url,
135
+ });
136
+ },
117
137
  };
118
- const eventosHighlightCfg = {
138
+ export const eventosHighlightCfg = {
119
139
  entidade: "eventos",
120
140
  scrape: () => listarEventosInternal({ limite: 100 }),
121
141
  metrica: (e) => e.comentarios,
@@ -126,7 +146,7 @@ const eventosHighlightCfg = {
126
146
  // fresh REST count: it stays byte-stable (no ping-pong / history churn against the daily crawl) and
127
147
  // also carries all v2 detail fields through untouched. The eventos splice is thus effectively inert
128
148
  // between daily crawls — the daily crawl is the sole source of the canonical count.
129
- splice: (corpus, _fresh) => buildEventoResumo({
149
+ splice: (corpus, fresh) => !corpus ? fresh : buildEventoResumo({
130
150
  id: corpus.id,
131
151
  titulo: corpus.titulo,
132
152
  data: corpus.data,
@@ -143,24 +163,29 @@ const eventosHighlightCfg = {
143
163
  url: corpus.url,
144
164
  }),
145
165
  };
146
- const ideiasHighlightCfg = {
166
+ export const ideiasHighlightCfg = {
147
167
  entidade: "ideias",
148
- scrape: () => listarIdeiasInternal({ limite: 100 }),
168
+ scrape: () => lerDestaquesIdeias(),
149
169
  metrica: (i) => i.apoios,
150
170
  comissao: () => null,
151
171
  // Preserves the corpus-authoritative detail fields (dataPublicacao/autorUf/descricao/plConvertido,
152
- // v2) so the 2h apoios splice never wipes them.
153
- splice: (corpus, fresh) => buildIdeiaResumo({
154
- id: fresh.id,
155
- titulo: corpus.titulo,
156
- apoios: fresh.apoios,
157
- dataPublicacao: corpus.dataPublicacao,
158
- autorUf: corpus.autorUf,
159
- descricao: corpus.descricao,
160
- plConvertido: corpus.plConvertido,
161
- status: corpus.status,
162
- url: corpus.url,
163
- }),
172
+ // v2) so the 2h apoios splice never wipes them; apoios only when the REST item carried it.
173
+ splice: (corpus, fresh) => {
174
+ if (!corpus) {
175
+ return fresh.apoios === null ? null : buildIdeiaResumo({ id: fresh.id, titulo: fresh.titulo, apoios: fresh.apoios });
176
+ }
177
+ return buildIdeiaResumo({
178
+ id: fresh.id,
179
+ titulo: mesclarCampo(corpus.titulo, fresh.titulo) ?? undefined,
180
+ apoios: mesclarCampo(fresh.apoios, corpus.apoios) ?? undefined,
181
+ dataPublicacao: corpus.dataPublicacao,
182
+ autorUf: corpus.autorUf,
183
+ descricao: corpus.descricao,
184
+ plConvertido: corpus.plConvertido,
185
+ status: corpus.status,
186
+ url: corpus.url,
187
+ });
188
+ },
164
189
  };
165
190
  /** Thin wrappers (kept by name for tests/back-compat). */
166
191
  export function refreshConsultasHighlights(db, now) {
@@ -45,6 +45,24 @@ export async function readCurrent(db, entidade) {
45
45
  });
46
46
  return { items, lastScrapedAt };
47
47
  }
48
+ /**
49
+ * One current row of an entity: its denormalized `status` and when it was scraped, or null when the id
50
+ * is not in the corpus (or D1 is absent/unavailable — the caller then derives what it needs).
51
+ */
52
+ export async function readCurrentStatus(db, entidade, entityId) {
53
+ if (!db)
54
+ return null;
55
+ try {
56
+ const row = await db
57
+ .prepare("SELECT status, scraped_at FROM ecidadania_current WHERE entidade = ? AND entity_id = ?")
58
+ .bind(entidade, entityId)
59
+ .first();
60
+ return row && row.status ? { status: row.status, scrapedAt: row.scraped_at } : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
48
66
  /**
49
67
  * Resolve a list with the D1-first / live-fallback policy above.
50
68
  * `liveScrape` is the existing live path (e.g. listarConsultasInternal).
@@ -12,13 +12,14 @@
12
12
  import { z } from "zod";
13
13
  import { cachedFetchWithMeta } from "../cache/manager.js";
14
14
  import { errorFrom } from "../utils/validation.js";
15
- import { leituraDoCache, provenanceEcidadania, NOTA_DERIVACAO_DATA_ESTIMADA, NOTA_DERIVACAO_PERCENTUAIS, NOTA_DERIVACAO_RANKING_ECIDADANIA, NOTA_DERIVACAO_SOMAS, derivado, derivadoSe, provenanceArquimedesVotos, resultWithProvenance, withFieldSources, } from "../utils/provenance.js";
15
+ import { leituraDoCache, provenanceEcidadania, NOTA_DERIVACAO_DATA_ESTIMADA, NOTA_DERIVACAO_PERCENTUAIS, NOTA_DERIVACAO_RANKING_ECIDADANIA, NOTA_DERIVACAO_SOMAS, NOTA_DERIVACAO_STATUS_IDEIA, derivado, derivadoSe, provenanceArquimedesVotos, resultWithProvenance, withFieldSources, } from "../utils/provenance.js";
16
16
  import { tagUntrustedFields, tagUntrustedList, neutralizeUntrustedText, sanitizeScrapedText } from "../utils/untrusted.js";
17
17
  import { CACHE_ON_DEMAND } from "../types.js";
18
- import { resolveList, writeDetalheThrough } from "../scraper/store.js";
19
- import { ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, estimarCriacaoIdeia, AVISO_DATAS_IDEIA, IDEIA_PRAZO_APOIOS_DIAS, } from "../scraper/ecidadania.js";
18
+ import { readCurrentStatus, resolveList, writeDetalheThrough } from "../scraper/store.js";
19
+ import { apoiosPublicados, detalheParaCorpus, statusDaLinhaDoTempo } from "../scraper/ideia-detalhe.js";
20
+ import { ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, estimarCriacaoIdeia, AVISO_DATAS_IDEIA, AVISO_DATAS_IDEIA_DETALHE, IDEIA_PRAZO_APOIOS_DIAS, } from "../scraper/ecidadania.js";
20
21
  // Re-export the scraper's pure/IO helpers so existing unit tests keep importing them from here.
21
- export { estimarCriacaoIdeia, AVISO_DATAS_IDEIA, IDEIA_PRAZO_APOIOS_DIAS, ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, };
22
+ export { estimarCriacaoIdeia, AVISO_DATAS_IDEIA, AVISO_DATAS_IDEIA_DETALHE, IDEIA_PRAZO_APOIOS_DIAS, ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, };
22
23
  /** Format an integer with pt-BR thousands separators ("253804" → "253.804").
23
24
  * Avoids toLocaleString, whose Intl data is not guaranteed on Workers (OBS-12). */
24
25
  export function formatIntBR(n) {
@@ -101,6 +102,67 @@ export const NOTA_DERIVACAO_ANALISE_CONSULTAS = `${NOTA_DERIVACAO_RANKING_ECIDAD
101
102
  export const provenanceDasConsultas = (prov) => derivado(prov, NOTA_DERIVACAO_PERCENTUAIS);
102
103
  export const provenanceDaAnaliseDeConsultas = (prov) => derivado(prov, NOTA_DERIVACAO_ANALISE_CONSULTAS);
103
104
  export const provenanceDasIdeias = (prov, ideias) => derivadoSe(prov, ideias.some((i) => i.dataCriacaoEstimada != null), NOTA_DERIVACAO_DATA_ESTIMADA);
105
+ /**
106
+ * Proveniência de `senado_ecidadania_obter_ideia`. O detalhe é lido ao vivo; a situação vem do corpus
107
+ * em D1 (categoria do portal, lida noutro instante → sub-fonte, topo = o mais antigo) ou, fora do
108
+ * corpus, é derivada da linha do tempo. A data estimada é sempre derivada quando há data limite.
109
+ */
110
+ export function provenanceDaIdeia(prov, detalhe, statusDoCorpus, leitura) {
111
+ const comFontes = statusDoCorpus
112
+ ? withFieldSources(prov, [
113
+ { fields: Object.keys(detalhe).filter((k) => k !== "status"), source_url: prov.source_url, dataset_id: `ideia=${detalhe.id}`, ...leituraDoCache(leitura) },
114
+ subFonteDoCorpus("/principalideia", "ideias", { lastScrapedAt: statusDoCorpus.scrapedAt }, ["status"]),
115
+ ])
116
+ : prov;
117
+ const notas = [
118
+ ...(detalhe.dataCriacaoEstimada != null ? [NOTA_DERIVACAO_DATA_ESTIMADA] : []),
119
+ ...(statusDoCorpus ? [] : [NOTA_DERIVACAO_STATUS_IDEIA]),
120
+ ];
121
+ return notas.length ? derivado(comFontes, notas.join(" ")) : comFontes;
122
+ }
123
+ /**
124
+ * Resposta de `senado_ecidadania_obter_ideia` a partir da leitura do leitor novo (fase A4). Campos que
125
+ * já existiam mantêm tipo e não-nulidade (saída que passa a aceitar null seria MAJOR): `descricao` ""
126
+ * quando ausente, `apoios` 0 quando a página não publica contagem (com aviso), `status` sempre preenchido,
127
+ * `autor` "NOME (UF)". Os novos vêm depois, na ordem da leitura.
128
+ */
129
+ export function montarDetalheDaIdeia(id, d, statusDoCorpus) {
130
+ const apoios = apoiosPublicados(d);
131
+ const avisos = [...d.avisos, ...(apoios === null ? ["a página não publica a contagem de apoios desta ideia; apoios saiu 0 por compatibilidade"] : [])];
132
+ const autor = d.autorNome ? `${d.autorNome}${d.autorUf ? ` (${d.autorUf})` : ""}` : null;
133
+ return {
134
+ id,
135
+ titulo: d.titulo,
136
+ descricao: d.descricao ?? "",
137
+ apoios: apoios ?? 0,
138
+ dataPublicacao: d.prazoApoios,
139
+ status: statusDoCorpus ?? statusDaLinhaDoTempo(d),
140
+ origemStatus: statusDoCorpus ? "categoria do portal (listagem)" : "derivado da linha do tempo",
141
+ autor,
142
+ url: `${ECIDADANIA_BASE}/visualizacaoideia?id=${id}`,
143
+ problema: null,
144
+ solucao: null,
145
+ // OBS-9: a página de ideia não tem recurso de comentários — null, não um 0 espúrio.
146
+ comentarios: null,
147
+ dataEncerramento: d.encerramento?.data ?? null,
148
+ plConvertido: detalheParaCorpus(d).plConvertido,
149
+ dataCriacaoEstimada: estimarCriacaoIdeia(d.prazoApoios),
150
+ avisoDatas: AVISO_DATAS_IDEIA_DETALHE,
151
+ autorNome: d.autorNome,
152
+ autorUf: d.autorUf,
153
+ dataProposta: d.dataProposta,
154
+ prazoApoios: d.prazoApoios,
155
+ dataMeta: d.dataMeta,
156
+ meta: d.meta,
157
+ sugestao: d.sugestao,
158
+ decisaoCdh: d.decisaoCdh,
159
+ encerramento: d.encerramento,
160
+ oficina: d.oficina,
161
+ proposicoes: d.proposicoes,
162
+ linhaDoTempo: d.linhaDoTempo,
163
+ avisos,
164
+ };
165
+ }
104
166
  export const provenanceDosVotosHistoricos = (prov) => derivado(prov, NOTA_DERIVACAO_SOMAS);
105
167
  /**
106
168
  * Proveniência de `senado_ecidadania_sugerir_tema_enquete`: as sugestões cruzam DOIS corpora
@@ -235,7 +297,7 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
235
297
  }
236
298
  });
237
299
  // G5. senado_ecidadania_listar_ideias
238
- server.tool("senado_ecidadania_listar_ideias", "Lista ideias legislativas propostas por cidadãos no e-Cidadania — **conjunto completo** (corpus persistido em D1; ideias em andamento atualizadas diariamente; ~118 mil ideias, incluindo encerradas e convertidas em proposição). Retorna `{ count, ideias, avisoDatas }`, cada ideia com `id`, `titulo`, `apoios`, `status` (`aberta`/`encerrada`/`convertida`/`removida` — `removida` = retirada do portal, que não a publica mais; o corpus guarda o último estado conhecido), `url` e os campos lidos do detalhe quando o backfill já passou por ela (`dataPublicacao`, `autorUf`, `descricao`, `plConvertido`; `null` até lá). **Datas:** `dataPublicacao` é a DATA LIMITE para receber os 20.000 apoios — a única data que o portal publica; o nome do campo é histórico — e por isso cai no futuro em ideia aberta; `dataCriacaoEstimada` (= data limite − 120 dias, a regra dos 4 meses do portal) é calculada pelo servidor e é estimativa, não dado da fonte; `avisoDatas` repete isso na resposta. Aceita filtro por `status` e `limite` (padrão 20). Para um ranking das mais apoiadas, ordene por apoios (`ordenarPor: \"apoios\"`, `ordem: \"desc\"`). Para o detalhe completo de uma ideia (texto, autor, se virou projeto de lei) chame `senado_ecidadania_obter_ideia` com o `id`.", {
300
+ server.tool("senado_ecidadania_listar_ideias", "Lista ideias legislativas propostas por cidadãos no e-Cidadania — **conjunto completo** (corpus persistido em D1; ideias em andamento atualizadas diariamente; ~118 mil ideias, incluindo encerradas e convertidas em proposição). Retorna `{ count, ideias, avisoDatas }`, cada ideia com `id`, `titulo`, `apoios`, `status` (a categoria da listagem do portal em que a ideia aparece: `aberta` = \"Aberta\", \"Na comissão\" ou \"Aguardando envio à CDH\"; `encerrada` = \"Encerrada – sem apoio suficiente\" ou \"Não acatada\" pela CDH; `convertida` = \"Transformada em proposição\"; `removida` = retirada do portal, que não a publica mais — o corpus guarda o último estado conhecido), `url` e os campos lidos do detalhe quando o backfill já passou por ela (`dataPublicacao`, `autorUf`, `descricao`, `plConvertido`; `null` até lá; `autorUf` traz a sigla da UF ou \"Exterior\", como o portal publica, e `null` quando o portal não publica a localidade). **Datas:** `dataPublicacao` é a DATA LIMITE para receber os 20.000 apoios — a única data que o corpus guarda; o nome do campo é histórico — e por isso cai no futuro em ideia aberta; `dataCriacaoEstimada` (= data limite − 120 dias, a regra dos 4 meses do portal) é calculada pelo servidor e é estimativa, não dado da fonte; a data REAL da proposição sai em `senado_ecidadania_obter_ideia` (`dataProposta`); `avisoDatas` repete isso na resposta. Aceita filtro por `status` e `limite` (padrão 20). Para um ranking das mais apoiadas, ordene por apoios (`ordenarPor: \"apoios\"`, `ordem: \"desc\"`). Para o detalhe completo de uma ideia (texto, autor, linha do tempo, Sugestão, decisão da CDH, proposições em que virou) chame `senado_ecidadania_obter_ideia` com o `id`.", {
239
301
  status: z.enum(["aberta", "encerrada", "convertida", "removida", "todas"]).optional().describe("Filtrar por status (`removida` = retirada do portal)"),
240
302
  ordenarPor: z.enum(["apoios", "data", "comentarios"]).optional().describe("Campo para ordenação (apoios é o disponível no corpus; data/comentarios só no detalhe)"),
241
303
  ordem: z.enum(["asc", "desc"]).optional().describe("Ordem de ordenação"),
@@ -267,21 +329,15 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
267
329
  }
268
330
  });
269
331
  // G6. senado_ecidadania_obter_ideia
270
- server.tool("senado_ecidadania_obter_ideia", "Obtém o detalhe de uma ideia legislativa do e-Cidadania. Retorna um objeto com `id`, `titulo`, `descricao` (texto completo, truncado em ~2000 caracteres), `apoios`, `dataPublicacao`, `dataCriacaoEstimada`, `avisoDatas`, `status`, `autor`, `url` e `plConvertido` (sigla/número quando virou projeto de lei). **Datas:** `dataPublicacao` é a DATA LIMITE para receber os 20.000 apoios — a única data que o portal publica; o nome do campo é histórico — e por isso cai no futuro em ideia aberta; `dataCriacaoEstimada` (= data limite − 120 dias, a regra dos 4 meses do portal) é calculada pelo servidor e é estimativa, não dado da fonte. O campo `comentarios` vem `null`: a página de ideia não possui recurso de comentários. Obtenha o `id` antes via `senado_ecidadania_listar_ideias`.", { id: z.number().int().positive().describe("ID da ideia legislativa") }, async (params) => {
332
+ server.tool("senado_ecidadania_obter_ideia", "Obtém o detalhe de uma ideia legislativa do e-Cidadania, lido ao vivo da página da ideia. Retorna `id`, `titulo`, `descricao`, `apoios`, `status`, `origemStatus`, `autor` (\"NOME (UF)\", o nome abreviado como o portal publica), `url`, `plConvertido` (a Sugestão gerada, \"SUG n/ano\"), `dataEncerramento` (fim do período de apoio, quando encerrado) e os campos da linha do tempo do portal: `dataProposta` (data em que a ideia foi proposta, publicada pela fonte), `prazoApoios` (data limite para os 20.000 apoios), `dataMeta`/`meta` (quando alcançou a meta), `sugestao` (número, ano, data e link da Sugestão), `decisaoCdh` (a CDH transformou ou não a Sugestão), `encerramento` (apoios recebidos e fim do período), `oficina` (ideia de Oficina Legislativa), `proposicoes` (cada proposição em que a ideia virou — pela CDH ou adotada por senador/deputado —, com sigla, número, ano, parlamentar e link), `linhaDoTempo` (os acontecimentos como publicados, mais recente primeiro) e `avisos` (acontecimento que o servidor não reconheceu, copiado sem interpretação). **Situação:** `status` é a categoria do portal guardada no corpus (`aberta`/`encerrada`/`convertida`/`removida`, as mesmas de `senado_ecidadania_listar_ideias`); se a ideia ainda não está no corpus, o servidor a classifica pela linha do tempo e `origemStatus` diz isso. **Datas:** use `dataProposta`; `dataPublicacao` é a data limite (nome histórico, igual a `prazoApoios`) e `dataCriacaoEstimada` é estimativa mantida por compatibilidade. `comentarios`, `problema` e `solucao` vêm `null`: a página não os publica. Obtenha o `id` antes via `senado_ecidadania_listar_ideias`.", { id: z.number().int().positive().describe("ID da ideia legislativa") }, async (params) => {
271
333
  try {
272
- const { value: r, fetchedAt } = await cachedFetchWithMeta("ecidadania_ideia", { id: params.id }, CACHE_ON_DEMAND, () => obterIdeiaInternal(params.id));
273
- writeDetalheThrough(db, ctx, "ideias", params.id, r);
274
- // OBS-9: idea pages have no comments resource — serve null instead of a spurious 0.
275
- // Datas: `dataCriacaoEstimada` e `avisoDatas` entram SÓ na resposta (não no write-through acima,
276
- // que guarda o que a página diz).
277
- const ideia = r;
278
- const detalhe = {
279
- ...r,
280
- dataCriacaoEstimada: estimarCriacaoIdeia(ideia.dataPublicacao),
281
- comentarios: null,
282
- avisoDatas: AVISO_DATAS_IDEIA,
283
- };
284
- return resultWithProvenance(tagUntrustedFields("ideias", detalhe), provenanceDasIdeias(provDetalhe(r, `/visualizacaoideia?id=${params.id}`, `ideia=${params.id}`, fetchedAt), [detalhe]));
334
+ // v2: a chave muda com o formato — o L1 guardaria a leitura do leitor antigo.
335
+ const { value: lida, fetchedAt, fromCache } = await cachedFetchWithMeta("ecidadania_ideia_v2", { id: params.id }, CACHE_ON_DEMAND, () => obterIdeiaInternal(params.id));
336
+ // A situação é a categoria do portal guardada no corpus (bucket da listagem), lida a cada chamada.
337
+ const doCorpus = await readCurrentStatus(db, "ideias", params.id);
338
+ const detalhe = montarDetalheDaIdeia(params.id, lida, doCorpus?.status ?? null);
339
+ writeDetalheThrough(db, ctx, "ideias", params.id, detalhe);
340
+ return resultWithProvenance(tagUntrustedFields("ideias", detalhe), provenanceDaIdeia(provDetalhe(detalhe, `/visualizacaoideia?id=${params.id}`, `ideia=${params.id}`, fetchedAt), detalhe, doCorpus, { fetchedAt, fromCache }));
285
341
  }
286
342
  catch (e) {
287
343
  return ecidadaniaError(e);
@@ -296,6 +296,8 @@ export const NOTA_DERIVACAO_PERCENTUAIS = "Percentuais de sim e não, e o total
296
296
  export const NOTA_DERIVACAO_RANKING_ECIDADANIA = "Polarização, consenso e ordem por participação calculados pelo servidor a partir dos votos e apoios publicados pela fonte.";
297
297
  /** Data de criação estimada das ideias legislativas. */
298
298
  export const NOTA_DERIVACAO_DATA_ESTIMADA = "A data de criação é estimada pelo servidor (fim do prazo de apoio menos 120 dias); a fonte não a publica.";
299
+ /** Situação de uma ideia fora do corpus: classificada pelo servidor a partir da linha do tempo. */
300
+ export const NOTA_DERIVACAO_STATUS_IDEIA = "A situação da ideia foi classificada pelo servidor a partir da linha do tempo publicada, porque a ideia ainda não está no corpus; a categoria do portal, quando lida da listagem, prevalece.";
299
301
  /** Legislatura sem número na fonte: o próprio número é o padrão do servidor. */
300
302
  export const NOTA_DERIVACAO_LEGISLATURA_PADRAO = "A fonte não informou a legislatura: o servidor assume a 57ª (2023–2027) e calcula o período e as datas.";
301
303
  /** Período e datas da legislatura derivados do número dela. */
@@ -70,7 +70,7 @@ function applyStringArray(value, fn) {
70
70
  */
71
71
  const CITIZEN_STRING_FIELDS = {
72
72
  consultas: [],
73
- ideias: ["titulo", "descricao", "autor"],
73
+ ideias: ["titulo", "descricao", "autor", "autorNome"],
74
74
  eventos: [],
75
75
  consultas_votos: [],
76
76
  };
@@ -91,6 +91,12 @@ const OFFICIAL_STRING_FIELDS = {
91
91
  const OFFICIAL_ARRAY_FIELDS = {
92
92
  eventos: ["pauta", "convidados"],
93
93
  };
94
+ /**
95
+ * Texto de cidadão dentro de estruturas do detalhe da ideia (fase A4): os acontecimentos da linha do
96
+ * tempo copiam o texto da página (com o nome do autor), e um aviso cita o item não reconhecido.
97
+ */
98
+ const CITIZEN_ARRAY_FIELDS = { ideias: ["avisos"] };
99
+ const CITIZEN_OBJECT_ARRAY_FIELDS = { ideias: { linhaDoTempo: ["texto"] } };
94
100
  /**
95
101
  * Retorna uma CÓPIA rasa de `item` com os campos de texto tratados para a entidade dada: campos de
96
102
  * cidadão demarcados, campos oficiais sanitizados. Não muta a entrada (o item persistido em
@@ -115,6 +121,24 @@ export function tagUntrustedFields(entidade, item) {
115
121
  if (f in out)
116
122
  out[f] = applyStringArray(out[f], sanitizeScrapedText);
117
123
  }
124
+ for (const f of CITIZEN_ARRAY_FIELDS[entidade] ?? []) {
125
+ if (f in out)
126
+ out[f] = applyStringArray(out[f], neutralizeUntrustedText);
127
+ }
128
+ for (const [f, campos] of Object.entries(CITIZEN_OBJECT_ARRAY_FIELDS[entidade] ?? {})) {
129
+ const arr = out[f];
130
+ if (!Array.isArray(arr))
131
+ continue;
132
+ out[f] = arr.map((el) => {
133
+ if (!el || typeof el !== "object")
134
+ return el;
135
+ const copia = { ...el };
136
+ for (const c of campos)
137
+ if (c in copia)
138
+ copia[c] = applyField(copia[c], neutralizeUntrustedText);
139
+ return copia;
140
+ });
141
+ }
118
142
  return out;
119
143
  }
120
144
  /** Versão para listas: mapeia `tagUntrustedFields` sobre cada item. */
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Single source of truth for the server version (kept in sync with package.json). */
2
- export const VERSION = "3.19.0";
2
+ export const VERSION = "3.20.1";
3
3
  export const USER_AGENT = `senado-br-mcp/${VERSION} (+https://senado.sidneybissoli.com; sbissoli76@gmail.com)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "senado-br-mcp",
3
- "version": "3.19.0",
3
+ "version": "3.20.1",
4
4
  "description": "MCP server for Brazilian Senate open data — runs hosted on Cloudflare Workers or locally via npx (stdio)",
5
5
  "keywords": [
6
6
  "mcp",