imprint-mcp 0.3.1 → 0.4.1
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/package.json +1 -1
- package/src/cli.ts +145 -0
- package/src/imprint/backend-ladder.ts +23 -10
- package/src/imprint/cdp-browser-fetch.ts +277 -170
- package/src/imprint/export-archive.ts +355 -0
- package/src/imprint/mcp-maintenance.ts +6 -12
- package/src/imprint/teach-state.ts +37 -0
- package/src/imprint/teach.ts +62 -29
|
@@ -109,6 +109,9 @@ export interface CdpBrowserFetchOptions {
|
|
|
109
109
|
abckWaitSeconds?: number;
|
|
110
110
|
/** Per-request in-page timeout (ms). Default 60000. */
|
|
111
111
|
requestTimeoutMs?: number;
|
|
112
|
+
/** Per-CDP-command timeout (ms). Default 20000. Prevents a wedged browser
|
|
113
|
+
* or CDP socket from hanging an MCP tool call forever. */
|
|
114
|
+
cdpCommandTimeoutMs?: number;
|
|
112
115
|
/** Launch a visible window instead of headless. Default false (headless). Only
|
|
113
116
|
* needed as a fallback on a GPU-less host where headless WebGL falls back to
|
|
114
117
|
* SwiftShader and the site fingerprints it — pair with `display`/Xvfb. */
|
|
@@ -135,6 +138,19 @@ export interface CdpBrowserFetchOptions {
|
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
type CdpClient = Awaited<ReturnType<typeof CDP>>;
|
|
141
|
+
type LaunchedChromium = Awaited<ReturnType<typeof launchChromium>>;
|
|
142
|
+
type ChromiumLauncher = (opts: Parameters<typeof launchChromium>[0]) => Promise<LaunchedChromium>;
|
|
143
|
+
type CdpConnector = (port: number) => Promise<CdpClient>;
|
|
144
|
+
|
|
145
|
+
let chromiumLauncherForTest: ChromiumLauncher | null = null;
|
|
146
|
+
let cdpConnectorForTest: CdpConnector | null = null;
|
|
147
|
+
|
|
148
|
+
export function __setCdpBrowserFetchHooksForTest(
|
|
149
|
+
hooks: { launchChromium?: ChromiumLauncher; connectCdp?: CdpConnector } | null,
|
|
150
|
+
): void {
|
|
151
|
+
chromiumLauncherForTest = hooks?.launchChromium ?? null;
|
|
152
|
+
cdpConnectorForTest = hooks?.connectCdp ?? null;
|
|
153
|
+
}
|
|
138
154
|
|
|
139
155
|
function abckIsValidated(v: string | undefined): boolean {
|
|
140
156
|
return !!v && v.split('~')[1] === '0';
|
|
@@ -201,168 +217,234 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
201
217
|
const navUrl = opts.bootstrapUrl ?? (baseLooksLikeApi ? `${baseOrigin}/` : opts.baseUrl);
|
|
202
218
|
const abckWaitMs = (opts.abckWaitSeconds ?? 25) * 1000;
|
|
203
219
|
const reqTimeoutMs = opts.requestTimeoutMs ?? 60_000;
|
|
220
|
+
const cdpCommandTimeoutMs = opts.cdpCommandTimeoutMs ?? 20_000;
|
|
221
|
+
const shortCdpTimeoutMs = Math.max(1, Math.min(cdpCommandTimeoutMs, 2_000));
|
|
204
222
|
|
|
205
|
-
let chrome:
|
|
223
|
+
let chrome: LaunchedChromium | null = null;
|
|
206
224
|
let client: CdpClient | null = null;
|
|
207
225
|
let bootstrapped = false;
|
|
208
226
|
let appliedUa: string | undefined;
|
|
209
227
|
|
|
210
|
-
async function
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// Page.navigate AFTER the override rather than passing the URL at launch.
|
|
218
|
-
// headless renders offscreen (no display); headed needs one (Xvfb on Linux).
|
|
219
|
-
chrome = await launchChromium({
|
|
220
|
-
headless: !headed,
|
|
221
|
-
extraArgs: gpuLaunchArgs(),
|
|
222
|
-
...(headed ? { display: opts.display } : {}),
|
|
223
|
-
});
|
|
224
|
-
await chrome.ready;
|
|
225
|
-
}
|
|
226
|
-
if (!client) client = await CDP({ port: chrome.port });
|
|
227
|
-
const { Runtime, Network, Input, Page } = client;
|
|
228
|
-
await Runtime.enable();
|
|
229
|
-
await Network.enable();
|
|
230
|
-
await Page.enable();
|
|
231
|
-
// Plant the high-trust seed cookies (the recording's validated Akamai jar)
|
|
232
|
-
// BEFORE navigating, so the first request to the protected origin carries the
|
|
233
|
-
// trusted session. A synthetic mint can reach `_abck~0~` yet still get its
|
|
234
|
-
// `.act` tarpitted; starting from the recording's earned trust is what makes
|
|
235
|
-
// the in-page protected POSTs succeed (the live bmak sensor then keeps it
|
|
236
|
-
// re-validated between calls).
|
|
237
|
-
if (opts.seedCookies && opts.seedCookies.length > 0) {
|
|
238
|
-
let planted = 0;
|
|
239
|
-
for (const c of opts.seedCookies) {
|
|
240
|
-
try {
|
|
241
|
-
await Network.setCookie({
|
|
242
|
-
name: c.name,
|
|
243
|
-
value: c.value,
|
|
244
|
-
domain: c.domain,
|
|
245
|
-
path: c.path ?? '/',
|
|
246
|
-
secure: c.secure ?? false,
|
|
247
|
-
httpOnly: c.httpOnly ?? false,
|
|
248
|
-
...(c.sameSite ? { sameSite: normalizeSameSite(c.sameSite) } : {}),
|
|
249
|
-
...(typeof c.expires === 'number' && c.expires > 0 ? { expires: c.expires } : {}),
|
|
250
|
-
});
|
|
251
|
-
planted++;
|
|
252
|
-
} catch {
|
|
253
|
-
// best-effort — a cookie Akamai re-issues on navigate isn't fatal
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
log(`seeded ${planted}/${opts.seedCookies.length} high-trust cookies before navigate`);
|
|
257
|
-
}
|
|
258
|
-
// Strip the `HeadlessChrome` UA token (Akamai's only headless edge-tell) and
|
|
259
|
-
// send matching client hints — BEFORE navigating to the protected origin.
|
|
228
|
+
async function close(): Promise<void> {
|
|
229
|
+
const c = client;
|
|
230
|
+
const ch = chrome;
|
|
231
|
+
client = null;
|
|
232
|
+
chrome = null;
|
|
233
|
+
bootstrapped = false;
|
|
234
|
+
appliedUa = undefined;
|
|
260
235
|
try {
|
|
261
|
-
|
|
262
|
-
expression: 'navigator.userAgent',
|
|
263
|
-
returnByValue: true,
|
|
264
|
-
});
|
|
265
|
-
const rawUa = String(result.value ?? '');
|
|
266
|
-
if (rawUa) {
|
|
267
|
-
const override = buildUaOverride(rawUa);
|
|
268
|
-
await Network.setUserAgentOverride(override);
|
|
269
|
-
appliedUa = override.userAgent;
|
|
270
|
-
log(`UA override: ${override.userAgent}`);
|
|
271
|
-
}
|
|
236
|
+
await withTimeout(Promise.resolve(c?.close()), 'CDP client close', 2_000);
|
|
272
237
|
} catch {
|
|
273
|
-
|
|
238
|
+
/* ignore */
|
|
274
239
|
}
|
|
275
|
-
// Navigate now (post-override). Page.navigate stalls forever on an Akamai
|
|
276
|
-
// origin ONLY when the UA still says HeadlessChrome; with the override it
|
|
277
|
-
// loads normally. Race a timeout and proceed regardless — _abck polling below
|
|
278
|
-
// tolerates a partial load.
|
|
279
240
|
try {
|
|
280
|
-
await Promise.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
throw new Error('navigate timeout');
|
|
284
|
-
}),
|
|
285
|
-
]);
|
|
286
|
-
await Promise.race([
|
|
287
|
-
Page.loadEventFired(),
|
|
288
|
-
sleep(Math.min(abckWaitMs, 5000)).then(() => undefined),
|
|
289
|
-
]).catch(() => {});
|
|
290
|
-
} catch (err) {
|
|
291
|
-
log(`navigation issue (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
241
|
+
await withTimeout(Promise.resolve(ch?.close()), 'Chromium close', 3_000);
|
|
242
|
+
} catch {
|
|
243
|
+
/* ignore */
|
|
292
244
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function ensure(): Promise<CdpClient> {
|
|
248
|
+
if (client && bootstrapped) return client;
|
|
249
|
+
try {
|
|
250
|
+
const headed = opts.headed ?? false;
|
|
251
|
+
if (!chrome) {
|
|
252
|
+
log(`launching real ${headed ? 'headed' : 'headless'} Chrome (will navigate ${navUrl})`);
|
|
253
|
+
// Launch at about:blank — we MUST attach CDP and override the UA before the
|
|
254
|
+
// first request to the protected origin fires, so we navigate via
|
|
255
|
+
// Page.navigate AFTER the override rather than passing the URL at launch.
|
|
256
|
+
// headless renders offscreen (no display); headed needs one (Xvfb on Linux).
|
|
257
|
+
const launch = chromiumLauncherForTest ?? launchChromium;
|
|
258
|
+
chrome = await withTimeout(
|
|
259
|
+
launch({
|
|
260
|
+
headless: !headed,
|
|
261
|
+
extraArgs: gpuLaunchArgs(),
|
|
262
|
+
...(headed ? { display: opts.display } : {}),
|
|
263
|
+
}),
|
|
264
|
+
'Chromium launch',
|
|
265
|
+
cdpCommandTimeoutMs,
|
|
266
|
+
);
|
|
267
|
+
await withTimeout(chrome.ready, 'Chromium CDP readiness', cdpCommandTimeoutMs);
|
|
268
|
+
}
|
|
269
|
+
if (!client) {
|
|
270
|
+
const connectCdp = cdpConnectorForTest ?? ((port: number) => CDP({ port }));
|
|
271
|
+
client = await withTimeout(connectCdp(chrome.port), 'CDP connect', cdpCommandTimeoutMs);
|
|
272
|
+
}
|
|
273
|
+
const { Runtime, Network, Input, Page } = client;
|
|
274
|
+
await withTimeout(Runtime.enable(), 'CDP Runtime.enable', cdpCommandTimeoutMs);
|
|
275
|
+
await withTimeout(Network.enable(), 'CDP Network.enable', cdpCommandTimeoutMs);
|
|
276
|
+
await withTimeout(Page.enable(), 'CDP Page.enable', cdpCommandTimeoutMs);
|
|
277
|
+
// Plant the high-trust seed cookies (the recording's validated Akamai jar)
|
|
278
|
+
// BEFORE navigating, so the first request to the protected origin carries the
|
|
279
|
+
// trusted session. A synthetic mint can reach `_abck~0~` yet still get its
|
|
280
|
+
// `.act` tarpitted; starting from the recording's earned trust is what makes
|
|
281
|
+
// the in-page protected POSTs succeed (the live bmak sensor then keeps it
|
|
282
|
+
// re-validated between calls).
|
|
283
|
+
if (opts.seedCookies && opts.seedCookies.length > 0) {
|
|
284
|
+
let planted = 0;
|
|
285
|
+
for (const c of opts.seedCookies) {
|
|
286
|
+
try {
|
|
287
|
+
await withTimeout(
|
|
288
|
+
Network.setCookie({
|
|
289
|
+
name: c.name,
|
|
290
|
+
value: c.value,
|
|
291
|
+
domain: c.domain,
|
|
292
|
+
path: c.path ?? '/',
|
|
293
|
+
secure: c.secure ?? false,
|
|
294
|
+
httpOnly: c.httpOnly ?? false,
|
|
295
|
+
...(c.sameSite ? { sameSite: normalizeSameSite(c.sameSite) } : {}),
|
|
296
|
+
...(typeof c.expires === 'number' && c.expires > 0 ? { expires: c.expires } : {}),
|
|
297
|
+
}),
|
|
298
|
+
`CDP Network.setCookie(${c.name})`,
|
|
299
|
+
shortCdpTimeoutMs,
|
|
300
|
+
);
|
|
301
|
+
planted++;
|
|
302
|
+
} catch {
|
|
303
|
+
// best-effort — a cookie Akamai re-issues on navigate isn't fatal
|
|
304
|
+
}
|
|
332
305
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
306
|
+
log(`seeded ${planted}/${opts.seedCookies.length} high-trust cookies before navigate`);
|
|
307
|
+
}
|
|
308
|
+
// Strip the `HeadlessChrome` UA token (Akamai's only headless edge-tell) and
|
|
309
|
+
// send matching client hints — BEFORE navigating to the protected origin.
|
|
310
|
+
try {
|
|
311
|
+
const { result } = await withTimeout(
|
|
312
|
+
Runtime.evaluate({
|
|
313
|
+
expression: 'navigator.userAgent',
|
|
314
|
+
returnByValue: true,
|
|
315
|
+
}),
|
|
316
|
+
'CDP Runtime.evaluate(navigator.userAgent)',
|
|
317
|
+
cdpCommandTimeoutMs,
|
|
318
|
+
);
|
|
319
|
+
const rawUa = String(result.value ?? '');
|
|
320
|
+
if (rawUa) {
|
|
321
|
+
const override = buildUaOverride(rawUa);
|
|
322
|
+
await withTimeout(
|
|
323
|
+
Network.setUserAgentOverride(override),
|
|
324
|
+
'CDP Network.setUserAgentOverride',
|
|
325
|
+
cdpCommandTimeoutMs,
|
|
326
|
+
);
|
|
327
|
+
appliedUa = override.userAgent;
|
|
328
|
+
log(`UA override: ${override.userAgent}`);
|
|
348
329
|
}
|
|
349
330
|
} catch {
|
|
350
|
-
//
|
|
331
|
+
// best-effort — a headed launch already has a clean UA
|
|
351
332
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
333
|
+
// Navigate now (post-override). Page.navigate stalls forever on an Akamai
|
|
334
|
+
// origin ONLY when the UA still says HeadlessChrome; with the override it
|
|
335
|
+
// loads normally. Bound the CDP command and proceed regardless — _abck
|
|
336
|
+
// polling below tolerates a partial load.
|
|
337
|
+
try {
|
|
338
|
+
await withTimeout(
|
|
339
|
+
Page.navigate({ url: navUrl }),
|
|
340
|
+
'CDP Page.navigate',
|
|
341
|
+
Math.max(1, Math.min(abckWaitMs, 25_000)),
|
|
342
|
+
);
|
|
343
|
+
await withTimeout(
|
|
344
|
+
Page.loadEventFired(),
|
|
345
|
+
'CDP Page.loadEventFired',
|
|
346
|
+
Math.max(1, Math.min(abckWaitMs, 5_000)),
|
|
347
|
+
).catch(() => {});
|
|
348
|
+
} catch (err) {
|
|
349
|
+
log(`navigation issue (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
350
|
+
}
|
|
351
|
+
// Give the sensor JS time to start.
|
|
352
|
+
await sleep(3000);
|
|
353
|
+
// Drive HUMAN-LIKE interaction until _abck validates (or budget expires).
|
|
354
|
+
// Akamai's bmak grades the behavioral SHAPE of trusted input, not just that
|
|
355
|
+
// it exists: a robotic linear lattice + a programmatic `window.scrollBy`
|
|
356
|
+
// (which is isTrusted=FALSE — a real bot tell) score low. Instead we move the
|
|
357
|
+
// cursor along Bezier paths with variable velocity + sub-pixel jitter, scroll
|
|
358
|
+
// via a TRUSTED CDP mouseWheel, and emit occasional key events — all through
|
|
359
|
+
// CDP Input (isTrusted=true). Note: this raises the behavioral score that
|
|
360
|
+
// sits ON TOP of IP reputation; it does NOT overcome a datacenter egress
|
|
361
|
+
// (Akamai serves a 200 empty-shell to a datacenter ASN regardless), which is
|
|
362
|
+
// what IMPRINT_PROXY (residential egress) is for.
|
|
363
|
+
const start = Date.now();
|
|
364
|
+
let i = 0;
|
|
365
|
+
let status = '?';
|
|
366
|
+
let pos = { x: rand(120, 1100), y: rand(120, 600) };
|
|
367
|
+
while (Date.now() - start < abckWaitMs) {
|
|
368
|
+
try {
|
|
369
|
+
const target = { x: rand(60, 1200), y: rand(80, 680) };
|
|
370
|
+
for (const p of bezierPoints(pos, target, Math.round(rand(8, 20)))) {
|
|
371
|
+
await withTimeout(
|
|
372
|
+
Input.dispatchMouseEvent({
|
|
373
|
+
type: 'mouseMoved',
|
|
374
|
+
x: Math.round(p.x),
|
|
375
|
+
y: Math.round(p.y),
|
|
376
|
+
timestamp: Date.now() / 1000,
|
|
377
|
+
}),
|
|
378
|
+
'CDP Input.dispatchMouseEvent(mouseMoved)',
|
|
379
|
+
shortCdpTimeoutMs,
|
|
380
|
+
);
|
|
381
|
+
await sleep(rand(8, 28)); // variable velocity, not a fixed cadence
|
|
382
|
+
}
|
|
383
|
+
pos = target;
|
|
384
|
+
if (i % 3 === 0) {
|
|
385
|
+
// TRUSTED wheel scroll via CDP Input (replaces the isTrusted=false
|
|
386
|
+
// programmatic window.scrollBy).
|
|
387
|
+
await withTimeout(
|
|
388
|
+
Input.dispatchMouseEvent({
|
|
389
|
+
type: 'mouseWheel',
|
|
390
|
+
x: Math.round(pos.x),
|
|
391
|
+
y: Math.round(pos.y),
|
|
392
|
+
deltaX: 0,
|
|
393
|
+
deltaY: rand(80, 260),
|
|
394
|
+
}),
|
|
395
|
+
'CDP Input.dispatchMouseEvent(mouseWheel)',
|
|
396
|
+
shortCdpTimeoutMs,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (i % 5 === 2) {
|
|
400
|
+
// A keystroke broadens the behavioral feature vector beyond mouse-only.
|
|
401
|
+
await withTimeout(
|
|
402
|
+
Input.dispatchKeyEvent({
|
|
403
|
+
type: 'keyDown',
|
|
404
|
+
key: 'ArrowDown',
|
|
405
|
+
code: 'ArrowDown',
|
|
406
|
+
windowsVirtualKeyCode: 40,
|
|
407
|
+
}),
|
|
408
|
+
'CDP Input.dispatchKeyEvent(keyDown)',
|
|
409
|
+
shortCdpTimeoutMs,
|
|
410
|
+
);
|
|
411
|
+
await sleep(rand(30, 90));
|
|
412
|
+
await withTimeout(
|
|
413
|
+
Input.dispatchKeyEvent({
|
|
414
|
+
type: 'keyUp',
|
|
415
|
+
key: 'ArrowDown',
|
|
416
|
+
code: 'ArrowDown',
|
|
417
|
+
windowsVirtualKeyCode: 40,
|
|
418
|
+
}),
|
|
419
|
+
'CDP Input.dispatchKeyEvent(keyUp)',
|
|
420
|
+
shortCdpTimeoutMs,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
// non-fatal
|
|
425
|
+
}
|
|
426
|
+
await sleep(rand(180, 520)); // non-uniform dwell between interaction bursts
|
|
427
|
+
const abck = await getCookie(client, '_abck');
|
|
428
|
+
status = abck?.split('~')[1] ?? '?';
|
|
429
|
+
if (abckIsValidated(abck)) break;
|
|
430
|
+
i++;
|
|
431
|
+
}
|
|
432
|
+
log(`_abck status after interaction: ~${status}~`);
|
|
433
|
+
bootstrapped = true;
|
|
434
|
+
return client;
|
|
435
|
+
} catch (err) {
|
|
436
|
+
await close();
|
|
437
|
+
throw err;
|
|
357
438
|
}
|
|
358
|
-
log(`_abck status after interaction: ~${status}~`);
|
|
359
|
-
bootstrapped = true;
|
|
360
|
-
return client;
|
|
361
439
|
}
|
|
362
440
|
|
|
363
441
|
async function getCookie(c: CdpClient, name: string): Promise<string | undefined> {
|
|
364
442
|
try {
|
|
365
|
-
const { cookies } = await
|
|
443
|
+
const { cookies } = await withTimeout(
|
|
444
|
+
c.Network.getCookies({ urls: [baseOrigin] }),
|
|
445
|
+
'CDP Network.getCookies',
|
|
446
|
+
shortCdpTimeoutMs,
|
|
447
|
+
);
|
|
366
448
|
return cookies.find((ck: { name: string; value: string }) => ck.name === name)?.value;
|
|
367
449
|
} catch (err) {
|
|
368
450
|
// A failed CDP call (dead/crashed browser, closed target) is
|
|
@@ -405,7 +487,11 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
405
487
|
if (requestOrigin !== baseOrigin) {
|
|
406
488
|
let cookieHeader: string | undefined;
|
|
407
489
|
try {
|
|
408
|
-
const { cookies } = await
|
|
490
|
+
const { cookies } = await withTimeout(
|
|
491
|
+
c.Network.getCookies({ urls: [requestOrigin] }),
|
|
492
|
+
'CDP Network.getCookies(cross-origin)',
|
|
493
|
+
cdpCommandTimeoutMs,
|
|
494
|
+
);
|
|
409
495
|
if (cookies.length) {
|
|
410
496
|
cookieHeader = cookies
|
|
411
497
|
.map((ck: { name: string; value: string }) => `${ck.name}=${ck.value}`)
|
|
@@ -453,11 +539,15 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
453
539
|
return JSON.stringify({ ok: false, error: String(e) });
|
|
454
540
|
}
|
|
455
541
|
})()`;
|
|
456
|
-
const { result } = await
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
542
|
+
const { result } = await withTimeout(
|
|
543
|
+
c.Runtime.evaluate({
|
|
544
|
+
expression: expr,
|
|
545
|
+
awaitPromise: true,
|
|
546
|
+
returnByValue: true,
|
|
547
|
+
}),
|
|
548
|
+
'CDP Runtime.evaluate(fetch)',
|
|
549
|
+
Math.max(cdpCommandTimeoutMs, reqTimeoutMs + 5_000),
|
|
550
|
+
);
|
|
461
551
|
const payload = JSON.parse(result.value as string) as
|
|
462
552
|
| { ok: true; status: number; body: string; headers: Record<string, string> }
|
|
463
553
|
| { ok: false; error: string };
|
|
@@ -476,7 +566,11 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
476
566
|
async ensureBootstrapped() {
|
|
477
567
|
const c = await ensure();
|
|
478
568
|
try {
|
|
479
|
-
const { cookies } = await
|
|
569
|
+
const { cookies } = await withTimeout(
|
|
570
|
+
c.Network.getCookies({ urls: [baseOrigin] }),
|
|
571
|
+
'CDP Network.getCookies',
|
|
572
|
+
cdpCommandTimeoutMs,
|
|
573
|
+
);
|
|
480
574
|
return cookies.map((ck: { name: string; value: string }) => ({
|
|
481
575
|
name: ck.name,
|
|
482
576
|
value: ck.value,
|
|
@@ -489,7 +583,11 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
489
583
|
const c = await ensure();
|
|
490
584
|
const cookies: MintedJar['cookies'] = [];
|
|
491
585
|
try {
|
|
492
|
-
const res = await
|
|
586
|
+
const res = await withTimeout(
|
|
587
|
+
c.Network.getCookies({ urls: [baseOrigin] }),
|
|
588
|
+
'CDP Network.getCookies',
|
|
589
|
+
cdpCommandTimeoutMs,
|
|
590
|
+
);
|
|
493
591
|
for (const ck of res.cookies as unknown as Array<Record<string, unknown>>) {
|
|
494
592
|
cookies.push({
|
|
495
593
|
name: ck.name as string,
|
|
@@ -508,10 +606,14 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
508
606
|
}
|
|
509
607
|
let html = '';
|
|
510
608
|
try {
|
|
511
|
-
const { result } = await
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
609
|
+
const { result } = await withTimeout(
|
|
610
|
+
c.Runtime.evaluate({
|
|
611
|
+
expression: 'document.documentElement.outerHTML',
|
|
612
|
+
returnByValue: true,
|
|
613
|
+
}),
|
|
614
|
+
'CDP Runtime.evaluate(document HTML)',
|
|
615
|
+
cdpCommandTimeoutMs,
|
|
616
|
+
);
|
|
515
617
|
html = String(result.value ?? '');
|
|
516
618
|
} catch {
|
|
517
619
|
// best-effort — html_regex captures will miss
|
|
@@ -527,21 +629,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
527
629
|
source: 'mint',
|
|
528
630
|
};
|
|
529
631
|
},
|
|
530
|
-
|
|
531
|
-
try {
|
|
532
|
-
await client?.close();
|
|
533
|
-
} catch {
|
|
534
|
-
/* ignore */
|
|
535
|
-
}
|
|
536
|
-
try {
|
|
537
|
-
await chrome?.close();
|
|
538
|
-
} catch {
|
|
539
|
-
/* ignore */
|
|
540
|
-
}
|
|
541
|
-
client = null;
|
|
542
|
-
chrome = null;
|
|
543
|
-
bootstrapped = false;
|
|
544
|
-
},
|
|
632
|
+
close,
|
|
545
633
|
};
|
|
546
634
|
}
|
|
547
635
|
|
|
@@ -549,6 +637,25 @@ function sleep(ms: number): Promise<void> {
|
|
|
549
637
|
return new Promise((r) => setTimeout(r, ms));
|
|
550
638
|
}
|
|
551
639
|
|
|
640
|
+
async function withTimeout<T>(promise: Promise<T>, label: string, timeoutMs: number): Promise<T> {
|
|
641
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
|
642
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
643
|
+
try {
|
|
644
|
+
return await Promise.race([
|
|
645
|
+
promise,
|
|
646
|
+
new Promise<never>((_, reject) => {
|
|
647
|
+
timer = setTimeout(
|
|
648
|
+
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
|
649
|
+
timeoutMs,
|
|
650
|
+
);
|
|
651
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
652
|
+
}),
|
|
653
|
+
]);
|
|
654
|
+
} finally {
|
|
655
|
+
if (timer) clearTimeout(timer);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
552
659
|
/** Uniform random in [min, max). Used to humanize interaction timing/geometry —
|
|
553
660
|
* bmak flags fixed cadences and uniform step sizes as synthetic. */
|
|
554
661
|
function rand(min: number, max: number): number {
|