@ultimat3/scraping 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/driver-cdp.ts CHANGED
@@ -10,14 +10,23 @@
10
10
  // The library is passed IN (`launcher`), never imported — see `cdp-port.ts` for why that seam is
11
11
  // what keeps a puppeteer type out of the vocabulary and this package free of a dependency.
12
12
 
13
+ import type { Logger } from '@ultimat3/core';
14
+ import type { MoneyValue } from '@ultimat3/schema';
13
15
  import type { CdpBrowserLike, CdpLauncherLike } from './cdp-port';
16
+ import type { CdpResolver } from './cdp-resolver';
17
+ import { acquireCdp } from './cdp-resolver';
14
18
  import { CDP_DRIVER, cdpTarget } from './cdp-target';
15
19
  import type { ScrapeDriver, ScrapeSession, SessionInit } from './driver';
16
20
  import { browserUnreachable, cdpAttachFailed, remoteRequired } from './error-throws';
21
+ import { egressUnsupported } from './error-throws-session';
17
22
  import { isScrapeError } from './errors';
23
+ import type { ScrapeEventFields } from './events';
24
+ import { errorCode } from './failures';
18
25
  import { httpOverFetch } from './http';
26
+ import { launchArgs } from './launch-args';
19
27
  import { pageOverTarget } from './page-over-target';
20
28
  import type { ScrapeTarget } from './target';
