@lincoln504/pi-research 1.0.4 → 1.0.6

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.
@@ -140,6 +140,57 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
140
140
  }
141
141
  }
142
142
 
143
+ /** Thrown by readBodyCapped when a streamed body crosses its byte cap. */
144
+ class BodyTooLargeError extends Error {
145
+ constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
146
+ super(`Response body exceeded ${maxBytes} byte cap`);
147
+ this.name = 'BodyTooLargeError';
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
153
+ * over-limit body aborts the moment the cap is crossed. The Content-Length
154
+ * pre-screen above is advisory only: a chunked (or lying) response carries no
155
+ * usable length header, and `response.arrayBuffer()` would otherwise buffer the
156
+ * entire body — potentially hundreds of MB from a hostile server — into the
157
+ * main process before any size check ran.
158
+ *
159
+ * A body-less response (test doubles, some mocks) falls back to arrayBuffer();
160
+ * callers keep their post-read size checks as the belt for that path.
161
+ */
162
+ async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
163
+ const body = response.body;
164
+ // Also tolerate non-standard doubles that expose a body without getReader.
165
+ if (!body || typeof body.getReader !== 'function') {
166
+ return new Uint8Array(await response.arrayBuffer());
167
+ }
168
+
169
+ const reader = body.getReader();
170
+ const chunks: Uint8Array[] = [];
171
+ let total = 0;
172
+ try {
173
+ for (;;) {
174
+ const { done, value } = await reader.read();
175
+ if (done) break;
176
+ if (value) {
177
+ total += value.byteLength;
178
+ if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
179
+ chunks.push(value);
180
+ }
181
+ }
182
+ } finally {
183
+ // Over-cap or downstream throw: release the connection instead of leaving
184
+ // the socket open (and the server still sending) until GC. No-op after a
185
+ // clean done=true read.
186
+ try { await reader.cancel(); } catch { /* already closed */ }
187
+ }
188
+ const out = new Uint8Array(total);
189
+ let offset = 0;
190
+ for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
191
+ return out;
192
+ }
193
+
143
194
  async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<ScrapeLayerResult> {
144
195
  await validateUrlForSSRF(url);
145
196
 
@@ -189,6 +240,9 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
189
240
  if (!location) break; // No Location header — treat as final response
190
241
  const resolved = new URL(location, currentUrl).href;
191
242
  await validateUrlForSSRF(resolved); // Block SSRF targets in redirects
243
+ // Drain the redirect response before following: an unconsumed body pins
244
+ // its socket (undici keeps it open until read/cancelled or GC).
245
+ try { await response.body?.cancel(); } catch { /* best-effort */ }
192
246
  currentUrl = resolved;
193
247
  continue;
194
248
  }
@@ -197,6 +251,8 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
197
251
 
198
252
  if (!response.ok) {
199
253
  metrics.increment('scrape_errors_total', 1, { error_type: 'http_error', status_code: String(response.status) });
254
+ // Same socket-pinning concern as the redirect drain above.
255
+ try { await response.body?.cancel(); } catch { /* best-effort */ }
200
256
  throw new Error(`HTTP ${response.status}`);
201
257
  }
202
258
 
@@ -211,19 +267,34 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
211
267
  const sizeMB = Math.round(size / 1024 / 1024);
212
268
  logger.warn(`[Scrapers] PDF too large (Content-Length: ${sizeMB}MB, max 100MB), skipping`);
213
269
  metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
270
+ // Same socket-pinning concern as the redirect/!ok drains above — and this
271
+ // throw fires precisely on the LARGEST responses.
272
+ try { await response.body?.cancel(); } catch { /* best-effort */ }
214
273
  throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
215
274
  }
216
275
  } else if (size > MAX_HTML_SIZE) {
217
276
  const sizeMB = Math.round(size / 1024 / 1024);
218
277
  logger.warn(`[Scrapers] HTML response too large (Content-Length: ${sizeMB}MB, max 25MB), skipping`);
219
278
  metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: contentType.split(';')[0] || 'unknown' });
279
+ try { await response.body?.cancel(); } catch { /* best-effort */ }
220
280
  throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
221
281
  }
222
282
  }
223
283
 
