@ultimat3/scraping 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cdp-target.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  // `ScrapeTarget` over a real browser, through the structural CDP port. Everything driver-specific
2
2
  // in this package lives here and in `driver-cdp.ts`; the vocabulary above it does not change.
3
3
 
4
- import { isUltimateError } from '@ultimat3/core';
4
+ import { isUltimateError, NotImplementedError } from '@ultimat3/core';
5
5
  import type { StandardSchemaV1 } from '@ultimat3/schema';
6
6
  import { parse, t } from '@ultimat3/schema';
7
7
  import { browserRecord } from './browser-record';
8
8
  import { axNodesFor } from './cdp-a11y';
9
9
  import type { CdpArmInit } from './cdp-arm';
10
- import { arm } from './cdp-arm';
10
+ import { arm, armBrowser } from './cdp-arm';
11
11
  import type { CdpBrowserLike, CdpFrameLike } from './cdp-port';
12
12
  import {
13
13
  clearExpression,
@@ -16,10 +16,10 @@ import {
16
16
  snapshotExpression,
17
17
  } from './cdp-snapshot';
18
18
  import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
19
- import { browserUnreachable, pageCrashed, scrapeNotImplemented } from './error-throws';
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,
@@ -54,7 +54,7 @@ const cookieSchema = t.array(
54
54
  *
55
55
  * | Code | Why a retry cannot change it |
56
56
  * |---|---|
57
- * | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's `scrapeNotImplemented()` can, and the method is still missing on attempt five |
57
+ * | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's own `NotImplementedError` refusals can, and the method is still missing on attempt five |
58
58
  * | `X_VALIDATION_FAILED` | the browser ANSWERED, and the answer did not match the shape this driver reads it with. The page is what it is; attempt five reads the same DOM |
59
59
  *
60
60
  * Both were re-labelled `X_SCRAPE_BROWSER_UNREACHABLE`, which `errors.ts` registers `retryable`,
@@ -94,11 +94,12 @@ 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
+ await armBrowser(init, network);
102
103
  let pendingStorage: SessionSnapshot | undefined;
103
104
 
104
105
  const originOf = (url: string): string => {
@@ -124,8 +125,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
124
125
  if (pending === undefined) return;
125
126
  if (originOf(init.page.url()) !== pending.origin || pending.origin === '') return;
126
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.
127
130
  await init.page.evaluate(
128
- `(() => { 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); })()`,
129
132
  );
130
133
  };
131
134
 
@@ -148,9 +151,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
148
151
  ...parent,
149
152
  url: () => frame.url(),
150
153
  content: () => guard('content', () => frame.content()),
151
- query: (selector) =>
154
+ query: (selector, options) =>
152
155
  guard('query', async () =>
153
- parseSnapshots(await frame.evaluate(snapshotExpression(selector))),
156
+ parseSnapshots(await frame.evaluate(snapshotExpression(selector, options))),
154
157
  ),
155
158
  click: (selector) => guard('click', () => frame.click(selector)),
156
159
  type: (selector, text) => guard('type', () => frame.type(selector, text)),
@@ -183,10 +186,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
183
186
  // rather than answered from the PARENT document, which is what the spread alone would do.
184
187
  accessibility: (_selector, _max): Promise<readonly AxNode[]> =>
185
188
  Promise.reject(
186
- scrapeNotImplemented(
187
- 'accessibility() on a frame of the puppeteer driver',
188
- 'read the accessibility tree through the page — page.accessibility(selector) — which covers the top-level document only',
189
- ),
189
+ new NotImplementedError({
190
+ cause:
191
+ 'accessibility() on a frame of the puppeteer driver is not implemented by this driver',
192
+ fix: 'read the accessibility tree through the page — page.accessibility(selector) — which covers the top-level document only',
193
+ }),
190
194
  ),
191
195
  });
192
196
 
@@ -204,9 +208,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
204
208
  await applyPendingStorage();
205
209
  }),
206
210
  content: () => guard('content', () => init.page.content()),
207
- query: (selector) =>
211
+ query: (selector, options) =>
208
212
  guard('query', async () =>
209
- parseSnapshots(await init.page.evaluate(snapshotExpression(selector))),
213
+ parseSnapshots(await init.page.evaluate(snapshotExpression(selector, options))),
210
214
  ),
