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 +2 -2
- package/dist/dataset/schema.js +9 -9
- package/dist/scraper/ecidadania.js +57 -119
- package/dist/scraper/ideia-detalhe.js +316 -0
- package/dist/scraper/mesclar.js +123 -0
- package/dist/scraper/pipeline.js +58 -33
- package/dist/scraper/store.js +18 -0
- package/dist/tools/comissoes.js +8 -2
- package/dist/tools/ecidadania.js +95 -27
- package/dist/tools/orcamento-senado.js +5 -3
- package/dist/tools/referencia.js +13 -4
- package/dist/tools/senadores-admin.js +4 -2
- package/dist/tools/servidores.js +16 -6
- package/dist/tools/votacao-comissao.js +32 -7
- package/dist/tools/votacoes.js +13 -7
- package/dist/utils/provenance.js +39 -5
- package/dist/utils/untrusted.js +25 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
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,
|
|
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 |
|
package/dist/dataset/schema.js
CHANGED
|
@@ -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: "
|
|
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: "
|
|
180
|
-
operationalization: "
|
|
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
|
|
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\"
|
|
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: "
|
|
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
|
|
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.
|
|
220
|
-
ementa: item.ementa
|
|
221
|
-
votosSim:
|
|
222
|
-
votosNao:
|
|
223
|
-
totalVotos:
|
|
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
|
|
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
|
|
354
|
-
const
|
|
355
|
-
|
|
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
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
|
718
|
-
comissaoNomeCompleto: d?.comissaoNomeCompleto
|
|
719
|
-
local: d?.local
|
|
720
|
-
descricao: d?.descricao
|
|
721
|
-
pauta: d?.pauta ?? [],
|
|
722
|
-
convidados: d?.convidados ?? [],
|
|
723
|
-
videoUrl: d?.videoUrl
|
|
724
|
-
|
|
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
|
}
|