@lincoln504/pi-research 1.3.3 → 1.3.5
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 +7 -2
- package/dist/cli.mjs +242 -95
- package/dist/thread-worker.mjs +122 -50
- package/docs/CHANGELOG.md +76 -0
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/core/service-registry.ts +37 -3
- package/src/healthcheck/index.ts +9 -7
- package/src/infrastructure/browser/browser-client.ts +5 -2
- package/src/infrastructure/browser/task-execution-service.ts +21 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +49 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +70 -25
- package/src/infrastructure/browser/thread-worker.mjs +122 -50
- 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/security/osv-types.ts +1 -1
- package/src/skill-install/skill-installer.ts +69 -6
- package/src/stackexchange/index.ts +15 -0
- package/src/stackexchange/rest-client.ts +13 -24
- package/src/tools/research-tool-definition.ts +3 -2
- package/src/tools/scrape.ts +6 -4
- package/src/tools/security.ts +13 -4
- package/src/utils/input-validation.ts +0 -10
- package/src/utils/json-utils.ts +0 -78
- package/src/utils/log-rotation.ts +21 -6
- package/src/utils/log-utils.ts +43 -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/text-utils.ts +6 -2
- 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
- package/src/youtube/potoken.ts +26 -2
- package/src/youtube/transcript-client.ts +5 -2
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;
|
|
@@ -128,22 +133,25 @@ const CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/g;
|
|
|
128
133
|
* Applied to every message before it reaches the log file or the console, so
|
|
129
134
|
* neither sink receives clear-text secrets or unbounded network data.
|
|
130
135
|
*/
|
|
136
|
+
// How much raw input the regex chain below is allowed to scan before a final
|
|
137
|
+
// truncation to MAX_LOG_MESSAGE_LENGTH. A multiple of MAX_LOG_MESSAGE_LENGTH,
|
|
138
|
+
// not 1:1, because most patterns below SHRINK text (a 64-char secret becomes
|
|
139
|
+
// "[REDACTED]"), so more than `keep` raw characters can be needed to fully
|
|
140
|
+
// populate the final truncated output. Nothing past this point can ever
|
|
141
|
+
// survive into the visible output regardless, so skipping it is free — this
|
|
142
|
+
// is what bounds redactSecrets() to O(MAX_LOG_MESSAGE_LENGTH) instead of
|
|
143
|
+
// O(input length) on multi-megabyte scraped payloads.
|
|
144
|
+
const REDACT_SCAN_LENGTH = MAX_LOG_MESSAGE_LENGTH * 4;
|
|
145
|
+
|
|
131
146
|
export function redactSecrets(message: string): string {
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
let
|
|
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;
|
|
147
|
+
// Redact BEFORE truncating. Every pattern below needs to see a secret in
|
|
148
|
+
// full to match it (e.g. LONG_HEX_SECRET_PATTERN requires 32+ contiguous
|
|
149
|
+
// hex chars) — truncating first can cut a secret mid-token, leaving
|
|
150
|
+
// whatever fragment survives the cut (e.g. the first 20 chars of a 64-hex
|
|
151
|
+
// browser auth secret) too short for any pattern to recognize, so it was
|
|
152
|
+
// written out in the clear right before the "…[truncated N chars]" marker.
|
|
153
|
+
const rawLength = message.length;
|
|
154
|
+
let out = rawLength > REDACT_SCAN_LENGTH ? message.slice(0, REDACT_SCAN_LENGTH) : message;
|
|
147
155
|
out = out.replace(ANSI_PATTERN, '');
|
|
148
156
|
out = out.replace(URL_CREDENTIALS_PATTERN, '$1[REDACTED]@');
|
|
149
157
|
// Redact whole-token formats BEFORE the key/value pass: the KV value class
|
|
@@ -161,6 +169,22 @@ export function redactSecrets(message: string): string {
|
|
|
161
169
|
out = out.replace(KNOWN_TOKEN_PATTERN, '[REDACTED]');
|
|
162
170
|
out = out.replace(PROVIDER_TOKEN_PATTERN, '[REDACTED]');
|
|
163
171
|
out = out.replace(LONG_HEX_SECRET_PATTERN, '[REDACTED]');
|
|
172
|
+
|
|
173
|
+
// slice() counts UTF-16 code units, so a naive cut at MAX_LOG_MESSAGE_LENGTH
|
|
174
|
+
// can land between the two halves of a surrogate pair (non-BMP characters —
|
|
175
|
+
// routine in scraped CJK/emoji content). The console sink then writes a
|
|
176
|
+
// lone surrogate, silently mangled to U+FFFD on UTF-8 encode; the JSON-file
|
|
177
|
+
// sink emits an unpaired \uXXXX escape that many strict JSON consumers
|
|
178
|
+
// reject outright. Same back-off truncateWithMarker (text-utils.ts) already
|
|
179
|
+
// applies for the same reason.
|
|
180
|
+
let keep = MAX_LOG_MESSAGE_LENGTH;
|
|
181
|
+
if (out.length > keep) {
|
|
182
|
+
const lastKeptUnit = out.charCodeAt(keep - 1);
|
|
183
|
+
if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
|
|
184
|
+
}
|
|
185
|
+
if (out.length > MAX_LOG_MESSAGE_LENGTH) {
|
|
186
|
+
out = `${out.slice(0, keep)}…[truncated ${rawLength - keep} chars]`;
|
|
187
|
+
}
|
|
164
188
|
return out;
|
|
165
189
|
}
|
|
166
190
|
|
|
@@ -189,23 +213,9 @@ const TERMINAL_ESCAPE_PATTERN = new RegExp(
|
|
|
189
213
|
* otherwise reach the user's terminal verbatim under PI_RESEARCH_CONSOLE_LOG.
|
|
190
214
|
* The console sink keeps its own explicit CR/LF→"" replace (the form CodeQL
|
|
191
215
|
* recognizes as the log-injection sanitizer); this handles everything else,
|
|
192
|
-
* spacing out any leftover C0/DEL byte (BEL, backspace, …)
|
|
193
|
-
*
|
|
216
|
+
* spacing out any leftover C0/DEL byte (BEL, backspace, …) via
|
|
217
|
+
* CONTROL_CHARS_PATTERN.
|
|
194
218
|
*/
|
|
195
219
|
export function stripTerminalEscapes(message: string): string {
|
|
196
220
|
return message.replace(TERMINAL_ESCAPE_PATTERN, '').replace(CONTROL_CHARS_PATTERN, ' ');
|
|
197
221
|
}
|
|
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/text-utils.ts
CHANGED
|
@@ -188,8 +188,12 @@ export function formatDuration(ms: number): string {
|
|
|
188
188
|
if (ms < 1000) return `${ms}ms`;
|
|
189
189
|
const seconds = ms / 1000;
|
|
190
190
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
// Round the total first, then derive minutes/seconds from that integer —
|
|
192
|
+
// rounding remainingSeconds independently of minutes let e.g. 59.6s round up
|
|
193
|
+
// to a nonsensical "Xm 60s" instead of rolling over to the next minute.
|
|
194
|
+
const totalSeconds = Math.round(seconds);
|
|
195
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
196
|
+
const remainingSeconds = totalSeconds % 60;
|
|
193
197
|
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
|
194
198
|
const hours = Math.floor(minutes / 60);
|
|
195
199
|
const remainingMinutes = minutes % 60;
|
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
|
|
package/src/youtube/potoken.ts
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
|
|
44
44
|
import { logger } from '../logger.ts';
|
|
45
|
+
import { safeUnref } from '../utils/safe-unref.ts';
|
|
45
46
|
import { metrics } from '../utils/metrics.ts';
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -76,6 +77,15 @@ function describeError(error: unknown): string {
|
|
|
76
77
|
// Module-level mutex: serialize all globalThis mutation across concurrent callers.
|
|
77
78
|
let mintChain: Promise<unknown> = Promise.resolve();
|
|
78
79
|
|
|
80
|
+
// How long the mutex waits for a straggling doMint() before force-releasing
|
|
81
|
+
// the chain for the next caller. doMint() awaits non-fetch operations with no
|
|
82
|
+
// abort signal (interpreter eval, BotGuard VM execution against a
|
|
83
|
+
// hostile/malformed attestation script) that could in principle hang forever;
|
|
84
|
+
// without this bound, a single wedged call would permanently break YouTube
|
|
85
|
+
// transcript fetching for the rest of the process. Mirrors the hard-release
|
|
86
|
+
// timer on the browser page-creation mutex in thread-worker-messaging.ts.
|
|
87
|
+
const MINT_HARD_RELEASE_MS = 5 * 60 * 1000;
|
|
88
|
+
|
|
79
89
|
/**
|
|
80
90
|
* Mint a session-bound token (for `identifiers[0]`, the visitorData) plus one
|
|
81
91
|
* content-bound token per remaining identifier (the video IDs), all from a
|
|
@@ -90,8 +100,22 @@ export async function mintPoTokens(
|
|
|
90
100
|
): Promise<Map<string, string>> {
|
|
91
101
|
// Chain on the mutex so only one bridged section runs at a time.
|
|
92
102
|
const run = mintChain.then(() => doMint(identifiers, opts), () => doMint(identifiers, opts));
|
|
93
|
-
// Keep the chain alive regardless of this call's success/failure
|
|
94
|
-
|
|
103
|
+
// Keep the chain alive regardless of this call's success/failure, but bound
|
|
104
|
+
// how long the NEXT caller waits on it: if `run` never settles, force the
|
|
105
|
+
// chain open after the hard timeout rather than wedging every future
|
|
106
|
+
// mintPoTokens() call behind it forever. The abandoned `run` may still
|
|
107
|
+
// mutate globalThis in the background after this — an accepted trade-off
|
|
108
|
+
// in the pathological case, same as the sibling browser-worker fix.
|
|
109
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
110
|
+
let hardTimer: NodeJS.Timeout;
|
|
111
|
+
const hardRelease = new Promise<void>(resolve => {
|
|
112
|
+
hardTimer = setTimeout(() => {
|
|
113
|
+
logger.warn('[youtube] potoken mint mutex force-released — doMint() did not settle within the hard timeout');
|
|
114
|
+
resolve();
|
|
115
|
+
}, MINT_HARD_RELEASE_MS);
|
|
116
|
+
safeUnref(hardTimer);
|
|
117
|
+
});
|
|
118
|
+
mintChain = Promise.race([settled, hardRelease]).finally(() => clearTimeout(hardTimer));
|
|
95
119
|
return run;
|
|
96
120
|
}
|
|
97
121
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-research/retry-utils.ts';
|
|
21
21
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
22
22
|
import { logger } from '../logger.ts';
|
|
23
|
+
import { redactSecrets } from '../utils/log-utils.ts';
|
|
23
24
|
import { metrics } from '../utils/metrics.ts';
|
|
24
25
|
import { readTextCapped } from '../utils/http-body.ts';
|
|
25
26
|
import { mintPoTokens } from './potoken.ts';
|
|
@@ -355,8 +356,10 @@ function parseJson3(body: string): string {
|
|
|
355
356
|
// Log the real cause: an unparseable body is NOT necessarily an empty
|
|
356
357
|
// bot-protection response (the caller's default diagnosis) — it can be an HTML
|
|
357
358
|
// interstitial or a response-shape change. A short snippet disambiguates without
|
|
358
|
-
// dumping a huge/again-sensitive body.
|
|
359
|
-
|
|
359
|
+
// dumping a huge/again-sensitive body. Redact BEFORE truncating to 120 chars —
|
|
360
|
+
// slicing first can cut a secret-shaped token mid-way, leaving a fragment too
|
|
361
|
+
// short for redactSecrets' whole-token patterns to recognize.
|
|
362
|
+
logger.debug(`[youtube] timedtext body was not valid json3 (first 120 chars): ${redactSecrets(body).slice(0, 120)}`);
|
|
360
363
|
return '';
|
|
361
364
|
}
|
|
362
365
|
const events = (parsed as { events?: unknown[] }).events;
|