@ultimat3/scraping 22.15.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 +187 -14
- package/package.json +6 -5
- package/src/artifacts.ts +3 -2
- package/src/auth.ts +156 -22
- 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 +31 -0
- package/src/cdp-resolver.ts +81 -0
- package/src/cdp-target.ts +70 -48
- package/src/clock.ts +34 -0
- package/src/driver-cdp.ts +175 -20
- package/src/driver.ts +26 -2
- package/src/error-throws-session.ts +111 -0
- package/src/error-throws.ts +40 -17
- package/src/errors.ts +18 -2
- package/src/event-prompt.ts +133 -0
- package/src/events.ts +6 -0
- package/src/expect.ts +21 -4
- package/src/html-target.ts +10 -15
- package/src/http-recorded.ts +7 -1
- package/src/http.ts +56 -11
- package/src/index.ts +43 -5
- package/src/launch-args.ts +51 -0
- package/src/offline-session.ts +6 -0
- package/src/page-over-target.ts +4 -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 +109 -21
- package/src/scrape.ts +77 -8
- package/src/secret-scan.ts +17 -0
- package/src/secrets.ts +86 -9
- package/src/session-state.ts +84 -10
- package/src/url-secrets.ts +139 -0
- package/src/usage.ts +101 -0
- /package/src/{driver-fixture.ts → driver-recorded.ts} +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// A prompt answered from ANOTHER process while the browser stays open: `eventPrompt()` is a
|
|
2
|
+
// `PromptHandler` that polls the stored job event bus, and `answerPrompt()` is the one way an
|
|
3
|
+
// answer is published to it.
|
|
4
|
+
//
|
|
5
|
+
// It polls IN PROCESS and never uses `step.waitForEvent`: that suspends the run, the attempt ends,
|
|
6
|
+
// `runScrape`'s `finally` closes the session — and the site is waiting for the code inside that
|
|
7
|
+
// session. So the run keeps its claim (the worker's heartbeat is a timer, untouched by a poll),
|
|
8
|
+
// keeps the browser alive between polls, and gives up on the run's own cancellation.
|
|
9
|
+
//
|
|
10
|
+
// The event name is DERIVED — `scrape-prompt:<runId>:<n>` — and it is not an authorization: the
|
|
11
|
+
// app's answering action is policy-checked like any action and calls `answerPrompt()`.
|
|
12
|
+
|
|
13
|
+
import type { SealKeySource } from '@ultimat3/core';
|
|
14
|
+
import { finiteCount, isLocal, isSealed, openText, seal } from '@ultimat3/core';
|
|
15
|
+
import type { EventBus, JobEvent } from '@ultimat3/jobs';
|
|
16
|
+
import { DriverUnavailableError, eventBus } from '@ultimat3/jobs';
|
|
17
|
+
import type { PromptHandler, PromptRequest } from './auth';
|
|
18
|
+
import { deadline, throwIfAborted } from './clock';
|
|
19
|
+
import { promptAnswerInvalid, promptTimedOut } from './error-throws-session';
|
|
20
|
+
|
|
21
|
+
/** How often the bus is asked. One indexed read and one browser round trip per interval. */
|
|
22
|
+
export const DEFAULT_PROMPT_POLL_MS = 1_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How long a published answer stays matchable. Short on purpose: an answer is a one-time code,
|
|
26
|
+
* and the stored bus's own default keeps an event for a week.
|
|
27
|
+
*/
|
|
28
|
+
export const DEFAULT_PROMPT_ANSWER_TTL_MS = 600_000;
|
|
29
|
+
|
|
30
|
+
/** The one spelling. `index` is `PromptRequest.index`: 1 for the first prompt of an attempt. */
|
|
31
|
+
export const promptEventName = (runId: string, index: number): string =>
|
|
32
|
+
`scrape-prompt:${runId}:${String(index)}`;
|
|
33
|
+
|
|
34
|
+
export interface EventPromptOptions {
|
|
35
|
+
/** Milliseconds one prompt waits for its answer before `X_SCRAPE_PROMPT_UNANSWERED`. */
|
|
36
|
+
readonly timeout: number;
|
|
37
|
+
/** Milliseconds between two looks at the bus. Defaults to `DEFAULT_PROMPT_POLL_MS`. */
|
|
38
|
+
readonly pollMs?: number | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* The bus to read. Defaults to the ambient one, read when the prompt is ASKED — boot installs
|
|
41
|
+
* the stored bus after this module is evaluated. It has to be the stored one in any deployment
|
|
42
|
+
* with more than one process: the answer is published by a web process, never by this worker.
|
|
43
|
+
*/
|
|
44
|
+
readonly bus?: EventBus | undefined;
|
|
45
|
+
/** Where the master key is read from, for opening the answer. `seal()`'s own two fields. */
|
|
46
|
+
readonly keySource?: SealKeySource | undefined;
|
|
47
|
+
/** Where the environment is read from. Defaults to the process's own. */
|
|
48
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `prompt: eventPrompt({ timeout: 300_000 })` on a `scrape()` definition.
|
|
53
|
+
*
|
|
54
|
+
* Only an answer published AFTER the prompt was asked is consumed. The run id is the same on
|
|
55
|
+
* every attempt, so a code left over from attempt 1 would otherwise be typed into the site by
|
|
56
|
+
* attempt 2 — and a stale code is a refused login. "After" is decided on ONE clock, the bus's
|
|
57
|
+
* (`bus.now()`); the timeout and the poll stay on the run's own.
|
|
58
|
+
*/
|
|
59
|
+
export function eventPrompt(options: EventPromptOptions): PromptHandler {
|
|
60
|
+
// Floors of 1, both: a zero timeout is a prompt that can never be answered and a zero interval
|
|
61
|
+
// is the poll loop spinning on the worker's only thread.
|
|
62
|
+
const timeoutMs = finiteCount('eventPrompt', 'timeout', options.timeout, 1);
|
|
63
|
+
const pollMs = finiteCount('eventPrompt', 'pollMs', options.pollMs ?? DEFAULT_PROMPT_POLL_MS, 1);
|
|
64
|
+
return async (request: PromptRequest): Promise<string> => {
|
|
65
|
+
const bus = options.bus ?? eventBus();
|
|
66
|
+
// Refused when the prompt is ASKED, before the wait: the answer is published by a web process
|
|
67
|
+
// and read by this worker, so an in-memory bus can only ever time out — minutes later, as
|
|
68
|
+
// `X_SCRAPE_PROMPT_UNANSWERED`, blaming the human who did answer. Development and test run
|
|
69
|
+
// every role in one process, where the in-memory bus is the right one. `=== false`, never
|
|
70
|
+
// `!bus.stored`: only a bus that SAYS it is not stored is refused.
|
|
71
|
+
if (bus.stored === false && !isLocal({ env: options.env, fallback: 'production' })) {
|
|
72
|
+
throw new DriverUnavailableError({
|
|
73
|
+
driver: 'event bus',
|
|
74
|
+
cause:
|
|
75
|
+
'eventPrompt() was asked on the in-memory event bus — the answer is published by another process and can never reach this one',
|
|
76
|
+
fix: 'call setEventBus(createPgEventBus({ executor })) at boot — x dev and the served roles do — or pass bus: createPgEventBus({ executor }) to eventPrompt()',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const event = promptEventName(request.runId, request.index);
|
|
80
|
+
// The BUS's clock, read once: an answer's `publishedAt` is stamped by it — the database, for
|
|
81
|
+
// the stored bus — and `request.clock` is this worker's. Comparing the two let a few seconds
|
|
82
|
+
// of skew between pods decide that an answer given after the ask had come before it.
|
|
83
|
+
const askedAt = await bus.now();
|
|
84
|
+
const budget = deadline(request.clock, timeoutMs, 'eventPrompt');
|
|
85
|
+
for (;;) {
|
|
86
|
+
if (request.signal !== undefined) throwIfAborted(request.signal);
|
|
87
|
+
const found = await bus.find(event, undefined, askedAt);
|
|
88
|
+
if (found !== undefined) {
|
|
89
|
+
if (!isSealed(found.payload)) {
|
|
90
|
+
throw promptAnswerInvalid({ scrape: request.scrape, label: request.label, event });
|
|
91
|
+
}
|
|
92
|
+
// The purpose IS the event name, so an answer sealed for another run or another prompt of
|
|
93
|
+
// this one fails its tag instead of being typed.
|
|
94
|
+
return await openText(found.payload, { purpose: event, ...options.keySource });
|
|
95
|
+
}
|
|
96
|
+
if (budget.expired()) {
|
|
97
|
+
throw promptTimedOut({
|
|
98
|
+
scrape: request.scrape,
|
|
99
|
+
label: request.label,
|
|
100
|
+
event,
|
|
101
|
+
waitedMs: timeoutMs,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
await request.keepAlive();
|
|
105
|
+
await request.clock.sleep(Math.min(pollMs, budget.remainingMs()), request.signal);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AnswerPromptInput {
|
|
111
|
+
readonly runId: string;
|
|
112
|
+
/** Which prompt of the run: `PromptRequest.index`. */
|
|
113
|
+
readonly index: number;
|
|
114
|
+
readonly answer: string;
|
|
115
|
+
/** Milliseconds the answer stays matchable. Defaults to `DEFAULT_PROMPT_ANSWER_TTL_MS`. */
|
|
116
|
+
readonly ttl?: number | undefined;
|
|
117
|
+
readonly bus?: EventBus | undefined;
|
|
118
|
+
readonly keySource?: SealKeySource | undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Publish one prompt's answer. Called from the app's own action, after its policy passed.
|
|
123
|
+
*
|
|
124
|
+
* The answer is SEALED before it is published: the stored bus is a table, and an OTP or a
|
|
125
|
+
* security answer written there in the clear is a credential in a queue row.
|
|
126
|
+
*/
|
|
127
|
+
export async function answerPrompt(input: AnswerPromptInput): Promise<JobEvent> {
|
|
128
|
+
const event = promptEventName(input.runId, input.index);
|
|
129
|
+
const sealed = await seal(input.answer, { purpose: event, ...input.keySource });
|
|
130
|
+
return (input.bus ?? eventBus()).publish(event, sealed, {
|
|
131
|
+
ttl: finiteCount('answerPrompt', 'ttl', input.ttl ?? DEFAULT_PROMPT_ANSWER_TTL_MS, 1),
|
|
132
|
+
});
|
|
133
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface ScrapeEventFields {
|
|
|
33
33
|
readonly refused?: number | undefined;
|
|
34
34
|
readonly reused?: boolean | undefined;
|
|
35
35
|
readonly burned?: boolean | undefined;
|
|
36
|
+
/** `ScrapeUsage`'s counts — numbers only, so a failed run still says what it spent. */
|
|
37
|
+
readonly browserMs?: number | undefined;
|
|
38
|
+
readonly navigations?: number | undefined;
|
|
39
|
+
readonly httpRequests?: number | undefined;
|
|
40
|
+
readonly bytesIn?: number | undefined;
|
|
41
|
+
readonly promptsAnswered?: number | undefined;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
/** One child logger per run, so every line downstream carries the run's identity unasked. */
|
package/src/expect.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// where the data went. `expect` turns that into a red run: a yield under `minRows`, or under
|
|
4
4
|
// `maxDrop` of what this scrape normally returns, throws.
|
|
5
5
|
|
|
6
|
-
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
6
|
+
import { assert, finiteCount, finiteOption } from '@ultimat3/core';
|
|
7
7
|
import { yieldCollapsed } from './error-throws';
|
|
8
8
|
import type { ScrapeError } from './errors';
|
|
9
9
|
|
|
@@ -57,6 +57,23 @@ export interface YieldCheck {
|
|
|
57
57
|
readonly history: readonly number[];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* `maxDrop`, screened: finite, then a FRACTION in `[0, 1)`. `1` and anything above it — `50`, the
|
|
62
|
+
* percent habit — make `baseline * (1 - maxDrop)` zero or negative, so no yield is ever under it
|
|
63
|
+
* and the alarm is silent forever; a negative one fires on a run that matched its baseline. Asked
|
|
64
|
+
* by the rule and by `scrape()`, where the number is written.
|
|
65
|
+
*/
|
|
66
|
+
export function maxDropFraction(scrape: string, value: number): number {
|
|
67
|
+
const fraction = finiteOption('the scrape expect', 'maxDrop', value);
|
|
68
|
+
assert(
|
|
69
|
+
fraction >= 0 && fraction < 1,
|
|
70
|
+
`scrape "${scrape}" declares expect.maxDrop: ${String(fraction)}, and maxDrop is a fraction of the trailing median from 0 up to but not including 1 — at 1 or above no yield is ever under the line and the alarm can never fire; write it as a fraction, expect: { maxDrop: 0.5 } allows half`,
|
|
71
|
+
// Lists every declaration outside [0, 1) — static, so no declared value reaches a shell.
|
|
72
|
+
"grep -rnE --include='*.ts' 'maxDrop: *(-|[1-9]|1\\.)' .",
|
|
73
|
+
);
|
|
74
|
+
return fraction;
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* The whole rule, pure: the error this yield earns, or `undefined`. Pure so a test can hand it
|
|
62
79
|
* fifty histories without a queue, a browser or a clock.
|
|
@@ -69,8 +86,8 @@ export function yieldProblem(check: YieldCheck): ScrapeError | undefined {
|
|
|
69
86
|
// instead, and an alarm that always fires is an alarm somebody turns off.
|
|
70
87
|
//
|
|
71
88
|
// `minRows: 0` is legal and stays legal: this file asks an author whose answer is legitimately
|
|
72
|
-
// sometimes zero to declare exactly that. `maxDrop` is a FRACTION of a median, so
|
|
73
|
-
//
|
|
89
|
+
// sometimes zero to declare exactly that. `maxDrop` is a FRACTION of a median, so not
|
|
90
|
+
// `finiteCount` — `0.5` is the documented value (`maxDropFraction`).
|
|
74
91
|
const minRows =
|
|
75
92
|
check.expect.minRows === undefined
|
|
76
93
|
? undefined
|
|
@@ -78,7 +95,7 @@ export function yieldProblem(check: YieldCheck): ScrapeError | undefined {
|
|
|
78
95
|
const maxDrop =
|
|
79
96
|
check.expect.maxDrop === undefined
|
|
80
97
|
? undefined
|
|
81
|
-
:
|
|
98
|
+
: maxDropFraction(check.scrape, check.expect.maxDrop);
|
|
82
99
|
if (minRows !== undefined && check.rows < minRows) {
|
|
83
100
|
return yieldCollapsed({ scrape: check.scrape, rows: check.rows, reason: 'min-rows', minRows });
|
|
84
101
|
}
|
package/src/html-target.ts
CHANGED
|
@@ -5,16 +5,11 @@
|
|
|
5
5
|
// through to the network would make a green offline suite that is secretly hitting production —
|
|
6
6
|
// the exact failure an offline driver exists to prevent.
|
|
7
7
|
|
|
8
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
8
9
|
import type { CaptureClip } from './capture-clip';
|
|
9
10
|
import type { ScrapeClock } from './clock';
|
|
10
11
|
import type { ColorScheme } from './color-scheme';
|
|
11
|
-
import {
|
|
12
|
-
browserUnreachable,
|
|
13
|
-
downloadTimeout,
|
|
14
|
-
fixtureMissing,
|
|
15
|
-
fixtureStale,
|
|
16
|
-
scrapeNotImplemented,
|
|
17
|
-
} from './error-throws';
|
|
12
|
+
import { browserUnreachable, downloadTimeout, fixtureMissing, fixtureStale } from './error-throws';
|
|
18
13
|
import { queryHtml } from './html-query';
|
|
19
14
|
import { markupRequests } from './html-requests';
|
|
20
15
|
import type { InterceptRules } from './intercept';
|
|
@@ -358,10 +353,10 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
|
|
|
358
353
|
* attribute would pass every test against the element a screen reader cannot reach.
|
|
359
354
|
*/
|
|
360
355
|
async accessibility(_selector: string, _max: number): Promise<readonly AxNode[]> {
|
|
361
|
-
throw
|
|
362
|
-
`accessibility() on the ${init.driver} driver`,
|
|
363
|
-
'run this assertion on localBrowser()/remoteBrowser(), whose accessibility() reads the tree the browser computed — an offline driver parses markup and computes no role',
|
|
364
|
-
);
|
|
356
|
+
throw new NotImplementedError({
|
|
357
|
+
cause: `accessibility() on the ${init.driver} driver is not implemented by this driver`,
|
|
358
|
+
fix: 'run this assertion on localBrowser()/remoteBrowser(), whose accessibility() reads the tree the browser computed — an offline driver parses markup and computes no role',
|
|
359
|
+
});
|
|
365
360
|
},
|
|
366
361
|
evaluate(expression: string): Promise<unknown> {
|
|
367
362
|
live();
|
|
@@ -381,10 +376,10 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
|
|
|
381
376
|
// `async`, so the refusal REJECTS: the method is typed `Promise<void>` and a synchronous
|
|
382
377
|
// `throw` from one jumps straight over `page.offline(true).catch(…)` at every caller.
|
|
383
378
|
async setOfflineMode(_enabled: boolean): Promise<void> {
|
|
384
|
-
throw
|
|
385
|
-
`setOfflineMode() on the ${init.driver} driver`,
|
|
386
|
-
'run this assertion on localBrowser()/remoteBrowser(), whose setOfflineMode() reaches a real browser — an offline driver has no network to cut, so it cannot prove an offline behaviour',
|
|
387
|
-
);
|
|
379
|
+
throw new NotImplementedError({
|
|
380
|
+
cause: `setOfflineMode() on the ${init.driver} driver is not implemented by this driver`,
|
|
381
|
+
fix: 'run this assertion on localBrowser()/remoteBrowser(), whose setOfflineMode() reaches a real browser — an offline driver has no network to cut, so it cannot prove an offline behaviour',
|
|
382
|
+
});
|
|
388
383
|
},
|
|
389
384
|
/**
|
|
390
385
|
* ACCEPTED and recorded, where `setOfflineMode` refuses — and the line between them is which
|
package/src/http-recorded.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { HttpRecording } from './recording';
|
|
|
15
15
|
import type { NetworkRing } from './rings';
|
|
16
16
|
import type { RobotsGate } from './robots';
|
|
17
17
|
import type { ScrapeSecrets } from './secrets';
|
|
18
|
+
import type { UsageMeter } from './usage';
|
|
18
19
|
|
|
19
20
|
export type HttpRecordingLookup = (
|
|
20
21
|
method: string,
|
|
@@ -36,6 +37,8 @@ export interface RecordedHttpInit {
|
|
|
36
37
|
readonly maxAgeMs?: number | undefined;
|
|
37
38
|
/** The SAME bag the live leg holds, so a recorded 4xx body redacts the way a real one does. */
|
|
38
39
|
readonly secrets?: ScrapeSecrets | undefined;
|
|
40
|
+
/** The SAME meter the live leg counts into, so a fixture run reports the usage a real one would. */
|
|
41
|
+
readonly usage?: UsageMeter | undefined;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/** `GET https://api.example.com/v1/orders?page=2` -> `http-get-api-example-com-v1-orders-page-2`. */
|
|
@@ -61,7 +64,9 @@ export function recordedHttp(init: RecordedHttpInit): ScrapeHttp {
|
|
|
61
64
|
if (found === undefined) throw fixtureMissing(`${method} ${url}`, init.source);
|
|
62
65
|
if (init.maxAgeMs !== undefined && found.recordedAt !== undefined) {
|
|
63
66
|
const age = init.clock.now().getTime() - new Date(found.recordedAt).getTime();
|
|
64
|
-
|
|
67
|
+
// `!(age <= limit)`, never `age > limit`: an unparseable `recordedAt` makes `age` NaN, and
|
|
68
|
+
// `NaN > limit` is false — a recording of unknown age replayed as fresh. `auth.ts` the same.
|
|
69
|
+
if (!(age <= init.maxAgeMs)) throw fixtureStale(`${method} ${url}`, age, init.maxAgeMs);
|
|
65
70
|
}
|
|
66
71
|
init.network.push({
|
|
67
72
|
method,
|
|
@@ -70,6 +75,7 @@ export function recordedHttp(init: RecordedHttpInit): ScrapeHttp {
|
|
|
70
75
|
resourceType: 'fetch',
|
|
71
76
|
at: init.clock.now().getTime(),
|
|
72
77
|
});
|
|
78
|
+
init.usage?.httpRequest(new TextEncoder().encode(found.body).length);
|
|
73
79
|
return responseOver(
|
|
74
80
|
url,
|
|
75
81
|
found.status,
|
package/src/http.ts
CHANGED
|
@@ -20,11 +20,14 @@ import type { RedirectHop } from './http-redirect';
|
|
|
20
20
|
import { MAX_REDIRECT_HOPS, redirectHop } from './http-redirect';
|
|
21
21
|
import type { InterceptRules } from './intercept';
|
|
22
22
|
import { interceptVerdict } from './intercept';
|
|
23
|
+
import type { DialTarget, HostResolve } from './pinned-host';
|
|
24
|
+
import { dialTarget, resolveHost } from './pinned-host';
|
|
23
25
|
import type { NetworkRing } from './rings';
|
|
24
26
|
import type { RobotsGate } from './robots';
|
|
25
27
|
import type { ScrapeSecrets } from './secrets';
|
|
26
28
|
import { redactSecrets } from './secrets';
|
|
27
29
|
import type { SessionSnapshot } from './session-state';
|
|
30
|
+
import type { UsageMeter } from './usage';
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Just the call. `typeof fetch` also carries `preconnect`, which no test double and no app wrapper
|
|
@@ -42,6 +45,11 @@ export type ScrapeFetch = (input: string, init: ScrapeFetchInit) => Promise<Resp
|
|
|
42
45
|
export interface ScrapeFetchInit extends RequestInit {
|
|
43
46
|
/** The session's exit. A different exit IP mid-session is a different client to an anti-bot. */
|
|
44
47
|
readonly proxy?: string | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* The NAME a pinned connection proves (`pinned-host.ts`): the url names the approved address,
|
|
50
|
+
* and Bun's `fetch` verifies the certificate against this instead.
|
|
51
|
+
*/
|
|
52
|
+
readonly tls?: { readonly serverName?: string | undefined } | undefined;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
export interface HttpRequestInit {
|
|
@@ -110,7 +118,11 @@ export interface HttpTransportInit {
|
|
|
110
118
|
* until 2026-08-24, so nothing on this leg COULD redact.
|
|
111
119
|
*/
|
|
112
120
|
readonly secrets?: ScrapeSecrets | undefined;
|
|
121
|
+
/** The run's meter: one `httpRequest` per hop on the wire, with the body bytes read for it. */
|
|
122
|
+
readonly usage?: UsageMeter | undefined;
|
|
113
123
|
readonly fetch?: ScrapeFetch | undefined;
|
|
124
|
+
/** The resolver a wildcard-admitted name is checked and pinned with. Defaults to `Bun.dns`. */
|
|
125
|
+
readonly resolve?: HostResolve | undefined;
|
|
114
126
|
}
|
|
115
127
|
|
|
116
128
|
/**
|
|
@@ -193,6 +205,22 @@ const withoutCredentials = (headers: Readonly<Record<string, string>>): Record<s
|
|
|
193
205
|
Object.entries(headers).filter(([name]) => !CROSS_ORIGIN_STRIPPED.has(name.toLowerCase())),
|
|
194
206
|
);
|
|
195
207
|
|
|
208
|
+
/**
|
|
209
|
+
* The request's headers from every source, LATER sources winning, keyed by the lower-cased name.
|
|
210
|
+
* HTTP names are case-insensitive and `Headers` APPENDS two spellings of one name: a caller's
|
|
211
|
+
* `User-Agent: Mine` beside the session's `user-agent` went out as `BrowserUA, Mine`, and a
|
|
212
|
+
* declared `Cookie` was joined to the jar's instead of replacing it.
|
|
213
|
+
*/
|
|
214
|
+
const composeHeaders = (
|
|
215
|
+
...sources: readonly Readonly<Record<string, string>>[]
|
|
216
|
+
): Record<string, string> => {
|
|
217
|
+
const out = new Map<string, string>();
|
|
218
|
+
for (const source of sources) {
|
|
219
|
+
for (const [name, value] of Object.entries(source)) out.set(name.toLowerCase(), value);
|
|
220
|
+
}
|
|
221
|
+
return Object.fromEntries(out);
|
|
222
|
+
};
|
|
223
|
+
|
|
196
224
|
/** Unparseable is not same-origin: a URL this package cannot read is one it cannot vouch for. */
|
|
197
225
|
const sameOrigin = (left: string, right: string): boolean => {
|
|
198
226
|
try {
|
|
@@ -213,8 +241,11 @@ const readResponse = async (
|
|
|
213
241
|
response: Response,
|
|
214
242
|
maxBytes: number,
|
|
215
243
|
secrets: ScrapeSecrets | undefined,
|
|
244
|
+
usage: UsageMeter | undefined,
|
|
216
245
|
): Promise<ScrapeResponse> => {
|
|
217
246
|
const capped = await readWithinLimit(response.body, maxBytes);
|
|
247
|
+
// Counted on BOTH branches: the bytes a refused body cost were read before the refusal.
|
|
248
|
+
usage?.httpRequest('over' in capped ? capped.over : capped.bytes.length);
|
|
218
249
|
if ('over' in capped) throw bodyTooLarge(url, capped.over, maxBytes);
|
|
219
250
|
const body = new TextDecoder().decode(capped.bytes);
|
|
220
251
|
return responseOver(
|
|
@@ -245,12 +276,21 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
|
|
|
245
276
|
* The three gates, in one place, so the initial URL and hop seven are screened by the same code
|
|
246
277
|
* in the same order. A second copy for redirects is how the two drift.
|
|
247
278
|
*/
|
|
248
|
-
const
|
|
279
|
+
const resolve = init.resolve ?? resolveHost;
|
|
280
|
+
const screen = async (target: string): Promise<DialTarget> => {
|
|
249
281
|
if (interceptVerdict(target, 'fetch', init.rules) !== 'allow') {
|
|
250
282
|
throw hostBlocked(target, init.rules.allowHosts);
|
|
251
283
|
}
|
|
284
|
+
// Through a proxy the PROXY resolves the name, from its own network, so an answer from this
|
|
285
|
+
// box's resolver says nothing about where the request lands — and pinning would hand the
|
|
286
|
+
// proxy an address where it expects the name.
|
|
287
|
+
const dial =
|
|
288
|
+
init.proxy === undefined
|
|
289
|
+
? await dialTarget(target, init.rules.allowHosts, resolve)
|
|
290
|
+
: { url: target };
|
|
252
291
|
await init.robots?.assertAllowed(target);
|
|
253
292
|
await init.pace?.(init.signal);
|
|
293
|
+
return dial;
|
|
254
294
|
};
|
|
255
295
|
return {
|
|
256
296
|
async request(url: string, request: HttpRequestInit = {}): Promise<ScrapeResponse> {
|
|
@@ -278,7 +318,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
|
|
|
278
318
|
1,
|
|
279
319
|
);
|
|
280
320
|
init.onActivity?.();
|
|
281
|
-
await screen(url);
|
|
321
|
+
let dial = await screen(url);
|
|
282
322
|
const session = await init.session();
|
|
283
323
|
// `AbortSignal.timeout` and NOT `clock.sleep`: this is a deadline handed to the platform's
|
|
284
324
|
// own fetch, not a wait this package performs — and under a test clock a slept deadline
|
|
@@ -313,14 +353,17 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
|
|
|
313
353
|
const declared = credentialsInScope
|
|
314
354
|
? request.headers
|
|
315
355
|
: withoutCredentials(request.headers ?? {});
|
|
316
|
-
const response = await call(
|
|
356
|
+
const response = await call(dial.url, {
|
|
317
357
|
method: hop.method,
|
|
318
|
-
headers:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
358
|
+
headers: composeHeaders(
|
|
359
|
+
carried,
|
|
360
|
+
session.userAgent === '' ? {} : { 'user-agent': session.userAgent },
|
|
361
|
+
cookies === undefined ? {} : { cookie: cookies },
|
|
362
|
+
declared ?? {},
|
|
363
|
+
// Last: a pinned request is reached AS the name that was screened, whatever was declared.
|
|
364
|
+
dial.host === undefined ? {} : { host: dial.host },
|
|
365
|
+
),
|
|
366
|
+
...(dial.serverName === undefined ? {} : { tls: { serverName: dial.serverName } }),
|
|
324
367
|
...(hop.body === undefined ? {} : { body: hop.body }),
|
|
325
368
|
signal: AbortSignal.any(signals),
|
|
326
369
|
// The whole point: the platform's own `follow` is what made the four lines above
|
|
@@ -345,14 +388,16 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
|
|
|
345
388
|
hop.body,
|
|
346
389
|
);
|
|
347
390
|
if (next === undefined)
|
|
348
|
-
return await readResponse(hop.url, response, maxBytes, init.secrets);
|
|
391
|
+
return await readResponse(hop.url, response, maxBytes, init.secrets, init.usage);
|
|
392
|
+
// A followed hop was a request on the wire too; its body is discarded unread.
|
|
393
|
+
init.usage?.httpRequest(0);
|
|
349
394
|
// Discarded BEFORE the refusal, not after it: a throw over an unread stream holds that
|
|
350
395
|
// hop's socket until the collector reaches it, and the refusal path is exactly the one a
|
|
351
396
|
// hostile chain drives ten times per request.
|
|
352
397
|
await discardHopBody(response);
|
|
353
398
|
if (followed >= MAX_REDIRECT_HOPS) throw redirectLoop(url, next.url, followed + 1);
|
|
354
399
|
init.onActivity?.();
|
|
355
|
-
await screen(next.url);
|
|
400
|
+
dial = await screen(next.url);
|
|
356
401
|
if (!sameOrigin(hop.url, next.url)) credentialsInScope = false;
|
|
357
402
|
hop = next;
|
|
358
403
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
export type {
|
|
13
13
|
AuthContext,
|
|
14
14
|
PromptHandler,
|
|
15
|
+
PromptInit,
|
|
15
16
|
PromptRequest,
|
|
16
17
|
ScrapeAuth,
|
|
17
18
|
} from './auth';
|
|
@@ -21,6 +22,8 @@ export type { CaptureClip, CaptureFraming } from './capture-clip';
|
|
|
21
22
|
export { assertCaptureFraming } from './capture-clip';
|
|
22
23
|
export type {
|
|
23
24
|
CdpBrowserLike,
|
|
25
|
+
CdpBrowserSessionLike,
|
|
26
|
+
CdpBrowserTargetLike,
|
|
24
27
|
CdpFrameLike,
|
|
25
28
|
CdpKeyboardLike,
|
|
26
29
|
CdpLauncherLike,
|
|
@@ -29,11 +32,21 @@ export type {
|
|
|
29
32
|
CdpScreenshotOptions,
|
|
30
33
|
CdpSessionLike,
|
|
31
34
|
} from './cdp-port';
|
|
35
|
+
export type { CdpResolution, CdpResolver, CdpResolverRequest } from './cdp-resolver';
|
|
32
36
|
export { snapshotExpression } from './cdp-snapshot';
|
|
33
37
|
export type { CdpTargetInit } from './cdp-target';
|
|
34
38
|
export { CDP_DRIVER, cdpTarget } from './cdp-target';
|
|
35
39
|
export type { Deadline, ScrapeClock, TestScrapeClock } from './clock';
|
|
36
|
-
export {
|
|
40
|
+
export {
|
|
41
|
+
deadline,
|
|
42
|
+
noWaitClock,
|
|
43
|
+
resetScrapeClock,
|
|
44
|
+
scrapeClock,
|
|
45
|
+
setScrapeClock,
|
|
46
|
+
systemScrapeClock,
|
|
47
|
+
testClock,
|
|
48
|
+
throwIfAborted,
|
|
49
|
+
} from './clock';
|
|
37
50
|
export type { ColorScheme } from './color-scheme';
|
|
38
51
|
export { COLOR_SCHEMES, isColorScheme } from './color-scheme';
|
|
39
52
|
export { cookieHeaderFor } from './cookie-scope';
|
|
@@ -43,14 +56,15 @@ export type { BrowserOptions, LocalBrowserOptions, RemoteBrowserOptions } from '
|
|
|
43
56
|
export { localBrowser, remoteBrowser } from './driver-cdp';
|
|
44
57
|
export type { FakeBrowserOptions, FakePageOptions, FakePages } from './driver-fake';
|
|
45
58
|
export { FAKE_DRIVER, FAKE_PAGE_URL, fakeBrowser, fakePage, recordingsOf } from './driver-fake';
|
|
46
|
-
export type { FixtureBrowserOptions } from './driver-
|
|
47
|
-
export { FIXTURE_DRIVER, fixtureBrowser } from './driver-
|
|
59
|
+
export type { FixtureBrowserOptions } from './driver-recorded';
|
|
60
|
+
export { FIXTURE_DRIVER, fixtureBrowser } from './driver-recorded';
|
|
48
61
|
export {
|
|
49
62
|
authFailed,
|
|
50
63
|
blocked,
|
|
51
64
|
bodyTooLarge,
|
|
52
65
|
browserUnreachable,
|
|
53
66
|
cdpAttachFailed,
|
|
67
|
+
cdpResolveFailed,
|
|
54
68
|
downloadTimeout,
|
|
55
69
|
driverUnknown,
|
|
56
70
|
fixtureMissing,
|
|
@@ -67,7 +81,6 @@ export {
|
|
|
67
81
|
redirectLoop,
|
|
68
82
|
remoteRequired,
|
|
69
83
|
robotsDisallowed,
|
|
70
|
-
scrapeNotImplemented,
|
|
71
84
|
scrapeTimeout,
|
|
72
85
|
secretExposed,
|
|
73
86
|
selectorMissing,
|
|
@@ -75,6 +88,12 @@ export {
|
|
|
75
88
|
wedged,
|
|
76
89
|
yieldCollapsed,
|
|
77
90
|
} from './error-throws';
|
|
91
|
+
export {
|
|
92
|
+
egressInPayload,
|
|
93
|
+
egressUnsupported,
|
|
94
|
+
promptAnswerInvalid,
|
|
95
|
+
promptTimedOut,
|
|
96
|
+
} from './error-throws-session';
|
|
78
97
|
export type { ScrapeErrorCode, ScrapeErrorInit, ScrapeOwnedErrorCode } from './errors';
|
|
79
98
|
export {
|
|
80
99
|
isRetryableScrapeError,
|
|
@@ -86,6 +105,14 @@ export {
|
|
|
86
105
|
SCRAPE_OWNED_ERROR_CODES,
|
|
87
106
|
ScrapeError,
|
|
88
107
|
} from './errors';
|
|
108
|
+
export type { AnswerPromptInput, EventPromptOptions } from './event-prompt';
|
|
109
|
+
export {
|
|
110
|
+
answerPrompt,
|
|
111
|
+
DEFAULT_PROMPT_ANSWER_TTL_MS,
|
|
112
|
+
DEFAULT_PROMPT_POLL_MS,
|
|
113
|
+
eventPrompt,
|
|
114
|
+
promptEventName,
|
|
115
|
+
} from './event-prompt';
|
|
89
116
|
export type { ScrapeEventFields, StepEvent } from './events';
|
|
90
117
|
export type { YieldCheck, YieldExpectation, YieldGuardInput, YieldHistory } from './expect';
|
|
91
118
|
export {
|
|
@@ -164,6 +191,7 @@ export type {
|
|
|
164
191
|
ScrapeDefinition,
|
|
165
192
|
ScrapeReport,
|
|
166
193
|
ScrapeRunArgs,
|
|
194
|
+
ScrapeSettled,
|
|
167
195
|
} from './scrape';
|
|
168
196
|
export { scrape } from './scrape';
|
|
169
197
|
export { DEFAULT_PAGE_TIMEOUT_MS, runScrape } from './scrape-run';
|
|
@@ -177,10 +205,17 @@ export {
|
|
|
177
205
|
safeNetwork,
|
|
178
206
|
safePageErrors,
|
|
179
207
|
} from './secrets';
|
|
180
|
-
export type {
|
|
208
|
+
export type {
|
|
209
|
+
ScrapeSessionStore,
|
|
210
|
+
SessionSnapshot,
|
|
211
|
+
SessionState,
|
|
212
|
+
StorageSessionStoreOptions,
|
|
213
|
+
} from './session-state';
|
|
181
214
|
export {
|
|
182
215
|
memorySessionStore,
|
|
183
216
|
parseSessionState,
|
|
217
|
+
recordVersion,
|
|
218
|
+
SESSION_SEAL_PURPOSE,
|
|
184
219
|
sessionDigest,
|
|
185
220
|
sessionKeyFor,
|
|
186
221
|
storageSessionStore,
|
|
@@ -196,5 +231,8 @@ export type {
|
|
|
196
231
|
ScrapeDownloadFile,
|
|
197
232
|
ScrapeTarget,
|
|
198
233
|
} from './target';
|
|
234
|
+
export { endpointLabel, urlSecretValues } from './url-secrets';
|
|
235
|
+
export type { RunUsageMeter, ScrapeUsage, UsageMeter } from './usage';
|
|
236
|
+
export { createUsageMeter } from './usage';
|
|
199
237
|
export type { WedgeGuard, WedgeGuardInit } from './watchdog';
|
|
200
238
|
export { DEFAULT_GRACE_MS } from './watchdog';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The argument list a launched browser gets: the caller's own, then the exit. The exit is the
|
|
2
|
+
// session's — the driver's `proxy` or the run's `egress` — so it is appended LAST and a caller
|
|
3
|
+
// switch that would decide the route instead is refused rather than ranked.
|
|
4
|
+
|
|
5
|
+
import { launchArgsInvalid } from './error-throws-session';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every switch Chrome reads a proxy decision from. Matched on the switch NAME, case-folded and
|
|
9
|
+
* with either prefix POSIX Chrome accepts (`--` and `-`), so a value that merely mentions a proxy
|
|
10
|
+
* (`--user-agent=proxy-server-test`) is not refused.
|
|
11
|
+
*/
|
|
12
|
+
const ROUTE_SWITCHES = new Set([
|
|
13
|
+
'proxy-server',
|
|
14
|
+
'proxy-bypass-list',
|
|
15
|
+
'proxy-pac-url',
|
|
16
|
+
'proxy-auto-detect',
|
|
17
|
+
'no-proxy-server',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const switchName = (arg: string): string | undefined => {
|
|
21
|
+
const match = /^--?([^=]+)/.exec(arg);
|
|
22
|
+
return match?.[1]?.toLowerCase();
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `undefined` when the launch carries no args at all — the launcher's own defaults stay untouched
|
|
27
|
+
* then. Refused with `X_SCRAPE_LAUNCH_ARGS_INVALID` before anything starts: a browser launched on a
|
|
28
|
+
* second route is a session that reports one exit and leaves the worker by another.
|
|
29
|
+
*/
|
|
30
|
+
export function launchArgs(
|
|
31
|
+
callerArgs: unknown,
|
|
32
|
+
proxyArg: string | undefined,
|
|
33
|
+
): string[] | undefined {
|
|
34
|
+
if (callerArgs !== undefined) {
|
|
35
|
+
if (!Array.isArray(callerArgs) || !callerArgs.every((arg) => typeof arg === 'string')) {
|
|
36
|
+
throw launchArgsInvalid('is not an array of strings', false);
|
|
37
|
+
}
|
|
38
|
+
for (const arg of callerArgs as readonly string[]) {
|
|
39
|
+
const name = switchName(arg);
|
|
40
|
+
if (name !== undefined && ROUTE_SWITCHES.has(name)) {
|
|
41
|
+
throw launchArgsInvalid(
|
|
42
|
+
`sets --${name}, which decides the route this session leaves by`,
|
|
43
|
+
true,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const args = [...((callerArgs as readonly string[] | undefined) ?? [])];
|
|
49
|
+
if (proxyArg !== undefined) args.push(`--proxy-server=${proxyArg}`);
|
|
50
|
+
return args.length === 0 ? undefined : args;
|
|
51
|
+
}
|
package/src/offline-session.ts
CHANGED
|
@@ -42,6 +42,10 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
|
|
|
42
42
|
if (init.session.restore !== undefined) await target.restore(init.session.restore);
|
|
43
43
|
return {
|
|
44
44
|
driver: init.driver,
|
|
45
|
+
// An offline session dials nothing — except the run's own `/robots.txt` read, which is a real
|
|
46
|
+
// request. Reporting the run's exit is what makes that one request leave through it, exactly
|
|
47
|
+
// as it would on the driver this recording stands in for.
|
|
48
|
+
...(init.session.proxy === undefined ? {} : { proxy: init.session.proxy }),
|
|
45
49
|
page: pageOverTarget(target, {
|
|
46
50
|
clock: init.session.clock,
|
|
47
51
|
allowHosts: init.session.rules.allowHosts,
|
|
@@ -51,6 +55,7 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
|
|
|
51
55
|
signal: init.session.signal,
|
|
52
56
|
onActivity: init.session.onActivity,
|
|
53
57
|
pace: init.session.pace,
|
|
58
|
+
usage: init.session.usage,
|
|
54
59
|
}),
|
|
55
60
|
http: recordedHttp({
|
|
56
61
|
lookup: init.http,
|
|
@@ -63,6 +68,7 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
|
|
|
63
68
|
// The same bag, for the same reason: a redaction only the live leg performs is one no
|
|
64
69
|
// fixture can prove.
|
|
65
70
|
secrets: init.session.secrets,
|
|
71
|
+
usage: init.session.usage,
|
|
66
72
|
maxAgeMs: init.maxAgeMs,
|
|
67
73
|
}),
|
|
68
74
|
close: () => target.close(),
|
package/src/page-over-target.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
ScrapeTarget,
|
|
36
36
|
} from './target';
|
|
37
37
|
import { ROOT_SELECTOR } from './target';
|
|
38
|
+
import type { UsageMeter } from './usage';
|
|
38
39
|
|
|
39
40
|
export interface PageContext {
|
|
40
41
|
readonly clock: ScrapeClock;
|
|
@@ -51,6 +52,8 @@ export interface PageContext {
|
|
|
51
52
|
readonly secrets?: ScrapeSecrets | undefined;
|
|
52
53
|
readonly robots?: RobotsGate | undefined;
|
|
53
54
|
readonly signal?: AbortSignal | undefined;
|
|
55
|
+
/** The run's meter. Counts a navigation where the wedge watchdog is told about one. */
|
|
56
|
+
readonly usage?: UsageMeter | undefined;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
/** Mutable, shared by the page and every frame under it: a taint is a property of the SESSION. */
|
|
@@ -251,6 +254,7 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
|
|
|
251
254
|
await guardNavigation(url, ctx);
|
|
252
255
|
await ctx.pace?.(ctx.signal);
|
|
253
256
|
ctx.onActivity?.();
|
|
257
|
+
ctx.usage?.navigation();
|
|
254
258
|
await target.goto(url, {
|
|
255
259
|
timeoutMs: options?.timeout ?? ctx.defaultTimeoutMs,
|
|
256
260
|
signal: ctx.signal,
|