211
215
  click: (selector) => guard('click', () => init.page.click(selector)),
212
216
  type: (selector, text) => guard('type', () => init.page.type(selector, text)),
@@ -228,10 +232,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
228
232
  await guard('press', async () => {
229
233
  const keyboard = init.page.keyboard;
230
234
  if (keyboard === undefined) {
231
- throw scrapeNotImplemented(
232
- 'press() on a CDP page with no keyboard',
233
- 'upgrade the launcher to a puppeteer-core that exposes page.keyboard, or dispatch the key from the app under test instead of the browser',
234
- );
235
+ throw new NotImplementedError({
236
+ cause: 'press() on a CDP page with no keyboard is not implemented by this driver',
237
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.keyboard, or dispatch the key from the app under test instead of the browser',
238
+ });
235
239
  }
236
240
  // Down in the order written, the key, then up in REVERSE — the order a hand releases
237
241
  // them, and the order the browser's own `press` with modifiers performs. `.call`-free:
@@ -248,10 +252,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
248
252
  guard('focus', async () => {
249
253
  const focus = init.page.focus;
250
254
  if (typeof focus !== 'function') {
251
- throw scrapeNotImplemented(
252
- 'focus() on a CDP page with no focus() method',
253
- 'upgrade the launcher to a puppeteer-core that exposes page.focus(), or click the element instead',
254
- );
255
+ throw new NotImplementedError({
256
+ cause: 'focus() on a CDP page with no focus() method is not implemented by this driver',
257
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.focus(), or click the element instead',
258
+ });
255
259
  }
256
260
  // `.call`, for `setColorScheme`'s reason: the member is read off the object.
257
261
  await focus.call(init.page, selector);
@@ -260,10 +264,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
260
264
  guard('accessibility', async () => {
261
265
  const createSession = init.page.createCDPSession;
262
266
  if (typeof createSession !== 'function') {
263
- throw scrapeNotImplemented(
264
- 'accessibility() on a CDP page with no createCDPSession() method',
265
- 'upgrade the launcher to a puppeteer-core that exposes page.createCDPSession(), or assert on the markup with page.query() — which reads attributes, not what the browser computed',
266
- );
267
+ throw new NotImplementedError({
268
+ cause:
269
+ 'accessibility() on a CDP page with no createCDPSession() method is not implemented by this driver',
270
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.createCDPSession(), or assert on the markup with page.query() — which reads attributes, not what the browser computed',
271
+ });
267
272
  }
268
273
  const session = await createSession.call(init.page);
269
274
  try {
@@ -276,10 +281,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
276
281
  guard('setOfflineMode', async () => {
277
282
  const source = init.page as { setOfflineMode?: (value: boolean) => Promise<void> };
278
283
  if (typeof source.setOfflineMode !== 'function') {
279
- throw scrapeNotImplemented(
280
- 'setOfflineMode() on a CDP page with no setOfflineMode() method',
281
- 'upgrade the launcher to a puppeteer-core that exposes page.setOfflineMode(), or drive the condition from the app under test instead of the browser',
282
- );
284
+ throw new NotImplementedError({
285
+ cause:
286
+ 'setOfflineMode() on a CDP page with no setOfflineMode() method is not implemented by this driver',
287
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.setOfflineMode(), or drive the condition from the app under test instead of the browser',
288
+ });
283
289
  }
284
290
  await source.setOfflineMode(enabled);
285
291
  }),
@@ -291,10 +297,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
291
297
  // older spelling.
292
298
  const emulate = init.page.emulateMediaFeatures;
293
299
  if (typeof emulate !== 'function') {
294
- throw scrapeNotImplemented(
295
- 'setColorScheme() on a CDP page with no emulateMediaFeatures() method',
296
- 'upgrade the launcher to a puppeteer-core that exposes page.emulateMediaFeatures(), or have the page under test set its own theme',
297
- );
300
+ throw new NotImplementedError({
301
+ cause:
302
+ 'setColorScheme() on a CDP page with no emulateMediaFeatures() method is not implemented by this driver',
303
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.emulateMediaFeatures(), or have the page under test set its own theme',
304
+ });
298
305
  }
299
306
  // `.call`, because the member is read off the object: an unbound method loses `this` and
300
307
  // puppeteer's own `Page` needs it.
@@ -305,10 +312,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
305
312
  // Read off the object and called through it, for `setColorScheme`'s reason.
306
313
  const evaluateOnNewDocument = init.page.evaluateOnNewDocument;
307
314
  if (typeof evaluateOnNewDocument !== 'function') {
308
- throw scrapeNotImplemented(
309
- 'prepare() on a CDP page with no evaluateOnNewDocument() method',
310
- 'upgrade the launcher to a puppeteer-core that exposes page.evaluateOnNewDocument(), or seed the state from the app under test instead of the browser',
311
- );
315
+ throw new NotImplementedError({
316
+ cause:
317
+ 'prepare() on a CDP page with no evaluateOnNewDocument() method is not implemented by this driver',
318
+ fix: 'upgrade the launcher to a puppeteer-core that exposes page.evaluateOnNewDocument(), or seed the state from the app under test instead of the browser',
319
+ });
312
320
  }
313
321
  await evaluateOnNewDocument.call(init.page, expression);
314
322
  }),
@@ -333,14 +341,15 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
333
341
  guard('cookies', async () => {
334
342
  const source = init.browser as { cookies?: () => Promise<unknown> };
335
343
  if (typeof source.cookies !== 'function') {
336
- throw scrapeNotImplemented(
337
- 'cookies() on a CDP browser with no cookies() method',
338
- 'upgrade the launcher to a puppeteer-core that exposes browser.cookies(), or read them with page.evaluate("document.cookie")',
339
- );
344
+ throw new NotImplementedError({
345
+ cause:
346
+ 'cookies() on a CDP browser with no cookies() method is not implemented by this driver',
347
+ fix: 'upgrade the launcher to a puppeteer-core that exposes browser.cookies(), or read them with page.evaluate("document.cookie")',
348
+ });
340
349
  }
341
350
  return parse(cookieSchema, await source.cookies());
342
351
  }),
343
- // 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
344
353
  // `Browser.setDownloadBehavior` over a raw CDP session plus a directory watch, and a
345
354
  // half-written version that silently returned empty bytes is worse than this line.
346
355
  //
@@ -349,10 +358,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
349
358
  // `try` — a synchronous `throw` jumps over the first of those entirely.
350
359
  download: (_options): Promise<ScrapeDownloadFile> =>
351
360
  Promise.reject(
352
- scrapeNotImplemented(
353
- 'download() on the puppeteer driver',
354
- 'fetch the file inside the page — page.evaluate("fetch(url).then(r => r.text())") — or run this scrape on fixtureBrowser(), whose download() is complete',
355
- ),
361
+ new NotImplementedError({
362
+ cause: 'download() on the puppeteer driver is not implemented by this driver',
363
+ fix: 'fetch the file inside the page — page.evaluate("fetch(url).then(r => r.text())") — or run this scrape on fixtureBrowser(), whose download() is complete',
364
+ }),
356
365
  ),
357
366
  frames: () =>
358
367
  guard('frames', () =>
@@ -378,10 +387,19 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
378
387
  } catch {
379
388
  origin = '';
380
389
  }
381
- const source = init.browser as { cookies?: () => Promise<unknown> };
390
+ // Refused, never `[]`: the HTTP leg reads its cookies from here on every request and
391
+ // `persistSession` stores what this answers, so an empty jar went out logged-out and was
392
+ // saved as the session — both silently, while every other missing port method refuses.
393
+ if (typeof init.browser.cookies !== 'function') {
394
+ throw new NotImplementedError({
395
+ cause:
396
+ 'session() on a CDP browser with no cookies() method is not implemented by this driver',
397
+ fix: 'upgrade the launcher to a puppeteer-core that exposes browser.cookies() — the HTTP leg and a persisted session both read the jar through it',
398
+ });
399
+ }
400
+ const jar = parse(cookieSchema, await init.browser.cookies());
382
401
  return {
383
- cookies:
384
- typeof source.cookies === 'function' ? parse(cookieSchema, await source.cookies()) : [],
402
+ cookies: jar,
385
403
  headers: {},
386
404
  // `browserRecord` and not `t.record()`: a page may legitimately hold a key called
387
405
  // `constructor`, and the schema refuses that name outright — see `browser-record.ts`.
@@ -394,11 +412,17 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
394
412
  guard('restore', async () => {
395
413
  // Two halves, because they belong to two different moments: cookies are the browser's and
396
414
  // can be put back now, storage is an ORIGIN's and cannot exist until one is loaded.
397
- const source = init.browser as {
398
- setCookie?: (...cookies: readonly unknown[]) => Promise<void>;
399
- };
400
- if (typeof source.setCookie === 'function' && session.cookies.length > 0) {
401
- await source.setCookie(...session.cookies);
415
+ if (session.cookies.length > 0) {
416
+ // Refused rather than skipped: a restore that dropped the cookies opened the session
417
+ // logged-out while the run believed it restored one.
418
+ if (typeof init.browser.setCookie !== 'function') {
419
+ throw new NotImplementedError({
420
+ cause:
421
+ 'restore() of cookies on a CDP browser with no setCookie() method is not implemented by this driver',
422
+ fix: 'upgrade the launcher to a puppeteer-core that exposes browser.setCookie(), or set auth: { reuse: false } on the scrape() definition so no session is restored',
423
+ });
424
+ }
425
+ await init.browser.setCookie(...session.cookies);
402
426
  }
403
427
  pendingStorage = Object.keys(session.storage).length > 0 ? session : undefined;
404
428
  await applyPendingStorage();
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
  }
package/src/driver-cdp.ts CHANGED
@@ -23,6 +23,7 @@ import { isScrapeError } from './errors';
23
23
  import type { ScrapeEventFields } from './events';
24
24
  import { errorCode } from './failures';
25
25
  import { httpOverFetch } from './http';
26
+ import { launchArgs } from './launch-args';
26
27
  import { pageOverTarget } from './page-over-target';
27
28
  import type { ScrapeTarget } from './target';
28
29
  import { endpointLabel, hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
@@ -38,7 +39,10 @@ export interface BrowserOptions {
38
39
  * `egress(input)` — `SessionInit.proxy` — wins over it, per session.
39
40
  */
40
41
  readonly proxy?: string | undefined;
41
- /** Extra launch/connect arguments, passed through untouched. */
42
+ /**
43
+ * Extra launch/connect options, passed through — except `args`, which `localBrowser()` puts
44
+ * BEFORE the exit's `--proxy-server` and screens for a second route (`launch-args.ts`).
45
+ */
42
46
  readonly options?: Record<string, unknown> | undefined;
43
47
  /** How long a graceful `close()` may take before the process is killed. */
44
48
  readonly graceMs?: number | undefined;
@@ -267,6 +271,11 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
267
271
  // The userinfo never reaches the argument list: Chrome ignores it there, and an argument is
268
272
  // readable by every process on the box. `opened()` answers the challenge instead.
269
273
  const proxyArg = proxy !== undefined && authenticate ? splitCredentials(proxy).bare : proxy;
274
+ // The caller's args FIRST and the exit appended after them, never a spread that lets one
275
+ // replace the other: `{ args: ['--no-sandbox'] }` — every container deploy's — used to
276
+ // overwrite the proxy flag, and the browser dialled direct while the session reported the exit.
277
+ const { args: callerArgs, ...passthrough } = options.options ?? {};
278
+ const args = launchArgs(callerArgs, proxyArg);
270
279
  let browser: CdpBrowserLike;
271
280
  try {
272
281
  browser = await launch.call(options.launcher, {
@@ -275,8 +284,8 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
275
284
  ? {}
276
285
  : { executablePath: options.executablePath }),
277
286
  ...(options.profileDir === undefined ? {} : { userDataDir: options.profileDir }),
278
- ...(proxyArg === undefined ? {} : { args: [`--proxy-server=${proxyArg}`] }),
279
- ...options.options,
287
+ ...passthrough,
288
+ ...(args === undefined ? {} : { args }),
280
289
  });
281
290
  } catch (thrown) {
282
291
  throw browserUnreachable(CDP_DRIVER, thrown);
package/src/driver.ts CHANGED
@@ -102,7 +102,7 @@ export interface ScrapeDriver {
102
102
 
103
103
  let ambient: ScrapeDriver | undefined;
104
104
 
105
- /** Set once at boot from `app.config.ts`. A scrape's own `driver:` overrides it per definition. */
105
+ /** Set once by the app at boot — no config key names it. A scrape's own `driver:` overrides it. */
106
106
  export function setScrapeDriver(driver: ScrapeDriver): void {
107
107
  ambient = driver;
108
108
  }
@@ -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,
@@ -72,3 +72,40 @@ export const promptAnswerInvalid = (input: {
72
72
  fix: 'publish the answer with answerPrompt({ runId, index, answer }) and never publishEvent() by hand — the answer is sealed for this one prompt, so a plain payload is refused rather than typed into the site',
73
73
  meta: { scrape: input.scrape, label: input.label, event: input.event },
74
74
  });
75
+
76
+ /** Lists every `localBrowser(` call site and every proxy switch in the app — where the edit goes. */
77
+ export const LAUNCH_ARGS_FIX =
78
+ "grep -rnE --include='*.ts' -e 'localBrowser\\(' -e '--(no-)?proxy-' .";
79
+
80
+ /**
81
+ * The caller's launch args set the exit themselves, or are not a list a launcher can take. The
82
+ * switch NAME only, never its value: `--proxy-server=http://user:pass@…` is a credential a caller
83
+ * may well have written, and this cause is written to the dead-letter row.
84
+ */
85
+
86
+ export const launchArgsInvalid = (reason: string, route: boolean): ScrapeError =>
87
+ new ScrapeError({
88
+ code: 'X_SCRAPE_LAUNCH_ARGS_INVALID',
89
+ cause: `localBrowser({ options: { args } }) ${reason} — ${
90
+ route
91
+ ? 'delete the switch from args and pass the exit as localBrowser({ proxy }) or scrape({ egress })'
92
+ : "pass args as a list of strings, e.g. options: { args: ['--no-sandbox'] }"
93
+ }`,
94
+ // One command, the same for both shapes: it lists every call site and every proxy switch.
95
+ fix: LAUNCH_ARGS_FIX,
96
+ meta: { reason },
97
+ });
98
+
99
+ /**
100
+ * A name a WILDCARD admitted, resolved to an address inside the network. The address CLASS and
101
+ * never the address: the cause reaches the dead-letter row, and the hostname is enough to act on.
102
+ */
103
+ export const hostResolvesInward = (url: string, hostname: string, kind: string): ScrapeError =>
104
+ new ScrapeError({
105
+ code: 'X_SCRAPE_HOST_BLOCKED',
106
+ cause: `${url} is admitted by a wildcard in allowHosts, and ${hostname} resolves to ${
107
+ kind === 'unresolvable' ? 'no address' : `a ${kind} address`
108
+ } — a wildcard admits public addresses only`,
109
+ fix: `name the host exactly on the scrape() definition — allowHosts: [${JSON.stringify(hostname)}] — if it is meant to be reached inside your network`,
110
+ meta: { url, host: hostname, addressClass: kind },
111
+ });
@@ -8,7 +8,6 @@ import {
8
8
  isRetryableStatus,
9
9
  renderFixShellArg,
10
10
  renderThrowable,
11
- UltimateError,
12
11
  } from '@ultimat3/core';
13
12
  import type { CaptureClip } from './capture-clip';
14
13
  import { ScrapeError } from './errors';
@@ -303,7 +302,11 @@ export const fixtureMissing = (url: string, dir: string): ScrapeError =>
303
302
  export const fixtureStale = (url: string, ageMs: number, maxAgeMs: number): ScrapeError =>
304
303
  new ScrapeError({
305
304
  code: 'X_SCRAPE_FIXTURE_STALE',
306
- cause: `the recording of ${url} is ${String(Math.round(ageMs / 86_400_000))} days old and fixtureBrowser declares maxAge ${String(Math.round(maxAgeMs / 86_400_000))} days`,
305
+ cause: `the recording of ${url} ${
306
+ Number.isFinite(ageMs)
307
+ ? `is ${String(Math.round(ageMs / 86_400_000))} days old`
308
+ : 'has a recordedAt that is not a date'
309
+ } and fixtureBrowser declares maxAge ${String(Math.round(maxAgeMs / 86_400_000))} days`,
307
310
  fix: 're-record the fixture directory, or raise maxAge on fixtureBrowser({ maxAge }) with the reason an old recording still proves something',
308
311
  meta: { url, ageMs, maxAgeMs },
309
312
  });
@@ -398,7 +401,7 @@ export const authFailed = (scrape: string, detail: string): ScrapeError =>
398
401
  meta: { scrape },
399
402
  });
400
403
 
401
- export const sessionExpired = (scrape: string, key: string): ScrapeError =>
404
+ export const scrapeSessionExpired = (scrape: string, key: string): ScrapeError =>
402
405
  new ScrapeError({
403
406
  code: 'X_SCRAPE_SESSION_EXPIRED',
404
407
  cause: `the stored session ${key} for scrape "${scrape}" failed its validate() probe and the definition declares no auth.login`,
@@ -426,19 +429,6 @@ export const blocked = (scrape: string, url: string, detail: string): ScrapeErro
426
429
  meta: { scrape, url },
427
430
  });
428
431
 
429
- /**
430
- * The honest stub, in the shape `packages/jobs/src/driver-redis.ts` uses: correct types so an app
431
- * can be written against the seam, and one labelled throw so nobody discovers the gap from a
432
- * silently-skipped recovery.
433
- */
434
- export const scrapeNotImplemented = (feature: string, fix: string): UltimateError =>
435
- new UltimateError({
436
- code: 'X_NOT_IMPLEMENTED',
437
- cause: `${feature} is declared and not implemented in @ultimat3/scraping`,
438
- fix,
439
- meta: { feature },
440
- });
441
-
442
432
  /**
443
433
  * The crop rectangle's three refusals, one code. Each is a caller's DECLARATION being impossible
444
434
  * rather than a site behaving badly, so each names the edit that fixes it — and the alternative in
package/src/errors.ts CHANGED
@@ -41,11 +41,12 @@ export const SCRAPE_OWNED_ERROR_CODES = [
41
41
  'X_SCRAPE_KEY_INVALID',
42
42
  'X_SCRAPE_EGRESS_UNSUPPORTED',
43
43
  'X_SCRAPE_EGRESS_IN_PAYLOAD',
44
+ 'X_SCRAPE_LAUNCH_ARGS_INVALID',
44
45
  ] as const;
45
46
 
46
47
  /**
47
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s and `recover.ts` throws it for the agent seam, the
48
- * way `packages/jobs/src/driver-redis.ts` does for its stub. `X_ENV_MISSING` is core's too: a
48
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s: `recover.ts`, `cdp-target.ts` and `html-target.ts`
49
+ * throw it as core's own `NotImplementedError`, never a constructor of this package's. `X_ENV_MISSING` is core's too: a
49
50
  * declared secret with no value in the environment is a missing environment variable, not a
50
51
  * scraping concept needing its own code. No title is kept here for either — one code, one owner,
51
52
  * or the two copies drift.
@@ -95,6 +96,8 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
95
96
  X_SCRAPE_KEY_INVALID: 'the key chord names no key a browser can press',
96
97
  X_SCRAPE_EGRESS_UNSUPPORTED: 'this driver cannot give the session the exit the run asked for',
97
98
  X_SCRAPE_EGRESS_IN_PAYLOAD: 'the run exit carries a credential that is also in the job payload',
99
+ X_SCRAPE_LAUNCH_ARGS_INVALID:
100
+ 'the launch options carry arguments this driver cannot hand the browser',
98
101
  };
99
102
 
100
103
  // One unconditional call, so a second package claiming one of these codes throws
@@ -202,6 +205,9 @@ export const SCRAPE_ERROR_RETRY = {
202
205
  X_SCRAPE_EGRESS_UNSUPPORTED: 'terminal',
203
206
  // The payload is the row: every remaining attempt reads the same credential out of it.
204
207
  X_SCRAPE_EGRESS_IN_PAYLOAD: 'terminal',
208
+ // The args are the deploy's own literal: attempt 2 launches with the same list and is refused
209
+ // the same way. Launching anyway is the retry that "works" from the worker's own address.
210
+ X_SCRAPE_LAUNCH_ARGS_INVALID: 'terminal',
205
211
  } as const satisfies Readonly<Record<ScrapeOwnedErrorCode, 'retryable' | 'terminal'>>;
206
212
 
207
213
  registerErrorRetry(SCRAPE_ERROR_RETRY);
@@ -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)) {
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 `finiteOption`
73
- // and not `finiteCount` — `0.5` is the documented value.
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
- : finiteOption('the scrape expect', 'maxDrop', check.expect.maxDrop);
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
  }