@ultimat3/scraping 24.0.0 → 25.1.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 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 (`createPgEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it |
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
- | `testClock()` | advances virtual time and yields a microtask | a test of a timeout: thirty seconds pass in none |
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`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "24.0.0",
3
+ "version": "25.1.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.0"
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": "24.0.0",
35
- "@ultimat3/jobs": "24.0.0",
36
- "@ultimat3/schema": "24.0.0",
37
- "@ultimat3/storage": "24.0.0"
34
+ "@ultimat3/core": "25.1.0",
35
+ "@ultimat3/jobs": "25.1.0",
36
+ "@ultimat3/schema": "25.1.0",
37
+ "@ultimat3/storage": "25.1.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 { StorageDriver } from '@ultimat3/storage';
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 createArtifactWriter(init: ArtifactWriterInit): ArtifactWriter {
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,7 +20,7 @@
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, sessionExpired } from './error-throws';
23
+ import { authFailed, promptUnanswered, scrapeSessionExpired } from './error-throws';
24
24
  import type { ScrapePage } from './page';
25
25
  import type { ScrapeSecrets } from './secrets';
26
26
  import {
@@ -124,7 +124,7 @@ export interface PromptInit<I = unknown> {
124
124
  */
125
125
  const KEEPALIVE_SELECTOR = 'ultimate-keepalive';
126
126
 
127
- export function createPrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
127
+ export function scrapePrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
128
128
  let asked = 0;
129
129
  return async (label: string): Promise<string> => {
130
130
  const { scrape, handler, page } = init;
@@ -316,7 +316,7 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
316
316
  args.logger.info('scrape.session.expired', { reused: false });
317
317
  await burnSession(args, recordVersion(args.restored));
318
318
  }
319
- if (auth.login === undefined) throw sessionExpired(args.scrape, args.key);
319
+ if (auth.login === undefined) throw scrapeSessionExpired(args.scrape, args.key);
320
320
  await auth.login(context);
321
321
  return true;
322
322
  }
@@ -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 = (selector: string): string => `(() => {
21
+ export const snapshotExpression = (
22
+ selector: string,
23
+ options: QueryOptions = {},
24
+ ): string => `(() => {
19
25
  const out = [];
20
- for (const el of document.querySelectorAll(${JSON.stringify(selector)})) {
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 = {};
package/src/cdp-target.ts CHANGED
@@ -19,7 +19,7 @@ import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
19
19
  import { browserUnreachable, pageCrashed } from './error-throws';
20
20
  import { parseKeyChord } from './key-chord';
21
21
  import type { ConsoleLine, NetworkEntry, PageError } from './rings';
22
- import { createRing } from './rings';
22
+ import { boundedRing } from './rings';
23
23
  import type { SessionSnapshot } from './session-state';
24
24
  import type {
25
25
  AxNode,
@@ -94,9 +94,9 @@ export interface CdpTargetInit extends CdpArmInit {
94
94
  }
95
95
 
96
96
  export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
97
- const console_ = createRing<ConsoleLine>();
98
- const network = createRing<NetworkEntry>();
99
- const pageErrors = createRing<PageError>();
97
+ const console_ = boundedRing<ConsoleLine>();
98
+ const network = boundedRing<NetworkEntry>();
99
+ const pageErrors = boundedRing<PageError>();
100
100
  const crashed: { value: string | undefined } = { value: undefined };
101
101
  await arm(init, { network, console: console_, pageErrors, crashed });
102
102
  await armBrowser(init, network);
@@ -125,8 +125,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
125
125
  if (pending === undefined) return;
126
126
  if (originOf(init.page.url()) !== pending.origin || pending.origin === '') return;
127
127
  pendingStorage = undefined;
128
+ // PAIRS, never an object literal: `{"__proto__": "…"}` in the page sets the literal's
129
+ // prototype and files no key, so a token stored under that legal name was never restored.
128
130
  await init.page.evaluate(
129
- `(() => { const entries = ${JSON.stringify(pending.storage)}; for (const key of Object.keys(entries)) localStorage.setItem(key, entries[key]); })()`,
131
+ `(() => { for (const [key, value] of ${JSON.stringify(Object.entries(pending.storage))}) localStorage.setItem(key, value); })()`,
130
132
  );
131
133
  };
132
134
 
@@ -149,9 +151,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
149
151
  ...parent,
150
152
  url: () => frame.url(),
151
153
  content: () => guard('content', () => frame.content()),
152
- query: (selector) =>
154
+ query: (selector, options) =>
153
155
  guard('query', async () =>
154
- parseSnapshots(await frame.evaluate(snapshotExpression(selector))),
156
+ parseSnapshots(await frame.evaluate(snapshotExpression(selector, options))),
155
157
  ),
156
158
  click: (selector) => guard('click', () => frame.click(selector)),
157
159
  type: (selector, text) => guard('type', () => frame.type(selector, text)),
@@ -206,9 +208,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
206
208
  await applyPendingStorage();
207
209
  }),
208
210
  content: () => guard('content', () => init.page.content()),
209
- query: (selector) =>
211
+ query: (selector, options) =>
210
212
  guard('query', async () =>
211
- parseSnapshots(await init.page.evaluate(snapshotExpression(selector))),
213
+ parseSnapshots(await init.page.evaluate(snapshotExpression(selector, options))),
212
214
  ),
213
215
  click: (selector) => guard('click', () => init.page.click(selector)),
214
216
  type: (selector, text) => guard('type', () => init.page.type(selector, text)),
@@ -347,7 +349,7 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
347
349
  }
348
350
  return parse(cookieSchema, await source.cookies());
349
351
  }),
