@lincoln504/pi-research 1.5.3 → 1.6.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 +11 -11
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +143 -123
- package/dist/thread-worker.mjs +105 -17
- package/docs/CHANGELOG.md +38 -0
- package/docs/SDK.md +72 -9
- package/package.json +2 -2
- package/src/infrastructure/browser/browser-client.ts +24 -6
- package/src/infrastructure/browser/browser-error-utils.ts +53 -0
- package/src/infrastructure/browser/browser-server.ts +9 -1
- package/src/infrastructure/browser/thread-worker-browser.ts +4 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +186 -3
- package/src/infrastructure/browser/thread-worker.mjs +105 -17
- package/src/knowledge/store-operations.ts +18 -7
- package/src/knowledge/store.ts +21 -3
- package/src/web-research/scraper-utils.ts +4 -36
- package/src/youtube/potoken.ts +47 -34
|
@@ -1516,11 +1516,25 @@ function isPoolShutdownError(error) {
|
|
|
1516
1516
|
// same transient swap the "being reset" string covers, not a hard fault.
|
|
1517
1517
|
err.message.includes("Cannot execute a task on not started pool"));
|
|
1518
1518
|
}
|
|
1519
|
-
|
|
1519
|
+
function isBenignScrapeFailure(error) {
|
|
1520
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1521
|
+
return msg.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
|
|
1522
|
+
msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
|
|
1523
|
+
msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
|
|
1524
|
+
msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
|
|
1525
|
+
msg.startsWith("Unsupported download:") || // attachment-served non-PDF (zip/xlsx/…) — nothing to extract
|
|
1526
|
+
BENIGN_NAVIGATION_FAILURE.test(msg) || // remote TLS/DNS/timeout — the URL could not be loaded
|
|
1527
|
+
msg.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
|
|
1528
|
+
msg.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
|
|
1529
|
+
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
1530
|
+
isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
|
|
1531
|
+
}
|
|
1532
|
+
var BENIGN_NAVIGATION_FAILURE, DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
|
|
1520
1533
|
var init_browser_error_utils = __esm({
|
|
1521
1534
|
"src/infrastructure/browser/browser-error-utils.ts"() {
|
|
1522
1535
|
"use strict";
|
|
1523
1536
|
init_circuit_breaker();
|
|
1537
|
+
BENIGN_NAVIGATION_FAILURE = /page\.goto: (?:Timeout \d+ *ms exceeded|net::|NS_ERROR_|SEC_ERROR_|MOZILLA_PKIX_ERROR_)/;
|
|
1524
1538
|
DEFAULT_BREAKER_CONFIG = {
|
|
1525
1539
|
// Raised from 3→8: parallel search bursts of 20 queries frequently produce
|
|
1526
1540
|
// simultaneous timeouts on slow/CF-protected sites. A threshold of 3 caused
|
|
@@ -1565,17 +1579,6 @@ import * as dnsCb from "node:dns";
|
|
|
1565
1579
|
import * as net from "node:net";
|
|
1566
1580
|
import crypto from "node:crypto";
|
|
1567
1581
|
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
1568
|
-
function isBenignScrapeFailure(error) {
|
|
1569
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1570
|
-
return msg.startsWith("Fetch blocked:") || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
|
|
1571
|
-
msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
|
|
1572
|
-
msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
|
|
1573
|
-
msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
|
|
1574
|
-
msg.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
|
|
1575
|
-
msg.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
|
|
1576
|
-
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
1577
|
-
isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
|
|
1578
|
-
}
|
|
1579
1582
|
function getRandomUserAgent() {
|
|
1580
1583
|
try {
|
|
1581
1584
|
const index = crypto.randomInt(0, USER_AGENTS.length);
|
|
@@ -2014,7 +2017,7 @@ import { platform as platform2 } from "node:os";
|
|
|
2014
2017
|
|
|
2015
2018
|
// src/infrastructure/browser/thread-worker-messaging.ts
|
|
2016
2019
|
init_log_utils();
|
|
2017
|
-
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
2020
|
+
import { appendFileSync as appendFileSync2, promises as fsp } from "node:fs";
|
|
2018
2021
|
|
|
2019
2022
|
// src/utils/safe-unref.ts
|
|
2020
2023
|
function safeUnref(timer) {
|
|
@@ -2286,6 +2289,66 @@ function seedSubresourceVerdict(validatedUrl) {
|
|
|
2286
2289
|
} catch {
|
|
2287
2290
|
}
|
|
2288
2291
|
}
|
|
2292
|
+
function isEvictedBodyError(err) {
|
|
2293
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2294
|
+
return /was evicted|Response body is unavailable/i.test(msg);
|
|
2295
|
+
}
|
|
2296
|
+
var PDF_REFETCH_TIMEOUT_MS = 9e4;
|
|
2297
|
+
async function refetchPdfBody(page, url, validateForWorker, signal) {
|
|
2298
|
+
await validateForWorker(url);
|
|
2299
|
+
if (signal?.aborted) throw new Error("Aborted");
|
|
2300
|
+
const requestPromise = page.context().request.get(url, {
|
|
2301
|
+
maxRedirects: 0,
|
|
2302
|
+
timeout: PDF_REFETCH_TIMEOUT_MS
|
|
2303
|
+
});
|
|
2304
|
+
requestPromise.catch(() => {
|
|
2305
|
+
});
|
|
2306
|
+
const response = await (signal ? Promise.race([
|
|
2307
|
+
requestPromise,
|
|
2308
|
+
new Promise((_, reject) => {
|
|
2309
|
+
if (signal.aborted) return reject(new Error("Aborted"));
|
|
2310
|
+
signal.addEventListener("abort", () => reject(new Error("Aborted")), { once: true });
|
|
2311
|
+
})
|
|
2312
|
+
]) : requestPromise);
|
|
2313
|
+
const status = Number(response.status?.() ?? 0);
|
|
2314
|
+
if (!response.ok?.()) throw new Error(`HTTP ${status}`);
|
|
2315
|
+
const declared = parseInt(String((await response.headersArray?.() ?? []).find?.((h) => String(h.name).toLowerCase() === "content-length")?.value ?? ""), 10);
|
|
2316
|
+
if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
|
|
2317
|
+
throw new Error(`PDF too large (${Math.round(declared / 1024 / 1024)}MB, max 100MB)`);
|
|
2318
|
+
}
|
|
2319
|
+
const buffer = await response.body();
|
|
2320
|
+
if (buffer.byteLength > MAX_PDF_SIZE) {
|
|
2321
|
+
throw new Error(`PDF too large (${Math.round(buffer.byteLength / 1024 / 1024)}MB, max 100MB)`);
|
|
2322
|
+
}
|
|
2323
|
+
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Re-fetched evicted PDF body (${buffer.byteLength} bytes) for: ${url}`);
|
|
2324
|
+
return buffer;
|
|
2325
|
+
}
|
|
2326
|
+
async function readPdfDownload(download, url, page, pendingAddrChecks, getPoisonedError) {
|
|
2327
|
+
const failure = await download.failure?.().catch(() => null);
|
|
2328
|
+
if (failure) throw new Error(`Fetch blocked: download failed (${failure})`);
|
|
2329
|
+
const suggested = String(download.suggestedFilename?.() ?? "");
|
|
2330
|
+
const downloadUrl = String(download.url?.() ?? url);
|
|
2331
|
+
const looksLikePdf = suggested.toLowerCase().endsWith(".pdf") || downloadUrl.toLowerCase().split("?")[0].endsWith(".pdf");
|
|
2332
|
+
if (!looksLikePdf) {
|
|
2333
|
+
throw new Error(`Unsupported download: ${suggested || downloadUrl} is not a PDF`);
|
|
2334
|
+
}
|
|
2335
|
+
const filePath = await download.path();
|
|
2336
|
+
if (!filePath) throw new Error("Fetch blocked: download produced no readable file");
|
|
2337
|
+
const { size } = await fsp.stat(filePath);
|
|
2338
|
+
if (size > MAX_PDF_SIZE) {
|
|
2339
|
+
const sizeMB = Math.round(size / 1024 / 1024);
|
|
2340
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
2341
|
+
}
|
|
2342
|
+
const buffer = await fsp.readFile(filePath);
|
|
2343
|
+
await Promise.allSettled(pendingAddrChecks);
|
|
2344
|
+
const poisoned = getPoisonedError();
|
|
2345
|
+
if (poisoned) throw poisoned;
|
|
2346
|
+
await download.delete?.().catch(() => {
|
|
2347
|
+
});
|
|
2348
|
+
await page.close();
|
|
2349
|
+
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Recovered attachment-served PDF (${buffer.byteLength} bytes) for: ${url}`);
|
|
2350
|
+
return { contentType: "application/pdf", bufferB64: buffer.toString("base64"), jitter: 0 };
|
|
2351
|
+
}
|
|
2289
2352
|
async function executeScrapeTask(_context, url, signal) {
|
|
2290
2353
|
const page = await createPageSafe(_context);
|
|
2291
2354
|
const SCRAPE_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SCRAPE_TIMEOUT_MS"], 15e3);
|
|
@@ -2302,6 +2365,7 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2302
2365
|
let poisonedError = null;
|
|
2303
2366
|
const pendingAddrChecks = [];
|
|
2304
2367
|
let oversizedPdfError = null;
|
|
2368
|
+
let pendingDownload = null;
|
|
2305
2369
|
try {
|
|
2306
2370
|
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting scrape for: ${url}`);
|
|
2307
2371
|
const { validateUrlForSSRF: validateForWorker, validateUrlForSSRFSync: validateForWorkerSync } = await Promise.resolve().then(() => (init_scraper_utils(), scraper_utils_exports));
|
|
@@ -2368,7 +2432,25 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2368
2432
|
} catch {
|
|
2369
2433
|
}
|
|
2370
2434
|
});
|
|
2371
|
-
|
|
2435
|
+
page.on("download", (d) => {
|
|
2436
|
+
pendingDownload ??= d;
|
|
2437
|
+
});
|
|
2438
|
+
let response;
|
|
2439
|
+
try {
|
|
2440
|
+
response = await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
2441
|
+
} catch (navErr) {
|
|
2442
|
+
const navMsg = navErr instanceof Error ? navErr.message : String(navErr);
|
|
2443
|
+
const isDownloadNav = /download is starting/i.test(navMsg);
|
|
2444
|
+
if (!isDownloadNav) throw navErr;
|
|
2445
|
+
if (!pendingDownload) {
|
|
2446
|
+
await Promise.race([
|
|
2447
|
+
page.waitForEvent("download", { timeout: 5e3 }).catch(() => null),
|
|
2448
|
+
new Promise((r) => setTimeout(r, 5e3))
|
|
2449
|
+
]);
|
|
2450
|
+
}
|
|
2451
|
+
if (!pendingDownload) throw navErr;
|
|
2452
|
+
return await readPdfDownload(pendingDownload, url, page, pendingAddrChecks, () => poisonedError);
|
|
2453
|
+
}
|
|
2372
2454
|
const serverAddr = await response?.serverAddr().catch(() => null);
|
|
2373
2455
|
if (serverAddr?.ipAddress) {
|
|
2374
2456
|
const ip = serverAddr.ipAddress;
|
|
@@ -2386,7 +2468,13 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2386
2468
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
2387
2469
|
}
|
|
2388
2470
|
if (oversizedPdfError) throw oversizedPdfError;
|
|
2389
|
-
|
|
2471
|
+
let buffer;
|
|
2472
|
+
try {
|
|
2473
|
+
buffer = await response.body();
|
|
2474
|
+
} catch (bodyErr) {
|
|
2475
|
+
if (!isEvictedBodyError(bodyErr)) throw bodyErr;
|
|
2476
|
+
buffer = await refetchPdfBody(page, finalUrl, validateForWorker, signal);
|
|
2477
|
+
}
|
|
2390
2478
|
if (buffer.byteLength > MAX_PDF_SIZE) {
|
|
2391
2479
|
const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
|
|
2392
2480
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
@@ -2749,7 +2837,7 @@ function logToDebugFile3(level, ...args) {
|
|
|
2749
2837
|
return String(arg);
|
|
2750
2838
|
}).join(" "))
|
|
2751
2839
|
};
|
|
2752
|
-
import("node:fs/promises").then((
|
|
2840
|
+
import("node:fs/promises").then((fsp2) => fsp2.appendFile(logFile, `${JSON.stringify(entry)}
|
|
2753
2841
|
`)).catch(() => {
|
|
2754
2842
|
});
|
|
2755
2843
|
} catch {
|
|
@@ -2888,7 +2976,7 @@ async function initBrowser() {
|
|
|
2888
2976
|
return initPromise;
|
|
2889
2977
|
}
|
|
2890
2978
|
async function acquireTaskContext() {
|
|
2891
|
-
const contextPromise = browser.newContext();
|
|
2979
|
+
const contextPromise = browser.newContext({ acceptDownloads: true });
|
|
2892
2980
|
contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
2893
2981
|
let contextTimeoutId;
|
|
2894
2982
|
let contextRaceLost = false;
|
|
@@ -13,6 +13,13 @@ import { logger } from '../logger.ts';
|
|
|
13
13
|
import { metrics } from '../utils/metrics.ts';
|
|
14
14
|
import { StoreDocument } from '../core/interfaces/knowledge-interfaces.ts';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* What a single add attempt actually did. Returned rather than logged/counted here
|
|
18
|
+
* so the caller — which owns the retry loop — records exactly one outcome per
|
|
19
|
+
* logical addDocuments call.
|
|
20
|
+
*/
|
|
21
|
+
export type AddDocumentsOutcome = 'written' | 'empty' | 'ignored_closing';
|
|
22
|
+
|
|
16
23
|
/**
|
|
17
24
|
* Guard against a broken embedder silently writing corrupt vectors: a
|
|
18
25
|
* wrong-count, empty, ragged, or NaN/Inf-poisoned result would otherwise land
|
|
@@ -69,12 +76,11 @@ export async function addDocumentsToStore(
|
|
|
69
76
|
isClosing: () => boolean,
|
|
70
77
|
workspace: string,
|
|
71
78
|
isGlobal: boolean
|
|
72
|
-
): Promise<
|
|
73
|
-
if (docs.length === 0) return;
|
|
79
|
+
): Promise<AddDocumentsOutcome> {
|
|
80
|
+
if (docs.length === 0) return 'empty';
|
|
74
81
|
if (isClosing()) {
|
|
75
82
|
logger.warn('[store] Ignoring addDocuments during close');
|
|
76
|
-
|
|
77
|
-
return;
|
|
83
|
+
return 'ignored_closing';
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
const startTime = Date.now();
|
|
@@ -98,14 +104,19 @@ export async function addDocumentsToStore(
|
|
|
98
104
|
await table.add(data);
|
|
99
105
|
const duration = Date.now() - startTime;
|
|
100
106
|
metrics.observe('knowledge_store_add_documents_duration_ms', duration);
|
|
101
|
-
metrics.increment('knowledge_store_add_documents_total', 1, { status: 'success' });
|
|
102
107
|
metrics.increment('knowledge_store_chunks_added_total', docs.length);
|
|
103
108
|
logger.log(`[store] Added ${docs.length} chunk(s) for ${docs[0]?.url} [workspace=${workspace}, global=${isGlobal}]`);
|
|
109
|
+
return 'written';
|
|
104
110
|
} catch (err) {
|
|
105
111
|
const duration = Date.now() - startTime;
|
|
106
112
|
metrics.observe('knowledge_store_add_documents_duration_ms', duration, { status: 'error' });
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
// Attempt-level failure only. The caller retries embedder-unreachable and Lance
|
|
114
|
+
// commit-conflict failures and usually succeeds, so logging ERROR and counting a
|
|
115
|
+
// terminal outcome here reported lost writes that were not lost, and double-counted
|
|
116
|
+
// the batch in knowledge_store_add_documents_total (once error, once success).
|
|
117
|
+
// Outcome accounting belongs to KnowledgeStore.addDocuments, which is the only
|
|
118
|
+
// layer that knows whether the write was ultimately abandoned.
|
|
119
|
+
logger.debug(`[store] add attempt failed after ${duration}ms: ${err instanceof Error ? err.message : String(err)}`);
|
|
109
120
|
throw err;
|
|
110
121
|
}
|
|
111
122
|
}
|
package/src/knowledge/store.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { MigrationStrategy, MigrationResult } from './migration.ts';
|
|
|
23
23
|
import { metrics } from '../utils/metrics.ts';
|
|
24
24
|
import { createStoreTable, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
|
|
25
25
|
import { addDocumentsToStore, assertValidEmbeddingVectors, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
|
|
26
|
+
import type { AddDocumentsOutcome } from './store-operations.ts';
|
|
26
27
|
import { isEmbedderUnreachable } from './embedder-utils.ts';
|
|
27
28
|
import { ServiceLifecycle } from '../core/service-registry.ts';
|
|
28
29
|
import { ServiceNames } from '../core/service-interfaces.ts';
|
|
@@ -713,13 +714,14 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
713
714
|
// Now check isClosing after registering
|
|
714
715
|
if (this.isClosing) {
|
|
715
716
|
logger.warn(`[store] Skipping addDocuments — store is closing`);
|
|
717
|
+
metrics.increment('knowledge_store_add_documents_total', 1, { status: 'ignored_closing' });
|
|
716
718
|
return;
|
|
717
719
|
}
|
|
718
720
|
|
|
719
721
|
let table = await this.getFreshTable();
|
|
720
722
|
const workspace = this.getWorkspace();
|
|
721
723
|
|
|
722
|
-
await this.withEmbedderReconnect(async (embedder) => {
|
|
724
|
+
const outcome = await this.withEmbedderReconnect<AddDocumentsOutcome>(async (embedder) => {
|
|
723
725
|
let retryCount = 0;
|
|
724
726
|
const MAX_RETRIES = 5;
|
|
725
727
|
const BASE_DELAY = 100;
|
|
@@ -727,7 +729,7 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
727
729
|
while (retryCount <= MAX_RETRIES) {
|
|
728
730
|
try {
|
|
729
731
|
const isGlobal = this.options.knowledgeMode === 'global';
|
|
730
|
-
|
|
732
|
+
return await addDocumentsToStore(
|
|
731
733
|
table,
|
|
732
734
|
docs,
|
|
733
735
|
embedder,
|
|
@@ -735,7 +737,6 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
735
737
|
workspace,
|
|
736
738
|
isGlobal
|
|
737
739
|
);
|
|
738
|
-
return;
|
|
739
740
|
} catch (err) {
|
|
740
741
|
// Detect commit conflicts typical of cross-process write contention
|
|
741
742
|
if (isLanceCommitConflict(err)) {
|
|
@@ -758,7 +759,24 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
758
759
|
throw err;
|
|
759
760
|
}
|
|
760
761
|
}
|
|
762
|
+
// Unreachable: the loop only exits via return or throw. Present so the
|
|
763
|
+
// outcome type stays non-optional for the accounting below.
|
|
764
|
+
throw new Error('[store] addDocuments retry loop exited without result');
|
|
761
765
|
});
|
|
766
|
+
|
|
767
|
+
// Exactly one outcome per logical addDocuments call. Attempts that failed and
|
|
768
|
+
// were then recovered by the reconnect/conflict retries are not counted here
|
|
769
|
+
// and are not logged at ERROR — only a write that is actually abandoned is.
|
|
770
|
+
// An empty batch is a no-op and is not counted at all, as before.
|
|
771
|
+
if (outcome !== 'empty') {
|
|
772
|
+
metrics.increment('knowledge_store_add_documents_total', 1, {
|
|
773
|
+
status: outcome === 'ignored_closing' ? 'ignored_closing' : 'success',
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
} catch (err) {
|
|
777
|
+
metrics.increment('knowledge_store_add_documents_total', 1, { status: 'error' });
|
|
778
|
+
logger.error('[store] Failed to add documents:', err);
|
|
779
|
+
throw err;
|
|
762
780
|
} finally {
|
|
763
781
|
this.activeWrites.delete(writePromise);
|
|
764
782
|
writeResolve!();
|
|
@@ -19,45 +19,13 @@ import {
|
|
|
19
19
|
INTERNAL_NETWORK_PATTERNS,
|
|
20
20
|
type NativeHtmlToMarkdownModule,
|
|
21
21
|
} from './scraper-types.ts';
|
|
22
|
-
import { isCloudflareBlockError, isTaskTimeoutError, isPoolShutdownError } from '../infrastructure/browser/browser-error-utils.ts';
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* races. None of these is a pi-research bug: the URL is already accounted for as
|
|
29
|
-
* "not scraped" (scrape_results_total{outcome=total_failure}) and surfaced to the
|
|
30
|
-
* user that way, and no operator action follows. Callers use this to log such
|
|
31
|
-
* failures at debug instead of error — keeping the ERROR log (and, via
|
|
32
|
-
* logger.error's error-tracker re-track, the diagnostic error count) focused on
|
|
33
|
-
* genuine faults. The messages matched here are produced by validateContent
|
|
34
|
-
* (`Fetch blocked:` / `Fetch returned stub:`), scrapeWithFetch (`HTTP <code>`)
|
|
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
|
|
38
|
-
* just as non-actionable as a stub page or a 404; the 25MB HTML policy caps
|
|
39
|
-
* (`HTML response too large` from the fetch layer, `Browser HTML too large`
|
|
40
|
-
* from the browser layer) are the same deliberate-cap class; the remaining
|
|
41
|
-
* cases delegate to the shared browser-layer predicates. Deliberately does NOT
|
|
42
|
-
* match 'PDF extraction unavailable' (a pdf-oxide-wasm native-module load
|
|
43
|
-
* failure) — that is an infrastructure fault affecting every URL, not a
|
|
44
|
-
* routine per-PDF outcome, and must still surface at ERROR.
|
|
24
|
+
* Re-exported from the browser error utilities, where it lives beside the three
|
|
25
|
+
* predicates it delegates to. Kept exported here because this is where every scrape
|
|
26
|
+
* caller already imports its error helpers from.
|
|
45
27
|
*/
|
|
46
|
-
export
|
|
47
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
48
|
-
return (
|
|
49
|
-
msg.startsWith('Fetch blocked:') || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
|
|
50
|
-
msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
|
|
51
|
-
msg.startsWith('Could not extract content from PDF') || // malformed/encrypted/scanned-only PDF
|
|
52
|
-
msg.startsWith('PDF too large') || // exceeds the deliberate 100MB policy cap
|
|
53
|
-
msg.startsWith('HTML response too large') || // fetch-layer 25MB policy cap — same class as the PDF cap
|
|
54
|
-
msg.startsWith('Browser HTML too large') || // browser-layer 25MB policy cap
|
|
55
|
-
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
56
|
-
isCloudflareBlockError(error) ||
|
|
57
|
-
isTaskTimeoutError(error) ||
|
|
58
|
-
isPoolShutdownError(error)
|
|
59
|
-
);
|
|
60
|
-
}
|
|
28
|
+
export { isBenignScrapeFailure } from '../infrastructure/browser/browser-error-utils.ts';
|
|
61
29
|
|
|
62
30
|
/**
|
|
63
31
|
* Get a random user agent
|
package/src/youtube/potoken.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* HTTP 200 with an EMPTY body (a silent failure). This module mints tokens with
|
|
7
7
|
* `bgutils-js`, running the BotGuard VM under a `jsdom` DOM.
|
|
8
8
|
*
|
|
9
|
-
* Hard-won implementation details (validated live against youtubei.js@
|
|
10
|
-
* bgutils-js@
|
|
9
|
+
* Hard-won implementation details (re-validated live against youtubei.js@18 /
|
|
10
|
+
* bgutils-js@4 / jsdom@29):
|
|
11
11
|
*
|
|
12
12
|
* 1. Cross-realm typed arrays. The BotGuard VM must run in the SAME JS realm as
|
|
13
13
|
* bgutils' `base64ToU8` (the Node realm). So the interpreter is eval'd via
|
|
@@ -171,9 +171,17 @@ async function doMint(
|
|
|
171
171
|
const started = Date.now();
|
|
172
172
|
|
|
173
173
|
// Lazy-load heavy deps — never imported at module load (keeps startup native-free).
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
// bgutils-js 4 dropped the barrel entry (package.json "exports" has no "."), so each
|
|
175
|
+
// piece comes from its own subpath: the challenge fetcher and VM client from
|
|
176
|
+
// /botguard, the minter from /webpo, the attestation URL/header helpers from /utils.
|
|
177
|
+
const [bgGuard, bgWebPo, bgUtils] = (await Promise.all([
|
|
178
|
+
import('bgutils-js/botguard'),
|
|
179
|
+
import('bgutils-js/webpo'),
|
|
180
|
+
import('bgutils-js/utils'),
|
|
181
|
+
])) as unknown as [BgGuardModule, BgWebPoModule, BgUtilsModule];
|
|
182
|
+
const { getChallenge, BotGuardClient } = bgGuard;
|
|
183
|
+
const { WebPoMinter } = bgWebPo;
|
|
184
|
+
const { buildURL, getHeaders } = bgUtils;
|
|
177
185
|
const { JSDOM } = await import('jsdom');
|
|
178
186
|
|
|
179
187
|
const dom = new JSDOM('<!DOCTYPE html><html lang="en"><body></body></html>', {
|
|
@@ -211,18 +219,18 @@ async function doMint(
|
|
|
211
219
|
|
|
212
220
|
bridge();
|
|
213
221
|
try {
|
|
214
|
-
|
|
215
|
-
|
|
222
|
+
// bgutils-js 4 replaced BG.Challenge.create(config) with a free getChallenge()
|
|
223
|
+
// whose config is narrower: no globalObj and no identifier (the content binding is
|
|
224
|
+
// supplied per-mint, which is what this module already did), and `fetch` renamed
|
|
225
|
+
// to `fetchFunction`.
|
|
226
|
+
const challenge = await getChallenge({
|
|
227
|
+
requestKey,
|
|
228
|
+
fetchFunction: ((input: unknown, init?: unknown) =>
|
|
216
229
|
fetchImpl(input as Parameters<typeof fetch>[0], {
|
|
217
230
|
...(init as RequestInit | undefined),
|
|
218
231
|
signal: (init as RequestInit | undefined)?.signal ?? opts.signal,
|
|
219
|
-
}),
|
|
220
|
-
|
|
221
|
-
identifier: identifiers[0]!,
|
|
222
|
-
requestKey,
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
const challenge = await BG.Challenge.create(bgConfig);
|
|
232
|
+
})) as unknown as typeof fetch,
|
|
233
|
+
});
|
|
226
234
|
if (!challenge) throw new Error('BotGuard challenge unavailable');
|
|
227
235
|
|
|
228
236
|
const interpreter = challenge.interpreterJavascript?.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
|
@@ -233,10 +241,16 @@ async function doMint(
|
|
|
233
241
|
// PoToken generation; trust rests on that TLS connection.
|
|
234
242
|
new Function(interpreter)();
|
|
235
243
|
|
|
236
|
-
|
|
244
|
+
if (!challenge.program || !challenge.globalName) {
|
|
245
|
+
throw new Error('BotGuard challenge missing program/globalName');
|
|
246
|
+
}
|
|
247
|
+
// `globalObj` was renamed `globalObject` in bgutils-js 4. Passing the old name
|
|
248
|
+
// leaves the VM without a global to attach to and the minter factory silently
|
|
249
|
+
// returns a non-function, so this rename is load-bearing, not cosmetic.
|
|
250
|
+
const botguard = await BotGuardClient.create({
|
|
237
251
|
program: challenge.program,
|
|
238
252
|
globalName: challenge.globalName,
|
|
239
|
-
|
|
253
|
+
globalObject: globalThis,
|
|
240
254
|
});
|
|
241
255
|
|
|
242
256
|
const webPoSignalOutput: unknown[] = [];
|
|
@@ -260,7 +274,7 @@ async function doMint(
|
|
|
260
274
|
throw new Error('Integrity token rejected by attestation server (likely a non-residential IP)');
|
|
261
275
|
}
|
|
262
276
|
|
|
263
|
-
const minter = await
|
|
277
|
+
const minter = await WebPoMinter.create({ integrityToken }, webPoSignalOutput);
|
|
264
278
|
|
|
265
279
|
const tokens = new Map<string, string>();
|
|
266
280
|
for (const id of identifiers) {
|
|
@@ -294,26 +308,27 @@ async function doMint(
|
|
|
294
308
|
// Minimal structural types for the loosely-typed bgutils-js surface we touch.
|
|
295
309
|
// ---------------------------------------------------------------------------
|
|
296
310
|
|
|
297
|
-
interface
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
requestKey: string;
|
|
311
|
+
interface BgChallenge {
|
|
312
|
+
interpreterJavascript?: { privateDoNotAccessOrElseSafeScriptWrappedValue?: string };
|
|
313
|
+
program?: string;
|
|
314
|
+
globalName?: string;
|
|
302
315
|
}
|
|
303
316
|
|
|
304
|
-
interface
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
};
|
|
317
|
+
interface BgGuardModule {
|
|
318
|
+
getChallenge(config: {
|
|
319
|
+
requestKey: string;
|
|
320
|
+
fetchFunction: typeof fetch;
|
|
321
|
+
interpreterHash?: string;
|
|
322
|
+
useYouTubeAPI?: boolean;
|
|
323
|
+
}): Promise<BgChallenge | undefined>;
|
|
312
324
|
BotGuardClient: {
|
|
313
|
-
create(opts: { program: string; globalName: string;
|
|
325
|
+
create(opts: { program: string; globalName: string; globalObject: unknown }): Promise<{
|
|
314
326
|
snapshot(args: { webPoSignalOutput: unknown[] }): Promise<string>;
|
|
315
327
|
}>;
|
|
316
328
|
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
interface BgWebPoModule {
|
|
317
332
|
WebPoMinter: {
|
|
318
333
|
create(
|
|
319
334
|
integrity: { integrityToken: string },
|
|
@@ -322,9 +337,7 @@ interface BgNamespace {
|
|
|
322
337
|
};
|
|
323
338
|
}
|
|
324
339
|
|
|
325
|
-
interface
|
|
326
|
-
BG?: BgNamespace;
|
|
327
|
-
default: BgNamespace;
|
|
340
|
+
interface BgUtilsModule {
|
|
328
341
|
buildURL(endpoint: string, useYouTubeAPI?: boolean): string;
|
|
329
342
|
getHeaders(): Record<string, string>;
|
|
330
343
|
}
|