@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
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.6.0] - 2026-08-22
|
|
9
|
+
|
|
10
|
+
Driven by log forensics rather than by the test suite: every entry below was found
|
|
11
|
+
by reading what the engine actually reported in production, and each fix was
|
|
12
|
+
re-measured against the exact URL, error line or counter that exposed it.
|
|
13
|
+
|
|
14
|
+
The starting observation was that the ERROR log had stopped meaning anything. Across
|
|
15
|
+
the retained run logs, **all 26 ERROR lines were either a condition the engine had
|
|
16
|
+
already recovered from or a routine per-URL outcome** — a blocked page, a slow site,
|
|
17
|
+
an untrusted certificate. Not one described an unrecovered engine fault. Two classes
|
|
18
|
+
account for all of them: content the scraper had already fetched and then dropped,
|
|
19
|
+
and recovered conditions reported as terminal failures.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **PDF sources the browser had already fetched were being discarded two different ways, both now recovered.** A server sending the PDF with `Content-Disposition: attachment` makes Firefox route the response to the download manager, so no document is produced and `page.goto()` rejects with "Download is starting" — the PDF branch was never reached even though the bytes had arrived. Separately, Firefox discards large navigation response bodies from its juggler-side cache, after which `response.body()` fails with "Response body … was evicted!". Both recur across the run logs, and both hit exactly the sources research most wants — vendor datasheets, standards-body documents, journal and university PDFs. The download is now read back from disk, and an evicted body is re-requested through the browser context's own request API — which carries the same cookies, headers, proxy and TLS profile as the navigation, the reason the URL reached the stealth-browser fallback at all. Verified live against the two URLs that failed: a Frontiers article PDF now yields 106k characters of text and a 13.9MB TU Delft lecture PDF 27k, both previously total failures. The re-fetch was measured before it was written: asking for the body earlier does not help, because the eviction happens when the browser drops the bytes, not when we ask for them — a first attempt built on that assumption was discarded after a direct camoufox probe disproved it (`src/infrastructure/browser/thread-worker-messaging.ts`, `src/infrastructure/browser/thread-worker-browser.ts`).
|
|
24
|
+
- **A knowledge-store write that was retried and succeeded no longer reports as a failed write.** `addDocumentsToStore` logged ERROR and incremented `knowledge_store_add_documents_total{status=error}` before rethrowing — but its caller catches exactly that throw, reconnects to a fresh embedder and retries, so the write usually completed. All 8 occurrences across the retained logs are immediately followed by the reconnect WARN — 8 of 8 recovered, none lost — and every one of them counted the same batch twice (once error, once success). The Lance commit-conflict retry fed the same path, so cross-process write contention did it too. Attempt-level accounting now stays in the attempt and outcome accounting moved to `KnowledgeStore.addDocuments`, which is the only layer that knows whether a write was actually abandoned — exactly one counter increment per call, and an ERROR only when the batch is genuinely lost (`src/knowledge/store-operations.ts`, `src/knowledge/store.ts`).
|
|
25
|
+
- **The browser leader logged every routine per-URL scrape outcome as a server fault.** `BrowserServer`'s handler classified Cloudflare challenges and pool-drain races but sent everything else to ERROR, including the HTTP 4xx/5xx, navigation timeouts and over-cap PDFs that the client side already classifies as expected. The leader saw each failure first, so the client's careful classification never prevented the ERROR — one false fault per affected URL. Both layers now share the same predicate (`src/infrastructure/browser/browser-server.ts`).
|
|
26
|
+
- **A leader handover was reported as a terminal error by the layer that was about to retry it.** `BrowserClient` logged ERROR and recorded a tracked error for any socket failure, including the ECONNREFUSED a follower gets against a leader that has already exited. `runBrowserTask` retries that within a second: 5 of 5 such lines in the log were followed immediately by the recovery WARN, and each was counted twice in the diagnostic error report — once here without context, once by the caller with it. A failure the caller will not retry still reports at ERROR, and a retryable one that exhausts its budget still surfaces upstream with URL context (`src/infrastructure/browser/browser-client.ts`).
|
|
27
|
+
- **Remote TLS, DNS and navigation-timeout failures are now classified as the per-URL outcomes they are.** `page.goto: SEC_ERROR_UNKNOWN_ISSUER` (a live university PDF host with an untrusted certificate) and `page.goto: Timeout 15000ms exceeded` reached the ERROR log and the diagnostic error count from both the leader and the scraper, though neither is actionable and the URL is already tallied as not scraped. `page.goto: Target closed` — how a crashed or force-closed browser surfaces — is deliberately excluded and still reports as a fault (`src/web-research/scraper-utils.ts`).
|
|
28
|
+
|
|
29
|
+
- **The unit suite was writing its deliberate failure fixtures into the user’s real shared log.** `unit-env.ts` redirects `PI_RESEARCH_LOG_PATH` before `src/logger.ts` is evaluated, but the global logger is built lazily and resolves its path at CONSTRUCTION — so a suite whose `beforeEach` deletes every `PI_RESEARCH_*` variable (config.test.ts did) left the first logger of the run binding to the production default instead. Measured at ~7KB per run appended to `/tmp/pi-research.log`, including a `{corrupt json` registry fixture whose ERROR line reads exactly like a live failure and cost real time during this release’s own forensic pass. The setup now constructs the logger while the redirect is still set, config.test.ts keeps the redirect out of its scrub, and a test pins the guarantee; whole-suite pollution measured at 0 bytes after the fix (`test/setup/unit.ts`, `test/unit/config.test.ts`, `test/unit/logger-test-isolation.test.ts`).
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`bgutils-js` upgraded 3.2.0 → 4.0.3**, the YouTube PoToken stack's most upstream-brittle dependency and the one pin with no written rationale. v4 is a hard break: the barrel entry is gone (the package now exposes only `/botguard`, `/webpo` and `/utils` subpaths, so the previous bare import resolves to nothing), `BG.Challenge.create` became a free `getChallenge()` whose config drops `globalObj`/`identifier` and renames `fetch` to `fetchFunction`, and `BotGuardClient`'s `globalObj` became `globalObject`. All three renames fail silently rather than loudly — a wrong `globalObject` leaves the VM with no global to attach to and the minter factory returns a non-function — so each is now pinned by a test that asserts the field names v4 actually reads, and the whole flow was re-validated live: a real PoToken minted against Google's attestation endpoint and a real transcript fetched. Staying on v3 meant sitting on an unmaintained line for the one subsystem that breaks whenever YouTube changes BotGuard, with the failure mode being a silent empty transcript body (`src/youtube/potoken.ts`).
|
|
34
|
+
- **`jsdom` deliberately stays at 29.1.1.** jsdom 30 narrows its supported Node range to `^22.22.2 || ^24.15.0 || >=26.0.0`, which excludes both part of this package's own `>=22.19.0` floor and Node 25 entirely; 29's `^20.19.0 || ^22.13.0 || >=24.0.0` covers the whole supported surface. Attempted, measured against the engine ranges, reverted — the pin now has the same recorded rationale as the others (`package.json`).
|
|
35
|
+
- **Tags now produce GitHub Releases.** 44 tags existed and zero Releases, so each version's changelog entry was reachable only by reading `docs/CHANGELOG.md` in the tree. A new job extracts that version's section from the tag's own copy of the changelog and publishes it. It runs after `publish` and needs it, so a failed npm publish cannot announce a version nobody can install; it holds `contents: write` and nothing else — in particular no `id-token` — and installs nothing, so no dependency lifecycle script runs while that token is live. A version that reached npm with no changelog section fails the job rather than publishing an empty Release (`.github/workflows/release.yml`).
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **A full SDK API reference.** `src/sdk.ts` exports 24 symbols; the documentation named 7. `runResearchDetailed`, `scrapeUrl`, `verifyUrl`, `exportKnowledge`, `getSessionMetrics`, `logRunErrorSummary` and the four `getLast*` telemetry accessors were all public and all undiscoverable short of reading the source. Every export now has a signature, a description and — where it differs — a note that it is reachable only through the `/sdk` subpath. A test keeps the two in sync (`docs/SDK.md`, `test/unit/sdk/sdk-docs-surface.test.ts`).
|
|
40
|
+
- **A contract test for the pi-host `ResourceLoader` adapter**, which had 0% coverage and was mocked out in the one test that imported it. This is the seam whose drift under pi 0.81.0 aborted *every* researcher session with "Research produced no report"; the defense against a repeat was a comment. The test runs the real adapter against the installed host: the registration lists the host iterates must be iterable, each resource getter must return the shape the host destructures, and the adapter must implement every public accessor the host's own loader exposes — so a future pi that extends the contract fails here instead of at runtime with a green suite (`test/unit/utils/make-resource-loader.test.ts`).
|
|
41
|
+
|
|
42
|
+
### Documentation
|
|
43
|
+
|
|
44
|
+
- README no longer frames read-only operation as a trust caveat. Being unable to run commands or write anything is the correct shape for an agent whose whole job is reading untrusted web pages: prompt injection picked up mid-run has nothing to act on. The cost line also states plainly that LLM tokens are the only cost, and the page reads less in the second person throughout.
|
|
45
|
+
|
|
8
46
|
## [1.5.3] - 2026-08-22
|
|
9
47
|
|
|
10
48
|
### Fixed
|
package/docs/SDK.md
CHANGED
|
@@ -75,15 +75,8 @@ await shutdownResearchSDK();
|
|
|
75
75
|
|
|
76
76
|
`initResearchSDK` must run before any research call. Auth resolves from
|
|
77
77
|
`options.apiKey` + `options.provider`, else `process.env.PI_RESEARCH_API_KEY` /
|
|
78
|
-
`PI_RESEARCH_PROVIDER`, else pi's `~/.pi/agent/auth.json`.
|
|
79
|
-
|
|
80
|
-
`getLastRunStats`, and `getSessionMetrics`, plus `exportKnowledge` (write the
|
|
81
|
-
knowledge store to a web-consumable JSON file) and the post-run telemetry accessors
|
|
82
|
-
`getLastRunMetrics`, `getLastRunSummary`, `getLastErrorReport`, and
|
|
83
|
-
`getLastResearcherOutcome` (planned/launched/succeeded/failed researcher counts plus
|
|
84
|
-
per-researcher failure reasons for the most recent run — lets a caller tell a thin
|
|
85
|
-
report caused by a sparse topic apart from one where most researchers failed). Both
|
|
86
|
-
`@lincoln504/pi-research` and `@lincoln504/pi-research/sdk` export these symbols.
|
|
78
|
+
`PI_RESEARCH_PROVIDER`, else pi's `~/.pi/agent/auth.json`. The five calls above are the
|
|
79
|
+
common path; [API reference](#api-reference) below lists every export with its signature.
|
|
87
80
|
|
|
88
81
|
> Concurrency: a single initialized SDK instance runs one research call at a time.
|
|
89
82
|
> Overlapping `runDeepResearch`/`runQuickResearch` calls on the same instance throw
|
|
@@ -156,6 +149,76 @@ that run, not the browser pool, LanceDB handles or worker processes.
|
|
|
156
149
|
The SDK does not write report files. Report export is a front-end concern — the pi
|
|
157
150
|
extension and the CLI / agent skill do it when `PI_RESEARCH_REPORT_EXPORT_ENABLED=true`.
|
|
158
151
|
|
|
152
|
+
### API reference
|
|
153
|
+
|
|
154
|
+
Everything below is exported from both `@lincoln504/pi-research` and
|
|
155
|
+
`@lincoln504/pi-research/sdk`, except the two marked *sdk subpath only* — the package
|
|
156
|
+
entry point deliberately does not re-publish them. Every call except `repairJson` and
|
|
157
|
+
`getSDKContainer` requires `initResearchSDK()` first and throws `SDK not initialized`
|
|
158
|
+
otherwise.
|
|
159
|
+
|
|
160
|
+
**Lifecycle**
|
|
161
|
+
|
|
162
|
+
| Export | Signature | Notes |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `initResearchSDK` | `(options?: ResearchSDKOptions) => Promise<void>` | Registers services. No-ops if already initialized; waits out an in-flight shutdown. |
|
|
165
|
+
| `shutdownResearchSDK` | `() => Promise<void>` | Required. Drains the writer queue, closes LanceDB, kills worker processes. Clears every `getLast*` accessor. |
|
|
166
|
+
| `getSDKContainer` | `() => ServiceContainer \| null` | *sdk subpath only.* Internal/test surface — the live service container, or `null` before init. Not covered by semver; the package entry point does not export it. |
|
|
167
|
+
|
|
168
|
+
**Research**
|
|
169
|
+
|
|
170
|
+
| Export | Signature | Notes |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| `runDeepResearch` | `(query, options?, signal?) => Promise<string>` | Depth 1–3. Returns the Markdown report. `options` is `ResearchOptions` minus the fields the SDK owns (`ctx`, `query`, `model`, `sessionId`, `researchId`). |
|
|
173
|
+
| `runQuickResearch` | `(query, options?, signal?) => Promise<string>` | Depth 0. Same as above with `depth` fixed and therefore not accepted. |
|
|
174
|
+
| `runResearchDetailed` | `(query, options?, signal?) => Promise<ResearchRunResult>` | Same run as `runDeepResearch`, returning `{ report, sessionId, runId, metrics, stats, reports }` instead of the bare string. |
|
|
175
|
+
| `getResearchReports` | `(researchId?) => Promise<Map<string, string>>` | Per-researcher reports keyed by researcher id. Defaults to the most recent run; empty map if there has not been one. |
|
|
176
|
+
|
|
177
|
+
**Web access**
|
|
178
|
+
|
|
179
|
+
| Export | Signature | Notes |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `scrapeUrl` | `(url, signal?) => Promise<ScrapeResult>` | One URL through the full pipeline (SSRF gate → fetch or stealth browser → PDF extraction → Markdown). |
|
|
182
|
+
| `verifyUrl` | `(url, signal?) => Promise<boolean>` | Reachability only, no content returned. Resolves `false` rather than throwing on a blocked or dead URL. |
|
|
183
|
+
|
|
184
|
+
**Knowledge store**
|
|
185
|
+
|
|
186
|
+
| Export | Signature | Notes |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `searchKnowledge` | `(queries: string[], signal?) => Promise<KnowledgeSearchResult>` | `{ text, found: 'yes' \| 'maybe' \| 'no', documentsSearched, citations }`. Resolves `found: 'no'` when the store is disabled, empty, or unavailable — it does not throw. |
|
|
189
|
+
| `exportKnowledge` | `(outputPath: string) => Promise<void>` | Writes the store to a web-consumable JSON file. |
|
|
190
|
+
|
|
191
|
+
**Post-run telemetry** — all reflect the most recent completed run, are `null` until one completes, and are cleared by `shutdownResearchSDK()`.
|
|
192
|
+
|
|
193
|
+
| Export | Signature | Notes |
|
|
194
|
+
|---|---|---|
|
|
195
|
+
| `getLastRunStats` | `() => ResearchStats \| null` | Headline counts (searches, scrapes, tokens, cost) derived from the run snapshot. |
|
|
196
|
+
| `getLastRunMetrics` | `() => IMetricsSnapshot \| null` | The raw counters/gauges/histograms for that run. |
|
|
197
|
+
| `getLastRunSummary` | `() => RunSummary \| null` | `{ runId, startedAt, completedAt, durationMs, status, snapshot }`. |
|
|
198
|
+
| `getLastErrorReport` | `() => ErrorReport \| null` | Aggregated errors — totals, patterns, by-domain, by-type. Lets an unattended caller see what failed without parsing logs. |
|
|
199
|
+
| `getLastResearcherOutcome` | `() => ResearcherOutcome \| null` | `{ planned, launched, succeeded, failed, failureReasons }`. Distinguishes a thin report caused by a sparse topic from one where most researchers failed. |
|
|
200
|
+
| `getSessionMetrics` | `() => IMetricsSnapshot` | Cumulative across every run since init, rather than per-run. |
|
|
201
|
+
| `logRunErrorSummary` | `(report, depthLabel, status) => void` | *sdk subpath only.* Emits one compact, secret-redacted line for a run's tracked errors. No-op when the report is null or empty. |
|
|
202
|
+
|
|
203
|
+
**Health and utilities**
|
|
204
|
+
|
|
205
|
+
| Export | Signature | Notes |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| `getResearchHealth` | `(opts?: { force?: boolean }) => Promise<HealthReport>` | Runs every registered check. `force` bypasses the cached result. |
|
|
208
|
+
| `repairJson` | `(json: string) => string` | Repairs truncated/malformed model JSON. Pure; usable before init. |
|
|
209
|
+
|
|
210
|
+
**Types** — `ResearchSDKOptions`, `ResearchRunResult`, `KnowledgeSearchResult`, `ResearcherOutcome`,
|
|
211
|
+
`ResearchOptions`, `ResearchObserver`, `IMetricsSnapshot`, `IMetricHistogram`, `RunSummary`, and
|
|
212
|
+
`ResearchStats`.
|
|
213
|
+
|
|
214
|
+
**Also on the package entry point**, for callers that want the orchestrators directly rather
|
|
215
|
+
than through the SDK wrapper: `DeepResearchOrchestrator`, `QuickResearchOrchestrator`,
|
|
216
|
+
`HeadlessObserver` (an observer that prints progress to stdout), `ServiceNames`,
|
|
217
|
+
`shutdownManager`, `extractRunStats`, `normalizeUrl`, the config accessors
|
|
218
|
+
`getConfig` / `setConfig` / `resetConfig` / `validateConfig`, and the team-size constants.
|
|
219
|
+
These are lower-level than the SDK functions above and assume you manage the service
|
|
220
|
+
container yourself.
|
|
221
|
+
|
|
159
222
|
### Init options
|
|
160
223
|
|
|
161
224
|
| Option | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincoln504/pi-research",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Web research for your agents with smart and safe tooling + knowledge store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"@kreuzberg/html-to-markdown-node": "^3.5.5",
|
|
119
119
|
"@lancedb/lancedb": "0.37.1",
|
|
120
120
|
"apache-arrow": "21.1.0",
|
|
121
|
-
"bgutils-js": "^
|
|
121
|
+
"bgutils-js": "^4.0.3",
|
|
122
122
|
"camoufox-js": "^0.10.2",
|
|
123
123
|
"esbuild": "^0.28.1",
|
|
124
124
|
"impit": "0.13.0",
|
|
@@ -16,6 +16,7 @@ import { errorTracker } from '../../utils/error-tracker.ts';
|
|
|
16
16
|
import { redactSecrets } from '../../utils/log-utils.ts';
|
|
17
17
|
import { Utf8Body } from '../../utils/http-body.ts';
|
|
18
18
|
import { getClientAgent } from './client-agent.ts';
|
|
19
|
+
import { isTransientSocketError } from './browser-error-utils.ts';
|
|
19
20
|
import { getHealthCheckBudgetMs } from './config.ts';
|
|
20
21
|
import type { NodeError } from '../../types/index.ts';
|
|
21
22
|
|
|
@@ -281,12 +282,29 @@ export class BrowserClient implements IScheduler {
|
|
|
281
282
|
errorType = 'unknown';
|
|
282
283
|
}
|
|
283
284
|
const error = new Error(errorMsg);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
285
|
+
// This is one ATTEMPT against the leader, not an outcome. runBrowserTask
|
|
286
|
+
// retries every transient socket failure — a leader handover (ECONNREFUSED
|
|
287
|
+
// against the departing leader's port) is the common case and recovers a
|
|
288
|
+
// few hundred milliseconds later. Logging ERROR and tracking here made every
|
|
289
|
+
// recovered handover look like a fault twice over: once in the ERROR log,
|
|
290
|
+
// and once more in the error tracker on top of the caller's own richer
|
|
291
|
+
// record (taskType + errorType) on the retry path. Observed 5 of 5 times in
|
|
292
|
+
// a real run log, each followed immediately by a successful recovery.
|
|
293
|
+
//
|
|
294
|
+
// A failure the caller will NOT retry still reports at ERROR here, and a
|
|
295
|
+
// retryable one that exhausts its budget still surfaces upstream with URL
|
|
296
|
+
// context (see web-scraper's isBenignScrapeFailure branch), so nothing that
|
|
297
|
+
// actually costs the user a result becomes invisible.
|
|
298
|
+
if (isTransientSocketError(error)) {
|
|
299
|
+
logger.debug(`[BrowserClient] Transient failure talking to http://127.0.0.1:${this.port}${path} (caller will retry): ${errorMsg}`);
|
|
300
|
+
} else {
|
|
301
|
+
logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path} failed:`, errorMsg);
|
|
302
|
+
errorTracker.trackError(error, {
|
|
303
|
+
component: 'browser-manager',
|
|
304
|
+
operation,
|
|
305
|
+
errorType,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
290
308
|
reject(error);
|
|
291
309
|
});
|
|
292
310
|
req.write(JSON.stringify(data));
|
|
@@ -124,6 +124,59 @@ export function isPoolShutdownError(error: unknown): boolean {
|
|
|
124
124
|
);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** Playwright navigation failures whose cause is at the remote end — see below. */
|
|
128
|
+
const BENIGN_NAVIGATION_FAILURE =
|
|
129
|
+
/page\.goto: (?:Timeout \d+ *ms exceeded|net::|NS_ERROR_|SEC_ERROR_|MOZILLA_PKIX_ERROR_)/;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True when a scrape failure is an EXPECTED per-URL outcome rather than an engine
|
|
133
|
+
* fault. Scraping the open web routinely hits bot-protection interstitials, thin
|
|
134
|
+
* "stub" pages, HTTP 4xx/5xx, slow/unresponsive remotes, and (on quit) pool-drain
|
|
135
|
+
* races. None of these is a pi-research bug: the URL is already accounted for as
|
|
136
|
+
* "not scraped" (scrape_results_total{outcome=total_failure}) and surfaced to the
|
|
137
|
+
* user that way, and no operator action follows. Callers use this to log such
|
|
138
|
+
* failures at debug instead of error — keeping the ERROR log (and, via
|
|
139
|
+
* logger.error's error-tracker re-track, the diagnostic error count) focused on
|
|
140
|
+
* genuine faults. The messages matched here are produced by validateContent
|
|
141
|
+
* (`Fetch blocked:` / `Fetch returned stub:`), scrapeWithFetch (`HTTP <code>`)
|
|
142
|
+
* and extractPdfToMarkdown (`Could not extract content from PDF`, `PDF too
|
|
143
|
+
* large`) — a malformed/encrypted/scanned-image-only PDF, or one that simply
|
|
144
|
+
* exceeds the deliberate 100MB policy cap, is routine on the open web and
|
|
145
|
+
* just as non-actionable as a stub page or a 404; the 25MB HTML policy caps
|
|
146
|
+
* (`HTML response too large` from the fetch layer, `Browser HTML too large`
|
|
147
|
+
* from the browser layer) are the same deliberate-cap class; the remaining
|
|
148
|
+
* cases delegate to the shared browser-layer predicates. Deliberately does NOT
|
|
149
|
+
* match 'PDF extraction unavailable' (a pdf-oxide-wasm native-module load
|
|
150
|
+
* failure) — that is an infrastructure fault affecting every URL, not a
|
|
151
|
+
* routine per-PDF outcome, and must still surface at ERROR.
|
|
152
|
+
*
|
|
153
|
+
* Navigation failures reported by Playwright (`page.goto: …`) are the same class
|
|
154
|
+
* once — and only once — the reason is a network, TLS or timeout condition at the
|
|
155
|
+
* remote end: an expired/self-signed certificate (`SEC_ERROR_UNKNOWN_ISSUER` on a
|
|
156
|
+
* live university PDF host), a DNS or connection error (`net::ERR_…`,
|
|
157
|
+
* `NS_ERROR_…`), or a site too slow to answer inside the scrape budget. These were
|
|
158
|
+
* logged at ERROR by both the leader and the scraper. `page.goto` failures with any
|
|
159
|
+
* OTHER reason — notably `Target closed`, which is how a crashed or force-closed
|
|
160
|
+
* browser surfaces — are deliberately excluded and still report as faults.
|
|
161
|
+
*/
|
|
162
|
+
export function isBenignScrapeFailure(error: unknown): boolean {
|
|
163
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
164
|
+
return (
|
|
165
|
+
msg.startsWith('Fetch blocked:') || // bot-protection interstitial (Cloudflare, DDoS-Guard, …)
|
|
166
|
+
msg.startsWith('Fetch returned stub:') || // nav-only / near-empty page
|
|
167
|
+
msg.startsWith('Could not extract content from PDF') || // malformed/encrypted/scanned-only PDF
|
|
168
|
+
msg.startsWith('PDF too large') || // exceeds the deliberate 100MB policy cap
|
|
169
|
+
msg.startsWith('Unsupported download:') || // attachment-served non-PDF (zip/xlsx/…) — nothing to extract
|
|
170
|
+
BENIGN_NAVIGATION_FAILURE.test(msg) || // remote TLS/DNS/timeout — the URL could not be loaded
|
|
171
|
+
msg.startsWith('HTML response too large') || // fetch-layer 25MB policy cap — same class as the PDF cap
|
|
172
|
+
msg.startsWith('Browser HTML too large') || // browser-layer 25MB policy cap
|
|
173
|
+
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
174
|
+
isCloudflareBlockError(error) ||
|
|
175
|
+
isTaskTimeoutError(error) ||
|
|
176
|
+
isPoolShutdownError(error)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
127
180
|
// Session-scoped circuit breaker storage to support concurrent research runs
|
|
128
181
|
const sessionCircuitBreakers = new Map<string, CircuitBreaker>();
|
|
129
182
|
// Hard cap so a cleanup path that ever skips clearSessionCircuitBreaker (e.g. an earlier
|
|
@@ -3,7 +3,7 @@ import * as crypto from 'node:crypto';
|
|
|
3
3
|
import { logger } from '../../logger.ts';
|
|
4
4
|
import { Utf8Body } from '../../utils/http-body.ts';
|
|
5
5
|
import type { SearchResult } from '../../web-research/types.ts';
|
|
6
|
-
import { isCloudflareBlockError, isPoolShutdownError } from './browser-error-utils.ts';
|
|
6
|
+
import { isCloudflareBlockError, isPoolShutdownError, isBenignScrapeFailure } from './browser-error-utils.ts';
|
|
7
7
|
|
|
8
8
|
export interface BrowserServerOptions {
|
|
9
9
|
onSearch: (query: string, signal?: AbortSignal) => Promise<SearchResult[]>;
|
|
@@ -204,6 +204,14 @@ export class BrowserServer {
|
|
|
204
204
|
// Pool destroyed mid-request during quit/SIGTERM teardown — expected,
|
|
205
205
|
// not a server fault. DEBUG so a normal shutdown doesn't emit ERRORs.
|
|
206
206
|
logger.debug('[BrowserServer] Request abandoned during shutdown:', error instanceof Error ? error.message : String(error));
|
|
207
|
+
} else if (isBenignScrapeFailure(error)) {
|
|
208
|
+
// Per-URL outcomes the CLIENT side already classifies as routine —
|
|
209
|
+
// an HTTP 4xx/5xx, a navigation timeout, an oversized PDF, a blocked
|
|
210
|
+
// fetch. The leader saw the same failure first and logged it at ERROR
|
|
211
|
+
// regardless, so every one of those outcomes still produced exactly
|
|
212
|
+
// one false ERROR per URL despite the client's classification. Same
|
|
213
|
+
// predicate as web-scraper.ts, so the two layers agree.
|
|
214
|
+
logger.debug('[BrowserServer] Expected per-URL scrape outcome:', error instanceof Error ? error.message.split('\n')[0] : String(error));
|
|
207
215
|
} else {
|
|
208
216
|
logger.error('[BrowserServer] Error handling request:', error);
|
|
209
217
|
}
|
|
@@ -368,7 +368,10 @@ export async function initBrowser(): Promise<void> {
|
|
|
368
368
|
* rather than left to leak.
|
|
369
369
|
*/
|
|
370
370
|
export async function acquireTaskContext(): Promise<any> {
|
|
371
|
-
|
|
371
|
+
// acceptDownloads is explicit, not defaulted: executeScrapeTask recovers PDFs that a
|
|
372
|
+
// server sends as an attachment by reading the resulting Download, and that object
|
|
373
|
+
// only exists when downloads are accepted.
|
|
374
|
+
const contextPromise = browser.newContext({ acceptDownloads: true });
|
|
372
375
|
contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
373
376
|
let contextTimeoutId: NodeJS.Timeout | undefined;
|
|
374
377
|
let contextRaceLost = false;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
let workerId: string = '';
|
|
8
8
|
|
|
9
|
-
import { appendFileSync } from 'node:fs';
|
|
9
|
+
import { appendFileSync, promises as fsp } from 'node:fs';
|
|
10
10
|
import { redactSecrets } from '../../utils/log-utils.ts';
|
|
11
11
|
import { safeUnref } from '../../utils/safe-unref.ts';
|
|
12
12
|
import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts';
|
|
@@ -405,6 +405,146 @@ function seedSubresourceVerdict(validatedUrl: string): void {
|
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* True when Firefox has discarded a response body it once held, so `response.body()`
|
|
410
|
+
* can never succeed for that response again. Distinct from every other body failure —
|
|
411
|
+
* a closed target, an aborted task — which must keep their original error.
|
|
412
|
+
*/
|
|
413
|
+
function isEvictedBodyError(err: unknown): boolean {
|
|
414
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
415
|
+
return /was evicted|Response body is unavailable/i.test(msg);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Upper bound on the PDF re-fetch below. A 14MB PDF measured ~20s on a normal link. */
|
|
419
|
+
const PDF_REFETCH_TIMEOUT_MS = 90_000;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Re-request a PDF whose navigation body Firefox has already discarded.
|
|
423
|
+
*
|
|
424
|
+
* Uses the browser context's own request API rather than a bare fetch so the retry
|
|
425
|
+
* carries the same cookies, headers, proxy and TLS profile the navigation used — the
|
|
426
|
+
* reason this URL reached the stealth-browser fallback in the first place is usually
|
|
427
|
+
* that a plain fetch does not get served.
|
|
428
|
+
*
|
|
429
|
+
* SSRF: this is the one request in the scrape path that does NOT pass through
|
|
430
|
+
* `page.route` or fire `page.on('response')`, so neither the request-time DNS check
|
|
431
|
+
* nor the serverAddr rebinding backstop covers it. Three things stand in for them:
|
|
432
|
+
* the URL is re-validated here with the same DNS-aware check the route guard uses;
|
|
433
|
+
* it is the FINAL navigation URL, whose actually-connected IP the caller validated
|
|
434
|
+
* via `response.serverAddr()` seconds earlier; and redirects are refused outright, so
|
|
435
|
+
* the re-fetch cannot be walked to an unvalidated host. What remains is a TTL-0
|
|
436
|
+
* rebind landing inside the seconds between the navigation and this retry, on a host
|
|
437
|
+
* that already answered from a public address — strictly narrower than the
|
|
438
|
+
* subresource TOCTOU window the request-time check already accepts.
|
|
439
|
+
*/
|
|
440
|
+
async function refetchPdfBody(
|
|
441
|
+
page: any,
|
|
442
|
+
url: string,
|
|
443
|
+
validateForWorker: (u: string) => Promise<unknown>,
|
|
444
|
+
signal?: AbortSignal,
|
|
445
|
+
): Promise<Buffer> {
|
|
446
|
+
await validateForWorker(url);
|
|
447
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
448
|
+
|
|
449
|
+
// Raced against the task signal, not merely checked before starting. Playwright's
|
|
450
|
+
// request API takes no AbortSignal, and closing the page does not cancel it — so
|
|
451
|
+
// without this race a slow retry would hold the poolifier worker slot for up to
|
|
452
|
+
// PDF_REFETCH_TIMEOUT_MS past the deadline that was supposed to free it. The request
|
|
453
|
+
// itself is left to settle and be discarded; what matters is that the slot is not
|
|
454
|
+
// pinned waiting for it.
|
|
455
|
+
const requestPromise = page.context().request.get(url, {
|
|
456
|
+
maxRedirects: 0,
|
|
457
|
+
timeout: PDF_REFETCH_TIMEOUT_MS,
|
|
458
|
+
});
|
|
459
|
+
requestPromise.catch(() => {});
|
|
460
|
+
const response = await (signal
|
|
461
|
+
? Promise.race([
|
|
462
|
+
requestPromise,
|
|
463
|
+
new Promise<never>((_, reject) => {
|
|
464
|
+
if (signal.aborted) return reject(new Error('Aborted'));
|
|
465
|
+
signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
|
466
|
+
}),
|
|
467
|
+
])
|
|
468
|
+
: requestPromise);
|
|
469
|
+
const status = Number(response.status?.() ?? 0);
|
|
470
|
+
// A 3xx here means the server wants to send us somewhere maxRedirects:0 refused to
|
|
471
|
+
// follow — report it as the HTTP outcome it is rather than reading a redirect body.
|
|
472
|
+
if (!response.ok?.()) throw new Error(`HTTP ${status}`);
|
|
473
|
+
|
|
474
|
+
const declared = parseInt(String((await response.headersArray?.() ?? []).find?.((h: any) => String(h.name).toLowerCase() === 'content-length')?.value ?? ''), 10);
|
|
475
|
+
if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
|
|
476
|
+
throw new Error(`PDF too large (${Math.round(declared / 1024 / 1024)}MB, max 100MB)`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const buffer: Buffer = await response.body();
|
|
480
|
+
if (buffer.byteLength > MAX_PDF_SIZE) {
|
|
481
|
+
throw new Error(`PDF too large (${Math.round(buffer.byteLength / 1024 / 1024)}MB, max 100MB)`);
|
|
482
|
+
}
|
|
483
|
+
logToDebugFile('DEBUG', `[Worker-${workerId}] Re-fetched evicted PDF body (${buffer.byteLength} bytes) for: ${url}`);
|
|
484
|
+
return buffer;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Finish a scrape from a Download the navigation turned into.
|
|
489
|
+
*
|
|
490
|
+
* A server that sends a PDF with `Content-Disposition: attachment` never produces a
|
|
491
|
+
* document for Playwright to inspect — Firefox hands the response to the download
|
|
492
|
+
* manager and `page.goto()` rejects with "Download is starting". The bytes have
|
|
493
|
+
* already arrived at that point, so the URL is recoverable; before this path existed
|
|
494
|
+
* every such source (vendor datasheets, standards-body PDFs, several journal hosts)
|
|
495
|
+
* was reported as an outright scrape failure.
|
|
496
|
+
*
|
|
497
|
+
* The SSRF guarantees are the ones the normal PDF branch relies on: the download's
|
|
498
|
+
* response passed through the same `page.on('response')` inspection, so settling
|
|
499
|
+
* `pendingAddrChecks` and re-checking the poison gate before returning bytes is
|
|
500
|
+
* equivalent here.
|
|
501
|
+
*/
|
|
502
|
+
async function readPdfDownload(
|
|
503
|
+
download: any,
|
|
504
|
+
url: string,
|
|
505
|
+
page: any,
|
|
506
|
+
pendingAddrChecks: Array<Promise<void>>,
|
|
507
|
+
getPoisonedError: () => Error | null,
|
|
508
|
+
): Promise<{ contentType: string; bufferB64: string; jitter: number }> {
|
|
509
|
+
const failure = await download.failure?.().catch(() => null);
|
|
510
|
+
if (failure) throw new Error(`Fetch blocked: download failed (${failure})`);
|
|
511
|
+
|
|
512
|
+
// Only PDFs are usable as binary content downstream; anything else (zip, csv, xlsx)
|
|
513
|
+
// is a routine unsupported-target outcome for this URL, not a fault.
|
|
514
|
+
const suggested = String(download.suggestedFilename?.() ?? '');
|
|
515
|
+
const downloadUrl = String(download.url?.() ?? url);
|
|
516
|
+
const looksLikePdf =
|
|
517
|
+
suggested.toLowerCase().endsWith('.pdf') || downloadUrl.toLowerCase().split('?')[0]!.endsWith('.pdf');
|
|
518
|
+
if (!looksLikePdf) {
|
|
519
|
+
throw new Error(`Unsupported download: ${suggested || downloadUrl} is not a PDF`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const filePath = await download.path();
|
|
523
|
+
if (!filePath) throw new Error('Fetch blocked: download produced no readable file');
|
|
524
|
+
|
|
525
|
+
// Same 100MB policy cap the PDF branch enforces, checked from the file size so the
|
|
526
|
+
// bytes are never read into the worker at all when the file is over the limit.
|
|
527
|
+
const { size } = await fsp.stat(filePath);
|
|
528
|
+
if (size > MAX_PDF_SIZE) {
|
|
529
|
+
const sizeMB = Math.round(size / 1024 / 1024);
|
|
530
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const buffer = await fsp.readFile(filePath);
|
|
534
|
+
|
|
535
|
+
await Promise.allSettled(pendingAddrChecks);
|
|
536
|
+
const poisoned = getPoisonedError();
|
|
537
|
+
if (poisoned) throw poisoned;
|
|
538
|
+
|
|
539
|
+
// Delete before the page closes: the context's download directory is removed on
|
|
540
|
+
// context close, but a worker's context is reused across tasks, so an undeleted
|
|
541
|
+
// file would accumulate for the life of the worker.
|
|
542
|
+
await download.delete?.().catch(() => {});
|
|
543
|
+
await page.close();
|
|
544
|
+
logToDebugFile('DEBUG', `[Worker-${workerId}] Recovered attachment-served PDF (${buffer.byteLength} bytes) for: ${url}`);
|
|
545
|
+
return { contentType: 'application/pdf', bufferB64: buffer.toString('base64'), jitter: 0 };
|
|
546
|
+
}
|
|
547
|
+
|
|
408
548
|
/**
|
|
409
549
|
* Execute a scrape task
|
|
410
550
|
*/
|
|
@@ -446,6 +586,14 @@ export async function executeScrapeTask(
|
|
|
446
586
|
// page.close() produces.
|
|
447
587
|
let oversizedPdfError: Error | null = null;
|
|
448
588
|
|
|
589
|
+
// Set when the navigation turns into a download instead of a document. A server that
|
|
590
|
+
// sends the PDF with Content-Disposition: attachment makes Firefox hand the response
|
|
591
|
+
// to the download manager, and page.goto() then rejects with "Download is starting" —
|
|
592
|
+
// the PDF branch below was never reached and the document was lost even though the
|
|
593
|
+
// bytes had already arrived. Captured here so the goto catch can finish the scrape
|
|
594
|
+
// from the downloaded file instead of failing the URL.
|
|
595
|
+
let pendingDownload: any = null;
|
|
596
|
+
|
|
449
597
|
try {
|
|
450
598
|
logToDebugFile('DEBUG', `[Worker-${workerId}] Starting scrape for: ${url}`);
|
|
451
599
|
|
|
@@ -589,8 +737,32 @@ export async function executeScrapeTask(
|
|
|
589
737
|
}
|
|
590
738
|
});
|
|
591
739
|
|
|
740
|
+
// A navigation the server marks as an attachment never becomes a document: Firefox
|
|
741
|
+
// routes it to the download manager and goto() rejects. Record the Download object
|
|
742
|
+
// so the goto catch below can read the bytes that already arrived.
|
|
743
|
+
page.on('download', (d: any) => { pendingDownload ??= d; });
|
|
744
|
+
|
|
592
745
|
// High-fidelity wait: try domcontentloaded first for speed
|
|
593
|
-
|
|
746
|
+
let response;
|
|
747
|
+
try {
|
|
748
|
+
response = await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
749
|
+
} catch (navErr: unknown) {
|
|
750
|
+
// Only the download case is recoverable here; everything else (timeout, DNS,
|
|
751
|
+
// 'Target closed' from an abort/poison) must keep its original error.
|
|
752
|
+
const navMsg = navErr instanceof Error ? navErr.message : String(navErr);
|
|
753
|
+
const isDownloadNav = /download is starting/i.test(navMsg);
|
|
754
|
+
if (!isDownloadNav) throw navErr;
|
|
755
|
+
// The 'download' event and the goto rejection are not ordered relative to each
|
|
756
|
+
// other, so give the event a brief moment to land before giving up on it.
|
|
757
|
+
if (!pendingDownload) {
|
|
758
|
+
await Promise.race([
|
|
759
|
+
page.waitForEvent('download', { timeout: 5000 }).catch(() => null),
|
|
760
|
+
new Promise(r => setTimeout(r, 5000)),
|
|
761
|
+
]);
|
|
762
|
+
}
|
|
763
|
+
if (!pendingDownload) throw navErr;
|
|
764
|
+
return await readPdfDownload(pendingDownload, url, page, pendingAddrChecks, () => poisonedError);
|
|
765
|
+
}
|
|
594
766
|
|
|
595
767
|
// DNS-rebinding defense-in-depth. The page.route guard's validateForWorker resolves the
|
|
596
768
|
// hostname independently of the browser's own connect-time resolution, so a TTL-0 rebind can
|
|
@@ -647,7 +819,18 @@ export async function executeScrapeTask(
|
|
|
647
819
|
// decided this and be in the middle of closing the page — skip the (redundant,
|
|
648
820
|
// about-to-fail) download attempt rather than race it.
|
|
649
821
|
if (oversizedPdfError) throw oversizedPdfError;
|
|
650
|
-
|
|
822
|
+
let buffer: Buffer;
|
|
823
|
+
try {
|
|
824
|
+
buffer = await response.body();
|
|
825
|
+
} catch (bodyErr) {
|
|
826
|
+
// Firefox drops large navigation response bodies from its juggler-side cache,
|
|
827
|
+
// after which response.body() can only ever fail. Measured against a 13.9MB
|
|
828
|
+
// TU Delft lecture PDF: the body is unavailable whether it is requested at
|
|
829
|
+
// headers-received time or after the load settles, so this is not a race that
|
|
830
|
+
// asking earlier can win — the bytes are simply no longer held for us.
|
|
831
|
+
if (!isEvictedBodyError(bodyErr)) throw bodyErr;
|
|
832
|
+
buffer = await refetchPdfBody(page, finalUrl, validateForWorker, signal);
|
|
833
|
+
}
|
|
651
834
|
// ...then the actual bytes: the header is advisory and absent on chunked
|
|
652
835
|
// responses, so the byte count is the check that actually bounds memory.
|
|
653
836
|
// Checked BEFORE toString('base64') doubles the allocation.
|