@ultimat3/scraping 24.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 +19 -2
- package/package.json +6 -6
- package/src/artifacts.ts +2 -4
- package/src/auth.ts +3 -3
- package/src/cdp-snapshot.ts +11 -3
- package/src/cdp-target.ts +12 -10
- package/src/clock.ts +4 -9
- package/src/error-throws-session.ts +1 -1
- package/src/error-throws.ts +1 -1
- package/src/event-prompt.ts +3 -3
- package/src/html-target.ts +4 -4
- package/src/index.ts +19 -16
- package/src/intercept.ts +2 -2
- package/src/page-over-target.ts +14 -6
- package/src/rate.ts +1 -1
- package/src/recover.ts +1 -1
- package/src/rings.ts +3 -3
- package/src/robots-fetch.ts +32 -16
- package/src/robots.ts +50 -12
- package/src/scrape-run.ts +15 -15
- package/src/scrape.ts +1 -2
- package/src/secrets.ts +1 -1
- package/src/target.ts +14 -2
- package/src/usage.ts +1 -1
- package/src/cdp-fake-target.ts +0 -67
- package/src/cdp-fake.ts +0 -428
- package/src/hosts.ts +0 -4
package/src/robots-fetch.ts
CHANGED
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
// The exit is a RESOLVER, not a string: `scrape-run.ts` builds this gate as an argument to
|
|
9
9
|
// `driver.open()`, and the proxy is a driver option the session only reports on the way back out.
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
11
|
+
import type { HostRule } from '@ultimat3/core';
|
|
12
|
+
import { finiteCount, isUltimateError, readWithinLimit } from '@ultimat3/core';
|
|
13
13
|
import type { ScrapeFetch } from './http';
|
|
14
14
|
import { redirectHop } from './http-redirect';
|
|
15
15
|
import { interceptVerdict } from './intercept';
|
|
16
16
|
import type { HostResolve } from './pinned-host';
|
|
17
17
|
import { dialTarget, resolveHost } from './pinned-host';
|
|
18
|
-
import type { RobotsFetch } from './robots';
|
|
18
|
+
import type { RobotsAnswer, RobotsFetch } from './robots';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* A deadline is applied ALWAYS, proxy or no proxy, session or no session: the failure it prevents
|
|
@@ -80,8 +80,18 @@ const sameHost = (from: string, to: string): boolean => {
|
|
|
80
80
|
}
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
83
|
+
/**
|
|
84
|
+
* RFC 9309 §2.3.1.4 names server errors; a 429 is the same condition spelled as a client status —
|
|
85
|
+
* the origin is shedding load, not reporting a missing file — and every major crawler reads it so.
|
|
86
|
+
*/
|
|
87
|
+
const isUnreachableStatus = (status: number): boolean => status >= 500 || status === 429;
|
|
88
|
+
|
|
89
|
+
/** The final hop's body under the cap, unreachable for a 5xx/429, `undefined` for another non-2xx. */
|
|
90
|
+
const answerOf = async (response: Response, limit: number): Promise<RobotsAnswer> => {
|
|
91
|
+
if (isUnreachableStatus(response.status)) {
|
|
92
|
+
await response.body?.cancel().catch(() => undefined);
|
|
93
|
+
return { unreachable: `status ${String(response.status)}` };
|
|
94
|
+
}
|
|
85
95
|
if (!response.ok) return undefined;
|
|
86
96
|
// Counted as it arrives rather than `.text()`, which materialises the whole body first: a
|
|
87
97
|
// multi-gigabyte robots.txt is a heap the worker never gets back.
|
|
@@ -90,21 +100,22 @@ const answerOf = async (response: Response, limit: number): Promise<string | und
|
|
|
90
100
|
};
|
|
91
101
|
|
|
92
102
|
/**
|
|
93
|
-
* Reads `robotsUrl
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
103
|
+
* Reads `robotsUrl`. Three answers, after RFC 9309 §2.3.1: the text; `undefined` when the file is
|
|
104
|
+
* UNAVAILABLE — a 4xx, a body past the cap, a redirect off `allowHosts` or past the hop cap, a
|
|
105
|
+
* host the pin refuses — which the gate reads as no rules; and `{ unreachable }` for a 5xx, a 429,
|
|
106
|
+
* a deadline, a cancellation or a network error, which the gate reads as complete disallow. A
|
|
107
|
+
* server that cannot answer is not a server that has no rules.
|
|
97
108
|
*/
|
|
98
109
|
export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
|
|
99
110
|
const call: ScrapeFetch = init.fetch ?? fetch;
|
|
100
111
|
const resolve = init.resolve ?? resolveHost;
|
|
101
|
-
// Both bounds are screened HERE, at construction, and both floors are 1 — because
|
|
102
|
-
//
|
|
112
|
+
// Both bounds are screened HERE, at construction, and both floors are 1 — because a bad bound
|
|
113
|
+
// turns into a failed read on every origin, and a read that fails is never evidence of a rule. A
|
|
103
114
|
// `NaN` deadline throws a bare `TypeError` out of `AbortSignal.timeout` (measured: "Value NaN is
|
|
104
115
|
// outside the range [0, 9007199254740991]") straight into the gate's own `.catch`, and a `NaN`
|
|
105
116
|
// cap makes `readWithinLimit` refuse after the request already left. A zero of either is the
|
|
106
|
-
// same outcome spelled deliberately: an expired deadline and a cap every file is over
|
|
107
|
-
//
|
|
117
|
+
// same outcome spelled deliberately: an expired deadline and a cap every file is over — every
|
|
118
|
+
// origin refused, or (the cap) every origin unrestricted, for the whole run.
|
|
108
119
|
const limit = finiteCount(
|
|
109
120
|
'robotsFetcher',
|
|
110
121
|
'maxBytes',
|
|
@@ -117,7 +128,7 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
|
|
|
117
128
|
init.timeoutMs ?? DEFAULT_ROBOTS_TIMEOUT_MS,
|
|
118
129
|
1,
|
|
119
130
|
);
|
|
120
|
-
return async (robotsUrl: string): Promise<
|
|
131
|
+
return async (robotsUrl: string): Promise<RobotsAnswer> => {
|
|
121
132
|
// Armed per read, not per gate: the gate is long-lived and reads once per origin, so a
|
|
122
133
|
// deadline created alongside it would already have expired by the second origin.
|
|
123
134
|
const deadline = AbortSignal.timeout(timeoutMs);
|
|
@@ -161,8 +172,13 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
|
|
|
161
172
|
if (init.allowHosts === undefined && !sameHost(robotsUrl, next.url)) return undefined;
|
|
162
173
|
url = next.url;
|
|
163
174
|
}
|
|
164
|
-
} catch {
|
|
165
|
-
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// The pin refusing an inward name is a POLICY answer about the host, and the navigation is
|
|
177
|
+
// refused by the same rule — it is not evidence the origin is down.
|
|
178
|
+
if (isUltimateError(error) && error.code === 'X_SCRAPE_HOST_BLOCKED') return undefined;
|
|
179
|
+
if (deadline.aborted) return { unreachable: 'timeout' };
|
|
180
|
+
if (init.signal?.aborted === true) return { unreachable: 'cancelled' };
|
|
181
|
+
return { unreachable: 'network' };
|
|
166
182
|
}
|
|
167
183
|
};
|
|
168
184
|
}
|
package/src/robots.ts
CHANGED
|
@@ -126,7 +126,20 @@ export interface RobotsGate {
|
|
|
126
126
|
readonly ignoredBecause: string | undefined;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
/**
|
|
130
|
+
* RFC 9309 §2.3.1.4: the file could not be obtained because of a server or network error — a 5xx,
|
|
131
|
+
* a 429, a refused connection, a deadline. The standard's answer is COMPLETE DISALLOW, and it is
|
|
132
|
+
* a different answer from `undefined` (a 4xx: the file is unavailable, so there are no rules).
|
|
133
|
+
* `unreachable` says why, for the log; nothing branches on its text.
|
|
134
|
+
*/
|
|
135
|
+
export interface RobotsUnreachable {
|
|
136
|
+
readonly unreachable: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The file's text, `undefined` for no file (no rules), or unreachable (no access). */
|
|
140
|
+
export type RobotsAnswer = string | undefined | RobotsUnreachable;
|
|
141
|
+
|
|
142
|
+
export type RobotsFetch = (robotsUrl: string) => Promise<RobotsAnswer>;
|
|
130
143
|
|
|
131
144
|
export interface RobotsGateInit extends RobotsFetchInit {
|
|
132
145
|
readonly policy: RobotsPolicy;
|
|
@@ -135,20 +148,32 @@ export interface RobotsGateInit extends RobotsFetchInit {
|
|
|
135
148
|
readonly fetchText?: RobotsFetch | undefined;
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
/** What the gate holds per origin: the rules, or the standard's complete disallow. */
|
|
152
|
+
type Verdict = RobotsRules | 'unreachable';
|
|
153
|
+
|
|
154
|
+
const verdictOf = (answer: RobotsAnswer, agent: string): Verdict => {
|
|
155
|
+
if (answer === undefined) return { rules: [] };
|
|
156
|
+
if (typeof answer === 'string') return parseRobots(answer, agent);
|
|
157
|
+
return 'unreachable';
|
|
158
|
+
};
|
|
159
|
+
|
|
138
160
|
/**
|
|
139
161
|
* One fetch of `/robots.txt` per ORIGIN per run, cached. A gate that re-fetched per navigation
|
|
140
162
|
* would triple the request count of every scrape it protects.
|
|
141
163
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
164
|
+
* A MISSING robots.txt (a 4xx, a redirect chain past the cap) is no restrictions — the standard's
|
|
165
|
+
* own answer. An UNREACHABLE one (5xx, 429, a network error, a deadline, a read that rejects) is
|
|
166
|
+
* complete disallow (RFC 9309 §2.3.1.4), and is NOT cached: the next navigation to the origin asks
|
|
167
|
+
* again, so a run behind a flaky CDN recovers the moment the file answers instead of failing for
|
|
168
|
+
* the rest of the run — and an origin cannot shed its own rules by answering 503 once.
|
|
144
169
|
*/
|
|
145
|
-
export function
|
|
170
|
+
export function robotsGate(init: RobotsGateInit): RobotsGate {
|
|
146
171
|
if (init.policy !== 'obey') {
|
|
147
172
|
return { assertAllowed: () => Promise.resolve(), ignoredBecause: init.policy.ignore };
|
|
148
173
|
}
|
|
149
174
|
const agent = init.agent ?? DEFAULT_ROBOTS_AGENT;
|
|
150
175
|
const read = init.fetchText ?? robotsFetcher(init);
|
|
151
|
-
const cache = new Map<string, Promise<
|
|
176
|
+
const cache = new Map<string, Promise<Verdict>>();
|
|
152
177
|
return {
|
|
153
178
|
ignoredBecause: undefined,
|
|
154
179
|
async assertAllowed(url: string): Promise<void> {
|
|
@@ -160,14 +185,27 @@ export function createRobotsGate(init: RobotsGateInit): RobotsGate {
|
|
|
160
185
|
}
|
|
161
186
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
|
|
162
187
|
const origin = parsed.origin;
|
|
163
|
-
let
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
188
|
+
let pending = cache.get(origin);
|
|
189
|
+
if (pending === undefined) {
|
|
190
|
+
// A caller-supplied read that rejects has not produced a file, and "could not ask" is
|
|
191
|
+
// the unreachable case, not the missing one.
|
|
192
|
+
const fresh = read(`${origin}/robots.txt`).then(
|
|
193
|
+
(answer) => verdictOf(answer, agent),
|
|
194
|
+
(): Verdict => 'unreachable',
|
|
195
|
+
);
|
|
196
|
+
pending = fresh;
|
|
197
|
+
cache.set(origin, fresh);
|
|
198
|
+
// Concurrent navigations still share the one in-flight read; only a settled unreachable
|
|
199
|
+
// verdict is evicted, and only if no later read has replaced it.
|
|
200
|
+
void fresh.then((verdict) => {
|
|
201
|
+
if (verdict === 'unreachable' && cache.get(origin) === fresh) cache.delete(origin);
|
|
202
|
+
});
|
|
169
203
|
}
|
|
170
|
-
|
|
204
|
+
const verdict = await pending;
|
|
205
|
+
if (
|
|
206
|
+
verdict === 'unreachable' ||
|
|
207
|
+
!robotsAllows(verdict, `${parsed.pathname}${parsed.search}`)
|
|
208
|
+
) {
|
|
171
209
|
throw robotsDisallowed(url, agent);
|
|
172
210
|
}
|
|
173
211
|
},
|
package/src/scrape-run.ts
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
13
13
|
import type { JobRunArgs } from '@ultimat3/jobs';
|
|
14
14
|
import { parse } from '@ultimat3/schema';
|
|
15
|
-
import {
|
|
15
|
+
import { artifactWriter } from './artifacts';
|
|
16
16
|
import type { AuthPlanInput } from './auth';
|
|
17
17
|
import {
|
|
18
18
|
burnSession,
|
|
19
|
-
createPrompt,
|
|
20
19
|
ensureAuthenticated,
|
|
21
20
|
markRefused,
|
|
22
21
|
observeSession,
|
|
23
22
|
persistSession,
|
|
23
|
+
scrapePrompt,
|
|
24
24
|
} from './auth';
|
|
25
25
|
import { scrapeClock } from './clock';
|
|
26
26
|
import type { ScrapeSession } from './driver';
|
|
@@ -31,16 +31,16 @@ import type { ScrapeEventFields } from './events';
|
|
|
31
31
|
import { scrapeLogger, withStepEvent } from './events';
|
|
32
32
|
import { guardYield } from './expect';
|
|
33
33
|
import { burnsSession, errorCode, neverRetried } from './failures';
|
|
34
|
-
import {
|
|
34
|
+
import { DEFAULT_NAVIGATION_RATE, scrapePacer } from './rate';
|
|
35
35
|
import { runRecovery } from './recover';
|
|
36
|
-
import {
|
|
36
|
+
import { robotsGate } from './robots';
|
|
37
37
|
import type { ScrapeDefinition, ScrapeReport } from './scrape';
|
|
38
38
|
import { containsSecret } from './secret-scan';
|
|
39
|
-
import {
|
|
39
|
+
import { MIN_REDACTABLE_LENGTH, secretBag } from './secrets';
|
|
40
40
|
import { sessionKeyFor } from './session-state';
|
|
41
41
|
import { hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
|
|
42
42
|
import type { ScrapeUsage } from './usage';
|
|
43
|
-
import {
|
|
43
|
+
import { rememberFailedUsage, usageMeter } from './usage';
|
|
44
44
|
|
|
45
45
|
/** `ctx.actor` is a structural read: this package never imports the auth types (tier 2). */
|
|
46
46
|
const orgOf = (ctx: unknown): string | undefined => {
|
|
@@ -99,7 +99,7 @@ export async function runScrape<I, Row>(
|
|
|
99
99
|
attempt: args.attempt,
|
|
100
100
|
driver: driver.name,
|
|
101
101
|
});
|
|
102
|
-
const secrets =
|
|
102
|
+
const secrets = secretBag(definition.secrets ?? []);
|
|
103
103
|
// The run's exit, resolved HERE — in the worker, under the job's tenant — so what the queue row
|
|
104
104
|
// carries is the id of whatever holds it. An empty answer is "none", the same as absent: a
|
|
105
105
|
// driver handed `''` would dial `--proxy-server=` and call that a decision.
|
|
@@ -120,11 +120,11 @@ export async function runScrape<I, Row>(
|
|
|
120
120
|
// refuses the DECLARATION and never sees a definition assembled by hand, which `runScrape` is
|
|
121
121
|
// exported to accept. `finiteOption` and not `finiteCount` — a rate of 0.5 is one navigation
|
|
122
122
|
// every two seconds, and `scrape()` owns the "greater than zero" half.
|
|
123
|
-
const pace =
|
|
123
|
+
const pace = scrapePacer(
|
|
124
124
|
finiteOption('the scrape definition', 'rate', definition.rate ?? DEFAULT_NAVIGATION_RATE),
|
|
125
125
|
clock,
|
|
126
126
|
);
|
|
127
|
-
const artifact =
|
|
127
|
+
const artifact = artifactWriter({
|
|
128
128
|
storage: definition.artifacts?.storage,
|
|
129
129
|
scrape: definition.name,
|
|
130
130
|
runId: args.runId,
|
|
@@ -156,7 +156,7 @@ export async function runScrape<I, Row>(
|
|
|
156
156
|
// during a navigation, which is after this is assigned.
|
|
157
157
|
let sessionProxy: string | undefined;
|
|
158
158
|
// Before `open()`: the time a rented browser takes to arrive is time it was held.
|
|
159
|
-
const usage =
|
|
159
|
+
const usage = usageMeter(clock);
|
|
160
160
|
const session = await driver.open({
|
|
161
161
|
name: definition.name,
|
|
162
162
|
logger,
|
|
@@ -173,7 +173,7 @@ export async function runScrape<I, Row>(
|
|
|
173
173
|
// unreachable by `ctx.signal`; without the third the read leaves from a different IP than
|
|
174
174
|
// every page load, and an origin reachable only through the proxy answers nothing — which
|
|
175
175
|
// this gate reads as "no restrictions".
|
|
176
|
-
robots:
|
|
176
|
+
robots: robotsGate({
|
|
177
177
|
policy: definition.robots ?? 'obey',
|
|
178
178
|
// Every hop of the read screened by the run's own host rule, as the HTTP leg's are.
|
|
179
179
|
allowHosts: rules.allowHosts,
|
|
@@ -205,7 +205,7 @@ export async function runScrape<I, Row>(
|
|
|
205
205
|
page: session.page,
|
|
206
206
|
secrets,
|
|
207
207
|
restored,
|
|
208
|
-
prompt:
|
|
208
|
+
prompt: scrapePrompt({
|
|
209
209
|
scrape: definition.name,
|
|
210
210
|
handler: definition.prompt,
|
|
211
211
|
input: args.input,
|
|
@@ -323,8 +323,8 @@ async function bodyWithRecovery<I, Row>(
|
|
|
323
323
|
args: JobRunArgs<I>,
|
|
324
324
|
session: ScrapeSession,
|
|
325
325
|
logger: ReturnType<typeof scrapeLogger>,
|
|
326
|
-
artifact: ReturnType<typeof
|
|
327
|
-
secrets: ReturnType<typeof
|
|
326
|
+
artifact: ReturnType<typeof artifactWriter>,
|
|
327
|
+
secrets: ReturnType<typeof secretBag>,
|
|
328
328
|
): Promise<readonly unknown[]> {
|
|
329
329
|
const body = (): Promise<readonly unknown[]> =>
|
|
330
330
|
definition.run({
|
|
@@ -365,7 +365,7 @@ async function bodyWithRecovery<I, Row>(
|
|
|
365
365
|
*/
|
|
366
366
|
async function saveFailureArtifact(
|
|
367
367
|
session: ScrapeSession,
|
|
368
|
-
artifact: ReturnType<typeof
|
|
368
|
+
artifact: ReturnType<typeof artifactWriter>,
|
|
369
369
|
): Promise<void> {
|
|
370
370
|
try {
|
|
371
371
|
await artifact.save('page.html', await session.page.html());
|
package/src/scrape.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// the account. So this file is a FACTORY over `job()`, and a scrape inherits `.enqueue()`, the
|
|
10
10
|
// worker's cancellation, the dead-letter path, `x jobs show` and its manifest row for free.
|
|
11
11
|
|
|
12
|
-
import type { Ctx } from '@ultimat3/core';
|
|
12
|
+
import type { Ctx, HostRule } from '@ultimat3/core';
|
|
13
13
|
import { assert } from '@ultimat3/core';
|
|
14
14
|
import type {
|
|
15
15
|
JobCompleted,
|
|
@@ -31,7 +31,6 @@ import type { ScrapeDriver } from './driver';
|
|
|
31
31
|
import { yieldHistoryMissing } from './error-throws';
|
|
32
32
|
import type { YieldExpectation, YieldHistory } from './expect';
|
|
33
33
|
import { maxDropFraction } from './expect';
|
|
34
|
-
import type { HostRule } from './hosts';
|
|
35
34
|
import type { ScrapeHttp } from './http';
|
|
36
35
|
import type { ScrapePage } from './page';
|
|
37
36
|
import type { Recovery } from './recover';
|
package/src/secrets.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type SecretResolver = (name: string) => string | undefined;
|
|
|
33
33
|
|
|
34
34
|
const fromEnvironment: SecretResolver = (name) => Bun.env[name];
|
|
35
35
|
|
|
36
|
-
export function
|
|
36
|
+
export function secretBag(
|
|
37
37
|
names: readonly string[],
|
|
38
38
|
resolve: SecretResolver = fromEnvironment,
|
|
39
39
|
): ScrapeSecrets {
|
package/src/target.ts
CHANGED
|
@@ -15,6 +15,18 @@ import type { SessionSnapshot } from './session-state';
|
|
|
15
15
|
*/
|
|
16
16
|
export const ROOT_SELECTOR = 'html';
|
|
17
17
|
|
|
18
|
+
/** What a `query()` may do to the page before it measures. */
|
|
19
|
+
export interface QueryOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Scroll the FIRST match into the viewport before measuring. `elementFromPoint` answers nothing
|
|
22
|
+
* for a point off-screen, so without it every element below the fold read `hitTarget: false` —
|
|
23
|
+
* "covered" — and each act on it waited out its whole timeout as `X_SCRAPE_NOT_ACTIONABLE`.
|
|
24
|
+
* Only the `actionable` wait asks (`page-over-target.ts`): a READ must not move the page, or the
|
|
25
|
+
* next screenshot is of somewhere else. A driver with no layout engine ignores it.
|
|
26
|
+
*/
|
|
27
|
+
readonly reveal?: boolean | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
export interface ElementBox {
|
|
19
31
|
readonly x: number;
|
|
20
32
|
readonly y: number;
|
|
@@ -105,7 +117,7 @@ export interface GotoOptions {
|
|
|
105
117
|
* so `page.screenshot({ timeout })` was a documented deadline that bounded nothing. Deleted
|
|
106
118
|
* rather than implemented: the CDP port's own `screenshot({ fullPage })` has no timeout slot to
|
|
107
119
|
* forward it to, and a deadline enforced in `page-over-target.ts` would have to race
|
|
108
|
-
* `ScrapeClock.sleep`, which under `
|
|
120
|
+
* `ScrapeClock.sleep`, which under `testScrapeClock` resolves on the first microtask and would time
|
|
109
121
|
* out every capture in every test. A driver's own default is the honest bound.
|
|
110
122
|
*/
|
|
111
123
|
export type CaptureOptions = CaptureFraming;
|
|
@@ -134,7 +146,7 @@ export interface ScrapeTarget {
|
|
|
134
146
|
goto(url: string, options: GotoOptions): Promise<void>;
|
|
135
147
|
/** Serialised HTML of THIS target — the document for a page, the subtree for a frame. */
|
|
136
148
|
content(): Promise<string>;
|
|
137
|
-
query(selector: string): Promise<readonly ElementSnapshot[]>;
|
|
149
|
+
query(selector: string, options?: QueryOptions): Promise<readonly ElementSnapshot[]>;
|
|
138
150
|
/**
|
|
139
151
|
* Clicks the FIRST match. It took an `index` until 2026-08 that `html-target.ts` honoured and
|
|
140
152
|
* `cdp-target.ts` dropped — its implementations are `click: (selector) => …`, so puppeteer
|
package/src/usage.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface RunUsageMeter extends UsageMeter {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/** Built BEFORE `driver.open()`, so the time a rented browser took to arrive is time it was held. */
|
|
46
|
-
export function
|
|
46
|
+
export function usageMeter(clock: ScrapeClock): RunUsageMeter {
|
|
47
47
|
const startedAt = clock.monotonic();
|
|
48
48
|
let navigations = 0;
|
|
49
49
|
let httpRequests = 0;
|
package/src/cdp-fake-target.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
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
|
-
}
|