29
+ import { endpointLabel, hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
21
30
  import { createWedgeGuard } from './watchdog';
22
31
 
23
32
  export { CDP_DRIVER } from './cdp-target';
@@ -25,8 +34,15 @@ export { CDP_DRIVER } from './cdp-target';
25
34
  export interface BrowserOptions {
26
35
  /** `puppeteer` itself, or anything with the same two methods. */
27
36
  readonly launcher: CdpLauncherLike;
37
+ /**
38
+ * The exit every session of this driver dials when the run names none. A scrape's own
39
+ * `egress(input)` — `SessionInit.proxy` — wins over it, per session.
40
+ */
28
41
  readonly proxy?: string | undefined;
29
- /** 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
+ */
30
46
  readonly options?: Record<string, unknown> | undefined;
31
47
  /** How long a graceful `close()` may take before the process is killed. */
32
48
  readonly graceMs?: number | undefined;
@@ -43,8 +59,23 @@ export interface LocalBrowserOptions extends BrowserOptions {
43
59
  }
44
60
 
45
61
  export interface RemoteBrowserOptions extends BrowserOptions {
46
- /** The `webSocketDebuggerUrl` from `/json/version`, or a provider's connect URL. */
47
- readonly cdpUrl: string;
62
+ /**
63
+ * The `webSocketDebuggerUrl` from `/json/version` or a provider's connect URL — or a
64
+ * `CdpResolver`, for a browser rented PER SESSION: called once per `open()`, told the run's
65
+ * exit, and its `release()` runs exactly once when the session ends. A fixed URL is one browser
66
+ * already bound to one exit, so a run asking for another is `X_SCRAPE_EGRESS_UNSUPPORTED`.
67
+ */
68
+ readonly cdpUrl: string | CdpResolver;
69
+ }
70
+
71
+ /** What one session was opened WITH, decided before the browser exists. */
72
+ interface SessionWiring {
73
+ /** The exit both legs dial: the run's, else the driver's. */
74
+ readonly proxy: string | undefined;
75
+ /** True when THIS driver must answer the proxy's auth challenge — a launched browser only. */
76
+ readonly authenticate: boolean;
77
+ readonly release?: (() => Promise<void>) | undefined;
78
+ readonly cost?: MoneyValue | undefined;
48
79
  }
49
80
 
50
81
  /**
@@ -53,9 +84,27 @@ export interface RemoteBrowserOptions extends BrowserOptions {
53
84
  * outside `cdpTarget`'s own `guard()`, so a bare library `Error` would otherwise reach the job's
54
85
  * retry classifier with no code at all.
55
86
  */
56
- async function opened(browser: CdpBrowserLike, init: SessionInit): Promise<ScrapeTarget> {
87
+ async function opened(
88
+ browser: CdpBrowserLike,
89
+ init: SessionInit,
90
+ wiring: SessionWiring,
91
+ ): Promise<ScrapeTarget> {
57
92
  try {
58
93
  const page = await browser.newPage();
94
+ if (wiring.authenticate && wiring.proxy !== undefined) {
95
+ // Before the first navigation, and through its owner: the challenge arrives with the first
96
+ // request, and `--proxy-server` was deliberately handed the exit WITHOUT its userinfo.
97
+ if (typeof page.authenticate !== 'function') {
98
+ throw egressUnsupported({
99
+ driver: CDP_DRIVER,
100
+ egress: wiring.proxy,
101
+ reason:
102
+ 'the exit carries credentials and this launcher has no page.authenticate() to answer the proxy with',
103
+ });
104
+ }
105
+ const { username, password } = splitCredentials(wiring.proxy);
106
+ await page.authenticate({ username, password });
107
+ }
59
108
  const target = await cdpTarget({ page, browser, rules: init.rules, clock: init.clock });
60
109
  if (init.restore !== undefined) await target.restore(init.restore);
61
110
  return target;
@@ -84,13 +133,14 @@ async function sessionOver(
84
133
  browser: CdpBrowserLike,
85
134
  init: SessionInit,
86
135
  options: BrowserOptions,
136
+ wiring: SessionWiring,
87
137
  ): Promise<ScrapeSession> {
88
138
  // Acquire, then roll back on ANY throw — the shape `releaseBoot` uses in `packages/cli/src/
89
139
  // serve.ts`. Between the launch and the `WedgeGuard` below, nothing else holds this browser:
90
140
  // `runScrape`'s `finally { session.close() }` never runs for a session `open()` did not return,
91
141
  // so a tab limit, a refused interception or a restore that threw left a real Chrome process —
92
142
  // or a remote session somebody is billing for — running per attempt, unattributed.
93
- const target = await opened(browser, init);
143
+ const target = await opened(browser, init, wiring);
94
144
  const guard = createWedgeGuard({
95
145
  clock: init.clock,
96
146
  what: `scrape "${init.name}"`,
@@ -116,10 +166,11 @@ async function sessionOver(
116
166
  return {
117
167
  driver: CDP_DRIVER,
118
168
  // The exit the browser was launched or attached with, handed back so the run's robots read
119
- // presents the SAME client identity to the origin the page loads from. `options.proxy` and
120
- // not `init.proxy`: this is what the launch args and the HTTP leg below actually carry, and a
121
- // reported exit that nothing dialled would be worse than none.
122
- ...(options.proxy === undefined ? {} : { proxy: options.proxy }),
169
+ // presents the SAME client identity to the origin the page loads from. `wiring.proxy` is what
170
+ // the launch args and the HTTP leg below actually carry — the run's exit when it named one,
171
+ // the driver's otherwise — and a reported exit that nothing dialled would be worse than none.
172
+ ...(wiring.proxy === undefined ? {} : { proxy: wiring.proxy }),
173
+ ...(wiring.cost === undefined ? {} : { browserCost: wiring.cost }),
123
174
  page: pageOverTarget(target, {
124
175
  clock: init.clock,
125
176
  allowHosts: init.rules.allowHosts,
@@ -129,6 +180,7 @@ async function sessionOver(
129
180
  signal,
130
181
  onActivity,
131
182
  pace: init.pace,
183
+ usage: init.usage,
132
184
  }),
133
185
  http: httpOverFetch({
134
186
  rules: init.rules,
@@ -142,14 +194,64 @@ async function sessionOver(
142
194
  pace: init.pace,
143
195
  signal,
144
196
  onActivity,
145
- proxy: options.proxy,
197
+ proxy: wiring.proxy,
198
+ usage: init.usage,
146
199
  // The run's secrets, so the ONE thing this leg quotes from the site — the first 200 bytes
147
200
  // of a non-2xx body, in `X_SCRAPE_HTTP_FAILED`'s cause — cannot carry the password the
148
201
  // login endpoint echoed back. The bag was in scope here and unread.
149
202
  secrets: init.secrets,
150
203
  }),
151
- close: () => guard.shutdown(),
204
+ // Both halves, in order, and neither can throw: the browser is quit under its ceiling, THEN
205
+ // the rental is handed back — a provider told "done" while the socket is still open bills the
206
+ // tail to nobody. `release` is latched, so a second `close()` does nothing.
207
+ close: async (): Promise<void> => {
208
+ await guard.shutdown();
209
+ await wiring.release?.();
210
+ },
211
+ };
212
+ }
213
+
214
+ /** The line a failed hand-back of a rented browser writes. Stable: alerts are keyed on it. */
215
+ const RELEASE_FAILED_EVENT = 'scrape.browser.release_failed';
216
+
217
+ /**
218
+ * The resolver with its `release` WATCHED. `acquireCdp` swallows a rejected hand-back on purpose
219
+ * — it runs on the way out of a run whose own outcome is the one the reader needs — and swallowed
220
+ * silently, a browser its provider is still billing for left no line anywhere. The error's CODE
221
+ * and the endpoint's scheme and host, nothing else: a provider's failure text quotes its connect
222
+ * URL, and that URL is its access token. Rethrown, so `acquireCdp`'s contract is untouched.
223
+ */
224
+ const watched =
225
+ (resolver: CdpResolver, logger: Logger): CdpResolver =>
226
+ async (request) => {
227
+ const resolution = await resolver(request);
228
+ if (resolution.release === undefined) return resolution;
229
+ return {
230
+ cdpUrl: resolution.cdpUrl,
231
+ cost: resolution.cost,
232
+ release: async (): Promise<void> => {
233
+ try {
234
+ await resolution.release?.();
235
+ } catch (thrown) {
236
+ const fields: ScrapeEventFields = {
237
+ code: errorCode(thrown),
238
+ driver: CDP_DRIVER,
239
+ origin: endpointLabel(resolution.cdpUrl),
240
+ };
241
+ logger.warn(RELEASE_FAILED_EVENT, { ...fields });
242
+ throw thrown;
243
+ }
244
+ },
245
+ };
152
246
  };
247
+
248
+ /** The run's exit over the driver's, and its credential-bearing parts into the redaction set. */
249
+ function exitFor(init: SessionInit, options: BrowserOptions): string | undefined {
250
+ const exit = init.proxy ?? options.proxy;
251
+ if (exit !== undefined && hasCredentials(exit)) {
252
+ for (const value of urlSecretValues(exit)) init.secrets?.conceal(value);
253
+ }
254
+ return exit;
153
255
  }
154
256
 
155
257
  /**
@@ -164,6 +266,16 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
164
266
  if (launch === undefined) {
165
267
  throw remoteRequired('local browser: the launcher has no launch()');
166
268
  }
269
+ const proxy = exitFor(init, options);
270
+ const authenticate = proxy !== undefined && hasCredentials(proxy);
271
+ // The userinfo never reaches the argument list: Chrome ignores it there, and an argument is
272
+ // readable by every process on the box. `opened()` answers the challenge instead.
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);
167
279
  let browser: CdpBrowserLike;
168
280
  try {
169
281
  browser = await launch.call(options.launcher, {
@@ -172,13 +284,13 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
172
284
  ? {}
173
285
  : { executablePath: options.executablePath }),
174
286
  ...(options.profileDir === undefined ? {} : { userDataDir: options.profileDir }),
175
- ...(options.proxy === undefined ? {} : { args: [`--proxy-server=${options.proxy}`] }),
176
- ...options.options,
287
+ ...passthrough,
288
+ ...(args === undefined ? {} : { args }),
177
289
  });
178
290
  } catch (thrown) {
179
291
  throw browserUnreachable(CDP_DRIVER, thrown);
180
292
  }
181
- return sessionOver(browser, init, options);
293
+ return sessionOver(browser, init, options, { proxy, authenticate });
182
294
  },
183
295
  };
184
296
  }
@@ -192,16 +304,59 @@ export function remoteBrowser(options: RemoteBrowserOptions): ScrapeDriver {
192
304
  if (connect === undefined || options.cdpUrl === '') {
193
305
  throw remoteRequired(CDP_DRIVER);
194
306
  }
195
- let browser: CdpBrowserLike;
307
+ // Refused BEFORE anything is rented or attached. A fixed URL is a browser that already
308
+ // exists on whatever exit it was started with; attaching and dialling the HTTP leg through
309
+ // the run's exit would be one session presenting two client identities.
310
+ if (
311
+ typeof options.cdpUrl === 'string' &&
312
+ init.proxy !== undefined &&
313
+ init.proxy !== options.proxy
314
+ ) {
315
+ throw egressUnsupported({
316
+ driver: CDP_DRIVER,
317
+ egress: init.proxy,
318
+ reason: `remoteBrowser({ cdpUrl }) was given a fixed URL, and the browser behind it is already bound to ${
319
+ options.proxy === undefined ? 'a direct connection' : endpointLabel(options.proxy)
320
+ }`,
321
+ });
322
+ }
323
+ const proxy = exitFor(init, options);
324
+ const source =
325
+ typeof options.cdpUrl === 'string' ? options.cdpUrl : watched(options.cdpUrl, init.logger);
326
+ const acquired = await acquireCdp(source, {
327
+ scrape: init.name,
328
+ runId: init.runId,
329
+ egress: proxy,
330
+ signal: init.signal,
331
+ });
332
+ // From here the rental exists, and `runScrape`'s `finally` cannot hand back a session
333
+ // `open()` never returned — so every throw below releases it first. `sessionOver` rolls the
334
+ // BROWSER back on its own; this is the half only the resolver knows how to end.
196
335
  try {
197
- browser = await connect.call(options.launcher, {
198
- browserWSEndpoint: options.cdpUrl,
199
- ...options.options,
336
+ if (acquired.cdpUrl === '') throw remoteRequired(CDP_DRIVER);
337
+ // A provider's connect URL is its access token. Into the run's secret set before the
338
+ // attach, so not even the failure artifact of a run that died here can carry it.
339
+ for (const value of urlSecretValues(acquired.cdpUrl)) init.secrets?.conceal(value);
340
+ let browser: CdpBrowserLike;
341
+ try {
342
+ browser = await connect.call(options.launcher, {
343
+ browserWSEndpoint: acquired.cdpUrl,
344
+ ...options.options,
345
+ });
346
+ } catch (thrown) {
347
+ throw cdpAttachFailed(acquired.cdpUrl, thrown);
348
+ }
349
+ return await sessionOver(browser, init, options, {
350
+ proxy,
351
+ // The provider launched this browser; answering its proxy is the provider's.
352
+ authenticate: false,
353
+ release: acquired.release,
354
+ cost: acquired.cost,
200
355
  });
201
356
  } catch (thrown) {
202
- throw cdpAttachFailed(options.cdpUrl, thrown);
357
+ await acquired.release();
358
+ throw thrown;
203
359
  }
204
- return sessionOver(browser, init, options);
205
360
  },
206
361
  };
207
362
  }
package/src/driver.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // Two methods. `open` hands back a session; `close` ends it. Everything else a scraper does goes
6
6
  // through `ScrapePage`, which is driver-blind by construction.
7
7
 
8
+ import type { Logger } from '@ultimat3/core';
9
+ import type { MoneyValue } from '@ultimat3/schema';
8
10
  import type { ScrapeClock } from './clock';
9
11
  import type { ScrapeHttp } from './http';
10
12
  import type { InterceptRules } from './intercept';
@@ -12,12 +14,20 @@ import type { ScrapePage } from './page';
12
14
  import type { RobotsGate } from './robots';
13
15
  import type { ScrapeSecrets } from './secrets';
14
16
  import type { SessionSnapshot } from './session-state';
17
+ import type { UsageMeter } from './usage';
15
18
 
16
19
  export interface SessionInit {
17
20
  /** The scrape's name — every error cause raised inside the session carries it. */
18
21
  readonly name: string;
19
22
  readonly rules: InterceptRules;
20
23
  readonly clock: ScrapeClock;
24
+ /**
25
+ * The run's logger — `scrapeLogger()`'s child, so every line carries the scrape and the run.
26
+ * Required, because `close()` never throws: a teardown that failed (a rented browser its
27
+ * provider would not take back) has no other way to be seen, and a session with nowhere to say
28
+ * so is how that bill went unattributed. Fields stay inside `ScrapeEventFields`.
29
+ */
30
+ readonly logger: Logger;
21
31
  /** Per-operation default, in ms. A `waitFor` with its own `timeout` overrides it. */
22
32
  readonly timeoutMs: number;
23
33
  readonly secrets?: ScrapeSecrets | undefined;
@@ -26,8 +36,17 @@ export interface SessionInit {
26
36
  readonly signal?: AbortSignal | undefined;
27
37
  /** A previously captured session, put back before the first navigation. */
28
38
  readonly restore?: SessionSnapshot | undefined;
29
- /** BOTH transports dial through it. A different exit IP mid-session is a different client. */
39
+ /**
40
+ * The exit THIS run asked for — `egress(input)` on the definition. It wins over a driver's own
41
+ * `proxy` option, BOTH transports dial through it, and a driver that cannot is required to
42
+ * refuse with `X_SCRAPE_EGRESS_UNSUPPORTED` rather than dial another: a different exit IP
43
+ * mid-session is a different client. May carry credentials; never print it.
44
+ */
30
45
  readonly proxy?: string | undefined;
46
+ /** The job run this session belongs to. A `CdpResolver` tags the browser it rents with it. */
47
+ readonly runId?: string | undefined;
48
+ /** The run's usage meter. Pass it to `pageOverTarget` and the HTTP transport; see `usage.ts`. */
49
+ readonly usage?: UsageMeter | undefined;
31
50
  /** Awaited before every navigation AND every HTTP request — one budget across both legs. */
32
51
  readonly pace?: ((signal?: AbortSignal) => Promise<void>) | undefined;
33
52
  /** Called on every operation on either transport. The wedge watchdog measures the gaps. */
@@ -51,6 +70,11 @@ export interface ScrapeSession {
51
70
  * as allow-everything. A driver that omits it is asked for its rules directly, as before.
52
71
  */
53
72
  readonly proxy?: string | undefined;
73
+ /**
74
+ * What the browser behind this session cost, when the driver rented it and the provider said
75
+ * so at acquire time. `Money`, never a float. It reaches `ScrapeReport.usage.browserCost`.
76
+ */
77
+ readonly browserCost?: MoneyValue | undefined;
54
78
  readonly page: ScrapePage;
55
79
  /**
56
80
  * The second transport, bound to the SAME session as the page: the browser's cookies, headers
@@ -78,7 +102,7 @@ export interface ScrapeDriver {
78
102
 
79
103
  let ambient: ScrapeDriver | undefined;
80
104
 
81
- /** 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. */
82
106
  export function setScrapeDriver(driver: ScrapeDriver): void {
83
107
  ambient = driver;
84
108
  }
@@ -0,0 +1,111 @@
1
+ // The failure modes of a SESSION a service is built on: an exit the driver cannot dial, and a
2
+ // prompt answered over the event bus. Split from `error-throws.ts`, which stands at its size
3
+ // ceiling; the codes themselves stay declared in `errors.ts` — one registry, one place.
4
+
5
+ import { ScrapeError } from './errors';
6
+ import { endpointLabel } from './url-secrets';
7
+
8
+ /**
9
+ * A driver handed an exit it cannot dial. Scheme and host of both exits and nothing else: an exit
10
+ * URL carries the proxy account in its userinfo, and this cause is written to the dead-letter row.
11
+ */
12
+ export const egressUnsupported = (input: {
13
+ readonly driver: string;
14
+ readonly egress: string;
15
+ readonly reason: string;
16
+ }): ScrapeError =>
17
+ new ScrapeError({
18
+ code: 'X_SCRAPE_EGRESS_UNSUPPORTED',
19
+ cause: `the ${input.driver} driver cannot give this session the exit ${endpointLabel(input.egress)}: ${input.reason}`,
20
+ fix: 'pass remoteBrowser({ cdpUrl: resolver }) so each run rents a browser on its own exit, or drop egress: from the scrape() definition and set the exit once on the driver — remoteBrowser({ proxy })',
21
+ meta: { driver: input.driver, egress: endpointLabel(input.egress) },
22
+ });
23
+
24
+ /**
25
+ * The exit `egress` answered carries a credential, and that credential is in the job's own input —
26
+ * so it rode the queue payload and sits in `x_jobs` in the clear (`redactInput` hides a key by
27
+ * NAME for display; the column is untouched). Scheme and host only, like every exit this package
28
+ * prints: the cause is written to the dead-letter row, beside the payload it is about.
29
+ */
30
+ export const egressInPayload = (input: {
31
+ readonly scrape: string;
32
+ readonly egress: string;
33
+ }): ScrapeError =>
34
+ new ScrapeError({
35
+ code: 'X_SCRAPE_EGRESS_IN_PAYLOAD',
36
+ cause: `scrape "${input.scrape}" was given the exit ${endpointLabel(input.egress)} with a credential that is also in the run's input, so the credential is stored in the queue row in the clear`,
37
+ fix: `enqueue the id of the row that holds the exit, and resolve it in the worker: egress: async ({ connectionId }) => (await repo.connectionById(connectionId))?.exit on scrape("${input.scrape}") — a .sealed() column keeps it encrypted at rest — then rotate the exposed credential`,
38
+ meta: { scrape: input.scrape, egress: endpointLabel(input.egress) },
39
+ });
40
+
41
+ /**
42
+ * `eventPrompt()` waited its whole budget. The event name is in the cause because it is the one
43
+ * fact the answering side needs and cannot guess: the run id and the prompt's index within it.
44
+ */
45
+ export const promptTimedOut = (input: {
46
+ readonly scrape: string;
47
+ readonly label: string;
48
+ readonly event: string;
49
+ readonly waitedMs: number;
50
+ }): ScrapeError =>
51
+ new ScrapeError({
52
+ code: 'X_SCRAPE_PROMPT_UNANSWERED',
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 })',
55
+ meta: {
56
+ scrape: input.scrape,
57
+ label: input.label,
58
+ event: input.event,
59
+ waitedMs: input.waitedMs,
60
+ },
61
+ });
62
+
63
+ /** Something was published to the prompt's event that `answerPrompt()` did not write. */
64
+ export const promptAnswerInvalid = (input: {
65
+ readonly scrape: string;
66
+ readonly label: string;
67
+ readonly event: string;
68
+ }): ScrapeError =>
69
+ new ScrapeError({
70
+ code: 'X_SCRAPE_PROMPT_UNANSWERED',
71
+ cause: `scrape "${input.scrape}" asked for "${input.label}" and the event published to ${input.event} does not carry a sealed answer`,
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
+ meta: { scrape: input.scrape, label: input.label, event: input.event },
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,10 +8,11 @@ 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';
14
+ import { SECRET_PLACEHOLDER } from './secrets';
15
+ import { endpointLabel, urlSecretValues } from './url-secrets';
15
16
 
16
17
  /**
17
18
  * Two shapes of the same failure, one code. `name` is the driver a definition ASKED for; `scrape`
@@ -34,12 +35,43 @@ export const driverUnknown = (
34
35
  meta: { driver: name ?? 'none', ...(scrape === undefined ? {} : { scrape }) },
35
36
  });
36
37
 
38
+ /**
39
+ * The launcher's own words with every credential-bearing part of `url` cut out. A library's
40
+ * connect error repeats the URL it was handed — `Unexpected server response: 401 for wss://…
41
+ * ?token=…` — so naming scheme and host in the cause is not enough on its own.
42
+ */
43
+ const renderWithout = (thrown: unknown, url: string): string => {
44
+ let text = renderThrowable(thrown);
45
+ const values = [...urlSecretValues(url)].sort((a, b) => b.length - a.length);
46
+ for (const value of values) text = text.split(value).join(SECRET_PLACEHOLDER);
47
+ return text;
48
+ };
49
+
50
+ /**
51
+ * SCHEME AND HOST ONLY, for every CDP URL — a fixed one included. A provider's connect URL carries
52
+ * its access token in the query or the path, this error's cause is written to the dead-letter row
53
+ * and printed by `x jobs show`, and a `cdpUrl` read from the environment is that same URL. The
54
+ * host is what tells a reader which endpoint refused; nothing after it is theirs to print.
55
+ */
37
56
  export const cdpAttachFailed = (cdpUrl: string, thrown: unknown): ScrapeError =>
38
57
  new ScrapeError({
39
58
  code: 'X_SCRAPE_CDP_ATTACH_FAILED',
40
- cause: `the CDP endpoint ${cdpUrl} refused the attach: ${renderThrowable(thrown)}`,
59
+ cause: `the CDP endpoint ${endpointLabel(cdpUrl)} refused the attach: ${renderWithout(thrown, cdpUrl)}`,
41
60
  fix: 'curl "$CDP_URL/json/version" to confirm the endpoint answers, then pass that webSocketDebuggerUrl to remoteBrowser({ cdpUrl })',
42
- meta: { cdpUrl },
61
+ meta: { cdpUrl: endpointLabel(cdpUrl) },
62
+ });
63
+
64
+ /**
65
+ * The resolver itself failed — the provider's API, not the browser it would have handed over. The
66
+ * same code and the same retryable classification: "no browser to attach to yet" is one fact,
67
+ * whichever call discovered it.
68
+ */
69
+ export const cdpResolveFailed = (scrape: string, thrown: unknown): ScrapeError =>
70
+ new ScrapeError({
71
+ code: 'X_SCRAPE_CDP_ATTACH_FAILED',
72
+ cause: `the CdpResolver on remoteBrowser({ cdpUrl }) could not rent a browser for scrape "${scrape}": ${renderThrowable(thrown)}`,
73
+ fix: "run the resolver's own request by hand against the provider to see its answer, then correct the credential or the endpoint the resolver passed to remoteBrowser({ cdpUrl }) uses",
74
+ meta: { scrape },
43
75
  });
44
76
 
45
77
  /**
@@ -270,7 +302,11 @@ export const fixtureMissing = (url: string, dir: string): ScrapeError =>
270
302
  export const fixtureStale = (url: string, ageMs: number, maxAgeMs: number): ScrapeError =>
271
303
  new ScrapeError({
272
304
  code: 'X_SCRAPE_FIXTURE_STALE',
273
- 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`,
274
310
  fix: 're-record the fixture directory, or raise maxAge on fixtureBrowser({ maxAge }) with the reason an old recording still proves something',
275
311
  meta: { url, ageMs, maxAgeMs },
276
312
  });
@@ -393,19 +429,6 @@ export const blocked = (scrape: string, url: string, detail: string): ScrapeErro
393
429
  meta: { scrape, url },
394
430
  });
395
431
 
396
- /**
397
- * The honest stub, in the shape `packages/jobs/src/driver-redis.ts` uses: correct types so an app
398
- * can be written against the seam, and one labelled throw so nobody discovers the gap from a
399
- * silently-skipped recovery.
400
- */
401
- export const scrapeNotImplemented = (feature: string, fix: string): UltimateError =>
402
- new UltimateError({
403
- code: 'X_NOT_IMPLEMENTED',
404
- cause: `${feature} is declared and not implemented in @ultimat3/scraping`,
405
- fix,
406
- meta: { feature },
407
- });
408
-
409
432
  /**
410
433
  * The crop rectangle's three refusals, one code. Each is a caller's DECLARATION being impossible
411
434
  * rather than a site behaving badly, so each names the edit that fixes it — and the alternative in
package/src/errors.ts CHANGED
@@ -39,11 +39,14 @@ export const SCRAPE_OWNED_ERROR_CODES = [
39
39
  'X_SCRAPE_PROMPT_UNANSWERED',
40
40
  'X_SCRAPE_BLOCKED',
41
41
  'X_SCRAPE_KEY_INVALID',
42
+ 'X_SCRAPE_EGRESS_UNSUPPORTED',
43
+ 'X_SCRAPE_EGRESS_IN_PAYLOAD',
44
+ 'X_SCRAPE_LAUNCH_ARGS_INVALID',
42
45
  ] as const;
43
46
 
44
47
  /**
45
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s and `recover.ts` throws it for the agent seam, the
46
- * 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
47
50
  * declared secret with no value in the environment is a missing environment variable, not a
48
51
  * scraping concept needing its own code. No title is kept here for either — one code, one owner,
49
52
  * or the two copies drift.
@@ -91,6 +94,10 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
91
94
  X_SCRAPE_PROMPT_UNANSWERED: 'a login step asked for a code and nothing answered',
92
95
  X_SCRAPE_BLOCKED: 'the site refused this client — the identity is spent',
93
96
  X_SCRAPE_KEY_INVALID: 'the key chord names no key a browser can press',
97
+ X_SCRAPE_EGRESS_UNSUPPORTED: 'this driver cannot give the session the exit the run asked for',
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',
94
101
  };
95
102
 
96
103
  // One unconditional call, so a second package claiming one of these codes throws
@@ -192,6 +199,15 @@ export const SCRAPE_ERROR_RETRY = {
192
199
  // literal, attempt 2 passes the identical string, and a retry is a browser launch for no chance
193
200
  // of a different parse. Refused before any key goes down, so no modifier is left held.
194
201
  X_SCRAPE_KEY_INVALID: 'terminal',
202
+ // The exit is a function of the run's INPUT and the driver is a constant of the deploy: attempt
203
+ // 2 computes the same exit and meets the same driver. Dialling the driver's own exit instead
204
+ // would be the retry that "works", as a different client — which is the failure this code names.
205
+ X_SCRAPE_EGRESS_UNSUPPORTED: 'terminal',
206
+ // The payload is the row: every remaining attempt reads the same credential out of it.
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',
195
211
  } as const satisfies Readonly<Record<ScrapeOwnedErrorCode, 'retryable' | 'terminal'>>;
196
212
 
197
213
  registerErrorRetry(SCRAPE_ERROR_RETRY);