@bluefields/fetcher 0.2.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/LICENSE +661 -0
- package/dist/asset-block-measure.d.ts +14 -0
- package/dist/asset-block-measure.js +82 -0
- package/dist/asset-block-measure.js.map +1 -0
- package/dist/browser-pool.d.ts +65 -0
- package/dist/browser-pool.js +161 -0
- package/dist/browser-pool.js.map +1 -0
- package/dist/cost.d.ts +51 -0
- package/dist/cost.js +54 -0
- package/dist/cost.js.map +1 -0
- package/dist/fetcher-brightdata.d.ts +52 -0
- package/dist/fetcher-brightdata.js +135 -0
- package/dist/fetcher-brightdata.js.map +1 -0
- package/dist/fetcher-escalating.d.ts +100 -0
- package/dist/fetcher-escalating.js +489 -0
- package/dist/fetcher-escalating.js.map +1 -0
- package/dist/fetcher-http.d.ts +81 -0
- package/dist/fetcher-http.js +395 -0
- package/dist/fetcher-http.js.map +1 -0
- package/dist/fetcher-patchright.d.ts +212 -0
- package/dist/fetcher-patchright.js +505 -0
- package/dist/fetcher-patchright.js.map +1 -0
- package/dist/fetcher-robots-gate.d.ts +60 -0
- package/dist/fetcher-robots-gate.js +87 -0
- package/dist/fetcher-robots-gate.js.map +1 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +219 -0
- package/dist/index.js.map +1 -0
- package/dist/internal-options.d.ts +59 -0
- package/dist/internal-options.js +16 -0
- package/dist/internal-options.js.map +1 -0
- package/dist/perf-harness.d.ts +105 -0
- package/dist/perf-harness.js +176 -0
- package/dist/perf-harness.js.map +1 -0
- package/dist/storage-memory.d.ts +18 -0
- package/dist/storage-memory.js +45 -0
- package/dist/storage-memory.js.map +1 -0
- package/dist/storage-r2.d.ts +44 -0
- package/dist/storage-r2.js +209 -0
- package/dist/storage-r2.js.map +1 -0
- package/dist/types.d.ts +231 -0
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Patchright fetcher adapter (chunks 8 + Phase A real wiring).
|
|
4
|
+
*
|
|
5
|
+
* Patchright is a Chromium fork with CDP-leak patches that defeat many
|
|
6
|
+
* naive bot-detection signals (navigator.webdriver, console.debug
|
|
7
|
+
* fingerprinting, etc.). API is Playwright-compatible — drop-in.
|
|
8
|
+
*
|
|
9
|
+
* Production wiring is gated on IPROYAL_* env vars: when they're set,
|
|
10
|
+
* the factory (`tryCreatePatchrightAdapter`) returns a real adapter
|
|
11
|
+
* that launches Chromium with the IPRoyal residential proxy. Without
|
|
12
|
+
* them, callers fall back to the HTTP adapter (`fetcher-http.ts`).
|
|
13
|
+
*
|
|
14
|
+
* Per-fetch lifecycle: launch fresh browser → newContext → newPage →
|
|
15
|
+
* goto → wait → content + (optional) screenshot → close. One browser
|
|
16
|
+
* per fetch keeps fingerprint state isolated; pooling can ship later
|
|
17
|
+
* if launch overhead becomes load-bearing (~500ms per fetch today).
|
|
18
|
+
*
|
|
19
|
+
* Fingerprint rotation: v1 uses a small pool of (UA, viewport, locale,
|
|
20
|
+
* timezone) tuples picked randomly per fetch. Full diversity (Review #4)
|
|
21
|
+
* comes later — we just need basic variation to defeat per-tuple
|
|
22
|
+
* fingerprint blocklists.
|
|
23
|
+
*
|
|
24
|
+
* Egress IP: deferred. IPRoyal residential proxies rotate per-session;
|
|
25
|
+
* resolving the actual egress IP requires an extra eval to api.ipify.org
|
|
26
|
+
* which we don't want to pay on every fetch. Logged as a placeholder for
|
|
27
|
+
* now; revisit in M14-17 when attestation needs it (Review #3 manifest).
|
|
28
|
+
*/
|
|
29
|
+
import { createLayerLogger, envInt } from '@bluefields/primitives';
|
|
30
|
+
import { BrowserPool } from './browser-pool.js';
|
|
31
|
+
const log = createLayerLogger('fetcher');
|
|
32
|
+
// Through a residential proxy a single page load can take 15-45s
|
|
33
|
+
// (proxy hop + per-asset proxy auth + slow CDN paths). 30s was too
|
|
34
|
+
// aggressive — bump to 60s.
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = envInt('BLUEFIELDS_BROWSER_TIMEOUT_MS', 60_000);
|
|
36
|
+
const FETCHER_ID = 'patchright-v1';
|
|
37
|
+
// ── Full-render settle tuning (see fullyRenderPage) ──────────────────────────
|
|
38
|
+
// All bounds are belt-and-suspenders: the loop stops at the FIRST of (content
|
|
39
|
+
// looks rendered), (DOM stopped growing), (max steps), or (deadline). Worst-case
|
|
40
|
+
// added wall-clock ≈ networkidle cap + maxSteps*stepMs, always clamped to the
|
|
41
|
+
// request's timeoutMs so full render can never hang past the caller's deadline.
|
|
42
|
+
/** Best-effort `networkidle` wait cap (ms) — well under the default request timeout. */
|
|
43
|
+
const FULL_RENDER_NETWORKIDLE_MS = 15_000;
|
|
44
|
+
/** Pause after each scroll so lazy/XHR content can paint before we re-measure. */
|
|
45
|
+
const FULL_RENDER_STEP_MS = 300;
|
|
46
|
+
/** Hard cap on settle iterations — primary bound so a stubbed clock can't spin. */
|
|
47
|
+
const FULL_RENDER_MAX_STEPS = 10;
|
|
48
|
+
/** Once the page has at least this much visible text, treat it as rendered. */
|
|
49
|
+
const FULL_RENDER_MIN_CHARS = 500;
|
|
50
|
+
// Only the gateway is strictly required — IPROYAL_USERNAME/PASSWORD are
|
|
51
|
+
// optional. If the proxy uses IP allowlist auth (Fly egress IP whitelisted
|
|
52
|
+
// on IPRoyal), no credentials are needed. The Chromium ↔ HTTP-proxy auth
|
|
53
|
+
// dance is broken anyway, so allowlist is the only path that works.
|
|
54
|
+
export const PATCHRIGHT_REQUIRED_ENV = ['IPROYAL_GATEWAY'];
|
|
55
|
+
const FINGERPRINT_POOL = [
|
|
56
|
+
{
|
|
57
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
58
|
+
viewport: { width: 1440, height: 900 },
|
|
59
|
+
locale: 'en-US',
|
|
60
|
+
timezoneId: 'America/New_York',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
64
|
+
viewport: { width: 1920, height: 1080 },
|
|
65
|
+
locale: 'en-US',
|
|
66
|
+
timezoneId: 'America/Chicago',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
70
|
+
viewport: { width: 1366, height: 768 },
|
|
71
|
+
locale: 'en-GB',
|
|
72
|
+
timezoneId: 'Europe/London',
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
function pickFingerprint(rand = Math.random) {
|
|
76
|
+
const idx = Math.floor(rand() * FINGERPRINT_POOL.length);
|
|
77
|
+
return FINGERPRINT_POOL[idx];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resource types whose bytes we discard. Images/video/audio/fonts are the bulk
|
|
81
|
+
* of page weight and we only keep DOM text, so aborting them on the metered
|
|
82
|
+
* residential proxy is pure savings. We NEVER block document/script/xhr/fetch —
|
|
83
|
+
* the page wouldn't render. Stylesheets are usually safe to drop too (we read
|
|
84
|
+
* text, not layout); that one is gated behind BLOCK_STYLESHEET so it's a
|
|
85
|
+
* one-line flip if it ever regresses a JS-render stratum.
|
|
86
|
+
*/
|
|
87
|
+
const BLOCKED_RESOURCE_TYPES = new Set(['image', 'media', 'font']);
|
|
88
|
+
/** Drop stylesheets too (default ON). Flip to `false` if it regresses rendering. */
|
|
89
|
+
const BLOCK_STYLESHEET = true;
|
|
90
|
+
/**
|
|
91
|
+
* Pure predicate: should the browser tier abort this request's download?
|
|
92
|
+
* Extracted so it's unit-tested without a live browser. The caller is
|
|
93
|
+
* responsible for skipping interception entirely when a screenshot is
|
|
94
|
+
* requested (a screenshot needs the full visual render).
|
|
95
|
+
*/
|
|
96
|
+
export function shouldBlockResource(resourceType, opts = {}) {
|
|
97
|
+
if (BLOCKED_RESOURCE_TYPES.has(resourceType))
|
|
98
|
+
return true;
|
|
99
|
+
if ((opts.blockStylesheet ?? BLOCK_STYLESHEET) && resourceType === 'stylesheet')
|
|
100
|
+
return true;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Returns the env-gated Patchright adapter when the required env vars
|
|
105
|
+
* are set; null otherwise. Callers (the factory in `index.ts`) fall
|
|
106
|
+
* back to the HTTP adapter when this returns null.
|
|
107
|
+
*/
|
|
108
|
+
export function tryCreatePatchrightAdapter(env = process.env) {
|
|
109
|
+
for (const key of PATCHRIGHT_REQUIRED_ENV) {
|
|
110
|
+
if (!env[key] || env[key]?.length === 0)
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// Pass username/password ONLY if they're set. Production path uses
|
|
114
|
+
// IP allowlist auth (Fly egress IP whitelisted on IPRoyal) — no
|
|
115
|
+
// creds needed, no Chromium auth dance, no broken CONNECT tunnels.
|
|
116
|
+
// Local dev with personal IP whitelisted works the same way.
|
|
117
|
+
// If creds ARE set (legacy callers), pass them through — Patchright
|
|
118
|
+
// will try the CDP challenge handshake; works on most proxies, fails
|
|
119
|
+
// on IPRoyal residential.
|
|
120
|
+
const poolSize = parseIntEnv(env.PATCHRIGHT_POOL ?? '', 0);
|
|
121
|
+
return createPatchrightAdapter({
|
|
122
|
+
proxy: {
|
|
123
|
+
server: normalizeProxyServer(env.IPROYAL_GATEWAY ?? ''),
|
|
124
|
+
username: env.IPROYAL_USERNAME ?? '',
|
|
125
|
+
password: env.IPROYAL_PASSWORD ?? '',
|
|
126
|
+
},
|
|
127
|
+
...(poolSize > 0 ? { pool: poolSize } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function parseIntEnv(raw, fallback) {
|
|
131
|
+
const n = Number.parseInt(raw, 10);
|
|
132
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Normalize the proxy server URL — Playwright requires a protocol
|
|
136
|
+
* scheme (`http://host:port`), but IPRoyal's docs typically show the
|
|
137
|
+
* gateway as bare `host:port`. Prepend `http://` if missing so a
|
|
138
|
+
* naively-pasted value still works.
|
|
139
|
+
*/
|
|
140
|
+
function normalizeProxyServer(raw) {
|
|
141
|
+
const trimmed = raw.trim();
|
|
142
|
+
if (!trimmed)
|
|
143
|
+
return '';
|
|
144
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed))
|
|
145
|
+
return trimmed;
|
|
146
|
+
return `http://${trimmed}`;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Direct constructor — bypass the env gate. Useful in tests + when the
|
|
150
|
+
* caller has the proxy creds from somewhere other than env vars.
|
|
151
|
+
*/
|
|
152
|
+
export function createPatchrightAdapter(config) {
|
|
153
|
+
let cachedLauncher = config.chromiumImpl ?? null;
|
|
154
|
+
let pool = null;
|
|
155
|
+
let poolInitPromise = null;
|
|
156
|
+
const getLauncher = async () => {
|
|
157
|
+
if (cachedLauncher)
|
|
158
|
+
return cachedLauncher;
|
|
159
|
+
// Dynamic import so missing chromium binary / missing patchright
|
|
160
|
+
// package doesn't fail at module-load time.
|
|
161
|
+
const mod = (await import('patchright'));
|
|
162
|
+
cachedLauncher = mod.chromium;
|
|
163
|
+
return cachedLauncher;
|
|
164
|
+
};
|
|
165
|
+
const getPool = async () => {
|
|
166
|
+
if (!config.pool || config.pool <= 0)
|
|
167
|
+
return null;
|
|
168
|
+
if (pool)
|
|
169
|
+
return pool;
|
|
170
|
+
if (!poolInitPromise) {
|
|
171
|
+
poolInitPromise = (async () => {
|
|
172
|
+
const launcher = await getLauncher();
|
|
173
|
+
pool = new BrowserPool({ launcher, proxy: config.proxy, size: config.pool });
|
|
174
|
+
try {
|
|
175
|
+
await pool.warm();
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
log.warn({ error: err instanceof Error ? err.message : String(err) }, 'browser-pool warm failed; falling back to per-fetch launch');
|
|
179
|
+
pool = null;
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
}
|
|
183
|
+
await poolInitPromise;
|
|
184
|
+
return pool;
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
/**
|
|
188
|
+
* L4: eagerly build + warm the browser pool so the first customer fetch doesn't
|
|
189
|
+
* pay the ~0.5–1.5s cold Chromium launch. No-op when `config.pool` is unset/0
|
|
190
|
+
* (getPool returns null). Best-effort — getPool already swallows a warm failure
|
|
191
|
+
* and falls back to per-fetch launch.
|
|
192
|
+
*/
|
|
193
|
+
async warmUp() {
|
|
194
|
+
await getPool();
|
|
195
|
+
},
|
|
196
|
+
async fetch(url, options = {}) {
|
|
197
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
198
|
+
const now = options.nowFn ?? (() => new Date());
|
|
199
|
+
const fingerprint = pickFingerprint(config.rand);
|
|
200
|
+
const userAgent = options.userAgent ?? config.defaultUserAgent ?? fingerprint.userAgent;
|
|
201
|
+
const startedAt = Date.now();
|
|
202
|
+
const fetchedAt = now();
|
|
203
|
+
const activePool = await getPool();
|
|
204
|
+
let slot = null;
|
|
205
|
+
let browser;
|
|
206
|
+
if (activePool) {
|
|
207
|
+
try {
|
|
208
|
+
slot = await activePool.acquire();
|
|
209
|
+
browser = slot.browser;
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
// Pool exhaustion / launch failure → fall back to per-fetch launch.
|
|
213
|
+
log.warn({ error: err instanceof Error ? err.message : String(err) }, 'browser-pool acquire failed; per-fetch launch fallback');
|
|
214
|
+
const launcher = await getLauncher();
|
|
215
|
+
browser = await launcher.launch({
|
|
216
|
+
proxy: config.proxy,
|
|
217
|
+
headless: true,
|
|
218
|
+
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
|
219
|
+
timeout: 20_000,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const launcher = await getLauncher();
|
|
225
|
+
browser = await launcher.launch({
|
|
226
|
+
proxy: config.proxy,
|
|
227
|
+
headless: true,
|
|
228
|
+
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
|
229
|
+
timeout: 20_000,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
let crashed = false;
|
|
233
|
+
let context = null;
|
|
234
|
+
// Abandonment hook: the escalating fetcher aborts this when it gives up on
|
|
235
|
+
// this tier's wall-clock budget. Closing the context makes any in-flight
|
|
236
|
+
// goto / full-render reject so the finally frees the browser — without it an
|
|
237
|
+
// abandoned browser fetch leaks headless Chromium (and the Node process).
|
|
238
|
+
const abortSignal = options.__abortSignal;
|
|
239
|
+
const onAbort = () => {
|
|
240
|
+
void context?.close?.().catch(() => undefined);
|
|
241
|
+
};
|
|
242
|
+
if (abortSignal) {
|
|
243
|
+
if (abortSignal.aborted)
|
|
244
|
+
onAbort();
|
|
245
|
+
else
|
|
246
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
context = await browser.newContext({
|
|
250
|
+
userAgent,
|
|
251
|
+
viewport: fingerprint.viewport,
|
|
252
|
+
locale: fingerprint.locale,
|
|
253
|
+
timezoneId: fingerprint.timezoneId,
|
|
254
|
+
...(options.headers ? { extraHTTPHeaders: options.headers } : {}),
|
|
255
|
+
});
|
|
256
|
+
if (options.cookies && options.cookies.length > 0) {
|
|
257
|
+
await context.addCookies(options.cookies);
|
|
258
|
+
}
|
|
259
|
+
const page = await context.newPage();
|
|
260
|
+
// Cut metered proxy egress: abort the image/media/font (+ stylesheet)
|
|
261
|
+
// downloads we never use — we only keep DOM text — and sum the bytes we
|
|
262
|
+
// DO transfer. Aborted requests never fire 'response', so the counter
|
|
263
|
+
// measures exactly what we kept. Skipped entirely when a screenshot is
|
|
264
|
+
// requested (it needs the full visual render). Both APIs are guarded
|
|
265
|
+
// because the narrow test mock omits them.
|
|
266
|
+
let bytesTransferred = 0;
|
|
267
|
+
if (typeof page.on === 'function') {
|
|
268
|
+
page.on('response', (resp) => {
|
|
269
|
+
const len = Number(resp.headers()['content-length'] ?? 0);
|
|
270
|
+
if (Number.isFinite(len) && len > 0)
|
|
271
|
+
bytesTransferred += len;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (!options.captureScreenshot && typeof page.route === 'function') {
|
|
275
|
+
await page.route('**/*', async (route) => {
|
|
276
|
+
try {
|
|
277
|
+
if (shouldBlockResource(route.request().resourceType()))
|
|
278
|
+
await route.abort();
|
|
279
|
+
else
|
|
280
|
+
await route.continue();
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// route already handled / page closed mid-flight — ignore.
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// Default to 'domcontentloaded' — much faster than 'load'
|
|
288
|
+
// (no waiting for images/fonts/iframes) and almost always gives
|
|
289
|
+
// us the rendered HTML. Customers can override via waitFor.
|
|
290
|
+
const goStart = Date.now();
|
|
291
|
+
const response = await page.goto(url, {
|
|
292
|
+
waitUntil: gotoWaitUntil(options.waitFor),
|
|
293
|
+
timeout: timeoutMs,
|
|
294
|
+
});
|
|
295
|
+
log.info({ outcome: 'partial', url, goto_ms: Date.now() - goStart }, 'patchright goto finished');
|
|
296
|
+
if (options.waitFor?.type === 'selector') {
|
|
297
|
+
await page.waitForSelector(options.waitFor.selector, {
|
|
298
|
+
timeout: options.waitFor.timeoutMs ?? 5_000,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else if (options.waitFor?.type === 'timeout') {
|
|
302
|
+
await page.waitForTimeout(options.waitFor.ms);
|
|
303
|
+
}
|
|
304
|
+
else if (options.__fullRender) {
|
|
305
|
+
// Escalation re-fetch of a JS shell with no customer waitFor: fully
|
|
306
|
+
// render the SPA (best-effort networkidle + autoscroll + content
|
|
307
|
+
// settle) so we read hydrated content instead of the same empty shell
|
|
308
|
+
// tier-0 returned. Clamped to the request deadline — never hangs.
|
|
309
|
+
await fullyRenderPage(page, { deadlineMs: startedAt + timeoutMs });
|
|
310
|
+
}
|
|
311
|
+
let actionResults;
|
|
312
|
+
if (options.actions && options.actions.length > 0) {
|
|
313
|
+
actionResults = await runActions(page, options.actions);
|
|
314
|
+
}
|
|
315
|
+
const html = await page.content();
|
|
316
|
+
const screenshot = options.captureScreenshot
|
|
317
|
+
? await page.screenshot({ fullPage: true })
|
|
318
|
+
: null;
|
|
319
|
+
const responseStatus = response?.status() ?? 0;
|
|
320
|
+
const responseHeaders = response?.headers() ?? {};
|
|
321
|
+
const finalUrl = page.url();
|
|
322
|
+
const fetchDurationMs = Math.max(1, Date.now() - startedAt);
|
|
323
|
+
log.info({
|
|
324
|
+
outcome: 'ok',
|
|
325
|
+
url,
|
|
326
|
+
final_url: finalUrl,
|
|
327
|
+
response_status: responseStatus,
|
|
328
|
+
duration_ms: fetchDurationMs,
|
|
329
|
+
screenshot: !!screenshot,
|
|
330
|
+
}, 'patchright fetch ok');
|
|
331
|
+
return {
|
|
332
|
+
html,
|
|
333
|
+
responseStatus,
|
|
334
|
+
responseHeaders,
|
|
335
|
+
fetchedAt,
|
|
336
|
+
fetchDurationMs,
|
|
337
|
+
fetcherId: FETCHER_ID,
|
|
338
|
+
proxyEgressIp: '0.0.0.0',
|
|
339
|
+
userAgent,
|
|
340
|
+
rawHtmlSizeBytes: Buffer.byteLength(html, 'utf8'),
|
|
341
|
+
bytesTransferred,
|
|
342
|
+
screenshot,
|
|
343
|
+
finalUrl,
|
|
344
|
+
...(actionResults ? { actionResults } : {}),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
crashed = true;
|
|
349
|
+
throw err;
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
if (abortSignal)
|
|
353
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
354
|
+
// Close the per-fetch context (and its pages) FIRST. A pooled browser is
|
|
355
|
+
// released (kept warm), not closed, so without this it would accumulate
|
|
356
|
+
// one BrowserContext per fetch — a steady memory leak. Best-effort and
|
|
357
|
+
// harmless on the per-fetch-launch path where browser.close() also tears
|
|
358
|
+
// it down. Fetcher-layer cleanup only; no behavior change to the result.
|
|
359
|
+
if (context?.close) {
|
|
360
|
+
await context.close().catch((err) => {
|
|
361
|
+
log.warn({ error: err instanceof Error ? err.message : String(err) }, 'context.close() failed');
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (slot && activePool) {
|
|
365
|
+
if (crashed)
|
|
366
|
+
activePool.dispose(slot);
|
|
367
|
+
else
|
|
368
|
+
activePool.release(slot);
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
await browser.close().catch((err) => {
|
|
372
|
+
log.warn({ error: err instanceof Error ? err.message : String(err) }, 'browser.close() failed');
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Map the customer waitFor to the goto() `waitUntil`. Only `networkIdle` uses
|
|
381
|
+
* Playwright 'networkidle'; everything else — the default, plus the selector /
|
|
382
|
+
* timeout conditions (which wait AFTER navigation) — uses the fast
|
|
383
|
+
* 'domcontentloaded' (much quicker than 'load' and enough to read rendered HTML).
|
|
384
|
+
*/
|
|
385
|
+
function gotoWaitUntil(wait) {
|
|
386
|
+
return wait?.type === 'networkIdle' ? 'networkidle' : 'domcontentloaded';
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Read visible-text length + scroll height in one page eval. Returns {0,0} on any
|
|
390
|
+
* failure (eval rejected, non-string result, unparseable) so callers never throw.
|
|
391
|
+
*/
|
|
392
|
+
async function readRenderMetric(page) {
|
|
393
|
+
try {
|
|
394
|
+
const raw = await page.evaluate('JSON.stringify({t:(document.body&&document.body.innerText||"").length,h:document.body?document.body.scrollHeight:0})');
|
|
395
|
+
if (typeof raw !== 'string')
|
|
396
|
+
return { textLen: 0, scrollHeight: 0 };
|
|
397
|
+
const parsed = JSON.parse(raw);
|
|
398
|
+
return { textLen: Number(parsed.t) || 0, scrollHeight: Number(parsed.h) || 0 };
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return { textLen: 0, scrollHeight: 0 };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Fully render a JS app before its content is read: a best-effort `networkidle`
|
|
406
|
+
* wait, then a bounded autoscroll + content-settle loop. Used on escalation
|
|
407
|
+
* re-fetches of a SPA shell (no customer waitFor) so we capture hydrated content
|
|
408
|
+
* instead of the empty mount point tier-0 returned.
|
|
409
|
+
*
|
|
410
|
+
* Hard-bounded so it can never hang: the loop stops
|
|
411
|
+
* at the FIRST of — the page looks rendered (>= FULL_RENDER_MIN_CHARS visible text
|
|
412
|
+
* AND the DOM stopped growing), `maxSteps` reached, or the `deadlineMs` passed.
|
|
413
|
+
* Every page call is best-effort (errors swallowed): a flaky settle must never
|
|
414
|
+
* fail an otherwise-good fetch. Fetcher-layer only — it issues no new outbound
|
|
415
|
+
* request of ours; sub-resource loads happen inside the already-proxied browser,
|
|
416
|
+
* so the SSRF posture is unchanged.
|
|
417
|
+
*/
|
|
418
|
+
export async function fullyRenderPage(page, opts) {
|
|
419
|
+
const now = opts.nowMs ?? Date.now;
|
|
420
|
+
const maxSteps = opts.maxSteps ?? FULL_RENDER_MAX_STEPS;
|
|
421
|
+
const remaining = () => opts.deadlineMs - now();
|
|
422
|
+
// 1. Best-effort networkidle — the common "SPA finished its XHRs" signal.
|
|
423
|
+
// Capped under the request timeout; a page that never goes idle (sockets,
|
|
424
|
+
// polling) just times out here and we fall through to the scroll loop.
|
|
425
|
+
if (page.waitForLoadState && remaining() > 0) {
|
|
426
|
+
const timeout = Math.min(FULL_RENDER_NETWORKIDLE_MS, remaining());
|
|
427
|
+
await page.waitForLoadState('networkidle', { timeout }).catch(() => undefined);
|
|
428
|
+
}
|
|
429
|
+
// 2. Autoscroll + content settle. Scroll to the bottom to trigger lazy /
|
|
430
|
+
// infinite content, pause to let it paint, then re-measure. Stop once the
|
|
431
|
+
// page looks rendered AND the DOM has stopped growing (two equal heights).
|
|
432
|
+
let lastHeight = Number.NaN;
|
|
433
|
+
for (let step = 0; step < maxSteps && remaining() > 0; step++) {
|
|
434
|
+
await page
|
|
435
|
+
.evaluate('window.scrollTo(0, document.body ? document.body.scrollHeight : 0)')
|
|
436
|
+
.catch(() => undefined);
|
|
437
|
+
const pause = Math.min(FULL_RENDER_STEP_MS, Math.max(0, remaining()));
|
|
438
|
+
await page.waitForTimeout(pause).catch(() => undefined);
|
|
439
|
+
const metric = await readRenderMetric(page);
|
|
440
|
+
if (metric.textLen >= FULL_RENDER_MIN_CHARS && metric.scrollHeight === lastHeight)
|
|
441
|
+
break;
|
|
442
|
+
lastHeight = metric.scrollHeight;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Execute Phase F actions sequentially against an open page. Throws on
|
|
447
|
+
* the first failure with the action index included for easier debugging
|
|
448
|
+
* — the route surfaces this in the 502 response detail.
|
|
449
|
+
*/
|
|
450
|
+
export async function runActions(page, actions) {
|
|
451
|
+
const results = { screenshots: [], scrapes: [], javascriptReturns: [] };
|
|
452
|
+
for (let i = 0; i < actions.length; i++) {
|
|
453
|
+
const a = actions[i];
|
|
454
|
+
try {
|
|
455
|
+
if (a.type === 'wait') {
|
|
456
|
+
if (typeof a.milliseconds === 'number' && a.milliseconds > 0) {
|
|
457
|
+
await page.waitForTimeout(a.milliseconds);
|
|
458
|
+
}
|
|
459
|
+
else if (a.selector) {
|
|
460
|
+
await page.waitForSelector(a.selector, { timeout: a.timeoutMs ?? 5_000 });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
else if (a.type === 'click') {
|
|
464
|
+
await page.click(a.selector, a.timeoutMs ? { timeout: a.timeoutMs } : undefined);
|
|
465
|
+
}
|
|
466
|
+
else if (a.type === 'write') {
|
|
467
|
+
if (a.selector) {
|
|
468
|
+
await page.fill(a.selector, a.text);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
await page.keyboard.type(a.text);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
else if (a.type === 'press') {
|
|
475
|
+
await page.keyboard.press(a.key);
|
|
476
|
+
}
|
|
477
|
+
else if (a.type === 'scroll') {
|
|
478
|
+
const sign = a.direction === 'up' ? -1 : 1;
|
|
479
|
+
// String form so TS doesn't see `window` in server-side code.
|
|
480
|
+
const expr = typeof a.amount === 'number' && a.amount > 0
|
|
481
|
+
? `window.scrollBy(0, ${sign * a.amount})`
|
|
482
|
+
: `window.scrollBy(0, ${sign} * window.innerHeight)`;
|
|
483
|
+
await page.evaluate(expr);
|
|
484
|
+
}
|
|
485
|
+
else if (a.type === 'screenshot') {
|
|
486
|
+
const shot = await page.screenshot({ fullPage: a.fullPage ?? true });
|
|
487
|
+
results.screenshots.push(Buffer.from(shot).toString('base64'));
|
|
488
|
+
}
|
|
489
|
+
else if (a.type === 'scrape') {
|
|
490
|
+
results.scrapes.push({ html: await page.content() });
|
|
491
|
+
}
|
|
492
|
+
else if (a.type === 'executeJavascript') {
|
|
493
|
+
// Runs in the fetched page's context (not our server). Result must
|
|
494
|
+
// be JSON-serializable; Playwright enforces this.
|
|
495
|
+
results.javascriptReturns.push({ value: await page.evaluate(a.script) });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
500
|
+
throw new Error(`action[${i}] (${a.type}) failed: ${msg}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return results;
|
|
504
|
+
}
|
|
505
|
+
//# sourceMappingURL=fetcher-patchright.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetcher-patchright.js","sourceRoot":"","sources":["../src/fetcher-patchright.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAEnE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAahD,MAAM,GAAG,GAAG,iBAAiB,CAAC,SAAS,CAAC,CAAC;AAEzC,iEAAiE;AACjE,mEAAmE;AACnE,4BAA4B;AAC5B,MAAM,kBAAkB,GAAG,MAAM,CAAC,+BAA+B,EAAE,MAAM,CAAC,CAAC;AAC3E,MAAM,UAAU,GAAG,eAAe,CAAC;AAEnC,gFAAgF;AAChF,8EAA8E;AAC9E,iFAAiF;AACjF,8EAA8E;AAC9E,gFAAgF;AAChF,wFAAwF;AACxF,MAAM,0BAA0B,GAAG,MAAM,CAAC;AAC1C,kFAAkF;AAClF,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAChC,mFAAmF;AACnF,MAAM,qBAAqB,GAAG,EAAE,CAAC;AACjC,+EAA+E;AAC/E,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAElC,wEAAwE;AACxE,2EAA2E;AAC3E,yEAAyE;AACzE,oEAAoE;AACpE,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,iBAAiB,CAAU,CAAC;AAcpE,MAAM,gBAAgB,GAA+B;IACnD;QACE,SAAS,EACP,uHAAuH;QACzH,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE;QACtC,MAAM,EAAE,OAAO;QACf,UAAU,EAAE,kBAAkB;KAC/B;IACD;QACE,SAAS,EACP,iHAAiH;QACnH,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;QACvC,MAAM,EAAE,OAAO;QACf,UAAU,EAAE,iBAAiB;KAC9B;IACD;QACE,SAAS,EACP,uGAAuG;QACzG,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE;QACtC,MAAM,EAAE,OAAO;QACf,UAAU,EAAE,eAAe;KAC5B;CACF,CAAC;AAEF,SAAS,eAAe,CAAC,OAAqB,IAAI,CAAC,MAAM;IACvD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,GAAG,gBAAgB,CAAC,MAAM,CAAC,CAAC;IACzD,OAAO,gBAAgB,CAAC,GAAG,CAAE,CAAC;AAChC,CAAC;AAcD;;;;;;;GAOG;AACH,MAAM,sBAAsB,GAAwB,IAAI,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;AACxF,oFAAoF;AACpF,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAE9B;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CACjC,YAAoB,EACpB,OAAsC,EAAE;IAExC,IAAI,sBAAsB,CAAC,GAAG,CAAC,YAAY,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1D,IAAI,CAAC,IAAI,CAAC,eAAe,IAAI,gBAAgB,CAAC,IAAI,YAAY,KAAK,YAAY;QAAE,OAAO,IAAI,CAAC;IAC7F,OAAO,KAAK,CAAC;AACf,CAAC;AAmHD;;;;GAIG;AACH,MAAM,UAAU,0BAA0B,CACxC,MAAyB,OAAO,CAAC,GAAG;IAEpC,KAAK,MAAM,GAAG,IAAI,uBAAuB,EAAE,CAAC;QAC1C,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,EAAE,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;IACvD,CAAC;IACD,mEAAmE;IACnE,gEAAgE;IAChE,mEAAmE;IACnE,6DAA6D;IAC7D,oEAAoE;IACpE,qEAAqE;IACrE,0BAA0B;IAC1B,MAAM,QAAQ,GAAG,WAAW,CAAC,GAAG,CAAC,eAAe,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC;IAC3D,OAAO,uBAAuB,CAAC;QAC7B,KAAK,EAAE;YACL,MAAM,EAAE,oBAAoB,CAAC,GAAG,CAAC,eAAe,IAAI,EAAE,CAAC;YACvD,QAAQ,EAAE,GAAG,CAAC,gBAAgB,IAAI,EAAE;YACpC,QAAQ,EAAE,GAAG,CAAC,gBAAgB,IAAI,EAAE;SACrC;QACD,GAAG,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5C,CAAC,CAAC;AACL,CAAC;AAED,SAAS,WAAW,CAAC,GAAW,EAAE,QAAgB;IAChD,MAAM,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACnC,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;AACpD,CAAC;AAED;;;;;GAKG;AACH,SAAS,oBAAoB,CAAC,GAAW;IACvC,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;IAC3B,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC;IACxB,IAAI,0BAA0B,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,OAAO,CAAC;IAC7D,OAAO,UAAU,OAAO,EAAE,CAAC;AAC7B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,uBAAuB,CAAC,MAA+B;IACrE,IAAI,cAAc,GAA4B,MAAM,CAAC,YAAY,IAAI,IAAI,CAAC;IAC1E,IAAI,IAAI,GAAuB,IAAI,CAAC;IACpC,IAAI,eAAe,GAAyB,IAAI,CAAC;IAEjD,MAAM,WAAW,GAAG,KAAK,IAA+B,EAAE;QACxD,IAAI,cAAc;YAAE,OAAO,cAAc,CAAC;QAC1C,iEAAiE;QACjE,4CAA4C;QAC5C,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,YAAY,CAAC,CAA8C,CAAC;QACtF,cAAc,GAAG,GAAG,CAAC,QAAQ,CAAC;QAC9B,OAAO,cAAc,CAAC;IACxB,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,KAAK,IAAiC,EAAE;QACtD,IAAI,CAAC,MAAM,CAAC,IAAI,IAAI,MAAM,CAAC,IAAI,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QAClD,IAAI,IAAI;YAAE,OAAO,IAAI,CAAC;QACtB,IAAI,CAAC,eAAe,EAAE,CAAC;YACrB,eAAe,GAAG,CAAC,KAAK,IAAI,EAAE;gBAC5B,MAAM,QAAQ,GAAG,MAAM,WAAW,EAAE,CAAC;gBACrC,IAAI,GAAG,IAAI,WAAW,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;gBAC7E,IAAI,CAAC;oBACH,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;gBACpB,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,GAAG,CAAC,IAAI,CACN,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,EAC3D,4DAA4D,CAC7D,CAAC;oBACF,IAAI,GAAG,IAAI,CAAC;gBACd,CAAC;YACH,CAAC,CAAC,EAAE,CAAC;QACP,CAAC;QACD,MAAM,eAAe,CAAC;QACtB,OAAO,IAAI,CAAC;IACd,CAAC,CAAC;IAEF,OAAO;QACL;;;;;WAKG;QACH,KAAK,CAAC,MAAM;YACV,MAAM,OAAO,EAAE,CAAC;QAClB,CAAC;QACD,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,UAAwB,EAAE;YACzC,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,kBAAkB,CAAC;YAC1D,MAAM,GAAG,GAAG,OAAO,CAAC,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;YAChD,MAAM,WAAW,GAAG,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;YACjD,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC,gBAAgB,IAAI,WAAW,CAAC,SAAS,CAAC;YACxF,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAC7B,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC;YAExB,MAAM,UAAU,GAAG,MAAM,OAAO,EAAE,CAAC;YACnC,IAAI,IAAI,GAAuD,IAAI,CAAC;YACpE,IAAI,OAAwD,CAAC;YAC7D,IAAI,UAAU,EAAE,CAAC;gBACf,IAAI,CAAC;oBACH,IAAI,GAAG,MAAM,UAAU,CAAC,OAAO,EAAE,CAAC;oBAClC,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;gBACzB,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,oEAAoE;oBACpE,GAAG,CAAC,IAAI,CACN,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,EAC3D,wDAAwD,CACzD,CAAC;oBACF,MAAM,QAAQ,GAAG,MAAM,WAAW,EAAE,CAAC;oBACrC,OAAO,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC;wBAC9B,KAAK,EAAE,MAAM,CAAC,KAAK;wBACnB,QAAQ,EAAE,IAAI;wBACd,IAAI,EAAE,CAAC,cAAc,EAAE,yBAAyB,EAAE,eAAe,CAAC;wBAClE,OAAO,EAAE,MAAM;qBAChB,CAAC,CAAC;gBACL,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,MAAM,QAAQ,GAAG,MAAM,WAAW,EAAE,CAAC;gBACrC,OAAO,GAAG,MAAM,QAAQ,CAAC,MAAM,CAAC;oBAC9B,KAAK,EAAE,MAAM,CAAC,KAAK;oBACnB,QAAQ,EAAE,IAAI;oBACd,IAAI,EAAE,CAAC,cAAc,EAAE,yBAAyB,EAAE,eAAe,CAAC;oBAClE,OAAO,EAAE,MAAM;iBAChB,CAAC,CAAC;YACL,CAAC;YAED,IAAI,OAAO,GAAG,KAAK,CAAC;YACpB,IAAI,OAAO,GAAoC,IAAI,CAAC;YACpD,2EAA2E;YAC3E,yEAAyE;YACzE,6EAA6E;YAC7E,0EAA0E;YAC1E,MAAM,WAAW,GAAI,OAAgC,CAAC,aAAa,CAAC;YACpE,MAAM,OAAO,GAAG,GAAS,EAAE;gBACzB,KAAK,OAAO,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YACjD,CAAC,CAAC;YACF,IAAI,WAAW,EAAE,CAAC;gBAChB,IAAI,WAAW,CAAC,OAAO;oBAAE,OAAO,EAAE,CAAC;;oBAC9B,WAAW,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YACtE,CAAC;YACD,IAAI,CAAC;gBACH,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CAAC;oBACjC,SAAS;oBACT,QAAQ,EAAE,WAAW,CAAC,QAAQ;oBAC9B,MAAM,EAAE,WAAW,CAAC,MAAM;oBAC1B,UAAU,EAAE,WAAW,CAAC,UAAU;oBAClC,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,gBAAgB,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBAClE,CAAC,CAAC;gBACH,IAAI,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAClD,MAAM,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;gBAC5C,CAAC;gBACD,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;gBAErC,sEAAsE;gBACtE,wEAAwE;gBACxE,sEAAsE;gBACtE,uEAAuE;gBACvE,qEAAqE;gBACrE,2CAA2C;gBAC3C,IAAI,gBAAgB,GAAG,CAAC,CAAC;gBACzB,IAAI,OAAO,IAAI,CAAC,EAAE,KAAK,UAAU,EAAE,CAAC;oBAClC,IAAI,CAAC,EAAE,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE;wBAC3B,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC;wBAC1D,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,GAAG,GAAG,CAAC;4BAAE,gBAAgB,IAAI,GAAG,CAAC;oBAC/D,CAAC,CAAC,CAAC;gBACL,CAAC;gBACD,IAAI,CAAC,OAAO,CAAC,iBAAiB,IAAI,OAAO,IAAI,CAAC,KAAK,KAAK,UAAU,EAAE,CAAC;oBACnE,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;wBACvC,IAAI,CAAC;4BACH,IAAI,mBAAmB,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,YAAY,EAAE,CAAC;gCAAE,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;;gCACxE,MAAM,KAAK,CAAC,QAAQ,EAAE,CAAC;wBAC9B,CAAC;wBAAC,MAAM,CAAC;4BACP,2DAA2D;wBAC7D,CAAC;oBACH,CAAC,CAAC,CAAC;gBACL,CAAC;gBAED,0DAA0D;gBAC1D,gEAAgE;gBAChE,4DAA4D;gBAC5D,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;gBAC3B,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE;oBACpC,SAAS,EAAE,aAAa,CAAC,OAAO,CAAC,OAAO,CAAC;oBACzC,OAAO,EAAE,SAAS;iBACnB,CAAC,CAAC;gBACH,GAAG,CAAC,IAAI,CACN,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,OAAO,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,EAAE,EAC1D,0BAA0B,CAC3B,CAAC;gBAEF,IAAI,OAAO,CAAC,OAAO,EAAE,IAAI,KAAK,UAAU,EAAE,CAAC;oBACzC,MAAM,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,OAAO,CAAC,QAAQ,EAAE;wBACnD,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,SAAS,IAAI,KAAK;qBAC5C,CAAC,CAAC;gBACL,CAAC;qBAAM,IAAI,OAAO,CAAC,OAAO,EAAE,IAAI,KAAK,SAAS,EAAE,CAAC;oBAC/C,MAAM,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;gBAChD,CAAC;qBAAM,IAAK,OAAgC,CAAC,YAAY,EAAE,CAAC;oBAC1D,oEAAoE;oBACpE,iEAAiE;oBACjE,sEAAsE;oBACtE,kEAAkE;oBAClE,MAAM,eAAe,CAAC,IAAI,EAAE,EAAE,UAAU,EAAE,SAAS,GAAG,SAAS,EAAE,CAAC,CAAC;gBACrE,CAAC;gBAED,IAAI,aAAwC,CAAC;gBAC7C,IAAI,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAClD,aAAa,GAAG,MAAM,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;gBAC1D,CAAC;gBAED,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;gBAClC,MAAM,UAAU,GAAG,OAAO,CAAC,iBAAiB;oBAC1C,CAAC,CAAC,MAAM,IAAI,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;oBAC3C,CAAC,CAAC,IAAI,CAAC;gBAET,MAAM,cAAc,GAAG,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC;gBAC/C,MAAM,eAAe,GAAG,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;gBAClD,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;gBAE5B,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,CAAC;gBAE5D,GAAG,CAAC,IAAI,CACN;oBACE,OAAO,EAAE,IAAI;oBACb,GAAG;oBACH,SAAS,EAAE,QAAQ;oBACnB,eAAe,EAAE,cAAc;oBAC/B,WAAW,EAAE,eAAe;oBAC5B,UAAU,EAAE,CAAC,CAAC,UAAU;iBACzB,EACD,qBAAqB,CACtB,CAAC;gBAEF,OAAO;oBACL,IAAI;oBACJ,cAAc;oBACd,eAAe;oBACf,SAAS;oBACT,eAAe;oBACf,SAAS,EAAE,UAAU;oBACrB,aAAa,EAAE,SAAS;oBACxB,SAAS;oBACT,gBAAgB,EAAE,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,MAAM,CAAC;oBACjD,gBAAgB;oBAChB,UAAU;oBACV,QAAQ;oBACR,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBAC5C,CAAC;YACJ,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,OAAO,GAAG,IAAI,CAAC;gBACf,MAAM,GAAG,CAAC;YACZ,CAAC;oBAAS,CAAC;gBACT,IAAI,WAAW;oBAAE,WAAW,CAAC,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;gBACnE,yEAAyE;gBACzE,wEAAwE;gBACxE,uEAAuE;gBACvE,yEAAyE;gBACzE,yEAAyE;gBACzE,IAAI,OAAO,EAAE,KAAK,EAAE,CAAC;oBACnB,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;wBAClC,GAAG,CAAC,IAAI,CACN,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,EAC3D,wBAAwB,CACzB,CAAC;oBACJ,CAAC,CAAC,CAAC;gBACL,CAAC;gBACD,IAAI,IAAI,IAAI,UAAU,EAAE,CAAC;oBACvB,IAAI,OAAO;wBAAE,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;;wBACjC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;gBAChC,CAAC;qBAAM,CAAC;oBACN,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;wBAClC,GAAG,CAAC,IAAI,CACN,EAAE,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,EAC3D,wBAAwB,CACzB,CAAC;oBACJ,CAAC,CAAC,CAAC;gBACL,CAAC;YACH,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,SAAS,aAAa,CAAC,IAAoB;IACzC,OAAO,IAAI,EAAE,IAAI,KAAK,aAAa,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,kBAAkB,CAAC;AAC3E,CAAC;AAkBD;;;GAGG;AACH,KAAK,UAAU,gBAAgB,CAAC,IAAoB;IAClD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,QAAQ,CAC7B,sHAAsH,CACvH,CAAC;QACF,IAAI,OAAO,GAAG,KAAK,QAAQ;YAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,CAAC;QACpE,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAA+B,CAAC;QAC7D,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;IACjF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,CAAC;IACzC,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,IAAoB,EACpB,IAAuB;IAEvB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,GAAG,CAAC;IACnC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,qBAAqB,CAAC;IACxD,MAAM,SAAS,GAAG,GAAW,EAAE,CAAC,IAAI,CAAC,UAAU,GAAG,GAAG,EAAE,CAAC;IAExD,0EAA0E;IAC1E,6EAA6E;IAC7E,0EAA0E;IAC1E,IAAI,IAAI,CAAC,gBAAgB,IAAI,SAAS,EAAE,GAAG,CAAC,EAAE,CAAC;QAC7C,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,0BAA0B,EAAE,SAAS,EAAE,CAAC,CAAC;QAClE,MAAM,IAAI,CAAC,gBAAgB,CAAC,aAAa,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;IACjF,CAAC;IAED,yEAAyE;IACzE,6EAA6E;IAC7E,8EAA8E;IAC9E,IAAI,UAAU,GAAG,MAAM,CAAC,GAAG,CAAC;IAC5B,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,QAAQ,IAAI,SAAS,EAAE,GAAG,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC;QAC9D,MAAM,IAAI;aACP,QAAQ,CAAC,oEAAoE,CAAC;aAC9E,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAC1B,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC;QACtE,MAAM,IAAI,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAExD,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC,IAAI,CAAC,CAAC;QAC5C,IAAI,MAAM,CAAC,OAAO,IAAI,qBAAqB,IAAI,MAAM,CAAC,YAAY,KAAK,UAAU;YAAE,MAAM;QACzF,UAAU,GAAG,MAAM,CAAC,YAAY,CAAC;IACnC,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,IAAoB,EACpB,OAAqB;IAErB,MAAM,OAAO,GAAkB,EAAE,WAAW,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,iBAAiB,EAAE,EAAE,EAAE,CAAC;IACvF,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACxC,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAE,CAAC;QACtB,IAAI,CAAC;YACH,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;gBACtB,IAAI,OAAO,CAAC,CAAC,YAAY,KAAK,QAAQ,IAAI,CAAC,CAAC,YAAY,GAAG,CAAC,EAAE,CAAC;oBAC7D,MAAM,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;gBAC5C,CAAC;qBAAM,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;oBACtB,MAAM,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,QAAQ,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,SAAS,IAAI,KAAK,EAAE,CAAC,CAAC;gBAC5E,CAAC;YACH,CAAC;iBAAM,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC9B,MAAM,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;YACnF,CAAC;iBAAM,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC9B,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;oBACf,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;gBACtC,CAAC;qBAAM,CAAC;oBACN,MAAM,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;gBACnC,CAAC;YACH,CAAC;iBAAM,IAAI,CAAC,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;gBAC9B,MAAM,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;YACnC,CAAC;iBAAM,IAAI,CAAC,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC/B,MAAM,IAAI,GAAG,CAAC,CAAC,SAAS,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;gBAC3C,8DAA8D;gBAC9D,MAAM,IAAI,GACR,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC;oBAC1C,CAAC,CAAC,sBAAsB,IAAI,GAAG,CAAC,CAAC,MAAM,GAAG;oBAC1C,CAAC,CAAC,sBAAsB,IAAI,wBAAwB,CAAC;gBACzD,MAAM,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;YAC5B,CAAC;iBAAM,IAAI,CAAC,CAAC,IAAI,KAAK,YAAY,EAAE,CAAC;gBACnC,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,UAAU,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,IAAI,IAAI,EAAE,CAAC,CAAC;gBACrE,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;YACjE,CAAC;iBAAM,IAAI,CAAC,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC/B,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;YACvD,CAAC;iBAAM,IAAI,CAAC,CAAC,IAAI,KAAK,mBAAmB,EAAE,CAAC;gBAC1C,mEAAmE;gBACnE,kDAAkD;gBAClD,OAAO,CAAC,iBAAiB,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,MAAM,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YAC3E,CAAC;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAC7D,MAAM,IAAI,KAAK,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,IAAI,aAAa,GAAG,EAAE,CAAC,CAAC;QAC7D,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robots-gated fetcher — the central robots.txt choke point (see ROBOTS_POLICY.md).
|
|
3
|
+
*
|
|
4
|
+
* Wraps any FetcherAdapter so every route that fetches through it gets uniform
|
|
5
|
+
* robots handling (instead of the per-path drift the audit found). When
|
|
6
|
+
* robots-respect is enabled it:
|
|
7
|
+
* - refuses URLs the target's robots.txt disallows for our product token →
|
|
8
|
+
* throws `RobotsDisallowedError` (routes map it to 403 `robots_disallowed`);
|
|
9
|
+
* - paces same-host requests by `max(Crawl-delay, politeness floor)`.
|
|
10
|
+
*
|
|
11
|
+
* Authorized opt-out: when the request carries the internal
|
|
12
|
+
* `__robotsBypassAuthorized` flag (resolved UPSTREAM at the API layer from the
|
|
13
|
+
* customer's `ignoreRobots` + `robotsAttestation` AND an account capability), the
|
|
14
|
+
* Disallow check is skipped and the bypass is logged for audit. The gate never
|
|
15
|
+
* decides authorization itself — that's an API/control concern (four-layer rule).
|
|
16
|
+
*
|
|
17
|
+
* Phase-in: respect is OFF by default; enable per-environment via
|
|
18
|
+
* `ROBOTS_RESPECT=1` or the explicit `respectRobots` option. When off, the gate
|
|
19
|
+
* is a transparent pass-through (zero behavior change). robots.txt evaluation is
|
|
20
|
+
* fail-open (a target's robots outage never blocks the fetch) — see
|
|
21
|
+
* `evaluateRobots`. robots.txt URLs are SSRF-validated inside `evaluateRobots`;
|
|
22
|
+
* this gate adds no new trust-boundary crossing.
|
|
23
|
+
*/
|
|
24
|
+
import { evaluateRobots } from '@bluefields/primitives';
|
|
25
|
+
import type { FetcherAdapter } from './types.js';
|
|
26
|
+
/** Audit record for an honored opt-out — the API layer adds customer/attestation context. */
|
|
27
|
+
export interface RobotsBypassAudit {
|
|
28
|
+
url: string;
|
|
29
|
+
host: string;
|
|
30
|
+
/** Wall-clock ms (Date.now) when the bypass was honored. */
|
|
31
|
+
atMs: number;
|
|
32
|
+
}
|
|
33
|
+
export interface RobotsGateOptions {
|
|
34
|
+
/** The wrapped fetcher (e.g. the escalating adapter). */
|
|
35
|
+
inner: FetcherAdapter;
|
|
36
|
+
/**
|
|
37
|
+
* Master switch (phase-in). Default `env.ROBOTS_RESPECT === '1'` (OFF until an
|
|
38
|
+
* operator enables it). When false the gate is a pass-through.
|
|
39
|
+
*/
|
|
40
|
+
respectRobots?: boolean;
|
|
41
|
+
/** Minimum same-host request spacing. Default `DEFAULT_POLITENESS_FLOOR_MS`. */
|
|
42
|
+
politenessFloorMs?: number;
|
|
43
|
+
/** Env used for the default `respectRobots` flag. Default `process.env`. */
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
/** Invoked when an authorized opt-out is honored (for the API-level audit log). */
|
|
46
|
+
onBypass?: (audit: RobotsBypassAudit) => void;
|
|
47
|
+
/** robots.txt fetch override (forwarded to `evaluateRobots`). */
|
|
48
|
+
robotsFetchImpl?: typeof fetch;
|
|
49
|
+
/** Override the robots evaluator. */
|
|
50
|
+
evaluate?: typeof evaluateRobots;
|
|
51
|
+
/** Clock for politeness pacing. Default `Date.now`. */
|
|
52
|
+
nowMsFn?: () => number;
|
|
53
|
+
/** Sleep for politeness pacing. Default real `setTimeout`. */
|
|
54
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build a robots-gated fetcher wrapping `opts.inner`. Drop-in `FetcherAdapter`,
|
|
58
|
+
* so adopting it is just wrapping the fetcher a route already constructs.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createRobotsGatedFetcher(opts: RobotsGateOptions): FetcherAdapter;
|