senado-br-mcp 3.18.3 → 3.18.4
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/dist/scraper/ecidadania.js +21 -7
- package/dist/throttle/upstream.js +63 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
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
|
-
import { upstreamCall } from "../throttle/upstream.js";
|
|
15
|
+
import { CorpoForaDoPrazoError, PrazoDoCorpo, upstreamCall } from "../throttle/upstream.js";
|
|
16
16
|
export const ECIDADANIA_BASE = "https://www12.senado.leg.br/ecidadania";
|
|
17
17
|
/**
|
|
18
18
|
* Error carrying a `retryable` flag, mirroring UpstreamError so the tool layer
|
|
@@ -36,10 +36,9 @@ const isTransientStatus = (status) => status >= 500 || status === 429;
|
|
|
36
36
|
* (`X-Requested-With: XMLHttpRequest`, wider `Accept`).
|
|
37
37
|
*/
|
|
38
38
|
export async function fetchPage(path, opts = {}) {
|
|
39
|
-
const
|
|
39
|
+
const text = await idaEcidadania(path, opts.ajax
|
|
40
40
|
? { Accept: "text/html,*/*", "User-Agent": USER_AGENT, "X-Requested-With": "XMLHttpRequest" }
|
|
41
41
|
: { Accept: "text/html", "User-Agent": USER_AGENT }, "e-Cidadania");
|
|
42
|
-
const text = await resp.text();
|
|
43
42
|
// AJAX comment fragments are legitimately empty for events with zero comments — allowEmpty
|
|
44
43
|
// lets the caller treat "" as a valid (empty) result instead of a transient failure.
|
|
45
44
|
if (!opts.allowEmpty && !text.trim()) {
|
|
@@ -52,10 +51,10 @@ export async function fetchPage(path, opts = {}) {
|
|
|
52
51
|
* Ensures the response is valid JSON and an array.
|
|
53
52
|
*/
|
|
54
53
|
export async function fetchEcidadaniaJson(endpoint) {
|
|
55
|
-
const
|
|
54
|
+
const corpo = await idaEcidadania(endpoint, { Accept: "application/json", "User-Agent": USER_AGENT }, "e-Cidadania REST API");
|
|
56
55
|
let data;
|
|
57
56
|
try {
|
|
58
|
-
data =
|
|
57
|
+
data = JSON.parse(corpo);
|
|
59
58
|
}
|
|
60
59
|
catch {
|
|
61
60
|
throw ecidadaniaFetchError(`e-Cidadania REST API retornou JSON inválido para ${endpoint}`, true);
|
|
@@ -77,11 +76,18 @@ export async function fetchEcidadaniaJson(endpoint) {
|
|
|
77
76
|
* descartável e a política vale igual. O token bucket NÃO se aplica: é outro
|
|
78
77
|
* host, e o portal nunca passou por ele. As mensagens e o `retryable` são os
|
|
79
78
|
* de antes — `ecidadaniaError` (tools) e o pipeline os leem.
|
|
79
|
+
*
|
|
80
|
+
* Devolve o CORPO, lido aqui sob o que sobra dos 10 s (`PrazoDoCorpo`): no modo
|
|
81
|
+
* `response` o pacote só cobre até os cabeçalhos, e um portal que trava no
|
|
82
|
+
* corpo pendurava a chamada (e o cron) sem prazo.
|
|
80
83
|
*/
|
|
81
84
|
async function idaEcidadania(path, headers, rotulo) {
|
|
82
85
|
const url = `${ECIDADANIA_BASE}${path}`;
|
|
86
|
+
const prazo = new PrazoDoCorpo();
|
|
87
|
+
const foraDoPrazo = () => ecidadaniaFetchError(`${rotulo}: timeout (${UPSTREAM_TIMEOUT_MS / 1000}s) ao acessar ${path}`, true, "fonte");
|
|
88
|
+
let resp;
|
|
83
89
|
try {
|
|
84
|
-
|
|
90
|
+
resp = await upstreamCall().response(url, { headers, signal: prazo.signal });
|
|
85
91
|
}
|
|
86
92
|
catch (e) {
|
|
87
93
|
if (!(e instanceof PkgUpstreamError))
|
|
@@ -89,7 +95,7 @@ async function idaEcidadania(path, headers, rotulo) {
|
|
|
89
95
|
switch (e.kind) {
|
|
90
96
|
case "timeout":
|
|
91
97
|
case "aborted":
|
|
92
|
-
throw
|
|
98
|
+
throw foraDoPrazo();
|
|
93
99
|
case "network": {
|
|
94
100
|
const causa = e.cause instanceof Error ? e.cause.message : String(e.cause ?? "desconhecido");
|
|
95
101
|
throw ecidadaniaFetchError(`${rotulo}: falha de rede ao acessar ${path} (${causa})`, true, "fonte");
|
|
@@ -108,6 +114,14 @@ async function idaEcidadania(path, headers, rotulo) {
|
|
|
108
114
|
}
|
|
109
115
|
}
|
|
110
116
|
}
|
|
117
|
+
try {
|
|
118
|
+
return await prazo.texto(resp);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
if (e instanceof CorpoForaDoPrazoError)
|
|
122
|
+
throw foraDoPrazo();
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
111
125
|
}
|
|
112
126
|
/** Parse Brazilian number: 1.234.567 -> 1234567 */
|
|
113
127
|
export function parseBrNum(s) {
|
|
@@ -213,6 +213,52 @@ export function withUpstreamCall(fn) {
|
|
|
213
213
|
export function upstreamCall() {
|
|
214
214
|
return currentCall() ?? upstreamSenado.call();
|
|
215
215
|
}
|
|
216
|
+
/** O corpo não chegou inteiro dentro do orçamento da ida (ver `PrazoDoCorpo`). */
|
|
217
|
+
export class CorpoForaDoPrazoError extends Error {
|
|
218
|
+
constructor() {
|
|
219
|
+
super("corpo da resposta fora do prazo");
|
|
220
|
+
this.name = "CorpoForaDoPrazoError";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* O prazo da LEITURA DO CORPO de uma ida no modo `response`.
|
|
225
|
+
*
|
|
226
|
+
* No modo `response` o `@sbissoli/mcp-upstream` (0.4.x) desarma o cronômetro da
|
|
227
|
+
* tentativa quando chegam os CABEÇALHOS: o corpo, lido aqui depois, ficava sem
|
|
228
|
+
* prazo nenhum, e uma fonte que manda os cabeçalhos e trava no corpo pendurava a
|
|
229
|
+
* chamada até o Worker morrer. Medido em 13/10/2026 ao subir o nock do medical
|
|
230
|
+
* (o `delay()` do nock 15 passou a segurar o corpo, não os cabeçalhos).
|
|
231
|
+
*
|
|
232
|
+
* O `signal` entra na ida e o pacote o combina ao dele; quem o aborta é SÓ
|
|
233
|
+
* `texto()`, depois dos cabeçalhos — durante a ida ele nunca dispara, então a
|
|
234
|
+
* classificação do pacote (timeout × abortado, a contagem do `retrieval`) não
|
|
235
|
+
* muda. O prazo do corpo é o que sobra do orçamento TOTAL da ida, contado do
|
|
236
|
+
* início dela: cabeçalhos + corpo cabem nos mesmos 10 s, como sempre se disse.
|
|
237
|
+
*/
|
|
238
|
+
export class PrazoDoCorpo {
|
|
239
|
+
#controller = new AbortController();
|
|
240
|
+
#inicio = Date.now();
|
|
241
|
+
/** Para passar na ida (`call.response(url, { signal })`). */
|
|
242
|
+
get signal() {
|
|
243
|
+
return this.#controller.signal;
|
|
244
|
+
}
|
|
245
|
+
/** Lê o corpo como texto; estourando o orçamento, lança `CorpoForaDoPrazoError`. */
|
|
246
|
+
async texto(response) {
|
|
247
|
+
const resta = UPSTREAM_POLICY.budgetMs - (Date.now() - this.#inicio);
|
|
248
|
+
const timer = setTimeout(() => this.#controller.abort(), Math.max(0, resta));
|
|
249
|
+
try {
|
|
250
|
+
return await response.text();
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
if (this.#controller.signal.aborted)
|
|
254
|
+
throw new CorpoForaDoPrazoError();
|
|
255
|
+
throw e;
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
216
262
|
/**
|
|
217
263
|
* O `retrieval` medido nesta chamada de tool — `null` fora de um coletor ou
|
|
218
264
|
* quando a resposta veio inteira do cache (nenhuma ida). É o que
|
|
@@ -260,6 +306,20 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
260
306
|
for (let i = 1; i < attempts; i++)
|
|
261
307
|
incr("upstreamRetries");
|
|
262
308
|
};
|
|
309
|
+
const prazo = new PrazoDoCorpo();
|
|
310
|
+
const lerCorpo = async (r) => {
|
|
311
|
+
try {
|
|
312
|
+
return await prazo.texto(r);
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
if (e instanceof CorpoForaDoPrazoError) {
|
|
316
|
+
incr("upstreamErrors");
|
|
317
|
+
logger.error("upstream_error", { path, status: 504, message: "corpo fora do prazo" });
|
|
318
|
+
throw new UpstreamError(`[${path}] Timeout na requisição upstream (${UPSTREAM_TIMEOUT_MS / 1000}s)`, 504, true, true);
|
|
319
|
+
}
|
|
320
|
+
throw e;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
263
323
|
let response;
|
|
264
324
|
inFlight++;
|
|
265
325
|
try {
|
|
@@ -269,6 +329,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
269
329
|
Accept: options.accept ?? "application/json",
|
|
270
330
|
"User-Agent": USER_AGENT,
|
|
271
331
|
},
|
|
332
|
+
signal: prazo.signal,
|
|
272
333
|
});
|
|
273
334
|
}
|
|
274
335
|
catch (err) {
|
|
@@ -278,7 +339,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
278
339
|
if (err.kind === "not_found" && options.on404 && err.response) {
|
|
279
340
|
incr("upstreamCalls");
|
|
280
341
|
log("upstream", path, 404, Date.now() - startTime, err.attempts - 1);
|
|
281
|
-
const corpo404 = await err.response
|
|
342
|
+
const corpo404 = await lerCorpo(err.response);
|
|
282
343
|
if (rotaInexistente(corpo404)) {
|
|
283
344
|
throw new UpstreamError(`[${path}] ${MSG_ROTA_INEXISTENTE} — o caminho montado não existe na API do Senado.`, 404, false, false, "defeito");
|
|
284
345
|
}
|
|
@@ -305,7 +366,7 @@ export async function upstreamFetch(path, params = {}, baseUrl, options = {}) {
|
|
|
305
366
|
if (contentLength && parseInt(contentLength, 10) > maxSize) {
|
|
306
367
|
throw new UpstreamError(`[${path}] Resposta upstream excede o limite de ${Math.round(maxSize / 1024 / 1024)} MB`, 413, false);
|
|
307
368
|
}
|
|
308
|
-
const text = await response
|
|
369
|
+
const text = await lerCorpo(response);
|
|
309
370
|
if (text.length > maxSize) {
|
|
310
371
|
throw new UpstreamError(`[${path}] Resposta upstream excede o limite de ${Math.round(maxSize / 1024 / 1024)} MB`, 413, false);
|
|
311
372
|
}
|
package/dist/version.js
CHANGED