@lincoln504/pi-research 1.1.2 → 1.3.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 +3 -3
- package/dist/cli.mjs +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +114 -12
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- package/src/core/health-check-service.ts +0 -265
|
@@ -17,6 +17,9 @@ import { getServiceContainer, getService } from '../../core/service-registry.ts'
|
|
|
17
17
|
import type { ServiceContainer } from '../../core/service-registry.ts';
|
|
18
18
|
import { ServiceNames } from '../../core/service-interfaces.ts';
|
|
19
19
|
import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
|
|
20
|
+
import type { ISchedulerInternals } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
21
|
+
import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
|
|
22
|
+
import { BrowserClient } from './browser-client.ts';
|
|
20
23
|
|
|
21
24
|
// Cooldown to prevent cascading scheduler restarts (thundering herd)
|
|
22
25
|
let lastRestartTime = 0;
|
|
@@ -62,6 +65,92 @@ async function recoverFromLeaderHandover(container: ServiceContainer): Promise<v
|
|
|
62
65
|
);
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Is the leader currently registered in state still a live process — and is it
|
|
70
|
+
* the one our cached handle actually targets?
|
|
71
|
+
*
|
|
72
|
+
* Used only to decide whether the restart cooldown below may be honoured.
|
|
73
|
+
* Anything that prevents a confident "yes" (no registered leader, an unreadable
|
|
74
|
+
* state file, a service-resolution failure, a cached client aimed at a different
|
|
75
|
+
* port than the registered leader's) answers `false`, because the cost of an
|
|
76
|
+
* unnecessary re-resolve is one cheap round-trip while the cost of skipping a
|
|
77
|
+
* needed one is a task failure against a dead port.
|
|
78
|
+
*/
|
|
79
|
+
async function isRegisteredLeaderAliveUncached(container: ServiceContainer): Promise<boolean> {
|
|
80
|
+
try {
|
|
81
|
+
const stateManager = await getService<IStateManager>(ServiceNames.STATE_MANAGER, undefined, container);
|
|
82
|
+
const info = await stateManager.getBrowserServer();
|
|
83
|
+
if (!info) return false;
|
|
84
|
+
// "Leader alive" is not enough: the cooldown protects the CACHED handle, so
|
|
85
|
+
// that handle must also target the registered leader. If our leader B died
|
|
86
|
+
// and another process already elected C, C is alive — but our cached client
|
|
87
|
+
// still aims at B's dead port, and honouring the cooldown would leave every
|
|
88
|
+
// retry failing against the corpse until the window expired.
|
|
89
|
+
const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, container);
|
|
90
|
+
const cached = schedulerService.getSchedulerInstance();
|
|
91
|
+
if (cached instanceof BrowserClient && cached.targetPort !== info.port) return false;
|
|
92
|
+
return await stateManager.isPidAlive(info.pid, info.schedulerId);
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Coalesce concurrent liveness probes.
|
|
100
|
+
*
|
|
101
|
+
* This runs on the failure path, where a whole query burst fails at once: a
|
|
102
|
+
* 20-query search losing its leader calls it up to 19 times within milliseconds
|
|
103
|
+
* (the first caller restarts and the rest land inside the cooldown). The probe is
|
|
104
|
+
* cheap on Linux — a `/proc/<pid>/stat` read — but on Windows the PID-reuse guard
|
|
105
|
+
* resolves the process start time by spawning `powershell -NoProfile`, so an
|
|
106
|
+
* uncoalesced burst would fan out into ~19 concurrent PowerShell processes on the
|
|
107
|
+
* slowest platform we support. The window is deliberately far shorter than
|
|
108
|
+
* RESTART_COOLDOWN_MS: it exists to collapse one burst, not to cache a verdict.
|
|
109
|
+
*/
|
|
110
|
+
const LEADER_LIVENESS_COALESCE_MS = 1000;
|
|
111
|
+
let leaderLivenessProbe: { startedAt: number; result: Promise<boolean> } | null = null;
|
|
112
|
+
|
|
113
|
+
async function isRegisteredLeaderAlive(container: ServiceContainer): Promise<boolean> {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (leaderLivenessProbe && now - leaderLivenessProbe.startedAt < LEADER_LIVENESS_COALESCE_MS) {
|
|
116
|
+
return leaderLivenessProbe.result;
|
|
117
|
+
}
|
|
118
|
+
const result = isRegisteredLeaderAliveUncached(container);
|
|
119
|
+
leaderLivenessProbe = { startedAt: now, result };
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Test seam: drop the coalesced liveness probe. */
|
|
124
|
+
export function _resetLeaderLivenessProbeForTests(): void {
|
|
125
|
+
leaderLivenessProbe = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Drop the scheduler handle and wait for the pool to settle, subject to a
|
|
130
|
+
* cooldown that stops N concurrent task failures from each restarting.
|
|
131
|
+
*
|
|
132
|
+
* The cooldown is only safe while the registered leader is still alive: then the
|
|
133
|
+
* cached handle points at a working process and skipping the restart costs
|
|
134
|
+
* nothing. It is NOT safe when the leader is gone. A hard leader kill (SIGKILL,
|
|
135
|
+
* OOM) surfaces to clients as ECONNREFUSED, which `isTransientSocketError`
|
|
136
|
+
* matches but `isPoolShutdownError` does not — so it lands here rather than on
|
|
137
|
+
* the handover path, and a bare wait leaves the cached client aimed at a corpse.
|
|
138
|
+
* The retry then re-resolves to the same dead port and fails identically: the
|
|
139
|
+
* exact stale-handle failure the leader-handover work fixed for the *graceful*
|
|
140
|
+
* shutdown case. So the cooldown yields whenever the leader is not confirmed alive.
|
|
141
|
+
*/
|
|
142
|
+
async function restartSchedulerWithHerdGuard(container: ServiceContainer): Promise<void> {
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
if (now - lastRestartTime > RESTART_COOLDOWN_MS || !(await isRegisteredLeaderAlive(container))) {
|
|
145
|
+
lastRestartTime = now;
|
|
146
|
+
logger.warn('[BrowserManager] Forcing scheduler restart and retrying...');
|
|
147
|
+
await forceSchedulerRestart(false, container);
|
|
148
|
+
} else {
|
|
149
|
+
logger.warn('[BrowserManager] Scheduler restart triggered recently and the registered leader is still alive — waiting for pool idle instead...');
|
|
150
|
+
}
|
|
151
|
+
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
152
|
+
}
|
|
153
|
+
|
|
65
154
|
/**
|
|
66
155
|
* Get the scheduler from the factory, optionally using a specific container.
|
|
67
156
|
*/
|
|
@@ -163,16 +252,7 @@ export async function runBrowserTask<T>(
|
|
|
163
252
|
// PID+start-time liveness probe confirms the registered leader is
|
|
164
253
|
// actually gone; it only skips clearing when that probe finds the
|
|
165
254
|
// leader alive, which is exactly the case we must not disturb.
|
|
166
|
-
|
|
167
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
168
|
-
lastRestartTime = now;
|
|
169
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
170
|
-
await forceSchedulerRestart(false, container);
|
|
171
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
172
|
-
} else {
|
|
173
|
-
logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
|
|
174
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
175
|
-
}
|
|
255
|
+
await restartSchedulerWithHerdGuard(container);
|
|
176
256
|
}
|
|
177
257
|
|
|
178
258
|
// Retry with jitter (100-500ms) to prevent thundering herd
|
|
@@ -211,15 +291,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
211
291
|
// See runBrowserTask: don't force-clear remote leader state on a bare
|
|
212
292
|
// transient socket error — let the liveness probe inside
|
|
213
293
|
// forceSchedulerRestart decide.
|
|
214
|
-
|
|
215
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
216
|
-
lastRestartTime = now;
|
|
217
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
218
|
-
await forceSchedulerRestart(false, container);
|
|
219
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
220
|
-
} else {
|
|
221
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
222
|
-
}
|
|
294
|
+
await restartSchedulerWithHerdGuard(container);
|
|
223
295
|
}
|
|
224
296
|
|
|
225
297
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
@@ -278,15 +350,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
278
350
|
// See runBrowserTask: don't force-clear remote leader state on a bare
|
|
279
351
|
// transient socket error — let the liveness probe inside
|
|
280
352
|
// forceSchedulerRestart decide.
|
|
281
|
-
|
|
282
|
-
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
283
|
-
lastRestartTime = now;
|
|
284
|
-
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
285
|
-
await forceSchedulerRestart(false, container);
|
|
286
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
287
|
-
} else {
|
|
288
|
-
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
289
|
-
}
|
|
353
|
+
await restartSchedulerWithHerdGuard(container);
|
|
290
354
|
}
|
|
291
355
|
|
|
292
356
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
@@ -206,14 +206,60 @@ export function setupOrphanProtection(): void {
|
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
// Capture the parent PID ONCE, at setup. `process.ppid` is re-read from the OS
|
|
210
|
+
// on every access, so on POSIX it changes the moment we are orphaned — and that
|
|
211
|
+
// change, not a liveness probe, is the reliable signal (see checkOrphan).
|
|
212
|
+
const originalPpid = process.ppid;
|
|
213
|
+
if (!originalPpid) {
|
|
210
214
|
return;
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
/** Tear down the browser and exit. Shared by both orphan signals below. */
|
|
218
|
+
const exitAsOrphan = async () => {
|
|
219
|
+
// Await cleanup to prevent browser/context leaks — but bounded. The browser is
|
|
220
|
+
// the plausible thing to be wedged after the parent died abruptly, and an
|
|
221
|
+
// unbounded browser.close() hang here would keep the orphan (and its browser)
|
|
222
|
+
// alive forever: the precise leak this guard exists to stop. The exit deadline
|
|
223
|
+
// wins over a perfect teardown.
|
|
224
|
+
if (cleanupBrowser) {
|
|
225
|
+
await Promise.race([
|
|
226
|
+
cleanupBrowser().catch(err => {
|
|
227
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
|
|
228
|
+
}),
|
|
229
|
+
new Promise<void>(resolve => {
|
|
230
|
+
const t = setTimeout(resolve, 10_000);
|
|
231
|
+
if (t.unref) t.unref();
|
|
232
|
+
}),
|
|
233
|
+
]);
|
|
234
|
+
}
|
|
235
|
+
// Clear the orphan check timer
|
|
236
|
+
cleanupOrphanProtection();
|
|
237
|
+
process.env['PI_PROCESS_EXITING'] = '1';
|
|
238
|
+
process.exit(1);
|
|
239
|
+
};
|
|
240
|
+
|
|
213
241
|
const checkOrphan = async () => {
|
|
214
242
|
try {
|
|
215
|
-
// signal
|
|
216
|
-
|
|
243
|
+
// Re-parenting is the authoritative orphan signal on POSIX. When the parent
|
|
244
|
+
// dies the kernel immediately re-parents us — to PID 1, or to the nearest
|
|
245
|
+
// subreaper (a user systemd instance, a container init). Probing the CURRENT
|
|
246
|
+
// ppid cannot see that: PID 1 always exists, and signalling it from an
|
|
247
|
+
// unprivileged process raises EPERM, which the handler below correctly reads
|
|
248
|
+
// as "alive but not ours to signal". Net effect on Linux was that this guard
|
|
249
|
+
// never fired at all and orphaned workers kept their browsers running.
|
|
250
|
+
// Comparing against the PID captured at setup detects it directly.
|
|
251
|
+
// Windows does not re-parent, so the liveness probe below remains the
|
|
252
|
+
// mechanism there and this check simply never trips.
|
|
253
|
+
if (process.ppid !== originalPpid) {
|
|
254
|
+
logToDebugFile('WARN', `[Worker-${workerId}] Re-parented (${originalPpid} -> ${process.ppid}): parent died, shutting down...`);
|
|
255
|
+
await exitAsOrphan();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// signal 0 checks if the process is alive. Probe the ORIGINAL ppid, never
|
|
260
|
+
// `process.ppid` — after a re-parent the latter points at init, not at the
|
|
261
|
+
// process whose liveness we actually care about.
|
|
262
|
+
process.kill(originalPpid, 0);
|
|
217
263
|
|
|
218
264
|
// Schedule next check
|
|
219
265
|
orphanCheckTimer = setTimeout(checkOrphan, 10000);
|
|
@@ -231,16 +277,7 @@ export function setupOrphanProtection(): void {
|
|
|
231
277
|
}
|
|
232
278
|
// If error is thrown, the parent process is likely dead or unreachable
|
|
233
279
|
logToDebugFile('WARN', `[Worker-${workerId}] Parent process died or unreachable (orphaned), shutting down...`);
|
|
234
|
-
|
|
235
|
-
if (cleanupBrowser) {
|
|
236
|
-
await cleanupBrowser().catch(err => {
|
|
237
|
-
logToDebugFile('WARN', `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
// Clear the orphan check timer
|
|
241
|
-
cleanupOrphanProtection();
|
|
242
|
-
process.env['PI_PROCESS_EXITING'] = '1';
|
|
243
|
-
process.exit(1);
|
|
280
|
+
await exitAsOrphan();
|
|
244
281
|
}
|
|
245
282
|
};
|
|
246
283
|
|
|
@@ -8,6 +8,7 @@ let workerId: string = '';
|
|
|
8
8
|
|
|
9
9
|
import { appendFileSync } from 'node:fs';
|
|
10
10
|
import { redactSecrets } from '../../utils/log-utils.ts';
|
|
11
|
+
import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Mutex lock to serialize page creation. Playwright/Firefox can deadlock if newPage()
|
|
@@ -311,7 +312,7 @@ export async function executeScrapeTask(
|
|
|
311
312
|
_context: any,
|
|
312
313
|
url: string,
|
|
313
314
|
signal?: AbortSignal
|
|
314
|
-
): Promise<{ contentType: string; html?: string;
|
|
315
|
+
): Promise<{ contentType: string; html?: string; bufferB64?: string; jitter: number }> {
|
|
315
316
|
const page = await createPageSafe(_context);
|
|
316
317
|
const SCRAPE_TIMEOUT = parseInt(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'] || '15000', 10);
|
|
317
318
|
page.setDefaultTimeout(SCRAPE_TIMEOUT);
|
|
@@ -458,15 +459,47 @@ export async function executeScrapeTask(
|
|
|
458
459
|
}
|
|
459
460
|
|
|
460
461
|
const contentType = (await response?.headerValue('content-type')) || '';
|
|
462
|
+
// HTTP status of the final navigation response. Never consulting it let
|
|
463
|
+
// 404/500/403 pages return as normal {contentType, html} results — major
|
|
464
|
+
// sites' rich error pages cleared the stub gate, were cached as scrape
|
|
465
|
+
// SUCCESS, and became citable. The fetch layer enforces !response.ok; this
|
|
466
|
+
// layer must match it (`HTTP <status>` is benign-classified per URL). The
|
|
467
|
+
// one legitimate exception is a Cloudflare challenge, which arrives with
|
|
468
|
+
// 403/503 — the HTML branch defers to the challenge wait below for that.
|
|
469
|
+
const status = response?.status?.() ?? 0;
|
|
461
470
|
|
|
462
471
|
if (contentType.includes('application/pdf')) {
|
|
463
472
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
473
|
+
// An errored "PDF" is an error page: fail BEFORE buffering its body.
|
|
474
|
+
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
475
|
+
// Enforce the parent's PDF cap HERE, not only after the IPC transfer:
|
|
476
|
+
// buffering a multi-hundred-MB PDF (plus the base64 copy below) in the
|
|
477
|
+
// worker can OOM-kill the process — stranding every sibling task — or hit
|
|
478
|
+
// ERR_STRING_TOO_LONG. Content-Length first (free when present, and it
|
|
479
|
+
// skips the read entirely)...
|
|
480
|
+
const declaredLength = parseInt((await response.headerValue('content-length').catch(() => null)) || '', 10);
|
|
481
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_PDF_SIZE) {
|
|
482
|
+
const sizeMB = Math.round(declaredLength / 1024 / 1024);
|
|
483
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
484
|
+
}
|
|
464
485
|
const buffer = await response.body();
|
|
486
|
+
// ...then the actual bytes: the header is advisory and absent on chunked
|
|
487
|
+
// responses, so the byte count is the check that actually bounds memory.
|
|
488
|
+
// Checked BEFORE toString('base64') doubles the allocation.
|
|
489
|
+
if (buffer.byteLength > MAX_PDF_SIZE) {
|
|
490
|
+
const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
|
|
491
|
+
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
492
|
+
}
|
|
465
493
|
// Discard the bytes if any observed response came from a private address.
|
|
466
494
|
await Promise.allSettled(pendingAddrChecks);
|
|
467
495
|
if (poisonedError) throw poisonedError;
|
|
468
496
|
await page.close();
|
|
469
|
-
|
|
497
|
+
// Base64, never a raw Buffer: this result crosses the poolifier cluster IPC
|
|
498
|
+
// channel (default JSON serialization), where a Buffer arrives as
|
|
499
|
+
// {type:'Buffer',data:[...]} — and the follower path adds a second JSON hop
|
|
500
|
+
// through the leader's browser-server. A base64 string survives both hops
|
|
501
|
+
// verbatim (and is ~3x smaller on the wire than the data-array form).
|
|
502
|
+
return { contentType, bufferB64: buffer.toString('base64'), jitter: 0 };
|
|
470
503
|
}
|
|
471
504
|
|
|
472
505
|
// If it's HTML, check if we need to wait longer (JS-heavy sites)
|
|
@@ -476,6 +509,15 @@ export async function executeScrapeTask(
|
|
|
476
509
|
const cfPatterns = ['_cf_chl_', 'cdn-cgi/challenge-platform', 'cf_chl_opt', 'Just a moment...', 'Checking your browser before accessing'];
|
|
477
510
|
const hasCloudflare = cfPatterns.some((pattern: string) => html.includes(pattern));
|
|
478
511
|
|
|
512
|
+
// HTTP-error enforcement for HTML (see the PDF branch above). A Cloudflare
|
|
513
|
+
// challenge legitimately arrives with 403/503 and the wait below may clear
|
|
514
|
+
// it to the real page, so a challenged response defers to that wait — whose
|
|
515
|
+
// failure path already throws its own 'Fetch blocked:' error. Everything
|
|
516
|
+
// else with a 4xx/5xx status is an error page, not content.
|
|
517
|
+
if (status >= 400 && !hasCloudflare) {
|
|
518
|
+
throw new Error(`HTTP ${status}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
479
521
|
if (hasCloudflare) {
|
|
480
522
|
logToDebugFile('WARN', `[Worker-${workerId}] Cloudflare challenge detected for: ${url}`);
|
|
481
523
|
|
|
@@ -521,6 +563,16 @@ export async function executeScrapeTask(
|
|
|
521
563
|
html = await page.content();
|
|
522
564
|
}
|
|
523
565
|
|
|
566
|
+
// Enforce the parent's rendered-HTML cap worker-side, BEFORE the string
|
|
567
|
+
// crosses JSON IPC (and, for followers, a second JSON hop through the
|
|
568
|
+
// leader's browser-server). The parent's identical check in web-scraper.ts
|
|
569
|
+
// runs only after the transfer — too late to protect this process from a
|
|
570
|
+
// pathological/hostile page ballooning worker memory. Same error wording.
|
|
571
|
+
if (html.length > MAX_HTML_SIZE) {
|
|
572
|
+
const sizeMB = Math.round(html.length / 1024 / 1024);
|
|
573
|
+
throw new Error(`Browser HTML too large (${sizeMB}MB, max 25MB)`);
|
|
574
|
+
}
|
|
575
|
+
|
|
524
576
|
// Skip jitter in mock mode; in real browsing, add 500–1500ms to mimic human behavior
|
|
525
577
|
const jitter = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true'
|
|
526
578
|
? 0
|
|
@@ -19,7 +19,9 @@ export interface TaskResult {
|
|
|
19
19
|
error?: string;
|
|
20
20
|
success?: boolean;
|
|
21
21
|
navMs?: number;
|
|
22
|
-
|
|
22
|
+
/** PDF bytes, base64-encoded: a raw Buffer would be mangled to
|
|
23
|
+
* {type:'Buffer',data:[...]} by the cluster-IPC JSON serialization. */
|
|
24
|
+
bufferB64?: string;
|
|
23
25
|
html?: string;
|
|
24
26
|
contentType?: string;
|
|
25
27
|
}
|