@lincoln504/pi-research 1.3.2 → 1.3.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/README.md +1 -1
- package/dist/cli.mjs +178 -73
- package/dist/thread-worker.mjs +56 -37
- package/docs/CHANGELOG.md +62 -0
- package/package.json +4 -4
- package/skills/pi-research/SKILL.md +1 -1
- package/src/core/llm/agentic-repair.ts +5 -1
- package/src/core/llm/model-registry-factory.ts +17 -7
- package/src/core/pi-version.ts +15 -2
- package/src/core/service-registry.ts +37 -3
- package/src/healthcheck/index.ts +9 -7
- package/src/infrastructure/browser/task-execution-service.ts +21 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +20 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +30 -22
- package/src/infrastructure/browser/thread-worker.mjs +56 -37
- package/src/infrastructure/knowledge-store-service.ts +27 -2
- package/src/knowledge/chunker.ts +14 -1
- package/src/orchestration/quick-research-orchestrator.ts +13 -2
- package/src/orchestration/research-orchestration-service.ts +16 -3
- package/src/sdk.ts +11 -2
- package/src/security/github-advisories.ts +13 -4
- package/src/skill-install/skill-installer.ts +40 -5
- package/src/stackexchange/index.ts +15 -0
- package/src/stackexchange/rest-client.ts +13 -24
- package/src/tools/research-knowledge-search.ts +6 -2
- package/src/utils/input-validation.ts +0 -10
- package/src/utils/json-utils.ts +0 -78
- package/src/utils/log-utils.ts +32 -33
- package/src/utils/metrics.ts +0 -8
- package/src/utils/shared-links.ts +0 -14
- package/src/utils/stdio-capture.ts +44 -7
- package/src/utils/url-utils.ts +7 -1
- package/src/web-research/scraper-utils.ts +8 -3
- package/src/web-research/web-scraper.ts +33 -1
|
@@ -89,13 +89,3 @@ export function validateAndSanitizeQuery(query: string): string {
|
|
|
89
89
|
|
|
90
90
|
return sanitizeQuery(query);
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Validate complexity level
|
|
95
|
-
*
|
|
96
|
-
* @param complexity - The complexity level to validate
|
|
97
|
-
* @returns True if valid, false otherwise
|
|
98
|
-
*/
|
|
99
|
-
export function validateComplexity(complexity: number): boolean {
|
|
100
|
-
return complexity === 0 || complexity === 1 || complexity === 2 || complexity === 3;
|
|
101
|
-
}
|
package/src/utils/json-utils.ts
CHANGED
|
@@ -356,81 +356,3 @@ export function extractJson<T = unknown>(
|
|
|
356
356
|
error: 'No valid JSON found using any extraction method',
|
|
357
357
|
};
|
|
358
358
|
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Result of string array normalization
|
|
362
|
-
*/
|
|
363
|
-
export interface NormalizeArrayResult {
|
|
364
|
-
/** Normalized strings */
|
|
365
|
-
strings: string[];
|
|
366
|
-
/** Count of items that were skipped (empty after normalization) */
|
|
367
|
-
skippedCount: number;
|
|
368
|
-
/** Count of items that were objects and needed extraction */
|
|
369
|
-
extractedCount: number;
|
|
370
|
-
/** Warnings about any issues */
|
|
371
|
-
warnings: string[];
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Normalize string array with detailed feedback
|
|
376
|
-
*
|
|
377
|
-
* Normalizes an array of values to strings with detailed feedback
|
|
378
|
-
* about what was normalized and any issues.
|
|
379
|
-
*
|
|
380
|
-
* @param arr - Array of values to normalize
|
|
381
|
-
* @returns Detailed normalization result
|
|
382
|
-
*/
|
|
383
|
-
export function normalizeStringArrayDetailed(
|
|
384
|
-
arr: unknown[]
|
|
385
|
-
): NormalizeArrayResult {
|
|
386
|
-
const strings: string[] = [];
|
|
387
|
-
const warnings: string[] = [];
|
|
388
|
-
let skippedCount = 0;
|
|
389
|
-
let extractedCount = 0;
|
|
390
|
-
|
|
391
|
-
arr.forEach((item, index) => {
|
|
392
|
-
if (typeof item === 'string') {
|
|
393
|
-
const trimmed = item.trim();
|
|
394
|
-
if (trimmed.length > 0) {
|
|
395
|
-
strings.push(trimmed);
|
|
396
|
-
} else {
|
|
397
|
-
skippedCount++;
|
|
398
|
-
warnings.push(`Item ${index}: empty string`);
|
|
399
|
-
}
|
|
400
|
-
} else if (item !== null && typeof item === 'object') {
|
|
401
|
-
extractedCount++;
|
|
402
|
-
const obj = item as Record<string, unknown>;
|
|
403
|
-
const candidate = obj['query'] ?? obj['topic'] ?? obj['text'] ?? obj['task'];
|
|
404
|
-
|
|
405
|
-
if (typeof candidate === 'string') {
|
|
406
|
-
const trimmed = candidate.trim();
|
|
407
|
-
if (trimmed.length > 0) {
|
|
408
|
-
strings.push(trimmed);
|
|
409
|
-
} else {
|
|
410
|
-
skippedCount++;
|
|
411
|
-
warnings.push(`Item ${index}: object extracted empty string`);
|
|
412
|
-
}
|
|
413
|
-
} else {
|
|
414
|
-
// No recognized key, stringify the whole object
|
|
415
|
-
const stringified = JSON.stringify(item);
|
|
416
|
-
if (stringified.length > 0) {
|
|
417
|
-
strings.push(stringified);
|
|
418
|
-
warnings.push(`Item ${index}: no recognized key, stringified object`);
|
|
419
|
-
} else {
|
|
420
|
-
skippedCount++;
|
|
421
|
-
warnings.push(`Item ${index}: object stringified to empty`);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
} else {
|
|
425
|
-
const stringified = String(item).trim();
|
|
426
|
-
if (stringified.length > 0) {
|
|
427
|
-
strings.push(stringified);
|
|
428
|
-
} else {
|
|
429
|
-
skippedCount++;
|
|
430
|
-
warnings.push(`Item ${index}: ${typeof item} converted to empty string`);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
return { strings, skippedCount, extractedCount, warnings };
|
|
436
|
-
}
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -91,9 +91,14 @@ const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi
|
|
|
91
91
|
// later one exposed. A cookie string is a ;-and-space separated list of secrets,
|
|
92
92
|
// not a single token — mask the ENTIRE header value to end-of-line.
|
|
93
93
|
const COOKIE_HEADER_PATTERN = /\b(set[_-]?cookie|cookie)\b(["']?\s*[:=]\s*["']?)([^\r\n]+)/gi;
|
|
94
|
-
// key=value / key: "value" pairs whose key names a secret.
|
|
94
|
+
// key=value / key: "value" pairs whose key names a secret. Includes bare
|
|
95
|
+
// `key` (not just `api_key`/`private_key`): several provider query-param
|
|
96
|
+
// APIs (e.g. StackExchange's `?key=<apikey>`) name the credential parameter
|
|
97
|
+
// with no qualifying prefix. Over-redacting an unrelated "key" field (a
|
|
98
|
+
// sort key, a map key) is the accepted trade-off — same one already made for
|
|
99
|
+
// the equally generic bare `session`/`cookie` entries below.
|
|
95
100
|
const SENSITIVE_KV_PATTERN =
|
|
96
|
-
/\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key)\b(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
|
|
101
|
+
/\b(api[_-]?key|apikey|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|client[_-]?secret|password|passwd|pwd|authorization|bearer|set[_-]?cookie|cookie|session[_-]?id|session|csrf[_-]?token|xsrf[_-]?token|private[_-]?key|key)\b(["']?\s*[:=]\s*["']?)([^\s"',&)]+)/gi;
|
|
97
102
|
// Well-known opaque credential formats (OpenAI, GitHub, AWS, Slack, Anthropic).
|
|
98
103
|
const KNOWN_TOKEN_PATTERN =
|
|
99
104
|
/\b(sk-[A-Za-z0-9_-]{16,}|gh[posru]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g;
|
|
@@ -129,21 +134,13 @@ const CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
|
|
|
129
134
|
* neither sink receives clear-text secrets or unbounded network data.
|
|
130
135
|
*/
|
|
131
136
|
export function redactSecrets(message: string): string {
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
let keep = MAX_LOG_MESSAGE_LENGTH;
|
|
140
|
-
if (message.length > keep) {
|
|
141
|
-
const lastKeptUnit = message.charCodeAt(keep - 1);
|
|
142
|
-
if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
|
|
143
|
-
}
|
|
144
|
-
let out = message.length > MAX_LOG_MESSAGE_LENGTH
|
|
145
|
-
? `${message.slice(0, keep)}…[truncated ${message.length - keep} chars]`
|
|
146
|
-
: message;
|
|
137
|
+
// Redact BEFORE truncating. Every pattern below needs to see a secret in
|
|
138
|
+
// full to match it (e.g. LONG_HEX_SECRET_PATTERN requires 32+ contiguous
|
|
139
|
+
// hex chars) — truncating first can cut a secret mid-token, leaving
|
|
140
|
+
// whatever fragment survives the cut (e.g. the first 20 chars of a 64-hex
|
|
141
|
+
// browser auth secret) too short for any pattern to recognize, so it was
|
|
142
|
+
// written out in the clear right before the "…[truncated N chars]" marker.
|
|
143
|
+
let out = message;
|
|
147
144
|
out = out.replace(ANSI_PATTERN, '');
|
|
148
145
|
out = out.replace(URL_CREDENTIALS_PATTERN, '$1[REDACTED]@');
|
|
149
146
|
// Redact whole-token formats BEFORE the key/value pass: the KV value class
|
|
@@ -161,6 +158,22 @@ export function redactSecrets(message: string): string {
|
|
|
161
158
|
out = out.replace(KNOWN_TOKEN_PATTERN, '[REDACTED]');
|
|
162
159
|
out = out.replace(PROVIDER_TOKEN_PATTERN, '[REDACTED]');
|
|
163
160
|
out = out.replace(LONG_HEX_SECRET_PATTERN, '[REDACTED]');
|
|
161
|
+
|
|
162
|
+
// slice() counts UTF-16 code units, so a naive cut at MAX_LOG_MESSAGE_LENGTH
|
|
163
|
+
// can land between the two halves of a surrogate pair (non-BMP characters —
|
|
164
|
+
// routine in scraped CJK/emoji content). The console sink then writes a
|
|
165
|
+
// lone surrogate, silently mangled to U+FFFD on UTF-8 encode; the JSON-file
|
|
166
|
+
// sink emits an unpaired \uXXXX escape that many strict JSON consumers
|
|
167
|
+
// reject outright. Same back-off truncateWithMarker (text-utils.ts) already
|
|
168
|
+
// applies for the same reason.
|
|
169
|
+
let keep = MAX_LOG_MESSAGE_LENGTH;
|
|
170
|
+
if (out.length > keep) {
|
|
171
|
+
const lastKeptUnit = out.charCodeAt(keep - 1);
|
|
172
|
+
if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
|
|
173
|
+
}
|
|
174
|
+
if (out.length > MAX_LOG_MESSAGE_LENGTH) {
|
|
175
|
+
out = `${out.slice(0, keep)}…[truncated ${out.length - keep} chars]`;
|
|
176
|
+
}
|
|
164
177
|
return out;
|
|
165
178
|
}
|
|
166
179
|
|
|
@@ -189,23 +202,9 @@ const TERMINAL_ESCAPE_PATTERN = new RegExp(
|
|
|
189
202
|
* otherwise reach the user's terminal verbatim under PI_RESEARCH_CONSOLE_LOG.
|
|
190
203
|
* The console sink keeps its own explicit CR/LF→"" replace (the form CodeQL
|
|
191
204
|
* recognizes as the log-injection sanitizer); this handles everything else,
|
|
192
|
-
* spacing out any leftover C0/DEL byte (BEL, backspace, …)
|
|
193
|
-
*
|
|
205
|
+
* spacing out any leftover C0/DEL byte (BEL, backspace, …) via
|
|
206
|
+
* CONTROL_CHARS_PATTERN.
|
|
194
207
|
*/
|
|
195
208
|
export function stripTerminalEscapes(message: string): string {
|
|
196
209
|
return message.replace(TERMINAL_ESCAPE_PATTERN, '').replace(CONTROL_CHARS_PATTERN, ' ');
|
|
197
210
|
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Neutralize control characters (newlines, carriage returns, etc.) so that
|
|
201
|
-
* untrusted content cannot forge or corrupt a raw console log line
|
|
202
|
-
* (log-injection defense). Use for sinks that are NOT already structurally
|
|
203
|
-
* escaped — the JSON log file escapes control chars on its own.
|
|
204
|
-
*/
|
|
205
|
-
export function neutralizeControlChars(message: string): string {
|
|
206
|
-
// Strip CR/LF explicitly first. These are the actual log-injection vector
|
|
207
|
-
// (forged/corrupted log lines) and an explicit line-break replacement is the
|
|
208
|
-
// form static analysis recognizes as a log-injection sanitizer. The second
|
|
209
|
-
// pass removes any remaining C0 control chars + DEL.
|
|
210
|
-
return message.replace(/\r\n|\r|\n/g, ' ').replace(CONTROL_CHARS_PATTERN, ' ');
|
|
211
|
-
}
|
package/src/utils/metrics.ts
CHANGED
|
@@ -198,14 +198,6 @@ export function runWithRunRegistry<T>(
|
|
|
198
198
|
return runRegistryStorage.run(registry, fn);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
/**
|
|
202
|
-
* Returns the run-scoped registry if called inside a runWithRunRegistry context,
|
|
203
|
-
* undefined otherwise.
|
|
204
|
-
*/
|
|
205
|
-
export function getCurrentRunRegistry(): MetricsRegistry | undefined {
|
|
206
|
-
return runRegistryStorage.getStore();
|
|
207
|
-
}
|
|
208
|
-
|
|
209
201
|
// ── Session-level manager ──────────────────────────────────────────────────
|
|
210
202
|
|
|
211
203
|
const MAX_RUN_HISTORY = 10;
|
|
@@ -201,20 +201,6 @@ export function registerResearcherScrapes(researchId: string, researcherId: stri
|
|
|
201
201
|
urls.forEach(url => set.add(normalizeUrl(url)));
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
/**
|
|
205
|
-
* Check if a specific researcher scraped a given URL.
|
|
206
|
-
*/
|
|
207
|
-
export function didResearcherScrape(researchId: string, researcherId: string, url: string): boolean {
|
|
208
|
-
return researcherScrapes.get(researchId)?.get(researcherId)?.has(normalizeUrl(url)) ?? false;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Get all URLs scraped by a specific researcher.
|
|
213
|
-
*/
|
|
214
|
-
export function getResearcherScrapes(researchId: string, researcherId: string): string[] {
|
|
215
|
-
return Array.from(researcherScrapes.get(researchId)?.get(researcherId) ?? []);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
204
|
/**
|
|
219
205
|
* Build the Session URL Pool footer for scrape responses.
|
|
220
206
|
* Shows URLs previously scraped in the session, filtered to exclude the current batch.
|
|
@@ -217,7 +217,21 @@ export async function captureStdio<T>(
|
|
|
217
217
|
isAnyLoggerCapturingOutput = true;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
|
|
220
|
+
// Separate decoder PER logical byte stream, each used with {stream: true}.
|
|
221
|
+
// A single native write/writeSync call can split a multi-byte UTF-8
|
|
222
|
+
// character across two calls — {stream: true} is what lets a TextDecoder
|
|
223
|
+
// carry an incomplete trailing sequence over to the next decode() and
|
|
224
|
+
// reconstruct it, rather than replacing each half with U+FFFD independently
|
|
225
|
+
// (decode() with no options treats every call as a complete, final input).
|
|
226
|
+
// A SHARED decoder across genuinely independent streams would be its own
|
|
227
|
+
// bug even with {stream: true}: stdout, stderr, and raw FD1/FD2 writes can
|
|
228
|
+
// interleave, and a decoder's carried-over partial bytes are only valid
|
|
229
|
+
// when the next call continues the SAME byte stream — mixing streams would
|
|
230
|
+
// try to complete one stream's dangling sequence with another's bytes.
|
|
231
|
+
const stderrDecoder = new TextDecoder();
|
|
232
|
+
const stdoutDecoder = new TextDecoder();
|
|
233
|
+
const fd1SyncDecoder = new TextDecoder();
|
|
234
|
+
const fd2SyncDecoder = new TextDecoder();
|
|
221
235
|
|
|
222
236
|
// Save originals
|
|
223
237
|
const originalStderrWrite = process.stderr.write;
|
|
@@ -269,7 +283,18 @@ export async function captureStdio<T>(
|
|
|
269
283
|
// Patch stderr.write
|
|
270
284
|
(process.stderr.write as any) = (chunk: string | Uint8Array, encodingOrCb?: any, callback?: any) => {
|
|
271
285
|
const cb = typeof encodingOrCb === 'function' ? encodingOrCb : callback;
|
|
272
|
-
const message = typeof chunk === 'string' ? chunk :
|
|
286
|
+
const message = typeof chunk === 'string' ? chunk : stderrDecoder.decode(chunk, { stream: true });
|
|
287
|
+
|
|
288
|
+
// A chunk that lands entirely inside a multi-byte character's bytes
|
|
289
|
+
// decodes to '' with {stream: true} — the bytes are buffered inside
|
|
290
|
+
// stderrDecoder and will surface once the completing chunk arrives. This
|
|
291
|
+
// patch (unlike stdout's) always diverts rather than passing through, so
|
|
292
|
+
// without this guard it would write an empty log entry every time a
|
|
293
|
+
// native write happens to split mid-character.
|
|
294
|
+
if (message.length === 0) {
|
|
295
|
+
if (typeof cb === 'function') cb();
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
273
298
|
|
|
274
299
|
// Check disk space before writing
|
|
275
300
|
if (!hasSufficientDiskSpace()) {
|
|
@@ -294,7 +319,7 @@ export async function captureStdio<T>(
|
|
|
294
319
|
|
|
295
320
|
// Patch stdout.write
|
|
296
321
|
(process.stdout.write as any) = (chunk: string | Uint8Array, encodingOrCb?: any, callback?: any) => {
|
|
297
|
-
const message = typeof chunk === 'string' ? chunk :
|
|
322
|
+
const message = typeof chunk === 'string' ? chunk : stdoutDecoder.decode(chunk, { stream: true });
|
|
298
323
|
|
|
299
324
|
// Native log patterns or plain text that we want to divert from TUI
|
|
300
325
|
const isNative = isNativeLog(message);
|
|
@@ -359,15 +384,27 @@ export async function captureStdio<T>(
|
|
|
359
384
|
if (!descriptor || (descriptor.writable || descriptor.set)) {
|
|
360
385
|
fs.writeSync = (fd: number, chunk: any, ...args: any[]) => {
|
|
361
386
|
if (fd === 1 || fd === 2) {
|
|
362
|
-
const
|
|
363
|
-
|
|
387
|
+
const fdDecoder = fd === 1 ? fd1SyncDecoder : fd2SyncDecoder;
|
|
388
|
+
const message = typeof chunk === 'string' ? chunk : fdDecoder.decode(chunk, { stream: true });
|
|
389
|
+
|
|
390
|
+
// FD 2 diverts unconditionally below (fd === 2 short-circuits
|
|
391
|
+
// shouldDivert), so unlike FD 1 — whose divert conditions already
|
|
392
|
+
// require non-empty content and naturally fall through to a
|
|
393
|
+
// passthrough write otherwise — an empty message here (a chunk that
|
|
394
|
+
// landed entirely inside a multi-byte character, buffered inside
|
|
395
|
+
// fdDecoder for the completing chunk) must be skipped explicitly to
|
|
396
|
+
// avoid writing an empty log entry on every mid-character split.
|
|
397
|
+
if (fd === 2 && message.length === 0) {
|
|
398
|
+
return (typeof chunk === 'string' ? Buffer.from(chunk).length : (chunk as any).length);
|
|
399
|
+
}
|
|
400
|
+
|
|
364
401
|
const isNative = isNativeLog(message);
|
|
365
402
|
const boxDrawing = isBoxDrawing(message);
|
|
366
403
|
const complexTui = isComplexTui(message);
|
|
367
404
|
const hasAnsi = message.includes('\x1b[');
|
|
368
405
|
|
|
369
|
-
const shouldDivert = fd === 2 ||
|
|
370
|
-
(isNative && !boxDrawing && !complexTui) ||
|
|
406
|
+
const shouldDivert = fd === 2 ||
|
|
407
|
+
(isNative && !boxDrawing && !complexTui) ||
|
|
371
408
|
(!hasAnsi && !boxDrawing && message.trim().length > 0);
|
|
372
409
|
|
|
373
410
|
if (shouldDivert) {
|
package/src/utils/url-utils.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { logger } from '../logger.ts';
|
|
9
|
+
import { redactSecrets } from './log-utils.ts';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Strips trailing punctuation an LLM commonly appends to a URL in prose
|
|
@@ -130,7 +131,12 @@ export function normalizeUrl(url: string): string {
|
|
|
130
131
|
// Fallback for invalid URLs: aggressive best-effort normalization.
|
|
131
132
|
// LLM hallucinations often produce broken URLs — log a warning so we can
|
|
132
133
|
// trace them, then do our best to clean them for dedup purposes.
|
|
133
|
-
|
|
134
|
+
// Redact BEFORE truncating: `url` can still carry userinfo credentials
|
|
135
|
+
// (https://user:pass@host/...) even when unparseable, and cutting at 200
|
|
136
|
+
// chars first can slice a credential mid-token before redactSecrets'
|
|
137
|
+
// patterns ever see the whole thing — same class of bug as the truncate-
|
|
138
|
+
// before-redact fix in log-utils.ts's redactSecrets itself.
|
|
139
|
+
logger.debug(`[url-utils] normalizing unparseable URL (${redactSecrets(url).slice(0, 200)}): ${_err instanceof Error ? _err.message : String(_err)}`);
|
|
134
140
|
|
|
135
141
|
let cleaned = url.trim()
|
|
136
142
|
// Strip leading/trailing markdown and punctuation often added by LLMs.
|
|
@@ -32,10 +32,14 @@ import { isCloudflareBlockError, isTaskTimeoutError, isPoolShutdownError } from
|
|
|
32
32
|
* logger.error's error-tracker re-track, the diagnostic error count) focused on
|
|
33
33
|
* genuine faults. The messages matched here are produced by validateContent
|
|
34
34
|
* (`Fetch blocked:` / `Fetch returned stub:`), scrapeWithFetch (`HTTP <code>`)
|
|
35
|
-
* and extractPdfToMarkdown (`Could not extract content from PDF`
|
|
36
|
-
* malformed/encrypted/scanned-image-only PDF
|
|
35
|
+
* and extractPdfToMarkdown (`Could not extract content from PDF`, `PDF too
|
|
36
|
+
* large`) — a malformed/encrypted/scanned-image-only PDF, or one that simply
|
|
37
|
+
* exceeds the deliberate 100MB policy cap, is routine on the open web and
|
|
37
38
|
* just as non-actionable as a stub page or a 404; the remaining cases
|
|
38
|
-
* delegate to the shared browser-layer predicates.
|
|
39
|
+
* delegate to the shared browser-layer predicates. Deliberately does NOT
|
|
40
|
+
* match 'PDF extraction unavailable' (a pdf-oxide-wasm native-module load
|
|
41
|
+
* failure) — that is an infrastructure fault affecting every URL, not a
|
|
42
|
+
* routine per-PDF outcome, and must still surface at ERROR.
|
|
39
43
|
*/
|
|
40
44
|
export function isBenignScrapeFailure(error: unknown): boolean {
|
|
41
45
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -43,6 +47,7 @@ export function isBenignScrapeFailure(error: unknown): boolean {
|
|
|
43
47
|
msg.startsWith('Fetch blocked:') || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
|
|
44
48
|
msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
|
|
45
49
|
msg.startsWith('Could not extract content from PDF') || // malformed/encrypted/scanned-only PDF
|
|
50
|
+
msg.startsWith('PDF too large') || // exceeds the deliberate 100MB policy cap
|
|
46
51
|
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
47
52
|
isCloudflareBlockError(error) ||
|
|
48
53
|
isTaskTimeoutError(error) ||
|
|
@@ -88,8 +88,40 @@ async function extractPdfToMarkdown(bytes: Uint8Array): Promise<string> {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const pdfExtractionStart = Date.now();
|
|
91
|
+
|
|
92
|
+
// The native module load is caught SEPARATELY from the parse/extraction
|
|
93
|
+
// logic below: a failure here (missing prebuilt binary for this platform,
|
|
94
|
+
// corrupted install) is an INFRASTRUCTURE fault — PDF extraction is broken
|
|
95
|
+
// for every URL, not just this one — not the routine, expected outcome of
|
|
96
|
+
// a malformed/encrypted/scanned-image-only PDF. Sharing one catch with the
|
|
97
|
+
// parse logic used to log both at `debug` and lump both under
|
|
98
|
+
// 'extraction_failed', so a broken install produced an endless stream of
|
|
99
|
+
// silent per-PDF "failures" instead of ever surfacing the real cause.
|
|
100
|
+
let WasmPdfDocument: (new (bytes: Uint8Array) => {
|
|
101
|
+
pageCount(): number;
|
|
102
|
+
toMarkdownAll(): string;
|
|
103
|
+
toMarkdown(page: number): string;
|
|
104
|
+
free(): void;
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
({ WasmPdfDocument } = await import('pdf-oxide-wasm'));
|
|
108
|
+
} catch (e) {
|
|
109
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
110
|
+
logger.error(`[Scrapers] pdf-oxide-wasm failed to load — PDF extraction is unavailable (not just for this URL): ${msg}`);
|
|
111
|
+
metrics.increment('scrape_pdf_errors_total', 1, { error_type: 'native_module_unavailable' });
|
|
112
|
+
errorTracker.trackError(e instanceof Error ? e : String(e), {
|
|
113
|
+
component: 'scrapers',
|
|
114
|
+
operation: 'pdf-extract',
|
|
115
|
+
contentType: 'pdf',
|
|
116
|
+
errorType: 'native_module_unavailable',
|
|
117
|
+
});
|
|
118
|
+
// Deliberately does NOT start with 'Could not extract content from PDF' —
|
|
119
|
+
// isBenignScrapeFailure must NOT classify this as the routine per-PDF
|
|
120
|
+
// outcome it is for the parse-failure branch below.
|
|
121
|
+
throw new Error(`PDF extraction unavailable (pdf-oxide-wasm failed to load: ${msg})`, { cause: e });
|
|
122
|
+
}
|
|
123
|
+
|
|
91
124
|
try {
|
|
92
|
-
const { WasmPdfDocument } = await import('pdf-oxide-wasm');
|
|
93
125
|
const doc = new WasmPdfDocument(bytes);
|
|
94
126
|
const pageCount = doc.pageCount();
|
|
95
127
|
|