@ultimat3/scraping 23.0.0 → 25.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 +23 -5
- package/package.json +6 -6
- package/src/artifacts.ts +2 -4
- package/src/auth.ts +93 -17
- package/src/cdp-arm.ts +65 -1
- package/src/cdp-port.ts +21 -0
- package/src/cdp-snapshot.ts +11 -3
- package/src/cdp-target.ts +82 -58
- package/src/clock.ts +4 -9
- package/src/driver-cdp.ts +12 -3
- package/src/driver.ts +1 -1
- package/src/error-throws-session.ts +38 -1
- package/src/error-throws.ts +6 -16
- package/src/errors.ts +8 -2
- package/src/event-prompt.ts +3 -3
- package/src/expect.ts +21 -4
- package/src/html-target.ts +14 -19
- package/src/http-recorded.ts +3 -1
- package/src/http.ts +47 -10
- package/src/index.ts +22 -17
- package/src/intercept.ts +2 -2
- package/src/launch-args.ts +51 -0
- package/src/page-over-target.ts +14 -6
- package/src/pinned-host.ts +65 -0
- package/src/rate.ts +1 -1
- package/src/recover.ts +4 -3
- package/src/rings.ts +3 -3
- package/src/robots-fetch.ts +105 -21
- package/src/robots.ts +50 -12
- package/src/scrape-run.ts +42 -25
- package/src/scrape.ts +5 -4
- package/src/secrets.ts +61 -9
- package/src/session-state.ts +11 -0
- package/src/target.ts +14 -2
- package/src/url-secrets.ts +68 -7
- package/src/usage.ts +1 -1
- package/src/cdp-fake.ts +0 -420
- package/src/hosts.ts +0 -4
package/README.md
CHANGED
|
@@ -84,6 +84,15 @@ Both legs replay from **one** fixture directory (`fixtureBrowser(dir)`), so a hy
|
|
|
84
84
|
login, session handoff, HTTP bulk fetch — is tested end to end. Both legs apply the same robots
|
|
85
85
|
gate, offline included.
|
|
86
86
|
|
|
87
|
+
The robots gate follows RFC 9309 §2.3.1: a `4xx` robots.txt is **unavailable** (no rules); a `5xx`,
|
|
88
|
+
a network error or the deadline is **unreachable**, which refuses every path on the origin
|
|
89
|
+
(`X_SCRAPE_ROBOTS_DISALLOWED`) and is not cached. A `429` is a `4xx` the RFC calls unavailable; this
|
|
90
|
+
gate treats it as unreachable anyway — our stricter policy, since a server that is rate-limiting
|
|
91
|
+
the crawler has not said "no rules". A hand-written `fetchText` answers the text,
|
|
92
|
+
`undefined` (no file) or `{ unreachable: '<why>' }` — `RobotsAnswer`. An offline scrape declares
|
|
93
|
+
`robots: { ignore: 'offline fixture' }`: under the sealed test network its robots read is
|
|
94
|
+
unreachable.
|
|
95
|
+
|
|
87
96
|
## A session a service is built on
|
|
88
97
|
|
|
89
98
|
One connection, one run at a time, on its own exit, in a browser rented for that run — with a
|
|
@@ -210,7 +219,7 @@ export const answer = (input: { runId: string; index: number; answer: string })
|
|
|
210
219
|
| `onSettled` | the job's own hook (`@ultimat3/jobs`), with what a scrape adds: `completed` hands over the `ScrapeReport`; `dead-lettered` / `dropped` / `refused` carry `code` and the last attempt's `usage` (`undefined` for `refused` — the body never ran). After the row is settled, under the job's tenant, **at most once** across a crash, and a hook that throws changes nothing |
|
|
211
220
|
| `run({ progress, finalAttempt })` | the job's own, passed through |
|
|
212
221
|
| `prompt` | `eventPrompt({ timeout, pollMs?, bus?, keySource?, env? })` polls the event bus in process — never `step.waitForEvent`, which would close the browser. An in-memory bus (`stored: false`) is refused outside development and test (`X_DRIVER_UNAVAILABLE`): the answer comes from another process. Event `promptEventName(runId, index)`; answered by `answerPrompt({ runId, index, answer })`, sealed for that one name. Timeout is `X_SCRAPE_PROMPT_UNANSWERED` |
|
|
213
|
-
| the bus | the **stored** one (`
|
|
222
|
+
| the bus | the **stored** one (`postgresEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it |
|
|
214
223
|
|
|
215
224
|
`PromptRequest` carries the run's `input` and `runId` — what ties a prompt to the app's own row
|
|
216
225
|
for the run — and `index`, `clock`, `signal` and `keepAlive()` beside `label`, `scrape` and `url`:
|
|
@@ -240,7 +249,7 @@ resetScrapeClock(); // in the afterEach
|
|
|
240
249
|
|---|---|---|
|
|
241
250
|
| `systemScrapeClock` | waits | production |
|
|
242
251
|
| `noWaitClock` | one turn of the event loop, whatever was asked; `now()` and deadlines are real | a test whose run waits on something that really happens in the same process — an answer the test publishes |
|
|
243
|
-
| `
|
|
252
|
+
| `testScrapeClock()` | advances virtual time and yields a microtask | a test of a timeout: thirty seconds pass in none |
|
|
244
253
|
|
|
245
254
|
A third-party driver takes part by reading three `SessionInit` fields: dial `proxy` or throw
|
|
246
255
|
`egressUnsupported()`, pass `usage` to `pageOverTarget` and its HTTP transport, and answer
|
|
@@ -261,6 +270,14 @@ the port's. A caller that had to compute its own wrote
|
|
|
261
270
|
`display !== 'none' && visibility !== 'hidden' && opacity !== '0'` a second time, which is the
|
|
262
271
|
copy axiom 1 forbids.
|
|
263
272
|
|
|
273
|
+
**An act scrolls its element into view; a read does not.** The `actionable` wait (every `click`,
|
|
274
|
+
`type`, `fill`, `select`, `focus`, and `waitFor` at its default state) calls
|
|
275
|
+
`ScrapeTarget.query(selector, { reveal: true })`, which scrolls the first match to the viewport's
|
|
276
|
+
centre before measuring — `elementFromPoint` answers nothing off-screen, so an element below the
|
|
277
|
+
fold read as covered. `text()`, `count()`, `values()` and `query()` never move the page. A target
|
|
278
|
+
with no layout engine ignores `QueryOptions` (`cdp-target-reveal.test.ts`,
|
|
279
|
+
`cdp-snapshot.e2e.test.ts`).
|
|
280
|
+
|
|
264
281
|
**A frame verb reaches the FRAME.** `fill`, `type`, `select`, `clear`, `click` and `query` through
|
|
265
282
|
a `ScrapeFrame` handle all address that frame's own document — never the parent's, even when the
|
|
266
283
|
two carry the same ids, which is what an iframe'd SSO login looks like. `driver-parity-frames.test.ts`
|
|
@@ -313,8 +330,9 @@ bound threw away, so a count taken from one is never quietly a floor.
|
|
|
313
330
|
All three are **redacted by value** on the way out, the same pass `page.html()` makes: a login
|
|
314
331
|
endpoint fetched with the password in its query string, or a site that logs the credential it
|
|
315
332
|
rejected, would otherwise put the value in `page.network()`/`page.console()` verbatim and from
|
|
316
|
-
there into the stored failure artifact
|
|
317
|
-
|
|
333
|
+
there into the stored failure artifact — percent-encoded, form-encoded or HTML-escaped as much as
|
|
334
|
+
raw, since a password in a query string is never the raw string. The HTTP leg's
|
|
335
|
+
`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
336
|
a substring of ordinary prose — and pixels never can be, which is why a typed secret TAINTS the
|
|
319
337
|
page and `screenshot()`/`pdf()` are refused outright.
|
|
320
338
|
|
|
@@ -362,7 +380,7 @@ on a machine with no browser — the case CI is.
|
|
|
362
380
|
| `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
|
|
363
381
|
| `driver-fake.ts` / `driver-recorded.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
|
|
364
382
|
| `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 |
|
|
383
|
+
| `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
384
|
| `event-prompt.ts` | `eventPrompt()` and `answerPrompt()`: a prompt answered from another process, over the job event bus, with the browser open |
|
|
367
385
|
| `cdp-resolver.ts` | a browser rented per session: acquired once, released once |
|
|
368
386
|
| `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": "25.0.0",
|
|
4
4
|
"description": "Browser automation as a job: scrape() returns a JobHandle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,16 +24,16 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"engines": {
|
|
27
|
-
"bun": ">=1.4.
|
|
27
|
+
"bun": ">=1.4.2"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
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": "25.0.0",
|
|
35
|
+
"@ultimat3/jobs": "25.0.0",
|
|
36
|
+
"@ultimat3/schema": "25.0.0",
|
|
37
|
+
"@ultimat3/storage": "25.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/artifacts.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Written through `@ultimat3/storage`'s driver, so the same call lands on a local disk in
|
|
6
6
|
// development and on S3 in production, and this package owns no upload path of its own.
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { DEFAULT_CONTENT_TYPE, type StorageDriver } from '@ultimat3/storage';
|
|
9
9
|
|
|
10
10
|
export interface ArtifactRef {
|
|
11
11
|
readonly key: string;
|
|
@@ -31,8 +31,6 @@ export interface ArtifactWriterInit {
|
|
|
31
31
|
|
|
32
32
|
export const DEFAULT_ARTIFACT_PREFIX = 'scrape';
|
|
33
33
|
|
|
34
|
-
export const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
|
35
|
-
|
|
36
34
|
/**
|
|
37
35
|
* A `Map`, not an object literal — the same choice `failures.ts` makes, for the same reason. The
|
|
38
36
|
* extension comes off a caller-supplied filename, and on a download that filename came off the
|
|
@@ -57,7 +55,7 @@ export const contentTypeFor = (name: string): string =>
|
|
|
57
55
|
* that has not configured storage should still be able to run a scrape; losing the artifact is a
|
|
58
56
|
* cost, and refusing the run over it is a bigger one.
|
|
59
57
|
*/
|
|
60
|
-
export function
|
|
58
|
+
export function artifactWriter(init: ArtifactWriterInit): ArtifactWriter {
|
|
61
59
|
const saved: ArtifactRef[] = [];
|
|
62
60
|
const prefix = `${init.prefix ?? DEFAULT_ARTIFACT_PREFIX}/${init.scrape}/${init.runId}`;
|
|
63
61
|
return {
|
package/src/auth.ts
CHANGED
|
@@ -20,10 +20,15 @@
|
|
|
20
20
|
import type { Logger } from '@ultimat3/core';
|
|
21
21
|
import { finiteCount } from '@ultimat3/core';
|
|
22
22
|
import type { ScrapeClock } from './clock';
|
|
23
|
-
import { authFailed, promptUnanswered,
|
|
23
|
+
import { authFailed, promptUnanswered, scrapeSessionExpired } 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
|
/**
|
|
@@ -119,7 +124,7 @@ export interface PromptInit<I = unknown> {
|
|
|
119
124
|
*/
|
|
120
125
|
const KEEPALIVE_SELECTOR = 'ultimate-keepalive';
|
|
121
126
|
|
|
122
|
-
export function
|
|
127
|
+
export function scrapePrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
|
|
123
128
|
let asked = 0;
|
|
124
129
|
return async (label: string): Promise<string> => {
|
|
125
130
|
const { scrape, handler, page } = init;
|
|
@@ -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
|
-
if (auth.login === undefined) throw
|
|
319
|
+
if (auth.login === undefined) throw scrapeSessionExpired(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
|
+
}
|
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-snapshot.ts
CHANGED
|
@@ -8,16 +8,24 @@
|
|
|
8
8
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
9
|
import { parse, t } from '@ultimat3/schema';
|
|
10
10
|
import { browserRecord } from './browser-record';
|
|
11
|
-
import type { ElementSnapshot } from './target';
|
|
11
|
+
import type { ElementSnapshot, QueryOptions } from './target';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Returns JSON TEXT, not an object: a CDP round trip serialises the result anyway, and a string
|
|
15
15
|
* has one deserialiser here — `parse` against the schema below — instead of an implicit one in
|
|
16
16
|
* the library plus a cast.
|
|
17
|
+
*
|
|
18
|
+
* `reveal` scrolls BEFORE the loop, so every box below is measured where the page now is — the
|
|
19
|
+
* centre a click must land on, and the point `elementFromPoint` is asked about (`QueryOptions`).
|
|
17
20
|
*/
|
|
18
|
-
export const snapshotExpression = (
|
|
21
|
+
export const snapshotExpression = (
|
|
22
|
+
selector: string,
|
|
23
|
+
options: QueryOptions = {},
|
|
24
|
+
): string => `(() => {
|
|
19
25
|
const out = [];
|
|
20
|
-
|
|
26
|
+
const matches = document.querySelectorAll(${JSON.stringify(selector)});
|
|
27
|
+
${options.reveal === true ? "matches[0]?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });" : ''}
|
|
28
|
+
for (const el of matches) {
|
|
21
29
|
const style = getComputedStyle(el);
|
|
22
30
|
const rect = el.getBoundingClientRect();
|
|
23
31
|
const attrs = {};
|