224
284
  if (contentType.includes('application/pdf') || url.toLowerCase().endsWith('.pdf')) {
225
- const buffer = await response.arrayBuffer();
226
- const markdown = await extractPdfToMarkdown(new Uint8Array(buffer));
285
+ let pdfBytes: Uint8Array;
286
+ try {
287
+ pdfBytes = await readBodyCapped(response, MAX_PDF_SIZE);
288
+ } catch (e) {
289
+ if (e instanceof BodyTooLargeError) {
290
+ const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
291
+ logger.warn(`[Scrapers] PDF too large (streamed ${sizeMB}MB, max 100MB), skipping`);
292
+ metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'size_exceeded' });
293
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`, { cause: e });
294
+ }
295
+ throw e;
296
+ }
297
+ const markdown = await extractPdfToMarkdown(pdfBytes);
227
298
  validateContent('', markdown, url);
228
299
  metrics.increment('scrape_operations_total', 1, { layer: 'fetch', content_type: 'pdf', status: 'success' });
229
300
  metrics.observe('scrape_latency_ms', fetchDuration, { layer: 'fetch', content_type: 'pdf', status: 'success' });
@@ -232,8 +303,28 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
232
303
 
233
304
  // Read the raw bytes and decode with the DECLARED charset. response.text() blindly UTF-8-decodes,
234
305
  // which mangles Shift_JIS/GBK/EUC-KR/ISO-8859-1 pages into mojibake that then gets cached + cited.
235
- // Size-check on the byte length before decoding.
236
- const bodyBytes = new Uint8Array(await response.arrayBuffer());
306
+ // Size-capped while streaming; the post-read check below is the belt for the
307
+ // body-less fallback path inside readBodyCapped.
308
+ let bodyBytes: Uint8Array;
309
+ try {
310
+ bodyBytes = await readBodyCapped(response, MAX_HTML_SIZE);
311
+ } catch (e) {
312
+ if (e instanceof BodyTooLargeError) {
313
+ const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
314
+ logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
315
+ metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: 'html' });
316
+ errorTracker.trackError(new Error(`HTML response too large (${sizeMB}MB, max 25MB)`), {
317
+ component: 'scrapers',
318
+ operation: 'fetch',
319
+ url,
320
+ domain: extractDomain(url),
321
+ layer: 'fetch',
322
+ contentType: 'html',
323
+ });
324
+ throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
325
+ }
326
+ throw e;
327
+ }
237
328
 
238
329
  if (bodyBytes.length > MAX_HTML_SIZE) {
239
330
  const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
@@ -121,10 +121,14 @@ function formatResults(results: VideoTranscript[], rejected: string[]): string {
121
121
  if (r.lang) meta.push(`Language: ${r.lang}`);
122
122
  if (typeof r.charCount === 'number') meta.push(`${r.charCount.toLocaleString()} chars`);
123
123
  if (meta.length) md += `**${meta.join(' · ')}**\n`;
124
- // Exact, copy-ready citation string so the report cites the real video title
125
- // and channel rather than a paraphrase (the watch URL is not identifiable).
124
+ // Exact, copy-ready citation string. It MUST lead with the watch URL: a
125
+ // title-only citation cannot be matched to a source by the synthesis
126
+ // citation pipeline (parseCitations extracts URLs), so the transcript's
127
+ // provenance would silently drop out of the final report's CITED LINKS.
128
+ // The title/channel follows so the report names the real video, not a
129
+ // paraphrase (the watch URL alone is not human-identifiable).
126
130
  if (r.title) {
127
- md += `**Cite as:** '${r.title}'${r.author ? ` by ${r.author}` : ''}\n`;
131
+ md += `**Cite as:** ${r.url} — '${r.title}'${r.author ? ` by ${r.author}` : ''}\n`;
128
132
  }
129
133
  md += `\n${r.text}\n\n---\n\n`;
130
134
  }
@@ -251,6 +251,8 @@ async function fetchOne(
251
251
  // 404 (removed) returns HTML that parseJson3 yields '' for, which would
252
252
  // be misreported below as a bot-protection/PoToken failure.
253
253
  if (!response.ok) {
254
+ // Drain before throwing so the error body doesn't pin its socket.
255
+ void response.body?.cancel()?.catch(() => { /* best-effort */ });
254
256
  throw new Error(`HTTP ${response.status} from timedtext endpoint`);
255
257
  }
256
258
  const body = await response.text();