@ultimat3/scraping 23.0.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/package.json +5 -5
- package/src/auth.ts +90 -14
- package/src/cdp-arm.ts +65 -1
- package/src/cdp-fake-target.ts +67 -0
- package/src/cdp-fake.ts +9 -1
- package/src/cdp-port.ts +21 -0
- package/src/cdp-target.ts +70 -48
- package/src/driver-cdp.ts +12 -3
- package/src/driver.ts +1 -1
- package/src/error-throws-session.ts +37 -0
- package/src/error-throws.ts +5 -15
- package/src/errors.ts +8 -2
- package/src/expect.ts +21 -4
- package/src/html-target.ts +10 -15
- package/src/http-recorded.ts +3 -1
- package/src/http.ts +47 -10
- package/src/index.ts +3 -1
- package/src/launch-args.ts +51 -0
- package/src/pinned-host.ts +65 -0
- package/src/recover.ts +3 -2
- package/src/robots-fetch.ts +79 -11
- package/src/scrape-run.ts +27 -10
- package/src/scrape.ts +4 -2
- package/src/secrets.ts +60 -8
- package/src/session-state.ts +11 -0
- package/src/url-secrets.ts +68 -7
package/README.md
CHANGED
|
@@ -313,8 +313,9 @@ bound threw away, so a count taken from one is never quietly a floor.
|
|
|
313
313
|
All three are **redacted by value** on the way out, the same pass `page.html()` makes: a login
|
|
314
314
|
endpoint fetched with the password in its query string, or a site that logs the credential it
|
|
315
315
|
rejected, would otherwise put the value in `page.network()`/`page.console()` verbatim and from
|
|
316
|
-
there into the stored failure artifact
|
|
317
|
-
|
|
316
|
+
there into the stored failure artifact — percent-encoded, form-encoded or HTML-escaped as much as
|
|
317
|
+
raw, since a password in a query string is never the raw string. The HTTP leg's
|
|
318
|
+
`X_SCRAPE_HTTP_FAILED` cause is redacted too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
|
|
318
319
|
a substring of ordinary prose — and pixels never can be, which is why a typed secret TAINTS the
|
|
319
320
|
page and `screenshot()`/`pdf()` are refused outright.
|
|
320
321
|
|
|
@@ -362,7 +363,7 @@ on a machine with no browser — the case CI is.
|
|
|
362
363
|
| `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
|
|
363
364
|
| `driver-fake.ts` / `driver-recorded.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
|
|
364
365
|
| `http.ts` / `http-recorded.ts` | the second transport, live and replayed |
|
|
365
|
-
| `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions. A stored session is sealed |
|
|
366
|
+
| `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions. A stored session is sealed. `burnSession(plan, seen)` takes `recordVersion(record)` of the record the run used, so a record another run saved since is not burned |
|
|
366
367
|
| `event-prompt.ts` | `eventPrompt()` and `answerPrompt()`: a prompt answered from another process, over the job event bus, with the browser open |
|
|
367
368
|
| `cdp-resolver.ts` | a browser rented per session: acquired once, released once |
|
|
368
369
|
| `usage.ts` | what one run used, counted where the work happens |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "Browser automation as a job: scrape() returns a JobHandle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/jobs": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/storage": "
|
|
34
|
+
"@ultimat3/core": "24.0.0",
|
|
35
|
+
"@ultimat3/jobs": "24.0.0",
|
|
36
|
+
"@ultimat3/schema": "24.0.0",
|
|
37
|
+
"@ultimat3/storage": "24.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/auth.ts
CHANGED
|
@@ -23,7 +23,12 @@ import type { ScrapeClock } from './clock';
|
|
|
23
23
|
import { authFailed, promptUnanswered, sessionExpired } from './error-throws';
|
|
24
24
|
import type { ScrapePage } from './page';
|
|
25
25
|
import type { ScrapeSecrets } from './secrets';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
recordVersion,
|
|
28
|
+
type ScrapeSessionStore,
|
|
29
|
+
type SessionState,
|
|
30
|
+
sessionDigest,
|
|
31
|
+
} from './session-state';
|
|
27
32
|
|
|
28
33
|
export interface PromptRequest<I = unknown> {
|
|
29
34
|
/**
|
|
@@ -167,10 +172,30 @@ export interface AuthPlanInput<I> {
|
|
|
167
172
|
export async function restorableSession<I>(
|
|
168
173
|
plan: AuthPlanInput<I>,
|
|
169
174
|
): Promise<SessionState | undefined> {
|
|
175
|
+
return (await observeSession(plan)).restorable;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What `observeSession` answers: the session to restore, and the version of what was found. */
|
|
179
|
+
export interface ObservedSession {
|
|
180
|
+
readonly restorable: SessionState | undefined;
|
|
181
|
+
/**
|
|
182
|
+
* `recordVersion` of the record found — restorable or NOT. A `reuse: false` run, or one whose
|
|
183
|
+
* record is past `maxAge`, still saw that record: a refused login must tombstone over it, and a
|
|
184
|
+
* run that compared against "nothing" let the rejected credential through again.
|
|
185
|
+
*/
|
|
186
|
+
readonly seen: string | undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `restorableSession`, plus the version of the record found, for the run's later writes. */
|
|
190
|
+
export async function observeSession<I>(plan: AuthPlanInput<I>): Promise<ObservedSession> {
|
|
170
191
|
const store = plan.auth?.store;
|
|
171
|
-
if (store === undefined) return undefined;
|
|
192
|
+
if (store === undefined) return { restorable: undefined, seen: undefined };
|
|
172
193
|
const found = await store.load(plan.key);
|
|
173
|
-
if (found === undefined) return undefined;
|
|
194
|
+
if (found === undefined) return { restorable: undefined, seen: undefined };
|
|
195
|
+
return { restorable: restorableFrom(plan, found), seen: recordVersion(found) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function restorableFrom<I>(plan: AuthPlanInput<I>, found: SessionState): SessionState | undefined {
|
|
174
199
|
// The tombstone is read BEFORE `reuse` is honoured. `reuse: false` says "do not restore this
|
|
175
200
|
// session"; it does not say "present the rejected credential again", and reading it first meant
|
|
176
201
|
// a `reuse: false` scrape walked a refused password back to the login form on every requeue.
|
|
@@ -194,13 +219,42 @@ export async function restorableSession<I>(
|
|
|
194
219
|
return found;
|
|
195
220
|
}
|
|
196
221
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
222
|
+
/**
|
|
223
|
+
* True when the stored record is still the one this run last saw — `seen` is the `recordVersion`
|
|
224
|
+
* it found or persisted, `undefined` when it found none. The store has no compare-and-set (nor has
|
|
225
|
+
* `StorageDriver` a conditional write to build one from), so a
|
|
226
|
+
* burn or a tombstone is a blind write: two runs on one key, and the one that failed destroyed the
|
|
227
|
+
* session the other had just persisted. Compared before the write, which narrows the race to the
|
|
228
|
+
* gap between this read and that write rather than the whole run. A record moved on is left alone.
|
|
229
|
+
*/
|
|
230
|
+
async function stillSeen<I>(
|
|
231
|
+
plan: AuthPlanInput<I>,
|
|
232
|
+
store: ScrapeSessionStore,
|
|
233
|
+
seen: string | undefined,
|
|
234
|
+
step: 'session.burn' | 'session.refuse',
|
|
235
|
+
): Promise<boolean> {
|
|
236
|
+
const current = await store.load(plan.key);
|
|
237
|
+
if (current === undefined || recordVersion(current) === seen) return true;
|
|
238
|
+
plan.logger.warn('scrape.session.superseded', { step });
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Written down so the next attempt cannot reach the login form with the same credentials — unless
|
|
244
|
+
* another run saved a session after this one read the record: that login SUCCEEDED, so this
|
|
245
|
+
* refusal is older news than the record it would overwrite.
|
|
246
|
+
*/
|
|
247
|
+
export async function markRefused<I>(
|
|
248
|
+
plan: AuthPlanInput<I>,
|
|
249
|
+
seen: string | undefined,
|
|
250
|
+
): Promise<void> {
|
|
199
251
|
const store = plan.auth?.store;
|
|
200
252
|
if (store === undefined) return;
|
|
253
|
+
if (!(await stillSeen(plan, store, seen, 'session.refuse'))) return;
|
|
201
254
|
await store.save({
|
|
202
255
|
key: plan.key,
|
|
203
256
|
savedAt: plan.clock.now().toISOString(),
|
|
257
|
+
version: crypto.randomUUID(),
|
|
204
258
|
refusedAt: plan.clock.now().toISOString(),
|
|
205
259
|
cookies: [],
|
|
206
260
|
headers: {},
|
|
@@ -210,10 +264,19 @@ export async function markRefused<I>(plan: AuthPlanInput<I>): Promise<void> {
|
|
|
210
264
|
});
|
|
211
265
|
}
|
|
212
266
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
267
|
+
/**
|
|
268
|
+
* New identity, from scratch. One call, because a flagged profile is unusable and must go — the
|
|
269
|
+
* profile THIS run used: `seen` is that record's `recordVersion` (`observeSession`'s, or the one
|
|
270
|
+
* `persistSession` answered), and a record another run saved since is not burned.
|
|
271
|
+
*/
|
|
272
|
+
export async function burnSession<I>(
|
|
273
|
+
plan: AuthPlanInput<I>,
|
|
274
|
+
seen: string | undefined,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const store = plan.auth?.store;
|
|
277
|
+
if (store === undefined) return;
|
|
278
|
+
if (!(await stillSeen(plan, store, seen, 'session.burn'))) return;
|
|
279
|
+
await store.burn(plan.key);
|
|
217
280
|
plan.logger.warn('scrape.session.burned', { burned: true });
|
|
218
281
|
}
|
|
219
282
|
|
|
@@ -251,18 +314,31 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
|
|
|
251
314
|
return false;
|
|
252
315
|
}
|
|
253
316
|
args.logger.info('scrape.session.expired', { reused: false });
|
|
254
|
-
await burnSession(args);
|
|
317
|
+
await burnSession(args, recordVersion(args.restored));
|
|
255
318
|
}
|
|
256
319
|
if (auth.login === undefined) throw sessionExpired(args.scrape, args.key);
|
|
257
320
|
await auth.login(context);
|
|
258
321
|
return true;
|
|
259
322
|
}
|
|
260
323
|
|
|
261
|
-
/**
|
|
262
|
-
|
|
324
|
+
/**
|
|
325
|
+
* After a login: keep what it produced. A 2FA code somebody typed is worth exactly this. Answers
|
|
326
|
+
* the `version` it wrote — the record this run now owns, for a later burn to compare against.
|
|
327
|
+
*/
|
|
328
|
+
export async function persistSession<I>(
|
|
329
|
+
plan: AuthPlanInput<I>,
|
|
330
|
+
page: ScrapePage,
|
|
331
|
+
): Promise<string | undefined> {
|
|
263
332
|
const store = plan.auth?.store;
|
|
264
|
-
if (store === undefined) return;
|
|
333
|
+
if (store === undefined) return undefined;
|
|
265
334
|
const snapshot = await page.session();
|
|
266
|
-
|
|
335
|
+
const version = crypto.randomUUID();
|
|
336
|
+
await store.save({
|
|
337
|
+
...snapshot,
|
|
338
|
+
key: plan.key,
|
|
339
|
+
savedAt: plan.clock.now().toISOString(),
|
|
340
|
+
version,
|
|
341
|
+
});
|
|
267
342
|
plan.logger.info('scrape.session.saved', sessionDigest(snapshot));
|
|
343
|
+
return version;
|
|
268
344
|
}
|
package/src/cdp-arm.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// three verbs `press`/`focus`/`accessibility` did not fit under it. The extraction is VERBATIM —
|
|
5
5
|
// the request, console, `pageerror` and `error` handlers are the same code, one file over.
|
|
6
6
|
|
|
7
|
-
import type { CdpPageLike, CdpRequestLike } from './cdp-port';
|
|
7
|
+
import type { CdpBrowserLike, CdpPageLike, CdpRequestLike } from './cdp-port';
|
|
8
8
|
import type { ScrapeClock } from './clock';
|
|
9
9
|
import type { InterceptRules } from './intercept';
|
|
10
10
|
import { interceptVerdict, refusalEntry } from './intercept';
|
|
@@ -174,3 +174,67 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
|
|
|
174
174
|
crashed.value = readStringFrom(payload, 'message') ?? 'renderer crashed';
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
/** A `Fetch.requestPaused` payload, read structurally — the fields the verdict needs, or nothing. */
|
|
179
|
+
interface PausedRequest {
|
|
180
|
+
readonly requestId: string;
|
|
181
|
+
readonly resourceType: string;
|
|
182
|
+
readonly url: string;
|
|
183
|
+
readonly method: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const asPaused = (payload: unknown): PausedRequest | undefined => {
|
|
187
|
+
if (typeof payload !== 'object' || payload === null) return undefined;
|
|
188
|
+
const event = payload as Record<string, unknown>;
|
|
189
|
+
const request = event['request'];
|
|
190
|
+
if (typeof request !== 'object' || request === null) return undefined;
|
|
191
|
+
const { url, method } = request as Record<string, unknown>;
|
|
192
|
+
const { requestId, resourceType } = event;
|
|
193
|
+
if (typeof requestId !== 'string' || typeof url !== 'string') return undefined;
|
|
194
|
+
return {
|
|
195
|
+
requestId,
|
|
196
|
+
url,
|
|
197
|
+
resourceType: typeof resourceType === 'string' ? resourceType : 'Other',
|
|
198
|
+
method: typeof method === 'string' ? method : 'GET',
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `allowHosts` and `block` on EVERY target the browser opens. Page-level interception (`arm`)
|
|
204
|
+
* covers the one page this package created; a popup — `window.open`, a `target=_blank` link — is
|
|
205
|
+
* a new target it never sees, and on Chrome 150 a scraped page's `window.open` to an off-list host
|
|
206
|
+
* reached that host. Browser-level `Fetch` pauses those requests too, and the same
|
|
207
|
+
* `interceptVerdict` decides them. The page's own requests are decided at the page first, so an
|
|
208
|
+
* allowed one is continued here as well and a refused one never arrives: one ring entry each.
|
|
209
|
+
*
|
|
210
|
+
* Enabled BEFORE the first navigation; a browser that refuses `Fetch.enable` rejects here, and
|
|
211
|
+
* `driver-cdp.ts`'s `opened()` closes it — a session whose rule would hold on one tab is refused.
|
|
212
|
+
*/
|
|
213
|
+
export async function armBrowser(
|
|
214
|
+
init: {
|
|
215
|
+
readonly browser: CdpBrowserLike;
|
|
216
|
+
readonly rules: InterceptRules;
|
|
217
|
+
readonly clock: ScrapeClock;
|
|
218
|
+
},
|
|
219
|
+
network: NetworkRing,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const session = await init.browser.target().createCDPSession();
|
|
222
|
+
session.on('Fetch.requestPaused', (payload) => {
|
|
223
|
+
const paused = asPaused(payload);
|
|
224
|
+
if (paused === undefined) return;
|
|
225
|
+
const type = asResourceType(paused.resourceType.toLowerCase());
|
|
226
|
+
const verdict = interceptVerdict(paused.url, type, init.rules);
|
|
227
|
+
const requestId = { requestId: paused.requestId };
|
|
228
|
+
// Caught, never floated, for `arm`'s reason: the target may be gone by the time this answers.
|
|
229
|
+
if (verdict === 'allow') {
|
|
230
|
+
session.send('Fetch.continueRequest', requestId).catch(() => undefined);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const at = init.clock.now().getTime();
|
|
234
|
+
network.push(refusalEntry(paused.url, type, verdict, at, paused.method));
|
|
235
|
+
session
|
|
236
|
+
.send('Fetch.failRequest', { ...requestId, errorReason: 'BlockedByClient' })
|
|
237
|
+
.catch(() => undefined);
|
|
238
|
+
});
|
|
239
|
+
await session.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
|
240
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The browser TARGET of `cdp-fake.ts`'s browser: one raw session that records what the driver
|
|
2
|
+
// enabled and answers `Fetch.requestPaused` the way Chrome does — a request from a target this
|
|
3
|
+
// package never created (a popup) pauses here and nowhere else. Split out because `cdp-fake.ts`
|
|
4
|
+
// stands near its size ceiling.
|
|
5
|
+
|
|
6
|
+
import type { CdpBrowserSessionLike, CdpBrowserTargetLike } from './cdp-port';
|
|
7
|
+
|
|
8
|
+
export interface FakeBrowserTarget {
|
|
9
|
+
readonly target: CdpBrowserTargetLike;
|
|
10
|
+
/** A request from another target — a popup — pausing at the browser-level `Fetch` domain. */
|
|
11
|
+
emitTargetRequest(url: string, resourceType: string, method?: string): void;
|
|
12
|
+
/** What browser-level interception decided, and whether it was enabled at all. */
|
|
13
|
+
readonly browserFetch: {
|
|
14
|
+
readonly enabled: boolean;
|
|
15
|
+
readonly continued: readonly string[];
|
|
16
|
+
readonly failed: readonly string[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `refuseEnable`: the browser answers `Fetch.enable` with a protocol error, as a locked-down provider might. */
|
|
21
|
+
export function fakeBrowserTarget(
|
|
22
|
+
options: { readonly refuseEnable?: boolean } = {},
|
|
23
|
+
): FakeBrowserTarget {
|
|
24
|
+
const listeners: ((payload: unknown) => void)[] = [];
|
|
25
|
+
const byId = new Map<string, string>();
|
|
26
|
+
const continued: string[] = [];
|
|
27
|
+
const failed: string[] = [];
|
|
28
|
+
let enabled = false;
|
|
29
|
+
const session: CdpBrowserSessionLike = {
|
|
30
|
+
send: (method, params) => {
|
|
31
|
+
if (method === 'Fetch.enable') {
|
|
32
|
+
if (options.refuseEnable === true) {
|
|
33
|
+
return Promise.reject(new Error("'Fetch.enable' wasn't found"));
|
|
34
|
+
}
|
|
35
|
+
enabled = true;
|
|
36
|
+
}
|
|
37
|
+
const id = typeof params?.['requestId'] === 'string' ? params['requestId'] : '';
|
|
38
|
+
if (method === 'Fetch.continueRequest') continued.push(byId.get(id) ?? id);
|
|
39
|
+
if (method === 'Fetch.failRequest') failed.push(byId.get(id) ?? id);
|
|
40
|
+
return Promise.resolve({});
|
|
41
|
+
},
|
|
42
|
+
detach: () => Promise.resolve(),
|
|
43
|
+
on: (event, handler) => {
|
|
44
|
+
if (event === 'Fetch.requestPaused') listeners.push(handler);
|
|
45
|
+
return undefined;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
target: { createCDPSession: () => Promise.resolve(session) },
|
|
50
|
+
emitTargetRequest(url, resourceType, method = 'GET'): void {
|
|
51
|
+
// Paused only while enabled, as in Chrome: before `Fetch.enable` the request just leaves.
|
|
52
|
+
if (!enabled) return;
|
|
53
|
+
const requestId = `interception-${String(byId.size + 1)}`;
|
|
54
|
+
byId.set(requestId, url);
|
|
55
|
+
for (const listener of listeners) {
|
|
56
|
+
listener({ requestId, resourceType, request: { url, method } });
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
browserFetch: {
|
|
60
|
+
get enabled(): boolean {
|
|
61
|
+
return enabled;
|
|
62
|
+
},
|
|
63
|
+
continued,
|
|
64
|
+
failed,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/cdp-fake.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Precedent: `packages/storage/src/driver-s3-fixture.ts` ships the same way.
|
|
10
10
|
|
|
11
|
+
import type { FakeBrowserTarget } from './cdp-fake-target';
|
|
12
|
+
import { fakeBrowserTarget } from './cdp-fake-target';
|
|
11
13
|
import type {
|
|
12
14
|
CdpBrowserLike,
|
|
13
15
|
CdpFrameLike,
|
|
@@ -72,7 +74,9 @@ export interface FakeCdpPageInit {
|
|
|
72
74
|
|
|
73
75
|
type Handlers = Map<string, ((payload: unknown) => void)[]>;
|
|
74
76
|
|
|
75
|
-
export interface FakeCdpBrowser
|
|
77
|
+
export interface FakeCdpBrowser
|
|
78
|
+
extends CdpBrowserLike,
|
|
79
|
+
Pick<FakeBrowserTarget, 'emitTargetRequest' | 'browserFetch'> {
|
|
76
80
|
/** Fire a request event, as a real browser would when the page fetches a subresource. */
|
|
77
81
|
emitRequest(url: string, resourceType: string): void;
|
|
78
82
|
/**
|
|
@@ -158,6 +162,7 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
158
162
|
const prepared: string[] = [];
|
|
159
163
|
const sessions = { created: 0, detached: 0 };
|
|
160
164
|
const axBySelector = init.accessibility ?? {};
|
|
165
|
+
const browserTarget = fakeBrowserTarget();
|
|
161
166
|
|
|
162
167
|
/**
|
|
163
168
|
* A raw session answering the four commands `cdp-a11y.ts` sends, in CDP's own wire shapes —
|
|
@@ -364,6 +369,9 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
364
369
|
|
|
365
370
|
return {
|
|
366
371
|
newPage: () => Promise.resolve(page),
|
|
372
|
+
target: () => browserTarget.target,
|
|
373
|
+
emitTargetRequest: (...args) => browserTarget.emitTargetRequest(...args),
|
|
374
|
+
browserFetch: browserTarget.browserFetch,
|
|
367
375
|
cookies: () => Promise.resolve(cookies),
|
|
368
376
|
setCookie: (...next: readonly unknown[]) => {
|
|
369
377
|
cookies = next as readonly ScrapeCookie[];
|
package/src/cdp-port.ts
CHANGED
|
@@ -167,8 +167,29 @@ export interface CdpFrameLike {
|
|
|
167
167
|
select(selector: string, ...values: string[]): Promise<string[]>;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* A raw session on the BROWSER target — `browser.target().createCDPSession()` under puppeteer —
|
|
172
|
+
* with the event half `CdpSessionLike` does not need: `Fetch.requestPaused` arrives as an event.
|
|
173
|
+
* `event` stays a bare `string` for `CdpPageLike.on`'s reason.
|
|
174
|
+
*/
|
|
175
|
+
export interface CdpBrowserSessionLike extends CdpSessionLike {
|
|
176
|
+
on(event: string, handler: (payload: unknown) => void): unknown;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The browser's own target — `browser.target()` under puppeteer. */
|
|
180
|
+
export interface CdpBrowserTargetLike {
|
|
181
|
+
createCDPSession(): Promise<CdpBrowserSessionLike>;
|
|
182
|
+
}
|
|
183
|
+
|
|
170
184
|
export interface CdpBrowserLike {
|
|
171
185
|
newPage(): Promise<CdpPageLike>;
|
|
186
|
+
/**
|
|
187
|
+
* REQUIRED, unlike the page's optional verbs: it is how `allowHosts` reaches every target the
|
|
188
|
+
* browser opens — a popup, a `target=_blank` link — and not only the one page this package
|
|
189
|
+
* created. Page-level interception never sees those, and a rule that holds on one tab of a
|
|
190
|
+
* browser a scraped site controls is advisory. A launcher without it does not compile.
|
|
191
|
+
*/
|
|
192
|
+
target(): CdpBrowserTargetLike;
|
|
172
193
|
/** Present on a browser that owns a cookie jar. Absent ones answer `X_NOT_IMPLEMENTED`. */
|
|
173
194
|
cookies?(): Promise<unknown>;
|
|
174
195
|
setCookie?(...cookies: readonly unknown[]): Promise<void>;
|
package/src/cdp-target.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// `ScrapeTarget` over a real browser, through the structural CDP port. Everything driver-specific
|
|
2
2
|
// in this package lives here and in `driver-cdp.ts`; the vocabulary above it does not change.
|
|
3
3
|
|
|
4
|
-
import { isUltimateError } from '@ultimat3/core';
|
|
4
|
+
import { isUltimateError, NotImplementedError } from '@ultimat3/core';
|
|
5
5
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
6
6
|
import { parse, t } from '@ultimat3/schema';
|
|
7
7
|
import { browserRecord } from './browser-record';
|
|
8
8
|
import { axNodesFor } from './cdp-a11y';
|
|
9
9
|
import type { CdpArmInit } from './cdp-arm';
|
|
10
|
-
import { arm } from './cdp-arm';
|
|
10
|
+
import { arm, armBrowser } from './cdp-arm';
|
|
11
11
|
import type { CdpBrowserLike, CdpFrameLike } from './cdp-port';
|
|
12
12
|
import {
|
|
13
13
|
clearExpression,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
snapshotExpression,
|
|
17
17
|
} from './cdp-snapshot';
|
|
18
18
|
import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
|
|
19
|
-
import { browserUnreachable, pageCrashed
|
|
19
|
+
import { browserUnreachable, pageCrashed } from './error-throws';
|
|
20
20
|
import { parseKeyChord } from './key-chord';
|
|
21
21
|
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
22
22
|
import { createRing } from './rings';
|
|
@@ -54,7 +54,7 @@ const cookieSchema = t.array(
|
|
|
54
54
|
*
|
|
55
55
|
* | Code | Why a retry cannot change it |
|
|
56
56
|
* |---|---|
|
|
57
|
-
* | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's `
|
|
57
|
+
* | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's own `NotImplementedError` refusals can, and the method is still missing on attempt five |
|
|
58
58
|
* | `X_VALIDATION_FAILED` | the browser ANSWERED, and the answer did not match the shape this driver reads it with. The page is what it is; attempt five reads the same DOM |
|
|
59
59
|
*
|
|
60
60
|
* Both were re-labelled `X_SCRAPE_BROWSER_UNREACHABLE`, which `errors.ts` registers `retryable`,
|
|
@@ -99,6 +99,7 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
99
99
|
const pageErrors = createRing<PageError>();
|
|
100
100
|
const crashed: { value: string | undefined } = { value: undefined };
|
|
101
101
|
await arm(init, { network, console: console_, pageErrors, crashed });
|
|
102
|
+
await armBrowser(init, network);
|
|
102
103
|
let pendingStorage: SessionSnapshot | undefined;
|
|
103
104
|
|
|
104
105
|
const originOf = (url: string): string => {
|
|
@@ -183,10 +184,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
183
184
|
// rather than answered from the PARENT document, which is what the spread alone would do.
|
|
184
185
|
accessibility: (_selector, _max): Promise<readonly AxNode[]> =>
|
|
185
186
|
Promise.reject(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
new NotImplementedError({
|
|
188
|
+
cause:
|
|
189
|
+
'accessibility() on a frame of the puppeteer driver is not implemented by this driver',
|
|
190
|
+
fix: 'read the accessibility tree through the page — page.accessibility(selector) — which covers the top-level document only',
|
|
191
|
+
}),
|
|
190
192
|
),
|
|
191
193
|
});
|
|
192
194
|
|
|
@@ -228,10 +230,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
228
230
|
await guard('press', async () => {
|
|
229
231
|
const keyboard = init.page.keyboard;
|
|
230
232
|
if (keyboard === undefined) {
|
|
231
|
-
throw
|
|
232
|
-
'press() on a CDP page with no keyboard',
|
|
233
|
-
'upgrade the launcher to a puppeteer-core that exposes page.keyboard, or dispatch the key from the app under test instead of the browser',
|
|
234
|
-
);
|
|
233
|
+
throw new NotImplementedError({
|
|
234
|
+
cause: 'press() on a CDP page with no keyboard is not implemented by this driver',
|
|
235
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.keyboard, or dispatch the key from the app under test instead of the browser',
|
|
236
|
+
});
|
|
235
237
|
}
|
|
236
238
|
// Down in the order written, the key, then up in REVERSE — the order a hand releases
|
|
237
239
|
// them, and the order the browser's own `press` with modifiers performs. `.call`-free:
|
|
@@ -248,10 +250,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
248
250
|
guard('focus', async () => {
|
|
249
251
|
const focus = init.page.focus;
|
|
250
252
|
if (typeof focus !== 'function') {
|
|
251
|
-
throw
|
|
252
|
-
'focus() on a CDP page with no focus() method',
|
|
253
|
-
'upgrade the launcher to a puppeteer-core that exposes page.focus(), or click the element instead',
|
|
254
|
-
);
|
|
253
|
+
throw new NotImplementedError({
|
|
254
|
+
cause: 'focus() on a CDP page with no focus() method is not implemented by this driver',
|
|
255
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.focus(), or click the element instead',
|
|
256
|
+
});
|
|
255
257
|
}
|
|
256
258
|
// `.call`, for `setColorScheme`'s reason: the member is read off the object.
|
|
257
259
|
await focus.call(init.page, selector);
|
|
@@ -260,10 +262,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
260
262
|
guard('accessibility', async () => {
|
|
261
263
|
const createSession = init.page.createCDPSession;
|
|
262
264
|
if (typeof createSession !== 'function') {
|
|
263
|
-
throw
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
throw new NotImplementedError({
|
|
266
|
+
cause:
|
|
267
|
+
'accessibility() on a CDP page with no createCDPSession() method is not implemented by this driver',
|
|
268
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.createCDPSession(), or assert on the markup with page.query() — which reads attributes, not what the browser computed',
|
|
269
|
+
});
|
|
267
270
|
}
|
|
268
271
|
const session = await createSession.call(init.page);
|
|
269
272
|
try {
|
|
@@ -276,10 +279,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
276
279
|
guard('setOfflineMode', async () => {
|
|
277
280
|
const source = init.page as { setOfflineMode?: (value: boolean) => Promise<void> };
|
|
278
281
|
if (typeof source.setOfflineMode !== 'function') {
|
|
279
|
-
throw
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
282
|
+
throw new NotImplementedError({
|
|
283
|
+
cause:
|
|
284
|
+
'setOfflineMode() on a CDP page with no setOfflineMode() method is not implemented by this driver',
|
|
285
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.setOfflineMode(), or drive the condition from the app under test instead of the browser',
|
|
286
|
+
});
|
|
283
287
|
}
|
|
284
288
|
await source.setOfflineMode(enabled);
|
|
285
289
|
}),
|
|
@@ -291,10 +295,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
291
295
|
// older spelling.
|
|
292
296
|
const emulate = init.page.emulateMediaFeatures;
|
|
293
297
|
if (typeof emulate !== 'function') {
|
|
294
|
-
throw
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
throw new NotImplementedError({
|
|
299
|
+
cause:
|
|
300
|
+
'setColorScheme() on a CDP page with no emulateMediaFeatures() method is not implemented by this driver',
|
|
301
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.emulateMediaFeatures(), or have the page under test set its own theme',
|
|
302
|
+
});
|
|
298
303
|
}
|
|
299
304
|
// `.call`, because the member is read off the object: an unbound method loses `this` and
|
|
300
305
|
// puppeteer's own `Page` needs it.
|
|
@@ -305,10 +310,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
305
310
|
// Read off the object and called through it, for `setColorScheme`'s reason.
|
|
306
311
|
const evaluateOnNewDocument = init.page.evaluateOnNewDocument;
|
|
307
312
|
if (typeof evaluateOnNewDocument !== 'function') {
|
|
308
|
-
throw
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
313
|
+
throw new NotImplementedError({
|
|
314
|
+
cause:
|
|
315
|
+
'prepare() on a CDP page with no evaluateOnNewDocument() method is not implemented by this driver',
|
|
316
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.evaluateOnNewDocument(), or seed the state from the app under test instead of the browser',
|
|
317
|
+
});
|
|
312
318
|
}
|
|
313
319
|
await evaluateOnNewDocument.call(init.page, expression);
|
|
314
320
|
}),
|
|
@@ -333,10 +339,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
333
339
|
guard('cookies', async () => {
|
|
334
340
|
const source = init.browser as { cookies?: () => Promise<unknown> };
|
|
335
341
|
if (typeof source.cookies !== 'function') {
|
|
336
|
-
throw
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
342
|
+
throw new NotImplementedError({
|
|
343
|
+
cause:
|
|
344
|
+
'cookies() on a CDP browser with no cookies() method is not implemented by this driver',
|
|
345
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes browser.cookies(), or read them with page.evaluate("document.cookie")',
|
|
346
|
+
});
|
|
340
347
|
}
|
|
341
348
|
return parse(cookieSchema, await source.cookies());
|
|
342
349
|
}),
|
|
@@ -349,10 +356,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
349
356
|
// `try` — a synchronous `throw` jumps over the first of those entirely.
|
|
350
357
|
download: (_options): Promise<ScrapeDownloadFile> =>
|
|
351
358
|
Promise.reject(
|
|
352
|
-
|
|
353
|
-
'download() on the puppeteer driver',
|
|
354
|
-
'fetch the file inside the page — page.evaluate("fetch(url).then(r => r.text())") — or run this scrape on fixtureBrowser(), whose download() is complete',
|
|
355
|
-
),
|
|
359
|
+
new NotImplementedError({
|
|
360
|
+
cause: 'download() on the puppeteer driver is not implemented by this driver',
|
|
361
|
+
fix: 'fetch the file inside the page — page.evaluate("fetch(url).then(r => r.text())") — or run this scrape on fixtureBrowser(), whose download() is complete',
|
|
362
|
+
}),
|
|
356
363
|
),
|
|
357
364
|
frames: () =>
|
|
358
365
|
guard('frames', () =>
|
|
@@ -378,10 +385,19 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
378
385
|
} catch {
|
|
379
386
|
origin = '';
|
|
380
387
|
}
|
|
381
|
-
|
|
388
|
+
// Refused, never `[]`: the HTTP leg reads its cookies from here on every request and
|
|
389
|
+
// `persistSession` stores what this answers, so an empty jar went out logged-out and was
|
|
390
|
+
// saved as the session — both silently, while every other missing port method refuses.
|
|
391
|
+
if (typeof init.browser.cookies !== 'function') {
|
|
392
|
+
throw new NotImplementedError({
|
|
393
|
+
cause:
|
|
394
|
+
'session() on a CDP browser with no cookies() method is not implemented by this driver',
|
|
395
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes browser.cookies() — the HTTP leg and a persisted session both read the jar through it',
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
const jar = parse(cookieSchema, await init.browser.cookies());
|
|
382
399
|
return {
|
|
383
|
-
cookies:
|
|
384
|
-
typeof source.cookies === 'function' ? parse(cookieSchema, await source.cookies()) : [],
|
|
400
|
+
cookies: jar,
|
|
385
401
|
headers: {},
|
|
386
402
|
// `browserRecord` and not `t.record()`: a page may legitimately hold a key called
|
|
387
403
|
// `constructor`, and the schema refuses that name outright — see `browser-record.ts`.
|
|
@@ -394,11 +410,17 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
394
410
|
guard('restore', async () => {
|
|
395
411
|
// Two halves, because they belong to two different moments: cookies are the browser's and
|
|
396
412
|
// can be put back now, storage is an ORIGIN's and cannot exist until one is loaded.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
413
|
+
if (session.cookies.length > 0) {
|
|
414
|
+
// Refused rather than skipped: a restore that dropped the cookies opened the session
|
|
415
|
+
// logged-out while the run believed it restored one.
|
|
416
|
+
if (typeof init.browser.setCookie !== 'function') {
|
|
417
|
+
throw new NotImplementedError({
|
|
418
|
+
cause:
|
|
419
|
+
'restore() of cookies on a CDP browser with no setCookie() method is not implemented by this driver',
|
|
420
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes browser.setCookie(), or set auth: { reuse: false } on the scrape() definition so no session is restored',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
await init.browser.setCookie(...session.cookies);
|
|
402
424
|
}
|
|
403
425
|
pendingStorage = Object.keys(session.storage).length > 0 ? session : undefined;
|
|
404
426
|
await applyPendingStorage();
|