350
- // Honest stub, in the shape `packages/jobs/src/driver-redis.ts` uses. A real one needs
352
+ // Honest stub: correct types and one labelled `X_NOT_IMPLEMENTED`. A real one needs
351
353
  // `Browser.setDownloadBehavior` over a raw CDP session plus a directory watch, and a
352
354
  // half-written version that silently returned empty bytes is worse than this line.
353
355
  //
package/src/clock.ts CHANGED
@@ -28,7 +28,7 @@ export const systemScrapeClock: ScrapeClock = Object.freeze({
28
28
  await Bun.sleep(ms);
29
29
  return;
30
30
  }
31
- throwIfAborted(signal);
31
+ signal.throwIfAborted();
32
32
  // A timer raced against the signal, never `Bun.sleep(ms).then(check)`: the watchdog aborts a
33
33
  // wedged run precisely so nothing waits out the rest of a five-minute budget, and a sleep
34
34
  // that only notices the abort when it expires would wait out every one of them.
@@ -52,7 +52,7 @@ export const systemScrapeClock: ScrapeClock = Object.freeze({
52
52
  * The system clock with the waiting taken out: `now()` and `monotonic()` are real, and every
53
53
  * `sleep` is ONE turn of the event loop whatever was asked for. For a test whose run waits on
54
54
  * something that really happens in the same process — an answer the test publishes, a job another
55
- * worker holds — where `testClock()`'s "sleeping is advancing" would run a five-minute budget out
55
+ * worker holds — where `testScrapeClock()`'s "sleeping is advancing" would run a five-minute budget out
56
56
  * in microtasks before the test's next line. A deadline is still a real one.
57
57
  */
58
58
  export const noWaitClock: ScrapeClock = Object.freeze({
@@ -82,11 +82,6 @@ export function resetScrapeClock(): void {
82
82
  ambient = undefined;
83
83
  }
84
84
 
85
- /** The abort reason, unwrapped — a caller's `AbortSignal.reason` is whatever they put there. */
86
- export function throwIfAborted(signal: AbortSignal): void {
87
- if (signal.aborted) throw signal.reason;
88
- }
89
-
90
85
  /**
91
86
  * A clock in which sleeping IS advancing: `await clock.sleep(30_000)` returns on the next
92
87
  * microtask with thirty seconds elapsed. This is what makes a poll-until-deadline test instant
@@ -97,7 +92,7 @@ export interface TestScrapeClock extends ScrapeClock {
97
92
  advance(ms: number): void;
98
93
  }
99
94
 
100
- export function testClock(at: Date | number = 0): TestScrapeClock {
95
+ export function testScrapeClock(at: Date | number = 0): TestScrapeClock {
101
96
  let epochMs = at instanceof Date ? at.getTime() : at;
102
97
  let mono = 0;
103
98
  const advance = (ms: number): void => {
@@ -113,7 +108,7 @@ export function testClock(at: Date | number = 0): TestScrapeClock {
113
108
  // Yield the microtask queue anyway: a loop that never awaits anything real starves whatever
114
109
  // the test armed to cancel it, and the wedge tests arm exactly that.
115
110
  await Promise.resolve();
116
- if (signal !== undefined) throwIfAborted(signal);
111
+ signal?.throwIfAborted();
117
112
  },
118
113
  };
119
114
  }
@@ -51,7 +51,7 @@ export const promptTimedOut = (input: {
51
51
  new ScrapeError({
52
52
  code: 'X_SCRAPE_PROMPT_UNANSWERED',
53
53
  cause: `scrape "${input.scrape}" asked for "${input.label}" and no answer was published to ${input.event} within ${String(input.waitedMs)}ms`,
54
- fix: 'call answerPrompt({ runId, index, answer }) with the run id and index the event name in the cause ends with, from a process on the same STORED event bus — setEventBus(createPgEventBus({ executor })) — or raise eventPrompt({ timeout })',
54
+ fix: 'call answerPrompt({ runId, index, answer }) with the run id and index the event name in the cause ends with, from a process on the same STORED event bus — setEventBus(postgresEventBus({ executor })) — or raise eventPrompt({ timeout })',
55
55
  meta: {
56
56
  scrape: input.scrape,
57
57
  label: input.label,
@@ -401,7 +401,7 @@ export const authFailed = (scrape: string, detail: string): ScrapeError =>
401
401
  meta: { scrape },
402
402
  });
403
403
 
404
- export const sessionExpired = (scrape: string, key: string): ScrapeError =>
404
+ export const scrapeSessionExpired = (scrape: string, key: string): ScrapeError =>
405
405
  new ScrapeError({
406
406
  code: 'X_SCRAPE_SESSION_EXPIRED',
407
407
  cause: `the stored session ${key} for scrape "${scrape}" failed its validate() probe and the definition declares no auth.login`,
@@ -15,7 +15,7 @@ import { finiteCount, isLocal, isSealed, openText, seal } from '@ultimat3/core';
15
15
  import type { EventBus, JobEvent } from '@ultimat3/jobs';
16
16
  import { DriverUnavailableError, eventBus } from '@ultimat3/jobs';
17
17
  import type { PromptHandler, PromptRequest } from './auth';
18
- import { deadline, throwIfAborted } from './clock';
18
+ import { deadline } from './clock';
19
19
  import { promptAnswerInvalid, promptTimedOut } from './error-throws-session';
20
20
 
21
21
  /** How often the bus is asked. One indexed read and one browser round trip per interval. */
@@ -73,7 +73,7 @@ export function eventPrompt(options: EventPromptOptions): PromptHandler {
73
73
  driver: 'event bus',
74
74
  cause:
75
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()',
76
+ fix: 'call setEventBus(postgresEventBus({ executor })) at boot — x dev and the served roles do — or pass bus: postgresEventBus({ executor }) to eventPrompt()',
77
77
  });
78
78
  }
79
79
  const event = promptEventName(request.runId, request.index);
@@ -83,7 +83,7 @@ export function eventPrompt(options: EventPromptOptions): PromptHandler {
83
83
  const askedAt = await bus.now();
84
84
  const budget = deadline(request.clock, timeoutMs, 'eventPrompt');
85
85
  for (;;) {
86
- if (request.signal !== undefined) throwIfAborted(request.signal);
86
+ request.signal?.throwIfAborted();
87
87
  const found = await bus.find(event, undefined, askedAt);
88
88
  if (found !== undefined) {
89
89
  if (!isSealed(found.payload)) {
@@ -18,7 +18,7 @@ import { parseKeyChord } from './key-chord';
18
18
  import type { PageRecording } from './recording';
19
19
  import { splitDownload } from './recording';
20
20
  import type { ConsoleLine, NetworkEntry, PageError } from './rings';
21
- import { createRing } from './rings';
21
+ import { boundedRing } from './rings';
22
22
  import type { SessionSnapshot } from './session-state';
23
23
  import { EMPTY_SESSION } from './session-state';
24
24
  import type {
@@ -119,14 +119,14 @@ interface OfflineDocument {
119
119
  }
120
120
 
121
121
  export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
122
- const consoleRing = createRing<ConsoleLine>();
123
- const networkRing = createRing<NetworkEntry>();
122
+ const consoleRing = boundedRing<ConsoleLine>();
123
+ const networkRing = boundedRing<NetworkEntry>();
124
124
  // Built and never pushed to, deliberately: this target parses markup and executes none of it, so
125
125
  // there is no uncaught exception for it to have. It ANSWERS rather than omitting the ring —
126
126
  // `page.pageErrors()` returning `[]` here is the honest "nothing threw, and nothing could",
127
127
  // where a missing ring would be a page method that throws on two of the three drivers.
128
128
  // `driver-parity.test.ts` pins the divergence, beside the box/hit-target one it already carries.
129
- const pageErrorRing = createRing<PageError>();
129
+ const pageErrorRing = boundedRing<PageError>();
130
130
  const overlay = new Map<string, string>();
131
131
  /**
132
132
  * One overlay per FRAME, held here rather than in the frame target, because `frames()` builds a
package/src/index.ts CHANGED
@@ -5,10 +5,7 @@
5
5
  export type { ActionabilityState, ActionabilityWait } from './actionability';
6
6
  export { awaitActionable } from './actionability';
7
7
  export type { ArtifactRef, ArtifactWriter, ArtifactWriterInit } from './artifacts';
8
- export {
9
- createArtifactWriter,
10
- DEFAULT_CONTENT_TYPE,
11
- } from './artifacts';
8
+ export { artifactWriter } from './artifacts';
12
9
  export type {
13
10
  AuthContext,
14
11
  PromptHandler,
@@ -16,7 +13,7 @@ export type {
16
13
  PromptRequest,
17
14
  ScrapeAuth,
18
15
  } from './auth';
19
- export { burnSession, createPrompt, ensureAuthenticated, restorableSession } from './auth';
16
+ export { burnSession, ensureAuthenticated, restorableSession, scrapePrompt } from './auth';
20
17
  export { browserRecord } from './browser-record';
21
18
  export type { CaptureClip, CaptureFraming } from './capture-clip';
22
19
  export { assertCaptureFraming } from './capture-clip';
@@ -44,8 +41,7 @@ export {
44
41
  scrapeClock,
45
42
  setScrapeClock,
46
43
  systemScrapeClock,
47
- testClock,
48
- throwIfAborted,
44
+ testScrapeClock,
49
45
  } from './clock';
50
46
  export type { ColorScheme } from './color-scheme';
51
47
  export { COLOR_SCHEMES, isColorScheme } from './color-scheme';
@@ -81,10 +77,10 @@ export {
81
77
  redirectLoop,
82
78
  remoteRequired,
83
79
  robotsDisallowed,
80
+ scrapeSessionExpired,
84
81
  scrapeTimeout,
85
82
  secretExposed,
86
83
  selectorMissing,
87
- sessionExpired,
88
84
  wedged,
89
85
  yieldCollapsed,
90
86
  } from './error-throws';
@@ -122,8 +118,6 @@ export {
122
118
  memoryYieldHistory,
123
119
  } from './expect';
124
120
  export { BURNS_SESSION, errorCode, NEVER_RETRIED } from './failures';
125
- export type { HostDecision, HostRule } from './hosts';
126
- export { ANY_HOST, hostDecision, hostMatches } from './hosts';
127
121
  export { queryHtml } from './html-query';
128
122
  export type { MarkupRequest } from './html-requests';
129
123
  export type { HtmlTargetInit, RecordingLookup } from './html-target';
@@ -152,7 +146,7 @@ export type {
152
146
  export type { PageContext } from './page-over-target';
153
147
  export { pageOverTarget } from './page-over-target';
154
148
  export type { Pacer } from './rate';
155
- export { createPacer } from './rate';
149
+ export { scrapePacer } from './rate';
156
150
  export type { HttpRecording, PageRecording } from './recording';
157
151
  export {
158
152
  httpRecordingSchema,
@@ -173,13 +167,21 @@ export type {
173
167
  Ring,
174
168
  } from './rings';
175
169
  export {
176
- createRing,
170
+ boundedRing,
177
171
  MAX_PAGE_ERROR_CHARS,
178
172
  pageErrorEntry,
179
173
  RESOURCE_TYPES,
180
174
  } from './rings';
181
- export type { RobotsFetch, RobotsGate, RobotsGateInit, RobotsPolicy, RobotsRules } from './robots';
182
- export { createRobotsGate, parseRobots, robotsAllows } from './robots';
175
+ export type {
176
+ RobotsAnswer,
177
+ RobotsFetch,
178
+ RobotsGate,
179
+ RobotsGateInit,
180
+ RobotsPolicy,
181
+ RobotsRules,
182
+ RobotsUnreachable,
183
+ } from './robots';
184
+ export { parseRobots, robotsAllows, robotsGate } from './robots';
183
185
  export type { RobotsFetchInit } from './robots-fetch';
184
186
  export {
185
187
  DEFAULT_ROBOTS_MAX_BYTES,
@@ -197,13 +199,13 @@ export { scrape } from './scrape';
197
199
  export { DEFAULT_PAGE_TIMEOUT_MS, runScrape } from './scrape-run';
198
200
  export type { ScrapeSecrets, SecretResolver } from './secrets';
199
201
  export {
200
- createSecretBag,
201
202
  MIN_REDACTABLE_LENGTH,
202
203
  redactSecrets,
203
204
  safeConsole,
204
205
  safeHtml,
205
206
  safeNetwork,
206
207
  safePageErrors,
208
+ secretBag,
207
209
  } from './secrets';
208
210
  export type {
209
211
  ScrapeSessionStore,
@@ -227,12 +229,13 @@ export type {
227
229
  ElementSnapshot,
228
230
  FrameRef,
229
231
  GotoOptions,
232
+ QueryOptions,
230
233
  ScrapeCookie,
231
234
  ScrapeDownloadFile,
232
235
  ScrapeTarget,
233
236
  } from './target';
234
237
  export { endpointLabel, urlSecretValues } from './url-secrets';
235
238
  export type { RunUsageMeter, ScrapeUsage, UsageMeter } from './usage';
236
- export { createUsageMeter } from './usage';
239
+ export { usageMeter } from './usage';
237
240
  export type { WedgeGuard, WedgeGuardInit } from './watchdog';
238
241
  export { DEFAULT_GRACE_MS } from './watchdog';
package/src/intercept.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  // differently — `driver-parity.test.ts` asserts the same refusal on all three, and a rule
6
6
  // re-implemented per driver is a rule that holds only on the driver nobody ships.
7
7
 
8
- import type { HostRule } from './hosts';
9
- import { hostDecision } from './hosts';
8
+ import type { HostRule } from '@ultimat3/core';
9
+ import { hostDecision } from '@ultimat3/core';
10
10
  import type { NetworkEntry, ResourceType } from './rings';
11
11
 
12
12
  export interface InterceptRules {
@@ -4,7 +4,7 @@
4
4
  // driver a test uses and the driver production uses.
5
5
 
6
6
  import type { Secret } from '@ultimat3/core';
7
- import { finiteCount, isSecret, revealSecret } from '@ultimat3/core';
7
+ import { finiteCount, hostDecision, isSecret, revealSecret } from '@ultimat3/core';
8
8
  import type { ActionabilityState } from './actionability';
9
9
  import { awaitActionable } from './actionability';
10
10
  import { assertCaptureFraming } from './capture-clip';
@@ -12,7 +12,6 @@ import type { ScrapeClock } from './clock';
12
12
  import { deadline } from './clock';
13
13
  import type { ColorScheme } from './color-scheme';
14
14
  import { hostBlocked, secretExposed, selectorMissing } from './error-throws';
15
- import { hostDecision } from './hosts';
16
15
  import type {
17
16
  AccessibilityOptions,
18
17
  CaptureRequest,
@@ -30,6 +29,7 @@ import type {
30
29
  AxNode,
31
30
  CaptureOptions,
32
31
  ElementSnapshot,
32
+ QueryOptions,
33
33
  ScrapeCookie,
34
34
  ScrapeDownloadFile,
35
35
  ScrapeTarget,
@@ -80,9 +80,13 @@ const toValue = (snapshot: ElementSnapshot): ElementValue => ({
80
80
  attrs: snapshot.attrs,
81
81
  });
82
82
 
83
- async function first(resolve: Resolve, selector: string): Promise<ElementSnapshot | undefined> {
83
+ async function first(
84
+ resolve: Resolve,
85
+ selector: string,
86
+ options?: QueryOptions,
87
+ ): Promise<ElementSnapshot | undefined> {
84
88
  const target = await resolve();
85
- return (await target.query(selector))[0];
89
+ return (await target.query(selector, options))[0];
86
90
  }
87
91
 
88
92
  function frameOver(
@@ -103,14 +107,18 @@ function frameOver(
103
107
  ): Promise<ElementSnapshot> => {
104
108
  const target = await resolve();
105
109
  lastUrl = target.url();
110
+ const state = options?.state ?? fallback;
111
+ // Only `actionable` reads `hitTarget`, so only it pays for moving the page — on every poll,
112
+ // because a layout shift between two can carry the element back out of view.
113
+ const query: QueryOptions = { reveal: state === 'actionable' };
106
114
  return awaitActionable({
107
115
  selector,
108
116
  url: lastUrl,
109
- state: options?.state ?? fallback,
117
+ state,
110
118
  timeoutMs: timeoutFor(options),
111
119
  clock: ctx.clock,
112
120
  signal: ctx.signal,
113
- snapshot: () => first(resolve, selector),
121
+ snapshot: () => first(resolve, selector, query),
114
122
  });
115
123
  };
116
124
 
package/src/rate.ts CHANGED
@@ -9,7 +9,7 @@ export const DEFAULT_NAVIGATION_RATE = 1;
9
9
 
10
10
  export type Pacer = (signal?: AbortSignal) => Promise<void>;
11
11
 
12
- export function createPacer(rate: number, clock: ScrapeClock): Pacer {
12
+ export function scrapePacer(rate: number, clock: ScrapeClock): Pacer {
13
13
  const intervalMs = 1_000 / rate;
14
14
  let nextAt = 0;
15
15
  return async (signal?: AbortSignal): Promise<void> => {
package/src/recover.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Two shapes. A FUNCTION is complete and shipped — an app writes its own fallback, gets the page
4
4
  // and the failure, and answers whether the attempt should be retried. `'agent'` is the seam for
5
5
  // letting a model re-derive the selector from the page, and it is an honest
6
- // `X_NOT_IMPLEMENTED` today, in the shape `packages/jobs/src/driver-redis.ts` uses: correct types
6
+ // `X_NOT_IMPLEMENTED` today: correct types
7
7
  // so an app can be written against it, one labelled throw, no silent no-op.
8
8
 
9
9
  import { NotImplementedError } from '@ultimat3/core';
package/src/rings.ts CHANGED
@@ -107,18 +107,18 @@ export type PageErrorRing = Ring<PageError>;
107
107
  *
108
108
  * Both wrong values fail in opposite directions and neither is survivable. A NEGATIVE capacity
109
109
  * makes `while (items.length > capacity) items.shift()` spin forever on an already-empty array
110
- * (`0 > -1`): `createRing(-1).push(1)` never returns, and it is a synchronous loop on the worker's
110
+ * (`0 > -1`): `boundedRing(-1).push(1)` never returns, and it is a synchronous loop on the worker's
111
111
  * only thread, inside a `page.on('request')` handler — past `ctx.signal`, past the wedge watchdog
112
112
  * and past the job timeout, which is incident #1 in this file's header. `NaN` fails the other way:
113
113
  * the comparison is false forever, the ring is UNBOUNDED, and the OOM the bound exists to prevent
114
114
  * arrives two hours in. Zero is a ring that discards everything silently, and a fraction is a
115
115
  * bound that is not the number anybody asked for.
116
116
  */
117
- export function createRing<T>(capacity: number = DEFAULT_RING_CAPACITY): Ring<T> {
117
+ export function boundedRing<T>(capacity: number = DEFAULT_RING_CAPACITY): Ring<T> {
118
118
  assert(
119
119
  Number.isSafeInteger(capacity) && capacity > 0,
120
120
  `a ring capacity must be a positive integer, got ${String(capacity)}: a negative one spins forever on push() and NaN makes the ring unbounded`,
121
- `pass a positive capacity — createRing(${String(DEFAULT_RING_CAPACITY)}) — or omit it and take DEFAULT_RING_CAPACITY`,
121
+ `pass a positive capacity — boundedRing(${String(DEFAULT_RING_CAPACITY)}) — or omit it and take DEFAULT_RING_CAPACITY`,
122
122
  );
123
123
  const items: T[] = [];
124
124
  let dropped = 0;