senado-br-mcp 3.19.0 → 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 +1 -1
- 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/ecidadania.js +75 -19
- package/dist/utils/provenance.js +2 -0
- package/dist/utils/untrusted.js +25 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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,
|
|
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
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
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(/ /g, " ")
|
|
37
|
+
.replace(/"/g, '"')
|
|
38
|
+
.replace(/'/g, "'")
|
|
39
|
+
.replace(/</g, "<")
|
|
40
|
+
.replace(/>/g, ">")
|
|
41
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
|
42
|
+
.replace(/&/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
|
+
/** Tenta cada tipo em ordem; devolve o tipo reconhecido (e preenche a ideia) ou null. */
|
|
131
|
+
function lerItem(c) {
|
|
132
|
+
const { html, item, onde } = c;
|
|
133
|
+
const t = item.texto;
|
|
134
|
+
if (/^Ideia proposta por /.test(t)) {
|
|
135
|
+
const m = /Ideia proposta por\s*<strong>\s*<span>([\s\S]*?)<\/span>\s*(?:<span>\s*\(([A-Z]{2})\)\s*<\/span>)?\s*<\/strong>/.exec(html);
|
|
136
|
+
if (!m)
|
|
137
|
+
throw new IdeiaDetalheForaDoMoldeError(`${onde}: proposta sem "<strong><span>NOME</span><span> (UF)</span>"`);
|
|
138
|
+
unico(c, "autorNome", textoDoHtml(m[1]) || null);
|
|
139
|
+
c.ideia.autorUf = m[2] ?? null;
|
|
140
|
+
unico(c, "dataProposta", item.data);
|
|
141
|
+
return "proposta";
|
|
142
|
+
}
|
|
143
|
+
if (/^O que pode acontecer com essa ideia\?/.test(t)) {
|
|
144
|
+
const m = /pode receber apoios até\s*<strong>\s*([^<]+?)\s*<\/strong>/.exec(html);
|
|
145
|
+
if (!m)
|
|
146
|
+
throw new IdeiaDetalheForaDoMoldeError(`${onde}: regras sem "pode receber apoios até <strong>data</strong>"`);
|
|
147
|
+
unico(c, "prazoApoios", isoDeDataBr(m[1], onde));
|
|
148
|
+
return "prazo";
|
|
149
|
+
}
|
|
150
|
+
if (/class="oficina_legislativa"/.test(html)) {
|
|
151
|
+
const nivel = /realizada com participantes de nível\s*<span>([^<]+)<\/span>/.exec(html);
|
|
152
|
+
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);
|
|
153
|
+
if (!nivel || !cad)
|
|
154
|
+
throw new IdeiaDetalheForaDoMoldeError(`${onde}: oficina sem nível ou sem data de cadastro`);
|
|
155
|
+
// Hora local do portal, sem fuso: o portal não publica o fuso (e houve horário de verão até 2019).
|
|
156
|
+
unico(c, "oficina", { nivel: nivel[1].trim(), dataCadastro: `${isoDeDataBr(cad[1], onde)}T${cad[2]}` });
|
|
157
|
+
return "oficina";
|
|
158
|
+
}
|
|
159
|
+
const meta = /^Ideia alcançou ([\d.]+) apoios$/.exec(t);
|
|
160
|
+
if (meta) {
|
|
161
|
+
unico(c, "dataMeta", item.data);
|
|
162
|
+
c.ideia.meta = inteiroBr(meta[1]);
|
|
163
|
+
return "meta";
|
|
164
|
+
}
|
|
165
|
+
if (/^Período de apoio encerrado /.test(t)) {
|
|
166
|
+
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);
|
|
167
|
+
if (!m)
|
|
168
|
+
throw new IdeiaDetalheForaDoMoldeError(`${onde}: encerramento sem "recebeu N apoios … terminou em data"`);
|
|
169
|
+
unico(c, "encerramento", { data: item.data, apoios: Number(m[1]), terminouEm: isoDeDataExtenso(m[2], onde) });
|
|
170
|
+
return "prazoEncerrado";
|
|
171
|
+
}
|
|
172
|
+
if (/^Essa ideia gerou a Sugestão /.test(t)) {
|
|
173
|
+
const l = links(html).find((x) => x.texto.startsWith("Sugestão"));
|
|
174
|
+
if (!l)
|
|
175
|
+
throw new IdeiaDetalheForaDoMoldeError(`${onde}: "gerou a Sugestão" sem o link da Sugestão`);
|
|
176
|
+
unico(c, "sugestao", { ...referenciaSugestao(l.texto, onde), data: item.data, url: l.href });
|
|
177
|
+
return "sugestao";
|
|
178
|
+
}
|
|
179
|
+
if (/^A CDH do Senado debateu e decidiu não transformar a Sugestão /.test(t)) {
|
|
180
|
+
const l = links(html).find((x) => x.texto.startsWith("Sugestão"));
|
|
181
|
+
if (!l)
|
|
182
|
+
throw new IdeiaDetalheForaDoMoldeError(`${onde}: decisão da CDH sem o link da Sugestão`);
|
|
183
|
+
unico(c, "decisaoCdh", { data: item.data, resultado: "não transformou", sugestao: referenciaSugestao(l.texto, onde) });
|
|
184
|
+
return "cdhNaoTransformou";
|
|
185
|
+
}
|
|
186
|
+
const titulo = /<h3>\s*Ideia transformada em\s*<span>([^<]+)<\/span>\s*(pela|por)\s*<span>([^<]+)<\/span>\s*<\/h3>/.exec(html);
|
|
187
|
+
if (titulo) {
|
|
188
|
+
const [, sigla, , quemBruto] = titulo;
|
|
189
|
+
const quem = quemBruto.trim();
|
|
190
|
+
// Autor fora do vocabulário medido (Comissão, Senador(a), Deputado(a)) é acontecimento novo:
|
|
191
|
+
// fica verbatim com aviso, em vez de virar um "por" adivinhado.
|
|
192
|
+
if (quem !== "Comissão" && !CARGOS[quem])
|
|
193
|
+
return null;
|
|
194
|
+
const prop = linkDaProposicao(html, onde);
|
|
195
|
+
const partes = partesDaProposicao(prop.texto);
|
|
196
|
+
const base = { ordem: item.ordem, data: item.data, tipoSigla: sigla.trim(), ...partes, url: prop.href };
|
|
197
|
+
if (quem === "Comissão") {
|
|
198
|
+
const sug = links(html).find((x) => x.texto.startsWith("Sugestão"));
|
|
199
|
+
if (!sug || !/A CDH do Senado debateu a Sugestão .+ e decidiu transformá-la no\(a\) /.test(t)) {
|
|
200
|
+
throw new IdeiaDetalheForaDoMoldeError(`${onde}: "transformada pela Comissão" sem "a CDH debateu a Sugestão … e decidiu transformá-la"`);
|
|
201
|
+
}
|
|
202
|
+
unico(c, "decisaoCdh", { data: item.data, resultado: "transformou", sugestao: referenciaSugestao(sug.texto, onde) });
|
|
203
|
+
c.ideia.proposicoes.push(novaProposicao({ ...base, transformadaPor: "Comissão", sexo: null, parlamentar: null, parlamentarUf: null }));
|
|
204
|
+
return "cdhTransformou";
|
|
205
|
+
}
|
|
206
|
+
const cargo = CARGOS[quem];
|
|
207
|
+
const autor = /A ideia foi adotada pel<span>[oa]<\/span>\s*<span>\s*(Senador|Senadora|Deputado|Deputada) (.+?) \(([A-Z]{2})\)\s*<\/span>/.exec(html);
|
|
208
|
+
if (!autor || autor[1] !== quem) {
|
|
209
|
+
throw new IdeiaDetalheForaDoMoldeError(`${onde}: "transformada por ${quem}" sem "adotada pelo(a) ${quem} NOME (UF)" correspondente`);
|
|
210
|
+
}
|
|
211
|
+
c.ideia.proposicoes.push(novaProposicao({ ...base, transformadaPor: cargo.por, sexo: cargo.sexo, parlamentar: autor[2].trim(), parlamentarUf: autor[3] }));
|
|
212
|
+
return "adocao";
|
|
213
|
+
}
|
|
214
|
+
if (/^Ideia transformada em /.test(t)) {
|
|
215
|
+
const prop = linkDaProposicao(html, onde);
|
|
216
|
+
c.ideia.proposicoes.push(novaProposicao({
|
|
217
|
+
ordem: item.ordem, data: item.data, transformadaPor: null, sexo: null, parlamentar: null, parlamentarUf: null,
|
|
218
|
+
tipoSigla: null, ...partesDaProposicao(prop.texto), url: prop.href,
|
|
219
|
+
}));
|
|
220
|
+
return "transformadaEm";
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
// ── a página ──────────────────────────────────────────────────────────────
|
|
225
|
+
/** Lê a página de detalhe inteira (ou o recorte `#container-ideia-legislativa`). */
|
|
226
|
+
export function lerIdeiaDetalhe(html) {
|
|
227
|
+
if (/class="object-not-found"/.test(html)) {
|
|
228
|
+
throw new IdeiaDetalheForaDoMoldeError('a página diz "Ideia Legislativa não encontrada!" (object-not-found)');
|
|
229
|
+
}
|
|
230
|
+
// Título em <b><div>, seguido da descrição no primeiro <div> (o mesmo par que o leitor antigo lia).
|
|
231
|
+
const cabeca = /id="ideia-legislativa"[\s\S]*?<b><div[^>]*>([\s\S]*?)<\/div><\/b>(?:\s*<div[^>]*>([\s\S]*?)<\/div>)?/.exec(html);
|
|
232
|
+
const titulo = cabeca ? textoDoHtml(cabeca[1]) : "";
|
|
233
|
+
if (!titulo)
|
|
234
|
+
throw new IdeiaDetalheForaDoMoldeError("sem título em #ideia-legislativa");
|
|
235
|
+
const descricao = cabeca?.[2] !== undefined ? textoDoHtml(cabeca[2]) || null : null;
|
|
236
|
+
const apoiosM = /<span class="contabilizacao">\s*([\d.]+)\s*<\/span>\s*apoios confirmados/.exec(html);
|
|
237
|
+
const linha = /<ol class="timeline">([\s\S]*?)<\/ol>\s*<\/section>/.exec(html);
|
|
238
|
+
if (!linha)
|
|
239
|
+
throw new IdeiaDetalheForaDoMoldeError("sem a Linha do Tempo (<ol class=\"timeline\">)");
|
|
240
|
+
const itens = linha[1].split(/(?=<li class="timeline-item)/).slice(1);
|
|
241
|
+
if (itens.length === 0)
|
|
242
|
+
throw new IdeiaDetalheForaDoMoldeError("Linha do Tempo vazia");
|
|
243
|
+
const ideia = {
|
|
244
|
+
titulo, descricao, apoios: apoiosM ? inteiroBr(apoiosM[1]) : null,
|
|
245
|
+
autorNome: null, autorUf: null, dataProposta: null, prazoApoios: null, dataMeta: null, meta: null,
|
|
246
|
+
sugestao: null, decisaoCdh: null, encerramento: null, oficina: null,
|
|
247
|
+
proposicoes: [], linhaDoTempo: [], avisos: [],
|
|
248
|
+
};
|
|
249
|
+
itens.forEach((li, ordem) => {
|
|
250
|
+
const corpo = li.replace(/<time\b[\s\S]*?<\/time>/, "").replace(/<a\b[^>]*href="declaracaoideia[^"]*"[\s\S]*?<\/a>/, "");
|
|
251
|
+
const item = {
|
|
252
|
+
ordem,
|
|
253
|
+
data: /<time datetime="(\d{4}-\d{2}-\d{2})"/.exec(li)?.[1] ?? null,
|
|
254
|
+
ativo: /^<li class="timeline-item active"/.test(li),
|
|
255
|
+
tipo: "naoReconhecido",
|
|
256
|
+
texto: textoDoHtml(corpo),
|
|
257
|
+
};
|
|
258
|
+
const tipo = lerItem({ ideia, item, html: corpo, onde: `item ${ordem}` });
|
|
259
|
+
if (tipo)
|
|
260
|
+
item.tipo = tipo;
|
|
261
|
+
else
|
|
262
|
+
ideia.avisos.push(`acontecimento não reconhecido no item ${ordem} (${item.data ?? "sem data"}): "${item.texto.slice(0, 120)}"`);
|
|
263
|
+
ideia.linhaDoTempo.push(item);
|
|
264
|
+
});
|
|
265
|
+
if (ideia.dataProposta === null)
|
|
266
|
+
throw new IdeiaDetalheForaDoMoldeError('Linha do Tempo sem "Ideia proposta por"');
|
|
267
|
+
return ideia;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Status DERIVADO da linha do tempo, para quando a categoria da listagem não está à mão. É
|
|
271
|
+
* classificação NOSSA — a categoria do portal (bucket da listagem) sempre prevalece sobre ele, e quem
|
|
272
|
+
* o usa marca a resposta como derivada.
|
|
273
|
+
*
|
|
274
|
+
* Regra medida em 17/10/2026 sobre a amostra de 336 ideias com bucket conhecido
|
|
275
|
+
* (`docs/_local/ideias-linha-do-tempo-2026-10-17/`): acerta 335. A divergência é irredutível — 103929
|
|
276
|
+
* (s10) e 136304 (s9) têm a MESMA linha do tempo (adoção por parlamentar e, depois, a CDH decidindo
|
|
277
|
+
* não transformar a Sugestão); o portal as classificou diferente.
|
|
278
|
+
* - decisão da CDH transformando, ou "transformada em" sem autor → convertida (s10)
|
|
279
|
+
* - CDH decidiu não transformar → encerrada (s9)
|
|
280
|
+
* - Sugestão sem decisão da CDH (mesmo adotada por parlamentar) → aberta (s6, na comissão)
|
|
281
|
+
* - adotada por parlamentar fora da Sugestão → convertida (s10)
|
|
282
|
+
* - período de apoio encerrado → encerrada (s7)
|
|
283
|
+
* - o resto (só proposta/prazo/oficina/meta) → aberta (s5, s8)
|
|
284
|
+
*/
|
|
285
|
+
export function statusDaLinhaDoTempo(d) {
|
|
286
|
+
const tem = (t) => d.linhaDoTempo.some((e) => e.tipo === t);
|
|
287
|
+
if (tem("cdhTransformou") || tem("transformadaEm"))
|
|
288
|
+
return "convertida";
|
|
289
|
+
if (tem("cdhNaoTransformou"))
|
|
290
|
+
return "encerrada";
|
|
291
|
+
if (tem("sugestao"))
|
|
292
|
+
return "aberta";
|
|
293
|
+
if (tem("adocao"))
|
|
294
|
+
return "convertida";
|
|
295
|
+
if (tem("prazoEncerrado"))
|
|
296
|
+
return "encerrada";
|
|
297
|
+
return "aberta";
|
|
298
|
+
}
|
|
299
|
+
/** Limite histórico da descrição no corpus (o leitor antigo cortava aqui; mantém o hash das linhas). */
|
|
300
|
+
const DESCRICAO_CORPUS_MAX = 2000;
|
|
301
|
+
/** `IdeiaDetalhe` → campos do corpus. UF-only: o nome do autor cidadão não vai para o corpus. */
|
|
302
|
+
export function detalheParaCorpus(d) {
|
|
303
|
+
return {
|
|
304
|
+
dataPublicacao: d.prazoApoios,
|
|
305
|
+
autorUf: d.autorUf,
|
|
306
|
+
descricao: d.descricao ? d.descricao.substring(0, DESCRICAO_CORPUS_MAX) : null,
|
|
307
|
+
plConvertido: d.sugestao ? `SUG ${d.sugestao.numero}/${d.sugestao.ano}` : null,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Apoios publicados: o contador da página, ou o número do acontecimento "Período de apoio encerrado"
|
|
312
|
+
* (os dois são da fonte). Null se nenhum dos dois aparece — nunca 0 inventado.
|
|
313
|
+
*/
|
|
314
|
+
export function apoiosPublicados(d) {
|
|
315
|
+
return d.apoios ?? d.encerramento?.apoios ?? null;
|
|
316
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/scraper/pipeline.js
CHANGED
|
@@ -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,
|
|
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 ?
|
|
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: () =>
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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,
|
|
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: () =>
|
|
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) =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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) {
|
package/dist/scraper/store.js
CHANGED
|
@@ -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).
|
package/dist/tools/ecidadania.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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á). **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
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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);
|
package/dist/utils/provenance.js
CHANGED
|
@@ -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. */
|
package/dist/utils/untrusted.js
CHANGED
|
@@ -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