imprint-mcp 0.6.0 → 0.6.2
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 +2 -0
- package/package.json +4 -2
- package/prompts/audit-agent.md +4 -2
- package/prompts/auth-compile-agent.md +149 -158
- package/prompts/build-planning.md +14 -9
- package/prompts/compile-agent.md +46 -23
- package/prompts/live-verifier-agent.md +101 -0
- package/prompts/playbook-compilation.md +8 -1
- package/prompts/tool-candidate-detection.md +9 -29
- package/src/cli.ts +83 -1
- package/src/imprint/audit.ts +219 -20
- package/src/imprint/auth-compile-agent.ts +215 -155
- package/src/imprint/auth-compile-tools.ts +300 -118
- package/src/imprint/auth-verifier.ts +167 -180
- package/src/imprint/backend-cache.ts +18 -11
- package/src/imprint/backend-ladder.ts +225 -177
- package/src/imprint/build-plan.ts +3 -8
- package/src/imprint/cdp-browser-fetch.ts +930 -83
- package/src/imprint/claude-cli-compile.ts +46 -20
- package/src/imprint/codex-cli-compile.ts +436 -139
- package/src/imprint/compile-agent-types.ts +15 -10
- package/src/imprint/compile-agent.ts +88 -28
- package/src/imprint/compile-tools.ts +228 -39
- package/src/imprint/compile-verification.ts +187 -0
- package/src/imprint/compile.ts +71 -2
- package/src/imprint/credential-store.ts +57 -44
- package/src/imprint/emit.ts +3 -1
- package/src/imprint/freeform-redact.ts +27 -6
- package/src/imprint/live-verifier.ts +1665 -0
- package/src/imprint/llm.ts +22 -3
- package/src/imprint/login.ts +107 -25
- package/src/imprint/mcp-compile-server.ts +447 -114
- package/src/imprint/mcp-live-verifier-server.ts +359 -0
- package/src/imprint/mcp-server.ts +116 -35
- package/src/imprint/probe-backends.ts +88 -7
- package/src/imprint/recording-request.ts +36 -0
- package/src/imprint/request-capture.ts +40 -17
- package/src/imprint/runtime.ts +490 -337
- package/src/imprint/teach-state.ts +36 -2
- package/src/imprint/teach.ts +302 -146
- package/src/imprint/tool-candidates.ts +8 -10
- package/src/imprint/tool-loader.ts +12 -4
- package/src/imprint/types.ts +137 -68
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import CDP from 'chrome-remote-interface';
|
|
32
|
+
import { getDomain } from 'tldts';
|
|
32
33
|
import { AKAMAI_SENSOR_COOKIE, abckFlag, isAbckValidated } from './bot-defense.ts';
|
|
33
34
|
import {
|
|
34
35
|
__registerChromePid,
|
|
@@ -93,9 +94,54 @@ export function jarCookiesValidated(cookies: Array<{ name: string; value: string
|
|
|
93
94
|
return cookies.some((c) => c.name === 'bm_sv');
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
export function buildNavigationSelectorExpression(selector: string): string {
|
|
98
|
+
return `document.querySelector(${JSON.stringify(selector)}) !== null`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildNavigationClickTargetExpression(selector: string): string {
|
|
102
|
+
return `(() => {
|
|
103
|
+
const target = document.querySelector(${JSON.stringify(selector)});
|
|
104
|
+
if (!(target instanceof Element)) return null;
|
|
105
|
+
target.scrollIntoView({ block: 'center', inline: 'center' });
|
|
106
|
+
const rect = target.getBoundingClientRect();
|
|
107
|
+
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
108
|
+
if (target.matches(':disabled')) return null;
|
|
109
|
+
const x = rect.left + rect.width / 2;
|
|
110
|
+
const y = rect.top + rect.height / 2;
|
|
111
|
+
const hit = document.elementFromPoint(x, y);
|
|
112
|
+
if (hit !== target && !target.contains(hit) && !hit?.contains(target)) return null;
|
|
113
|
+
target.focus();
|
|
114
|
+
return { x, y };
|
|
115
|
+
})()`;
|
|
116
|
+
}
|
|
117
|
+
|
|
96
118
|
export interface CdpBrowserFetch {
|
|
97
119
|
/** typeof fetch — executes the request inside the live trusted Chrome page. */
|
|
98
120
|
readonly fetchImpl: typeof fetch;
|
|
121
|
+
/** Perform a real top-level document navigation and wait for recording-grounded
|
|
122
|
+
* URL/cookie evidence. Page JavaScript owns all browser-minted state. */
|
|
123
|
+
navigate?(
|
|
124
|
+
url: string,
|
|
125
|
+
options?: {
|
|
126
|
+
method?: string;
|
|
127
|
+
headers?: Record<string, string>;
|
|
128
|
+
body?: string;
|
|
129
|
+
waitUntil?: 'domcontentloaded' | 'load';
|
|
130
|
+
timeoutMs?: number;
|
|
131
|
+
pollIntervalMs?: number;
|
|
132
|
+
urlIncludes?: string;
|
|
133
|
+
selector?: string;
|
|
134
|
+
actions?: Array<{ action: 'click'; selector: string }>;
|
|
135
|
+
resultSelector?: string;
|
|
136
|
+
cookie?: { name: string; domain?: string; path?: string };
|
|
137
|
+
},
|
|
138
|
+
): Promise<Response>;
|
|
139
|
+
/** Snapshot every cookie in this dedicated browser, including cookies minted
|
|
140
|
+
* on origins reached by redirects rather than the bootstrap origin. */
|
|
141
|
+
snapshotCookies?(): Promise<MintedJar['cookies']>;
|
|
142
|
+
/** Inspect the currently rendered page without navigating or mutating browser
|
|
143
|
+
* state. Cookie values are intentionally excluded from this diagnostic view. */
|
|
144
|
+
inspectPage?(): Promise<CdpPageSnapshot>;
|
|
99
145
|
/** Force the bootstrap navigation + `_abck` validation now; returns the
|
|
100
146
|
* session cookies so callers can read session tokens (CSRF) for `${state.X}`. */
|
|
101
147
|
ensureBootstrapped(): Promise<Array<{ name: string; value: string }>>;
|
|
@@ -107,6 +153,252 @@ export interface CdpBrowserFetch {
|
|
|
107
153
|
close(): Promise<void>;
|
|
108
154
|
}
|
|
109
155
|
|
|
156
|
+
export interface CdpPageSnapshot {
|
|
157
|
+
url: string;
|
|
158
|
+
title: string;
|
|
159
|
+
bodyText: string;
|
|
160
|
+
cookies: Array<{
|
|
161
|
+
name: string;
|
|
162
|
+
domain: string;
|
|
163
|
+
path: string;
|
|
164
|
+
expires?: number;
|
|
165
|
+
httpOnly?: boolean;
|
|
166
|
+
secure?: boolean;
|
|
167
|
+
sameSite?: string;
|
|
168
|
+
}>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildFormPostNavigationExpr(url: string, body: string): string {
|
|
172
|
+
return `(() => {
|
|
173
|
+
const targetUrl = new URL(${JSON.stringify(url)}, location.href).href;
|
|
174
|
+
const params = Array.from(new URLSearchParams(${JSON.stringify(body)}));
|
|
175
|
+
const targetNames = new Set(params.map(([name]) => name));
|
|
176
|
+
const forms = Array.from(document.forms).filter(
|
|
177
|
+
(candidate) => candidate.method.toUpperCase() === 'POST',
|
|
178
|
+
);
|
|
179
|
+
const isSubmitter = (control) => {
|
|
180
|
+
const tag = control.tagName;
|
|
181
|
+
const type = String(control.type || '').toLowerCase();
|
|
182
|
+
return (tag === 'BUTTON' && (type === '' || type === 'submit')) ||
|
|
183
|
+
(tag === 'INPUT' && (type === 'submit' || type === 'image'));
|
|
184
|
+
};
|
|
185
|
+
const canAssign = (control, value) => {
|
|
186
|
+
if (isSubmitter(control)) return String(control.value) === value;
|
|
187
|
+
const type = String(control.type || '').toLowerCase();
|
|
188
|
+
if (type === 'checkbox' || type === 'radio') return String(control.value) === value;
|
|
189
|
+
return !control.disabled && control.name;
|
|
190
|
+
};
|
|
191
|
+
const assign = (control, value) => {
|
|
192
|
+
const type = String(control.type || '').toLowerCase();
|
|
193
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
194
|
+
control.checked = String(control.value) === value;
|
|
195
|
+
} else {
|
|
196
|
+
control.value = value;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const planForm = (candidate) => {
|
|
200
|
+
const controls = Array.from(candidate.elements);
|
|
201
|
+
const submitterIndexes = controls
|
|
202
|
+
.map((control, index) => isSubmitter(control) && !control.disabled ? index : -1)
|
|
203
|
+
.filter((index) => index >= 0);
|
|
204
|
+
const choices = submitterIndexes.length > 0 ? submitterIndexes : [-1];
|
|
205
|
+
const validPlans = [];
|
|
206
|
+
for (const submitterIndex of choices) {
|
|
207
|
+
const clone = candidate.cloneNode(true);
|
|
208
|
+
const cloneControls = Array.from(clone.elements);
|
|
209
|
+
const used = new Set();
|
|
210
|
+
const assignments = [];
|
|
211
|
+
let possible = true;
|
|
212
|
+
for (const [name, value] of params) {
|
|
213
|
+
const indexes = cloneControls
|
|
214
|
+
.map((control, index) => control.name === name && !used.has(index) ? index : -1)
|
|
215
|
+
.filter((index) => index >= 0);
|
|
216
|
+
const exact = indexes.find((index) => String(cloneControls[index].value) === value);
|
|
217
|
+
const compatible = indexes.find((index) => canAssign(cloneControls[index], value));
|
|
218
|
+
const index = exact ?? compatible;
|
|
219
|
+
if (index === undefined) {
|
|
220
|
+
const input = document.createElement('input');
|
|
221
|
+
input.type = 'hidden';
|
|
222
|
+
input.name = name;
|
|
223
|
+
input.value = value;
|
|
224
|
+
clone.appendChild(input);
|
|
225
|
+
assignments.push({ index: -1, name, value });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (isSubmitter(cloneControls[index]) && index !== submitterIndex) {
|
|
229
|
+
possible = false;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
used.add(index);
|
|
233
|
+
assignments.push({ index, name, value });
|
|
234
|
+
if (!isSubmitter(cloneControls[index])) assign(cloneControls[index], value);
|
|
235
|
+
}
|
|
236
|
+
if (!possible) continue;
|
|
237
|
+
const cloneSubmitter = submitterIndex >= 0 ? cloneControls[submitterIndex] : undefined;
|
|
238
|
+
let encoded;
|
|
239
|
+
try {
|
|
240
|
+
encoded = Array.from(new FormData(clone, cloneSubmitter))
|
|
241
|
+
.filter(([name]) => targetNames.has(name))
|
|
242
|
+
.map(([name, value]) => [name, String(value)]);
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (JSON.stringify(encoded) !== JSON.stringify(params)) continue;
|
|
247
|
+
validPlans.push({ submitterIndex, assignments });
|
|
248
|
+
}
|
|
249
|
+
if (validPlans.length !== 1) return null;
|
|
250
|
+
const action = candidate.getAttribute('action') || location.href;
|
|
251
|
+
const actionMatches = new URL(action, location.href).href === targetUrl;
|
|
252
|
+
const candidateControls = Array.from(candidate.elements);
|
|
253
|
+
const namedPairs = params.filter(([name]) =>
|
|
254
|
+
candidateControls.some((control) => control.name === name),
|
|
255
|
+
);
|
|
256
|
+
const exactPairs = params.filter(([name, value]) =>
|
|
257
|
+
candidateControls.some(
|
|
258
|
+
(control) => control.name === name && String(control.value) === value,
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
if (!actionMatches) return null;
|
|
262
|
+
return {
|
|
263
|
+
form: candidate,
|
|
264
|
+
actionMatches,
|
|
265
|
+
namedPairs: namedPairs.length,
|
|
266
|
+
exactPairs: exactPairs.length,
|
|
267
|
+
...validPlans[0],
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
const plans = forms.map(planForm).filter(Boolean);
|
|
271
|
+
plans.sort((left, right) =>
|
|
272
|
+
Number(right.actionMatches) - Number(left.actionMatches) ||
|
|
273
|
+
right.namedPairs - left.namedPairs ||
|
|
274
|
+
right.exactPairs - left.exactPairs,
|
|
275
|
+
);
|
|
276
|
+
const best = plans[0];
|
|
277
|
+
const next = plans[1];
|
|
278
|
+
const tied = best && next && best.actionMatches === next.actionMatches &&
|
|
279
|
+
best.namedPairs === next.namedPairs && best.exactPairs === next.exactPairs;
|
|
280
|
+
const renderedForm = Boolean(best && !tied);
|
|
281
|
+
const form = renderedForm ? best.form : document.createElement('form');
|
|
282
|
+
window.__imprintForm = form;
|
|
283
|
+
form.method = 'POST';
|
|
284
|
+
form.setAttribute('action', targetUrl);
|
|
285
|
+
|
|
286
|
+
if (!renderedForm) {
|
|
287
|
+
form.style.display = 'none';
|
|
288
|
+
for (const [name, value] of params) {
|
|
289
|
+
const input = document.createElement('input');
|
|
290
|
+
input.type = 'hidden';
|
|
291
|
+
input.name = name;
|
|
292
|
+
input.value = value;
|
|
293
|
+
form.appendChild(input);
|
|
294
|
+
}
|
|
295
|
+
const parent = document.body || document.documentElement;
|
|
296
|
+
if (!parent) throw new Error('document has no element that can contain a POST form');
|
|
297
|
+
parent.appendChild(form);
|
|
298
|
+
form.submit();
|
|
299
|
+
return { action: 'submitted', renderedForm: false };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const typedFields = [];
|
|
303
|
+
for (const assignment of best.assignments) {
|
|
304
|
+
if (assignment.index < 0) {
|
|
305
|
+
const input = document.createElement('input');
|
|
306
|
+
input.type = 'hidden';
|
|
307
|
+
input.name = assignment.name;
|
|
308
|
+
input.value = assignment.value;
|
|
309
|
+
form.appendChild(input);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const control = form.elements[assignment.index];
|
|
313
|
+
if (isSubmitter(control)) continue;
|
|
314
|
+
const type = String(control.type || '').toLowerCase();
|
|
315
|
+
if (['email', 'password', 'search', 'tel', 'text', 'url'].includes(type) || control.tagName === 'TEXTAREA') {
|
|
316
|
+
control.value = '';
|
|
317
|
+
typedFields.push({ index: assignment.index, value: assignment.value });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
assign(control, assignment.value);
|
|
321
|
+
control.dispatchEvent(new Event('input', { bubbles: true }));
|
|
322
|
+
control.dispatchEvent(new Event('change', { bubbles: true }));
|
|
323
|
+
}
|
|
324
|
+
const submitter = best.submitterIndex >= 0 ? form.elements[best.submitterIndex] : null;
|
|
325
|
+
if (submitter) {
|
|
326
|
+
const rect = submitter.getBoundingClientRect();
|
|
327
|
+
const style = getComputedStyle(submitter);
|
|
328
|
+
const visible = rect.width > 0 && rect.height > 0 && style.pointerEvents !== 'none' &&
|
|
329
|
+
style.visibility !== 'hidden';
|
|
330
|
+
if (!visible) throw new Error('recorded form submitter is not interactable');
|
|
331
|
+
window.__imprintSubmitter = submitter;
|
|
332
|
+
submitter.focus();
|
|
333
|
+
submitter.addEventListener('click', () => { window.__imprintFormClickObserved = true; }, { once: true });
|
|
334
|
+
return {
|
|
335
|
+
action: 'click',
|
|
336
|
+
x: rect.left + rect.width / 2,
|
|
337
|
+
y: rect.top + rect.height / 2,
|
|
338
|
+
tag: submitter.tagName,
|
|
339
|
+
type: submitter.type,
|
|
340
|
+
typedFields,
|
|
341
|
+
renderedForm: true,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (typeof form.requestSubmit === 'function') form.requestSubmit();
|
|
345
|
+
else form.submit();
|
|
346
|
+
return { action: 'submitted', renderedForm: true };
|
|
347
|
+
})()`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function validateFormPostNavigationHeaders(headers: Record<string, string>): void {
|
|
351
|
+
const entries = Object.entries(headers);
|
|
352
|
+
const unsupported = entries.filter(
|
|
353
|
+
([name]) => !['content-type', 'cookie'].includes(name.toLowerCase()),
|
|
354
|
+
);
|
|
355
|
+
if (unsupported.length > 0) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`top-level form POST cannot preserve request header(s): ${unsupported
|
|
358
|
+
.map(([name]) => name)
|
|
359
|
+
.join(', ')}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const contentTypes = entries
|
|
363
|
+
.filter(([name]) => name.toLowerCase() === 'content-type')
|
|
364
|
+
.map(([, value]) => value);
|
|
365
|
+
if (contentTypes.length !== 1) {
|
|
366
|
+
throw new Error('top-level form POST requires an explicit Content-Type header');
|
|
367
|
+
}
|
|
368
|
+
const mediaType = contentTypes[0]?.split(';', 1)[0]?.trim().toLowerCase();
|
|
369
|
+
if (mediaType !== 'application/x-www-form-urlencoded') {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`top-level form POST requires application/x-www-form-urlencoded, got ${contentTypes[0]}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function parseCdpPageInspectionResult(evaluation: {
|
|
377
|
+
result?: { value?: unknown };
|
|
378
|
+
exceptionDetails?: { text?: string; exception?: { description?: string } };
|
|
379
|
+
}): { url: string; title: string; bodyText: string } {
|
|
380
|
+
if (evaluation.exceptionDetails) {
|
|
381
|
+
const detail =
|
|
382
|
+
evaluation.exceptionDetails.exception?.description ??
|
|
383
|
+
evaluation.exceptionDetails.text ??
|
|
384
|
+
'unknown page evaluation error';
|
|
385
|
+
throw new Error(`rendered page inspection failed: ${detail}`);
|
|
386
|
+
}
|
|
387
|
+
const value = evaluation.result?.value;
|
|
388
|
+
if (!value || typeof value !== 'object') {
|
|
389
|
+
throw new Error('rendered page inspection returned no page snapshot');
|
|
390
|
+
}
|
|
391
|
+
const page = value as { url?: unknown; title?: unknown; bodyText?: unknown };
|
|
392
|
+
if (typeof page.url !== 'string' || !page.url.startsWith('http')) {
|
|
393
|
+
throw new Error('rendered page inspection returned no usable URL');
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
url: page.url,
|
|
397
|
+
title: typeof page.title === 'string' ? page.title : '',
|
|
398
|
+
bodyText: typeof page.bodyText === 'string' ? page.bodyText : '',
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
110
402
|
export interface CdpBrowserFetchOptions {
|
|
111
403
|
/** Origin used to resolve relative request URLs + cookie lookups. */
|
|
112
404
|
baseUrl: string;
|
|
@@ -145,6 +437,14 @@ export interface CdpBrowserFetchOptions {
|
|
|
145
437
|
sameSite?: string;
|
|
146
438
|
expires?: number;
|
|
147
439
|
}>;
|
|
440
|
+
/** Browser storage to restore before page scripts run. Entries are scoped to
|
|
441
|
+
* their recorded origin and applied on every matching document navigation. */
|
|
442
|
+
seedStorage?: Array<{
|
|
443
|
+
origin: string;
|
|
444
|
+
kind: 'localStorage' | 'sessionStorage';
|
|
445
|
+
key: string;
|
|
446
|
+
value: string;
|
|
447
|
+
}>;
|
|
148
448
|
/** Opt-in: write a cross-origin response's `Set-Cookie` back into the browser
|
|
149
449
|
* cookie jar (the cross-origin plain-fetch path can't do this itself). OFF by
|
|
150
450
|
* default — it changes the jar, so it must be a DELIBERATE decision grounded in
|
|
@@ -160,6 +460,24 @@ type LaunchedChromium = Awaited<ReturnType<typeof launchChromium>>;
|
|
|
160
460
|
type ChromiumLauncher = (opts: Parameters<typeof launchChromium>[0]) => Promise<LaunchedChromium>;
|
|
161
461
|
type CdpConnector = (port: number) => Promise<CdpClient>;
|
|
162
462
|
|
|
463
|
+
export function buildStorageSeedExpression(
|
|
464
|
+
records: NonNullable<CdpBrowserFetchOptions['seedStorage']>,
|
|
465
|
+
): string {
|
|
466
|
+
return `(() => {
|
|
467
|
+
if (typeof window !== 'undefined' && window.top !== window) return;
|
|
468
|
+
const records = ${JSON.stringify(records)};
|
|
469
|
+
for (const record of records) {
|
|
470
|
+
if (record.origin !== location.origin) continue;
|
|
471
|
+
try {
|
|
472
|
+
const storage = record.kind === 'sessionStorage' ? sessionStorage : localStorage;
|
|
473
|
+
if (storage.getItem(record.key) === null) storage.setItem(record.key, record.value);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
throw new Error('__IMPRINT_STORAGE_SEED_FAILED__' + record.kind + ':' + record.key + ':' + String(error));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
})();`;
|
|
479
|
+
}
|
|
480
|
+
|
|
163
481
|
let chromiumLauncherForTest: ChromiumLauncher | null = null;
|
|
164
482
|
let cdpConnectorForTest: CdpConnector | null = null;
|
|
165
483
|
|
|
@@ -170,49 +488,45 @@ export function __setCdpBrowserFetchHooksForTest(
|
|
|
170
488
|
cdpConnectorForTest = hooks?.connectCdp ?? null;
|
|
171
489
|
}
|
|
172
490
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
491
|
+
export function normalizeCdpResponseHeaders(
|
|
492
|
+
input: Record<string, unknown>,
|
|
493
|
+
): Record<string, string> {
|
|
494
|
+
const headers: Record<string, string> = {};
|
|
495
|
+
for (const [rawName, rawValue] of Object.entries(input)) {
|
|
496
|
+
if (rawValue == null || /^\d+$/.test(rawName)) continue;
|
|
497
|
+
const name = rawName.toLowerCase();
|
|
498
|
+
const value = String(rawValue).replace(/[\r\n]+/g, ', ');
|
|
499
|
+
try {
|
|
500
|
+
const probe = new Headers();
|
|
501
|
+
probe.set(name, value);
|
|
502
|
+
const normalized = probe.get(name);
|
|
503
|
+
if (normalized !== null) headers[name] = normalized;
|
|
504
|
+
} catch {
|
|
505
|
+
// CDP may expose malformed pseudo/array entries for large response
|
|
506
|
+
// headers. They are metadata only and must not fail navigation replay.
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return headers;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The registrable domain (eTLD+1) of a host. Private suffixes are enabled so
|
|
513
|
+
* independent tenants such as `foo.github.io` never share browser credentials.
|
|
514
|
+
* Used to decide whether a cross-origin request is a SIBLING
|
|
515
|
+
* subdomain under the SAME site (e.g. `login.example.com` vs
|
|
516
|
+
* `www.example.com` → both `example.com`) versus a genuinely
|
|
178
517
|
* third-party origin (analytics/CDN). Siblings share the page's anti-bot umbrella
|
|
179
518
|
* and the recording proves they accept the credentialed cross-origin request, so
|
|
180
519
|
* they must be issued IN-PAGE (live sensor + Chrome CORS) — not via plain fetch.
|
|
181
520
|
* Site-agnostic: no host literals, derived purely from the URL structure. */
|
|
182
521
|
function registrableDomain(host: string): string {
|
|
183
|
-
|
|
184
|
-
if (labels.length <= 2) return labels.join('.');
|
|
185
|
-
// Common multi-part public suffixes where eTLD+1 needs three labels.
|
|
186
|
-
const twoPartTld = new Set([
|
|
187
|
-
'co.uk',
|
|
188
|
-
'co.jp',
|
|
189
|
-
'co.kr',
|
|
190
|
-
'co.in',
|
|
191
|
-
'co.nz',
|
|
192
|
-
'co.za',
|
|
193
|
-
'com.au',
|
|
194
|
-
'com.br',
|
|
195
|
-
'com.cn',
|
|
196
|
-
'com.mx',
|
|
197
|
-
'com.sg',
|
|
198
|
-
'com.hk',
|
|
199
|
-
'com.tr',
|
|
200
|
-
'org.uk',
|
|
201
|
-
'gov.uk',
|
|
202
|
-
'ac.uk',
|
|
203
|
-
'net.au',
|
|
204
|
-
'org.au',
|
|
205
|
-
]);
|
|
206
|
-
const lastTwo = labels.slice(-2).join('.');
|
|
207
|
-
if (twoPartTld.has(lastTwo)) return labels.slice(-3).join('.');
|
|
208
|
-
return lastTwo;
|
|
522
|
+
return getDomain(host, { allowPrivateDomains: true }) ?? '';
|
|
209
523
|
}
|
|
210
524
|
|
|
211
525
|
/** Two origins are SIBLINGS when they share a registrable domain but differ in
|
|
212
526
|
* origin (subdomain and/or scheme). A request to a sibling origin is still under
|
|
213
527
|
* the page's site/anti-bot umbrella, so it should ride the live in-page sensor +
|
|
214
528
|
* Chrome's credentialed-CORS engine rather than escaping to plain fetch. */
|
|
215
|
-
function isSiblingOrigin(originA: string, originB: string): boolean {
|
|
529
|
+
export function isSiblingOrigin(originA: string, originB: string): boolean {
|
|
216
530
|
if (originA === originB) return false;
|
|
217
531
|
try {
|
|
218
532
|
const a = new URL(originA);
|
|
@@ -235,7 +549,7 @@ function isSiblingOrigin(originA: string, originB: string): boolean {
|
|
|
235
549
|
* origin's cookie jar, so we must NOT pass a manual Cookie header (it's stripped
|
|
236
550
|
* by the caller anyway). `fullUrl` may be same-origin OR an absolute sibling URL.
|
|
237
551
|
*
|
|
238
|
-
* Some SPAs monkeypatch window.fetch (
|
|
552
|
+
* Some SPAs monkeypatch window.fetch (application code may throw from its patched
|
|
239
553
|
* version), so we grab the native fetch from a hidden iframe whose fresh
|
|
240
554
|
* browsing context has the unpatched global but shares the page cookie jar.
|
|
241
555
|
* The iframe stays alive until the body is read, then is removed.
|
|
@@ -251,7 +565,7 @@ function isSiblingOrigin(originA: string, originB: string): boolean {
|
|
|
251
565
|
* origin + native fetch + preflight 200; about:blank/srcdoc → `null`). For a
|
|
252
566
|
* same-origin request the document origin already equals the request origin, so
|
|
253
567
|
* `about:blank` (faster, no load wait) is fine and this stays unset. */
|
|
254
|
-
function buildInPageFetchExpr(
|
|
568
|
+
export function buildInPageFetchExpr(
|
|
255
569
|
fullUrl: string,
|
|
256
570
|
method: string,
|
|
257
571
|
headers: Record<string, string>,
|
|
@@ -289,19 +603,22 @@ function buildInPageFetchExpr(
|
|
|
289
603
|
} catch (_) {}
|
|
290
604
|
const ctrl = new AbortController();
|
|
291
605
|
const to = setTimeout(() => ctrl.abort(), ${reqTimeoutMs});
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
606
|
+
try {
|
|
607
|
+
const r = await _f(${JSON.stringify(fullUrl)}, {
|
|
608
|
+
method: ${JSON.stringify(method)},
|
|
609
|
+
headers: ${JSON.stringify(headers)},
|
|
610
|
+
${body !== null ? `body: ${JSON.stringify(body)},` : ''}
|
|
611
|
+
credentials: 'include',
|
|
612
|
+
signal: ctrl.signal,
|
|
613
|
+
});
|
|
614
|
+
const text = await r.text();
|
|
615
|
+
const h = {};
|
|
616
|
+
r.headers.forEach((v, k) => { h[k] = v; });
|
|
617
|
+
if (ifr) ifr.remove();
|
|
618
|
+
return { ok: true, status: r.status, body: text, headers: h };
|
|
619
|
+
} finally {
|
|
620
|
+
clearTimeout(to);
|
|
621
|
+
}
|
|
305
622
|
} catch (e) {
|
|
306
623
|
if (ifr) try { ifr.remove(); } catch (_) {}
|
|
307
624
|
return { ok: false, error: String(e) };
|
|
@@ -387,6 +704,13 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
387
704
|
let bootstrapped = false;
|
|
388
705
|
let appliedUa: string | undefined;
|
|
389
706
|
let bootstrapResponseHeaders: Record<string, string> = {};
|
|
707
|
+
let currentPageUrl = navUrl;
|
|
708
|
+
let mainFrameId: string | undefined;
|
|
709
|
+
let lastDocumentResponse: {
|
|
710
|
+
url: string;
|
|
711
|
+
status: number;
|
|
712
|
+
headers: Record<string, string>;
|
|
713
|
+
} | null = null;
|
|
390
714
|
|
|
391
715
|
async function close(): Promise<void> {
|
|
392
716
|
const c = client;
|
|
@@ -397,6 +721,9 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
397
721
|
bootstrapped = false;
|
|
398
722
|
appliedUa = undefined;
|
|
399
723
|
bootstrapResponseHeaders = {};
|
|
724
|
+
currentPageUrl = navUrl;
|
|
725
|
+
mainFrameId = undefined;
|
|
726
|
+
lastDocumentResponse = null;
|
|
400
727
|
try {
|
|
401
728
|
await withTimeout(Promise.resolve(c?.close()), 'CDP client close', 2_000);
|
|
402
729
|
} catch {
|
|
@@ -444,15 +771,32 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
444
771
|
await withTimeout(Runtime.enable(), 'CDP Runtime.enable', cdpCommandTimeoutMs);
|
|
445
772
|
await withTimeout(Network.enable(), 'CDP Network.enable', cdpCommandTimeoutMs);
|
|
446
773
|
await withTimeout(Page.enable(), 'CDP Page.enable', cdpCommandTimeoutMs);
|
|
774
|
+
if (typeof Page.getFrameTree === 'function') {
|
|
775
|
+
const tree = await withTimeout(
|
|
776
|
+
Page.getFrameTree(),
|
|
777
|
+
'CDP Page.getFrameTree',
|
|
778
|
+
cdpCommandTimeoutMs,
|
|
779
|
+
);
|
|
780
|
+
mainFrameId = tree.frameTree?.frame?.id;
|
|
781
|
+
}
|
|
447
782
|
bootstrapResponseHeaders = {};
|
|
448
783
|
let navDocumentResponseUrl = '';
|
|
449
784
|
const onResponseReceived = (event: {
|
|
450
785
|
type?: string;
|
|
451
|
-
|
|
786
|
+
frameId?: string;
|
|
787
|
+
response?: { url?: string; status?: number; headers?: Record<string, unknown> };
|
|
452
788
|
}) => {
|
|
453
789
|
if (event.type !== 'Document') return;
|
|
790
|
+
if (mainFrameId && event.frameId && event.frameId !== mainFrameId) return;
|
|
454
791
|
const url = event.response?.url;
|
|
455
792
|
if (!url) return;
|
|
793
|
+
currentPageUrl = url;
|
|
794
|
+
const headers = normalizeCdpResponseHeaders(event.response?.headers ?? {});
|
|
795
|
+
lastDocumentResponse = {
|
|
796
|
+
url,
|
|
797
|
+
status: Number(event.response?.status ?? 200),
|
|
798
|
+
headers,
|
|
799
|
+
};
|
|
456
800
|
try {
|
|
457
801
|
const responseUrl = new URL(url);
|
|
458
802
|
const targetUrl = new URL(navUrl);
|
|
@@ -461,11 +805,6 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
461
805
|
return;
|
|
462
806
|
}
|
|
463
807
|
navDocumentResponseUrl = url;
|
|
464
|
-
const headers: Record<string, string> = {};
|
|
465
|
-
for (const [k, v] of Object.entries(event.response?.headers ?? {})) {
|
|
466
|
-
if (v == null) continue;
|
|
467
|
-
headers[k.toLowerCase()] = String(v);
|
|
468
|
-
}
|
|
469
808
|
bootstrapResponseHeaders = headers;
|
|
470
809
|
};
|
|
471
810
|
Network.responseReceived(onResponseReceived);
|
|
@@ -525,6 +864,40 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
525
864
|
} catch {
|
|
526
865
|
// best-effort — a headed launch already has a clean UA
|
|
527
866
|
}
|
|
867
|
+
let storageSeedError: string | undefined;
|
|
868
|
+
if (opts.seedStorage && opts.seedStorage.length > 0) {
|
|
869
|
+
if (
|
|
870
|
+
typeof Page.addScriptToEvaluateOnNewDocument !== 'function' ||
|
|
871
|
+
typeof Runtime.exceptionThrown !== 'function'
|
|
872
|
+
) {
|
|
873
|
+
throw new Error('CDP cannot restore required browser storage before navigation');
|
|
874
|
+
}
|
|
875
|
+
Runtime.exceptionThrown(
|
|
876
|
+
(event: {
|
|
877
|
+
exceptionDetails?: { text?: string; exception?: { description?: string } };
|
|
878
|
+
}) => {
|
|
879
|
+
const detail =
|
|
880
|
+
event.exceptionDetails?.exception?.description ?? event.exceptionDetails?.text ?? '';
|
|
881
|
+
const marker = '__IMPRINT_STORAGE_SEED_FAILED__';
|
|
882
|
+
const markerIndex = detail.indexOf(marker);
|
|
883
|
+
if (markerIndex >= 0) storageSeedError = detail.slice(markerIndex + marker.length);
|
|
884
|
+
},
|
|
885
|
+
);
|
|
886
|
+
try {
|
|
887
|
+
await withTimeout(
|
|
888
|
+
Page.addScriptToEvaluateOnNewDocument({
|
|
889
|
+
source: buildStorageSeedExpression(opts.seedStorage),
|
|
890
|
+
}),
|
|
891
|
+
'CDP Page.addScriptToEvaluateOnNewDocument(storage)',
|
|
892
|
+
cdpCommandTimeoutMs,
|
|
893
|
+
);
|
|
894
|
+
log(`registered ${opts.seedStorage.length} browser storage seed entries`);
|
|
895
|
+
} catch (err) {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`browser storage seed registration failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
528
901
|
// Navigate now (post-override). Page.navigate stalls forever on an Akamai
|
|
529
902
|
// origin ONLY when the UA still says HeadlessChrome; with the override it
|
|
530
903
|
// loads normally. Bound the CDP command and proceed regardless — _abck
|
|
@@ -546,6 +919,10 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
546
919
|
} catch (err) {
|
|
547
920
|
log(`navigation issue (continuing): ${err instanceof Error ? err.message : String(err)}`);
|
|
548
921
|
}
|
|
922
|
+
await sleep(0);
|
|
923
|
+
if (typeof storageSeedError === 'string') {
|
|
924
|
+
throw new Error(`browser storage seed execution failed: ${storageSeedError}`);
|
|
925
|
+
}
|
|
549
926
|
// Give the sensor JS time to start.
|
|
550
927
|
await sleep(3000);
|
|
551
928
|
// Drive HUMAN-LIKE interaction until _abck validates (or budget expires).
|
|
@@ -661,7 +1038,22 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
661
1038
|
const c = await ensure();
|
|
662
1039
|
const url =
|
|
663
1040
|
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
664
|
-
|
|
1041
|
+
let pageUrl = currentPageUrl;
|
|
1042
|
+
try {
|
|
1043
|
+
const { result } = await withTimeout(
|
|
1044
|
+
c.Runtime.evaluate({ expression: 'location.href', returnByValue: true }),
|
|
1045
|
+
'CDP Runtime.evaluate(location.href)',
|
|
1046
|
+
shortCdpTimeoutMs,
|
|
1047
|
+
);
|
|
1048
|
+
if (typeof result.value === 'string' && result.value.startsWith('http')) {
|
|
1049
|
+
pageUrl = result.value;
|
|
1050
|
+
currentPageUrl = pageUrl;
|
|
1051
|
+
}
|
|
1052
|
+
} catch {
|
|
1053
|
+
// Fall back to the last observed Document URL.
|
|
1054
|
+
}
|
|
1055
|
+
const pageOrigin = new URL(pageUrl).origin;
|
|
1056
|
+
const fullUrl = new URL(url, pageUrl).toString();
|
|
665
1057
|
const method = (init?.method ?? 'GET').toUpperCase();
|
|
666
1058
|
const headers: Record<string, string> = {};
|
|
667
1059
|
if (init?.headers) {
|
|
@@ -777,8 +1169,8 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
777
1169
|
};
|
|
778
1170
|
|
|
779
1171
|
// Cross-origin routing. A request to a SIBLING origin (same registrable domain
|
|
780
|
-
// as the live page, e.g. www →
|
|
781
|
-
// the page's anti-bot umbrella
|
|
1172
|
+
// as the live page, e.g. www → login on `example.com`) is still under
|
|
1173
|
+
// the page's anti-bot umbrella when the site CORS-allows the
|
|
782
1174
|
// credentialed cross-origin POST (ACAO names the page origin, ACA-credentials
|
|
783
1175
|
// true), and Akamai edge-403s the SAME request when it leaves the browser as a
|
|
784
1176
|
// plain fetch (no live sensor, no isTrusted, no `Origin` the edge expects). So
|
|
@@ -789,8 +1181,8 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
789
1181
|
// ONLY if the browser genuinely CORS-blocks it (in-page payload ok:false). A
|
|
790
1182
|
// genuinely third-party origin (analytics/CDN) — NOT a sibling — keeps the plain
|
|
791
1183
|
// fetch path: it isn't behind this site's wall and may not CORS-allow the page.
|
|
792
|
-
if (requestOrigin !==
|
|
793
|
-
if (isSiblingOrigin(requestOrigin,
|
|
1184
|
+
if (requestOrigin !== pageOrigin) {
|
|
1185
|
+
if (isSiblingOrigin(requestOrigin, pageOrigin)) {
|
|
794
1186
|
log(`cross-origin SIBLING ${method} ${requestOrigin} via in-page fetch`);
|
|
795
1187
|
// Use a SAME-ORIGIN iframe (loaded from the page origin) for the unpatched
|
|
796
1188
|
// fetch — NOT about:blank, whose opaque ("null") origin would make the
|
|
@@ -799,7 +1191,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
799
1191
|
// src gives the iframe the live document's true origin (empirically
|
|
800
1192
|
// verified). `/favicon.ico` is a universal, cheap same-origin resource; its
|
|
801
1193
|
// status is irrelevant — only that the iframe inherits the origin.
|
|
802
|
-
const sameOriginIframeSrc = `${
|
|
1194
|
+
const sameOriginIframeSrc = `${pageOrigin}/favicon.ico`;
|
|
803
1195
|
let payload:
|
|
804
1196
|
| { ok: true; status: number; body: string; headers: Record<string, string> }
|
|
805
1197
|
| { ok: false; error: string };
|
|
@@ -827,7 +1219,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
827
1219
|
|
|
828
1220
|
// Execute the fetch INSIDE the trusted page. credentials:'include' so the
|
|
829
1221
|
// browser attaches the validated session cookies. Uses the shared in-page
|
|
830
|
-
// mechanism (iframe-escape for monkeypatched window.fetch
|
|
1222
|
+
// mechanism (iframe-escape for a monkeypatched window.fetch).
|
|
831
1223
|
const payload = await runInPageFetch();
|
|
832
1224
|
if (!payload.ok) {
|
|
833
1225
|
// Surface as a network-style failure so the ladder treats it like a fetch throw.
|
|
@@ -839,8 +1231,485 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
839
1231
|
});
|
|
840
1232
|
}) as typeof fetch;
|
|
841
1233
|
|
|
1234
|
+
const snapshotAllCookies = async (c: CdpClient): Promise<MintedJar['cookies']> => {
|
|
1235
|
+
const cookies: MintedJar['cookies'] = [];
|
|
1236
|
+
try {
|
|
1237
|
+
// With no URL filter CDP returns the dedicated browser's complete jar.
|
|
1238
|
+
// This includes host-only cookies minted on relying-party origins reached
|
|
1239
|
+
// through redirects, which a bootstrap-origin-only query cannot see.
|
|
1240
|
+
const res = await withTimeout(
|
|
1241
|
+
c.Network.getCookies({}),
|
|
1242
|
+
'CDP Network.getCookies(all origins)',
|
|
1243
|
+
cdpCommandTimeoutMs,
|
|
1244
|
+
);
|
|
1245
|
+
for (const ck of res.cookies as unknown as Array<Record<string, unknown>>) {
|
|
1246
|
+
cookies.push({
|
|
1247
|
+
name: ck.name as string,
|
|
1248
|
+
value: ck.value as string,
|
|
1249
|
+
domain: ck.domain as string,
|
|
1250
|
+
path: (ck.path as string) ?? '/',
|
|
1251
|
+
expires:
|
|
1252
|
+
typeof ck.expires === 'number' && ck.expires > 0 ? (ck.expires as number) : undefined,
|
|
1253
|
+
httpOnly: ck.httpOnly as boolean | undefined,
|
|
1254
|
+
secure: ck.secure as boolean | undefined,
|
|
1255
|
+
sameSite: ck.sameSite as string | undefined,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
} catch {
|
|
1259
|
+
// best-effort
|
|
1260
|
+
}
|
|
1261
|
+
return cookies;
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
const readCurrentPageUrl = async (c: CdpClient): Promise<string> => {
|
|
1265
|
+
try {
|
|
1266
|
+
const { result } = await withTimeout(
|
|
1267
|
+
c.Runtime.evaluate({ expression: 'location.href', returnByValue: true }),
|
|
1268
|
+
'CDP Runtime.evaluate(location.href)',
|
|
1269
|
+
shortCdpTimeoutMs,
|
|
1270
|
+
);
|
|
1271
|
+
if (typeof result.value === 'string' && result.value.startsWith('http')) {
|
|
1272
|
+
currentPageUrl = result.value;
|
|
1273
|
+
}
|
|
1274
|
+
} catch {
|
|
1275
|
+
// Fall back to the last Document response observed by Network.
|
|
1276
|
+
}
|
|
1277
|
+
return currentPageUrl;
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
const dispatchTrustedClick = async (
|
|
1281
|
+
c: CdpClient,
|
|
1282
|
+
x: number,
|
|
1283
|
+
y: number,
|
|
1284
|
+
label: string,
|
|
1285
|
+
timeoutMs: number,
|
|
1286
|
+
): Promise<void> => {
|
|
1287
|
+
await withTimeout(c.Page.bringToFront(), `CDP Page.bringToFront(${label})`, timeoutMs);
|
|
1288
|
+
const timestamp = Date.now() / 1000;
|
|
1289
|
+
await withTimeout(
|
|
1290
|
+
c.Input.dispatchMouseEvent({ type: 'mouseMoved', x, y, timestamp }),
|
|
1291
|
+
`CDP Input.dispatchMouseEvent(${label} mouseMoved)`,
|
|
1292
|
+
timeoutMs,
|
|
1293
|
+
);
|
|
1294
|
+
await sleep(100);
|
|
1295
|
+
await withTimeout(
|
|
1296
|
+
c.Input.dispatchMouseEvent({
|
|
1297
|
+
type: 'mousePressed',
|
|
1298
|
+
button: 'left',
|
|
1299
|
+
buttons: 1,
|
|
1300
|
+
clickCount: 1,
|
|
1301
|
+
x,
|
|
1302
|
+
y,
|
|
1303
|
+
timestamp,
|
|
1304
|
+
}),
|
|
1305
|
+
`CDP Input.dispatchMouseEvent(${label} mousePressed)`,
|
|
1306
|
+
timeoutMs,
|
|
1307
|
+
);
|
|
1308
|
+
await sleep(50);
|
|
1309
|
+
await withTimeout(
|
|
1310
|
+
c.Input.dispatchMouseEvent({
|
|
1311
|
+
type: 'mouseReleased',
|
|
1312
|
+
button: 'left',
|
|
1313
|
+
clickCount: 1,
|
|
1314
|
+
x,
|
|
1315
|
+
y,
|
|
1316
|
+
timestamp: Date.now() / 1000,
|
|
1317
|
+
}),
|
|
1318
|
+
`CDP Input.dispatchMouseEvent(${label} mouseReleased)`,
|
|
1319
|
+
timeoutMs,
|
|
1320
|
+
);
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
const navigate = async (
|
|
1324
|
+
rawUrl: string,
|
|
1325
|
+
options: {
|
|
1326
|
+
method?: string;
|
|
1327
|
+
headers?: Record<string, string>;
|
|
1328
|
+
body?: string;
|
|
1329
|
+
waitUntil?: 'domcontentloaded' | 'load';
|
|
1330
|
+
timeoutMs?: number;
|
|
1331
|
+
pollIntervalMs?: number;
|
|
1332
|
+
urlIncludes?: string;
|
|
1333
|
+
selector?: string;
|
|
1334
|
+
actions?: Array<{ action: 'click'; selector: string }>;
|
|
1335
|
+
resultSelector?: string;
|
|
1336
|
+
cookie?: { name: string; domain?: string; path?: string };
|
|
1337
|
+
} = {},
|
|
1338
|
+
): Promise<Response> => {
|
|
1339
|
+
const c = await ensure();
|
|
1340
|
+
const startUrl = await readCurrentPageUrl(c);
|
|
1341
|
+
const targetUrl = new URL(rawUrl, startUrl).toString();
|
|
1342
|
+
const method = (options.method ?? 'GET').toUpperCase();
|
|
1343
|
+
if (method !== 'GET' && method !== 'POST') {
|
|
1344
|
+
throw new Error(`top-level browser navigation does not support ${method}`);
|
|
1345
|
+
}
|
|
1346
|
+
if (method === 'POST') {
|
|
1347
|
+
validateFormPostNavigationHeaders(options.headers ?? {});
|
|
1348
|
+
}
|
|
1349
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
1350
|
+
const pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
1351
|
+
const lifecyclePromise = (
|
|
1352
|
+
options.waitUntil === 'domcontentloaded'
|
|
1353
|
+
? c.Page.domContentEventFired()
|
|
1354
|
+
: c.Page.loadEventFired()
|
|
1355
|
+
).then(
|
|
1356
|
+
() => true,
|
|
1357
|
+
() => false,
|
|
1358
|
+
);
|
|
1359
|
+
|
|
1360
|
+
lastDocumentResponse = null;
|
|
1361
|
+
if (method === 'GET') {
|
|
1362
|
+
await withTimeout(
|
|
1363
|
+
c.Page.navigate({ url: targetUrl }),
|
|
1364
|
+
`CDP Page.navigate(${new URL(targetUrl).origin})`,
|
|
1365
|
+
Math.min(timeoutMs, cdpCommandTimeoutMs),
|
|
1366
|
+
);
|
|
1367
|
+
} else {
|
|
1368
|
+
const evaluation = await withTimeout(
|
|
1369
|
+
c.Runtime.evaluate({
|
|
1370
|
+
expression: buildFormPostNavigationExpr(targetUrl, options.body ?? ''),
|
|
1371
|
+
returnByValue: true,
|
|
1372
|
+
}),
|
|
1373
|
+
`CDP Runtime.evaluate(form POST to ${new URL(targetUrl).origin})`,
|
|
1374
|
+
Math.min(timeoutMs, cdpCommandTimeoutMs),
|
|
1375
|
+
);
|
|
1376
|
+
if (evaluation.exceptionDetails) {
|
|
1377
|
+
const detail =
|
|
1378
|
+
evaluation.exceptionDetails.exception?.description ?? evaluation.exceptionDetails.text;
|
|
1379
|
+
throw new Error(`browser form preparation failed: ${detail}`);
|
|
1380
|
+
}
|
|
1381
|
+
const directive = evaluation.result.value as
|
|
1382
|
+
| {
|
|
1383
|
+
action?: unknown;
|
|
1384
|
+
x?: unknown;
|
|
1385
|
+
y?: unknown;
|
|
1386
|
+
tag?: unknown;
|
|
1387
|
+
type?: unknown;
|
|
1388
|
+
typedFields?: unknown;
|
|
1389
|
+
}
|
|
1390
|
+
| undefined;
|
|
1391
|
+
if (
|
|
1392
|
+
directive?.action === 'click' &&
|
|
1393
|
+
typeof directive.x === 'number' &&
|
|
1394
|
+
typeof directive.y === 'number'
|
|
1395
|
+
) {
|
|
1396
|
+
await withTimeout(
|
|
1397
|
+
c.Page.bringToFront(),
|
|
1398
|
+
'CDP Page.bringToFront(form)',
|
|
1399
|
+
cdpCommandTimeoutMs,
|
|
1400
|
+
);
|
|
1401
|
+
const typedFields = Array.isArray(directive.typedFields)
|
|
1402
|
+
? directive.typedFields.filter(
|
|
1403
|
+
(field): field is { index: number; value: string } =>
|
|
1404
|
+
Boolean(field) &&
|
|
1405
|
+
typeof field === 'object' &&
|
|
1406
|
+
typeof (field as { index?: unknown }).index === 'number' &&
|
|
1407
|
+
typeof (field as { value?: unknown }).value === 'string',
|
|
1408
|
+
)
|
|
1409
|
+
: [];
|
|
1410
|
+
for (const field of typedFields) {
|
|
1411
|
+
const focused = await withTimeout(
|
|
1412
|
+
c.Runtime.evaluate({
|
|
1413
|
+
expression: `(() => {
|
|
1414
|
+
const control = window.__imprintForm?.elements?.[${field.index}];
|
|
1415
|
+
if (!control) return false;
|
|
1416
|
+
control.focus();
|
|
1417
|
+
control.value = '';
|
|
1418
|
+
return document.activeElement === control;
|
|
1419
|
+
})()`,
|
|
1420
|
+
returnByValue: true,
|
|
1421
|
+
}),
|
|
1422
|
+
'CDP Runtime.evaluate(form field focus)',
|
|
1423
|
+
cdpCommandTimeoutMs,
|
|
1424
|
+
);
|
|
1425
|
+
if (focused.result.value !== true) {
|
|
1426
|
+
throw new Error(`browser form field at index ${field.index} could not be focused`);
|
|
1427
|
+
}
|
|
1428
|
+
for (const character of field.value) {
|
|
1429
|
+
await withTimeout(
|
|
1430
|
+
c.Input.dispatchKeyEvent({ type: 'char', text: character }),
|
|
1431
|
+
'CDP Input.dispatchKeyEvent(form character)',
|
|
1432
|
+
cdpCommandTimeoutMs,
|
|
1433
|
+
);
|
|
1434
|
+
await sleep(15 + Math.floor(Math.random() * 26));
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const focusProbe = await withTimeout(
|
|
1438
|
+
c.Runtime.evaluate({
|
|
1439
|
+
expression:
|
|
1440
|
+
'window.__imprintSubmitter?.focus(); document.activeElement === window.__imprintSubmitter',
|
|
1441
|
+
returnByValue: true,
|
|
1442
|
+
}),
|
|
1443
|
+
'CDP Runtime.evaluate(form focus restore)',
|
|
1444
|
+
cdpCommandTimeoutMs,
|
|
1445
|
+
);
|
|
1446
|
+
const targetProbe = await withTimeout(
|
|
1447
|
+
c.Runtime.evaluate({
|
|
1448
|
+
expression: `(() => {
|
|
1449
|
+
const target = window.__imprintSubmitter;
|
|
1450
|
+
if (!target) return null;
|
|
1451
|
+
const rect = target.getBoundingClientRect();
|
|
1452
|
+
const x = rect.left + rect.width / 2;
|
|
1453
|
+
const y = rect.top + rect.height / 2;
|
|
1454
|
+
const hit = document.elementFromPoint(x, y);
|
|
1455
|
+
return { x, y, hit: hit === target || target.contains(hit) };
|
|
1456
|
+
})()`,
|
|
1457
|
+
returnByValue: true,
|
|
1458
|
+
}),
|
|
1459
|
+
'CDP Runtime.evaluate(form click target)',
|
|
1460
|
+
cdpCommandTimeoutMs,
|
|
1461
|
+
);
|
|
1462
|
+
const liveTarget = targetProbe.result.value as {
|
|
1463
|
+
x?: unknown;
|
|
1464
|
+
y?: unknown;
|
|
1465
|
+
hit?: unknown;
|
|
1466
|
+
} | null;
|
|
1467
|
+
if (typeof liveTarget?.x !== 'number' || typeof liveTarget.y !== 'number') {
|
|
1468
|
+
throw new Error('browser form submit control lost its rendered position');
|
|
1469
|
+
}
|
|
1470
|
+
const clickX = liveTarget.x;
|
|
1471
|
+
const clickY = liveTarget.y;
|
|
1472
|
+
log(
|
|
1473
|
+
`form POST: trusted click on ${String(directive.tag ?? 'control')} type=${String(directive.type ?? 'unknown')} focused=${focusProbe.result.value === true} hit=${liveTarget.hit === true}`,
|
|
1474
|
+
);
|
|
1475
|
+
await dispatchTrustedClick(c, clickX, clickY, 'form', cdpCommandTimeoutMs);
|
|
1476
|
+
await sleep(250);
|
|
1477
|
+
const clickProbe = await withTimeout(
|
|
1478
|
+
c.Runtime.evaluate({
|
|
1479
|
+
expression: 'window.__imprintFormClickObserved === true',
|
|
1480
|
+
returnByValue: true,
|
|
1481
|
+
}),
|
|
1482
|
+
'CDP Runtime.evaluate(form click probe)',
|
|
1483
|
+
cdpCommandTimeoutMs,
|
|
1484
|
+
).catch(() => null);
|
|
1485
|
+
let clickObserved = clickProbe?.result.value === true;
|
|
1486
|
+
const navigationObserved = lastDocumentResponse !== null;
|
|
1487
|
+
log(`form POST: click observed=${clickObserved} navigation observed=${navigationObserved}`);
|
|
1488
|
+
if (!clickObserved && !navigationObserved) {
|
|
1489
|
+
await withTimeout(
|
|
1490
|
+
c.Input.dispatchKeyEvent({
|
|
1491
|
+
type: 'rawKeyDown',
|
|
1492
|
+
key: 'Enter',
|
|
1493
|
+
code: 'Enter',
|
|
1494
|
+
windowsVirtualKeyCode: 13,
|
|
1495
|
+
nativeVirtualKeyCode: 13,
|
|
1496
|
+
}),
|
|
1497
|
+
'CDP Input.dispatchKeyEvent(form Enter down)',
|
|
1498
|
+
cdpCommandTimeoutMs,
|
|
1499
|
+
);
|
|
1500
|
+
await sleep(50);
|
|
1501
|
+
await withTimeout(
|
|
1502
|
+
c.Input.dispatchKeyEvent({
|
|
1503
|
+
type: 'keyUp',
|
|
1504
|
+
key: 'Enter',
|
|
1505
|
+
code: 'Enter',
|
|
1506
|
+
windowsVirtualKeyCode: 13,
|
|
1507
|
+
nativeVirtualKeyCode: 13,
|
|
1508
|
+
}),
|
|
1509
|
+
'CDP Input.dispatchKeyEvent(form Enter up)',
|
|
1510
|
+
cdpCommandTimeoutMs,
|
|
1511
|
+
);
|
|
1512
|
+
await sleep(250);
|
|
1513
|
+
const keyProbe = await withTimeout(
|
|
1514
|
+
c.Runtime.evaluate({
|
|
1515
|
+
expression: 'window.__imprintFormClickObserved === true',
|
|
1516
|
+
returnByValue: true,
|
|
1517
|
+
}),
|
|
1518
|
+
'CDP Runtime.evaluate(form key probe)',
|
|
1519
|
+
cdpCommandTimeoutMs,
|
|
1520
|
+
).catch(() => null);
|
|
1521
|
+
clickObserved = keyProbe?.result.value === true;
|
|
1522
|
+
log(`form POST: Enter fallback click observed=${clickObserved}`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
const selectorExists = async (selector?: string): Promise<boolean> => {
|
|
1528
|
+
if (!selector) return true;
|
|
1529
|
+
const evaluated = await withTimeout(
|
|
1530
|
+
c.Runtime.evaluate({
|
|
1531
|
+
expression: buildNavigationSelectorExpression(selector),
|
|
1532
|
+
returnByValue: true,
|
|
1533
|
+
}),
|
|
1534
|
+
'CDP Runtime.evaluate(navigation selector)',
|
|
1535
|
+
cdpCommandTimeoutMs,
|
|
1536
|
+
);
|
|
1537
|
+
return evaluated.result.value === true;
|
|
1538
|
+
};
|
|
1539
|
+
const hasPredicate = Boolean(options.urlIncludes || options.selector || options.cookie);
|
|
1540
|
+
const deadline = Date.now() + timeoutMs;
|
|
1541
|
+
if (!hasPredicate) {
|
|
1542
|
+
await withTimeout(lifecyclePromise, `CDP Page.${options.waitUntil ?? 'load'}`, timeoutMs);
|
|
1543
|
+
} else {
|
|
1544
|
+
let lastUrl = targetUrl;
|
|
1545
|
+
while (Date.now() < deadline) {
|
|
1546
|
+
lastUrl = await readCurrentPageUrl(c);
|
|
1547
|
+
const urlMatches = !options.urlIncludes || lastUrl.includes(options.urlIncludes);
|
|
1548
|
+
const selectorMatches = await selectorExists(options.selector);
|
|
1549
|
+
let cookieMatches = !options.cookie;
|
|
1550
|
+
if (options.cookie) {
|
|
1551
|
+
const expectedDomain = options.cookie.domain?.replace(/^\./, '').toLowerCase();
|
|
1552
|
+
const allCookies = await snapshotAllCookies(c);
|
|
1553
|
+
cookieMatches = allCookies.some((cookie) => {
|
|
1554
|
+
if (cookie.name !== options.cookie?.name) return false;
|
|
1555
|
+
if (
|
|
1556
|
+
expectedDomain &&
|
|
1557
|
+
cookie.domain.replace(/^\./, '').toLowerCase() !== expectedDomain
|
|
1558
|
+
) {
|
|
1559
|
+
return false;
|
|
1560
|
+
}
|
|
1561
|
+
return !options.cookie?.path || cookie.path === options.cookie.path;
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
if (urlMatches && selectorMatches && cookieMatches) break;
|
|
1565
|
+
await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
await withTimeout(
|
|
1569
|
+
lifecyclePromise,
|
|
1570
|
+
`CDP Page.${options.waitUntil ?? 'load'} after navigation predicate`,
|
|
1571
|
+
Math.max(1, deadline - Date.now()),
|
|
1572
|
+
);
|
|
1573
|
+
// The lifecycle event precedes some async form/challenge initialization.
|
|
1574
|
+
await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
|
|
1575
|
+
|
|
1576
|
+
const finalUrl = await readCurrentPageUrl(c);
|
|
1577
|
+
const finalSelectorMatches = await selectorExists(options.selector);
|
|
1578
|
+
const allCookies = await snapshotAllCookies(c);
|
|
1579
|
+
const expectedDomain = options.cookie?.domain?.replace(/^\./, '').toLowerCase();
|
|
1580
|
+
const matchedCookie = !options.cookie
|
|
1581
|
+
? true
|
|
1582
|
+
: allCookies.some((cookie) => {
|
|
1583
|
+
if (cookie.name !== options.cookie?.name) return false;
|
|
1584
|
+
if (
|
|
1585
|
+
expectedDomain &&
|
|
1586
|
+
cookie.domain.replace(/^\./, '').toLowerCase() !== expectedDomain
|
|
1587
|
+
) {
|
|
1588
|
+
return false;
|
|
1589
|
+
}
|
|
1590
|
+
return !options.cookie?.path || cookie.path === options.cookie.path;
|
|
1591
|
+
});
|
|
1592
|
+
if (
|
|
1593
|
+
(options.urlIncludes && !finalUrl.includes(options.urlIncludes)) ||
|
|
1594
|
+
!finalSelectorMatches ||
|
|
1595
|
+
!matchedCookie
|
|
1596
|
+
) {
|
|
1597
|
+
const criteria = [
|
|
1598
|
+
options.urlIncludes ? `URL containing ${JSON.stringify(options.urlIncludes)}` : null,
|
|
1599
|
+
options.selector ? `CSS selector ${JSON.stringify(options.selector)}` : null,
|
|
1600
|
+
options.cookie ? `cookie ${options.cookie.name}` : null,
|
|
1601
|
+
]
|
|
1602
|
+
.filter(Boolean)
|
|
1603
|
+
.join(' and ');
|
|
1604
|
+
throw new Error(
|
|
1605
|
+
`browser navigation timed out after ${timeoutMs}ms waiting for ${criteria}; final URL was ${finalUrl}`,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
for (const interaction of options.actions ?? []) {
|
|
1611
|
+
if (interaction.action !== 'click') continue;
|
|
1612
|
+
const target = await withTimeout(
|
|
1613
|
+
c.Runtime.evaluate({
|
|
1614
|
+
expression: buildNavigationClickTargetExpression(interaction.selector),
|
|
1615
|
+
returnByValue: true,
|
|
1616
|
+
}),
|
|
1617
|
+
`CDP Runtime.evaluate(navigation click target ${JSON.stringify(interaction.selector)})`,
|
|
1618
|
+
Math.max(1, deadline - Date.now()),
|
|
1619
|
+
).catch(() => null);
|
|
1620
|
+
const point = target?.result.value as { x?: unknown; y?: unknown } | null | undefined;
|
|
1621
|
+
if (typeof point?.x !== 'number' || typeof point.y !== 'number') {
|
|
1622
|
+
throw new Error(
|
|
1623
|
+
`browser navigation action could not find a visible click target for ${JSON.stringify(interaction.selector)}`,
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
await dispatchTrustedClick(
|
|
1627
|
+
c,
|
|
1628
|
+
point.x,
|
|
1629
|
+
point.y,
|
|
1630
|
+
'navigation',
|
|
1631
|
+
Math.max(1, deadline - Date.now()),
|
|
1632
|
+
);
|
|
1633
|
+
await sleep(150);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
if (options.resultSelector) {
|
|
1637
|
+
let matched = false;
|
|
1638
|
+
while (Date.now() < deadline) {
|
|
1639
|
+
matched = await selectorExists(options.resultSelector).catch(() => false);
|
|
1640
|
+
if (matched) break;
|
|
1641
|
+
await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
|
|
1642
|
+
}
|
|
1643
|
+
if (!matched) {
|
|
1644
|
+
throw new Error(
|
|
1645
|
+
`browser navigation timed out after ${timeoutMs}ms waiting for post-action CSS selector ${JSON.stringify(options.resultSelector)}`,
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
currentPageUrl = await readCurrentPageUrl(c);
|
|
1652
|
+
let html = '';
|
|
1653
|
+
try {
|
|
1654
|
+
const { result } = await withTimeout(
|
|
1655
|
+
c.Runtime.evaluate({
|
|
1656
|
+
expression: 'document.documentElement.outerHTML',
|
|
1657
|
+
returnByValue: true,
|
|
1658
|
+
}),
|
|
1659
|
+
'CDP Runtime.evaluate(document HTML after navigate)',
|
|
1660
|
+
cdpCommandTimeoutMs,
|
|
1661
|
+
);
|
|
1662
|
+
html = String(result.value ?? '');
|
|
1663
|
+
} catch {
|
|
1664
|
+
// A navigation can still be successful when the final document is empty.
|
|
1665
|
+
}
|
|
1666
|
+
const doc = lastDocumentResponse as {
|
|
1667
|
+
url: string;
|
|
1668
|
+
status: number;
|
|
1669
|
+
headers: Record<string, string>;
|
|
1670
|
+
} | null;
|
|
1671
|
+
const status = doc && doc.status >= 200 && doc.status <= 599 ? doc.status : 200;
|
|
1672
|
+
return new Response(html, {
|
|
1673
|
+
status,
|
|
1674
|
+
headers: new Headers({
|
|
1675
|
+
...(doc?.headers ?? {}),
|
|
1676
|
+
'x-imprint-final-url': currentPageUrl,
|
|
1677
|
+
}),
|
|
1678
|
+
});
|
|
1679
|
+
};
|
|
1680
|
+
|
|
842
1681
|
return {
|
|
843
1682
|
fetchImpl,
|
|
1683
|
+
navigate,
|
|
1684
|
+
async inspectPage(): Promise<CdpPageSnapshot> {
|
|
1685
|
+
const c = await ensure();
|
|
1686
|
+
const evaluation = await withTimeout(
|
|
1687
|
+
c.Runtime.evaluate({
|
|
1688
|
+
expression:
|
|
1689
|
+
'({ url: location.href, title: document.title, bodyText: document.body?.innerText ?? "" })',
|
|
1690
|
+
returnByValue: true,
|
|
1691
|
+
}),
|
|
1692
|
+
'CDP Runtime.evaluate(rendered page inspection)',
|
|
1693
|
+
cdpCommandTimeoutMs,
|
|
1694
|
+
);
|
|
1695
|
+
const page = parseCdpPageInspectionResult(evaluation);
|
|
1696
|
+
const cookies = await snapshotAllCookies(c);
|
|
1697
|
+
return {
|
|
1698
|
+
...page,
|
|
1699
|
+
cookies: cookies.map((cookie) => ({
|
|
1700
|
+
name: cookie.name,
|
|
1701
|
+
domain: cookie.domain,
|
|
1702
|
+
path: cookie.path,
|
|
1703
|
+
expires: cookie.expires,
|
|
1704
|
+
httpOnly: cookie.httpOnly,
|
|
1705
|
+
secure: cookie.secure,
|
|
1706
|
+
sameSite: cookie.sameSite,
|
|
1707
|
+
})),
|
|
1708
|
+
};
|
|
1709
|
+
},
|
|
1710
|
+
async snapshotCookies() {
|
|
1711
|
+
return snapshotAllCookies(await ensure());
|
|
1712
|
+
},
|
|
844
1713
|
async ensureBootstrapped() {
|
|
845
1714
|
const c = await ensure();
|
|
846
1715
|
try {
|
|
@@ -859,29 +1728,7 @@ export function createCdpBrowserFetch(opts: CdpBrowserFetchOptions): CdpBrowserF
|
|
|
859
1728
|
},
|
|
860
1729
|
async mintJar(): Promise<MintedJar> {
|
|
861
1730
|
const c = await ensure();
|
|
862
|
-
const cookies
|
|
863
|
-
try {
|
|
864
|
-
const res = await withTimeout(
|
|
865
|
-
c.Network.getCookies({ urls: [baseOrigin] }),
|
|
866
|
-
'CDP Network.getCookies',
|
|
867
|
-
cdpCommandTimeoutMs,
|
|
868
|
-
);
|
|
869
|
-
for (const ck of res.cookies as unknown as Array<Record<string, unknown>>) {
|
|
870
|
-
cookies.push({
|
|
871
|
-
name: ck.name as string,
|
|
872
|
-
value: ck.value as string,
|
|
873
|
-
domain: ck.domain as string,
|
|
874
|
-
path: (ck.path as string) ?? '/',
|
|
875
|
-
expires:
|
|
876
|
-
typeof ck.expires === 'number' && ck.expires > 0 ? (ck.expires as number) : undefined,
|
|
877
|
-
httpOnly: ck.httpOnly as boolean | undefined,
|
|
878
|
-
secure: ck.secure as boolean | undefined,
|
|
879
|
-
sameSite: ck.sameSite as string | undefined,
|
|
880
|
-
});
|
|
881
|
-
}
|
|
882
|
-
} catch {
|
|
883
|
-
// best-effort
|
|
884
|
-
}
|
|
1731
|
+
const cookies = await snapshotAllCookies(c);
|
|
885
1732
|
let html = '';
|
|
886
1733
|
try {
|
|
887
1734
|
const { result } = await withTimeout(
|