@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.
@@ -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 { finiteCount, readWithinLimit } from '@ultimat3/core';
12
- import type { HostRule } from './hosts';
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
- /** The final hop's body under the cap, or `undefined` for a non-2xx answer. */
84
- const answerOf = async (response: Response, limit: number): Promise<string | undefined> => {
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`, or answers `undefined` — which the gate reads as "no restrictions", the
94
- * standard's own answer for a file it cannot obtain. A deadline that fires, a body past the cap,
95
- * a 404 and a redirect off `allowHosts` are all the same answer on purpose: none of them is
96
- * evidence of a rule.
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 every way this
102
- // read can fail is the same answer, `undefined`, which the gate reads as "no restrictions". A
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. Robots
107
- // enforcement off, for the whole run, with nothing in the log.
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<string | undefined> => {
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
- return undefined;
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
- export type RobotsFetch = (robotsUrl: string) => Promise<string | undefined>;
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
- * An origin whose robots.txt cannot be read is ALLOWED. That is the standard's own answer — a
143
- * missing file means no restrictions — and the alternative fails every run behind a flaky CDN.
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 createRobotsGate(init: RobotsGateInit): RobotsGate {
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<RobotsRules>>();
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 rules = cache.get(origin);
164
- if (rules === undefined) {
165
- rules = read(`${origin}/robots.txt`)
166
- .then((text) => (text === undefined ? { rules: [] } : parseRobots(text, agent)))
167
- .catch(() => ({ rules: [] }));
168
- cache.set(origin, rules);
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
- if (!robotsAllows(await rules, `${parsed.pathname}${parsed.search}`)) {
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 { createArtifactWriter } from './artifacts';
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 { createPacer, DEFAULT_NAVIGATION_RATE } from './rate';
34
+ import { DEFAULT_NAVIGATION_RATE, scrapePacer } from './rate';
35
35
  import { runRecovery } from './recover';
36
- import { createRobotsGate } from './robots';
36
+ import { robotsGate } from './robots';
37
37
  import type { ScrapeDefinition, ScrapeReport } from './scrape';
38
38
  import { containsSecret } from './secret-scan';
39
- import { createSecretBag, MIN_REDACTABLE_LENGTH } from './secrets';
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 { createUsageMeter, rememberFailedUsage } from './usage';
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 = createSecretBag(definition.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 = createPacer(
123
+ const pace = scrapePacer(
124
124
  finiteOption('the scrape definition', 'rate', definition.rate ?? DEFAULT_NAVIGATION_RATE),
125
125
  clock,
126
126
  );
127
- const artifact = createArtifactWriter({
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 = createUsageMeter(clock);
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: createRobotsGate({
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: createPrompt({
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 createArtifactWriter>,
327
- secrets: ReturnType<typeof createSecretBag>,
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 createArtifactWriter>,
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 createSecretBag(
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 `testClock` resolves on the first microtask and would time
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 createUsageMeter(clock: ScrapeClock): RunUsageMeter {
46
+ export function usageMeter(clock: ScrapeClock): RunUsageMeter {
47
47
  const startedAt = clock.monotonic();
48
48
  let navigations = 0;
49
49
  let httpRequests = 0;
@@ -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
- }