@ultimat3/scraping 22.14.0 → 23.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.
@@ -15,6 +15,7 @@ import type { HttpRecording } from './recording';
15
15
  import type { NetworkRing } from './rings';
16
16
  import type { RobotsGate } from './robots';
17
17
  import type { ScrapeSecrets } from './secrets';
18
+ import type { UsageMeter } from './usage';
18
19
 
19
20
  export type HttpRecordingLookup = (
20
21
  method: string,
@@ -36,6 +37,8 @@ export interface RecordedHttpInit {
36
37
  readonly maxAgeMs?: number | undefined;
37
38
  /** The SAME bag the live leg holds, so a recorded 4xx body redacts the way a real one does. */
38
39
  readonly secrets?: ScrapeSecrets | undefined;
40
+ /** The SAME meter the live leg counts into, so a fixture run reports the usage a real one would. */
41
+ readonly usage?: UsageMeter | undefined;
39
42
  }
40
43
 
41
44
  /** `GET https://api.example.com/v1/orders?page=2` -> `http-get-api-example-com-v1-orders-page-2`. */
@@ -70,6 +73,7 @@ export function recordedHttp(init: RecordedHttpInit): ScrapeHttp {
70
73
  resourceType: 'fetch',
71
74
  at: init.clock.now().getTime(),
72
75
  });
76
+ init.usage?.httpRequest(new TextEncoder().encode(found.body).length);
73
77
  return responseOver(
74
78
  url,
75
79
  found.status,
package/src/http.ts CHANGED
@@ -25,6 +25,7 @@ import type { RobotsGate } from './robots';
25
25
  import type { ScrapeSecrets } from './secrets';
26
26
  import { redactSecrets } from './secrets';
27
27
  import type { SessionSnapshot } from './session-state';
28
+ import type { UsageMeter } from './usage';
28
29
 
29
30
  /**
30
31
  * Just the call. `typeof fetch` also carries `preconnect`, which no test double and no app wrapper
@@ -110,6 +111,8 @@ export interface HttpTransportInit {
110
111
  * until 2026-08-24, so nothing on this leg COULD redact.
111
112
  */
112
113
  readonly secrets?: ScrapeSecrets | undefined;
114
+ /** The run's meter: one `httpRequest` per hop on the wire, with the body bytes read for it. */
115
+ readonly usage?: UsageMeter | undefined;
113
116
  readonly fetch?: ScrapeFetch | undefined;
114
117
  }
115
118
 
@@ -213,8 +216,11 @@ const readResponse = async (
213
216
  response: Response,
214
217
  maxBytes: number,
215
218
  secrets: ScrapeSecrets | undefined,
219
+ usage: UsageMeter | undefined,
216
220
  ): Promise<ScrapeResponse> => {
217
221
  const capped = await readWithinLimit(response.body, maxBytes);
222
+ // Counted on BOTH branches: the bytes a refused body cost were read before the refusal.
223
+ usage?.httpRequest('over' in capped ? capped.over : capped.bytes.length);
218
224
  if ('over' in capped) throw bodyTooLarge(url, capped.over, maxBytes);
219
225
  const body = new TextDecoder().decode(capped.bytes);
220
226
  return responseOver(
@@ -345,7 +351,9 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
345
351
  hop.body,
346
352
  );
347
353
  if (next === undefined)
348
- return await readResponse(hop.url, response, maxBytes, init.secrets);
354
+ return await readResponse(hop.url, response, maxBytes, init.secrets, init.usage);
355
+ // A followed hop was a request on the wire too; its body is discarded unread.
356
+ init.usage?.httpRequest(0);
349
357
  // Discarded BEFORE the refusal, not after it: a throw over an unread stream holds that
350
358
  // hop's socket until the collector reaches it, and the refusal path is exactly the one a
351
359
  // hostile chain drives ten times per request.
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  export type {
13
13
  AuthContext,
14
14
  PromptHandler,
15
+ PromptInit,
15
16
  PromptRequest,
16
17
  ScrapeAuth,
17
18
  } from './auth';
@@ -29,11 +30,21 @@ export type {
29
30
  CdpScreenshotOptions,
30
31
  CdpSessionLike,
31
32
  } from './cdp-port';
33
+ export type { CdpResolution, CdpResolver, CdpResolverRequest } from './cdp-resolver';
32
34
  export { snapshotExpression } from './cdp-snapshot';
33
35
  export type { CdpTargetInit } from './cdp-target';
34
36
  export { CDP_DRIVER, cdpTarget } from './cdp-target';
35
37
  export type { Deadline, ScrapeClock, TestScrapeClock } from './clock';
36
- export { deadline, systemScrapeClock, testClock, throwIfAborted } from './clock';
38
+ export {
39
+ deadline,
40
+ noWaitClock,
41
+ resetScrapeClock,
42
+ scrapeClock,
43
+ setScrapeClock,
44
+ systemScrapeClock,
45
+ testClock,
46
+ throwIfAborted,
47
+ } from './clock';
37
48
  export type { ColorScheme } from './color-scheme';
38
49
  export { COLOR_SCHEMES, isColorScheme } from './color-scheme';
39
50
  export { cookieHeaderFor } from './cookie-scope';
@@ -43,14 +54,15 @@ export type { BrowserOptions, LocalBrowserOptions, RemoteBrowserOptions } from '
43
54
  export { localBrowser, remoteBrowser } from './driver-cdp';
44
55
  export type { FakeBrowserOptions, FakePageOptions, FakePages } from './driver-fake';
45
56
  export { FAKE_DRIVER, FAKE_PAGE_URL, fakeBrowser, fakePage, recordingsOf } from './driver-fake';
46
- export type { FixtureBrowserOptions } from './driver-fixture';
47
- export { FIXTURE_DRIVER, fixtureBrowser } from './driver-fixture';
57
+ export type { FixtureBrowserOptions } from './driver-recorded';
58
+ export { FIXTURE_DRIVER, fixtureBrowser } from './driver-recorded';
48
59
  export {
49
60
  authFailed,
50
61
  blocked,
51
62
  bodyTooLarge,
52
63
  browserUnreachable,
53
64
  cdpAttachFailed,
65
+ cdpResolveFailed,
54
66
  downloadTimeout,
55
67
  driverUnknown,
56
68
  fixtureMissing,
@@ -75,6 +87,12 @@ export {
75
87
  wedged,
76
88
  yieldCollapsed,
77
89
  } from './error-throws';
90
+ export {
91
+ egressInPayload,
92
+ egressUnsupported,
93
+ promptAnswerInvalid,
94
+ promptTimedOut,
95
+ } from './error-throws-session';
78
96
  export type { ScrapeErrorCode, ScrapeErrorInit, ScrapeOwnedErrorCode } from './errors';
79
97
  export {
80
98
  isRetryableScrapeError,
@@ -86,6 +104,14 @@ export {
86
104
  SCRAPE_OWNED_ERROR_CODES,
87
105
  ScrapeError,
88
106
  } from './errors';
107
+ export type { AnswerPromptInput, EventPromptOptions } from './event-prompt';
108
+ export {
109
+ answerPrompt,
110
+ DEFAULT_PROMPT_ANSWER_TTL_MS,
111
+ DEFAULT_PROMPT_POLL_MS,
112
+ eventPrompt,
113
+ promptEventName,
114
+ } from './event-prompt';
89
115
  export type { ScrapeEventFields, StepEvent } from './events';
90
116
  export type { YieldCheck, YieldExpectation, YieldGuardInput, YieldHistory } from './expect';
91
117
  export {
@@ -164,6 +190,7 @@ export type {
164
190
  ScrapeDefinition,
165
191
  ScrapeReport,
166
192
  ScrapeRunArgs,
193
+ ScrapeSettled,
167
194
  } from './scrape';
168
195
  export { scrape } from './scrape';
169
196
  export { DEFAULT_PAGE_TIMEOUT_MS, runScrape } from './scrape-run';
@@ -177,10 +204,16 @@ export {
177
204
  safeNetwork,
178
205
  safePageErrors,
179
206
  } from './secrets';
180
- export type { ScrapeSessionStore, SessionSnapshot, SessionState } from './session-state';
207
+ export type {
208
+ ScrapeSessionStore,
209
+ SessionSnapshot,
210
+ SessionState,
211
+ StorageSessionStoreOptions,
212
+ } from './session-state';
181
213
  export {
182
214
  memorySessionStore,
183
215
  parseSessionState,
216
+ SESSION_SEAL_PURPOSE,
184
217
  sessionDigest,
185
218
  sessionKeyFor,
186
219
  storageSessionStore,
@@ -196,5 +229,8 @@ export type {
196
229
  ScrapeDownloadFile,
197
230
  ScrapeTarget,
198
231
  } from './target';
232
+ export { endpointLabel, urlSecretValues } from './url-secrets';
233
+ export type { RunUsageMeter, ScrapeUsage, UsageMeter } from './usage';
234
+ export { createUsageMeter } from './usage';
199
235
  export type { WedgeGuard, WedgeGuardInit } from './watchdog';
200
236
  export { DEFAULT_GRACE_MS } from './watchdog';
@@ -42,6 +42,10 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
42
42
  if (init.session.restore !== undefined) await target.restore(init.session.restore);
43
43
  return {
44
44
  driver: init.driver,
45
+ // An offline session dials nothing — except the run's own `/robots.txt` read, which is a real
46
+ // request. Reporting the run's exit is what makes that one request leave through it, exactly
47
+ // as it would on the driver this recording stands in for.
48
+ ...(init.session.proxy === undefined ? {} : { proxy: init.session.proxy }),
45
49
  page: pageOverTarget(target, {
46
50
  clock: init.session.clock,
47
51
  allowHosts: init.session.rules.allowHosts,
@@ -51,6 +55,7 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
51
55
  signal: init.session.signal,
52
56
  onActivity: init.session.onActivity,
53
57
  pace: init.session.pace,
58
+ usage: init.session.usage,
54
59
  }),
55
60
  http: recordedHttp({
56
61
  lookup: init.http,
@@ -63,6 +68,7 @@ export async function openOfflineSession(init: OfflineSessionInit): Promise<Scra
63
68
  // The same bag, for the same reason: a redaction only the live leg performs is one no
64
69
  // fixture can prove.
65
70
  secrets: init.session.secrets,
71
+ usage: init.session.usage,
66
72
  maxAgeMs: init.maxAgeMs,
67
73
  }),
68
74
  close: () => target.close(),
@@ -35,6 +35,7 @@ import type {
35
35
  ScrapeTarget,
36
36
  } from './target';
37
37
  import { ROOT_SELECTOR } from './target';
38
+ import type { UsageMeter } from './usage';
38
39
 
39
40
  export interface PageContext {
40
41
  readonly clock: ScrapeClock;
@@ -51,6 +52,8 @@ export interface PageContext {
51
52
  readonly secrets?: ScrapeSecrets | undefined;
52
53
  readonly robots?: RobotsGate | undefined;
53
54
  readonly signal?: AbortSignal | undefined;
55
+ /** The run's meter. Counts a navigation where the wedge watchdog is told about one. */
56
+ readonly usage?: UsageMeter | undefined;
54
57
  }
55
58
 
56
59
  /** Mutable, shared by the page and every frame under it: a taint is a property of the SESSION. */
@@ -251,6 +254,7 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
251
254
  await guardNavigation(url, ctx);
252
255
  await ctx.pace?.(ctx.signal);
253
256
  ctx.onActivity?.();
257
+ ctx.usage?.navigation();
254
258
  await target.goto(url, {
255
259
  timeoutMs: options?.timeout ?? ctx.defaultTimeoutMs,
256
260
  signal: ctx.signal,
package/src/scrape-run.ts CHANGED
@@ -22,10 +22,12 @@ import {
22
22
  persistSession,
23
23
  restorableSession,
24
24
  } from './auth';
25
- import { systemScrapeClock } from './clock';
25
+ import { scrapeClock } from './clock';
26
26
  import type { ScrapeSession } from './driver';
27
27
  import { scrapeDriver } from './driver';
28
28
  import { driverUnknown, outputInvalid } from './error-throws';
29
+ import { egressInPayload } from './error-throws-session';
30
+ import type { ScrapeEventFields } from './events';
29
31
  import { scrapeLogger, withStepEvent } from './events';
30
32
  import { guardYield } from './expect';
31
33
  import { burnsSession, errorCode, neverRetried } from './failures';
@@ -33,8 +35,12 @@ import { createPacer, DEFAULT_NAVIGATION_RATE } from './rate';
33
35
  import { runRecovery } from './recover';
34
36
  import { createRobotsGate } from './robots';
35
37
  import type { ScrapeDefinition, ScrapeReport } from './scrape';
36
- import { createSecretBag } from './secrets';
38
+ import { containsSecret } from './secret-scan';
39
+ import { createSecretBag, MIN_REDACTABLE_LENGTH } from './secrets';
37
40
  import { sessionKeyFor } from './session-state';
41
+ import { hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
42
+ import type { ScrapeUsage } from './usage';
43
+ import { createUsageMeter, rememberFailedUsage } from './usage';
38
44
 
39
45
  /** `ctx.actor` is a structural read: this package never imports the auth types (tier 2). */
40
46
  const orgOf = (ctx: unknown): string | undefined => {
@@ -63,11 +69,26 @@ const toMillis = (value: string | number | undefined, fallback: number, option:
63
69
 
64
70
  export const DEFAULT_PAGE_TIMEOUT_MS = 30_000;
65
71
 
72
+ /**
73
+ * True when the exit's password is readable out of the run's own input — which is the queue row.
74
+ * Both spellings, because a password with a quote or a backslash is stored JSON-escaped; and
75
+ * nothing shorter than `MIN_REDACTABLE_LENGTH`, which is a substring of ordinary payload text.
76
+ */
77
+ function credentialInPayload(egress: string, input: unknown): boolean {
78
+ const { password } = splitCredentials(egress);
79
+ if (password.length < MIN_REDACTABLE_LENGTH) return false;
80
+ const payload = JSON.stringify(input ?? null);
81
+ const escaped = JSON.stringify(password).slice(1, -1);
82
+ // Both scans always run: an `||` would skip the second exactly when the first found the secret.
83
+ const plain = containsSecret(payload, password);
84
+ return containsSecret(payload, escaped) || plain;
85
+ }
86
+
66
87
  export async function runScrape<I, Row>(
67
88
  definition: ScrapeDefinition<I, Row>,
68
89
  args: JobRunArgs<I>,
69
90
  ): Promise<ScrapeReport<Row>> {
70
- const clock = definition.clock ?? systemScrapeClock;
91
+ const clock = definition.clock ?? scrapeClock();
71
92
  const driver = definition.driver ?? scrapeDriver();
72
93
  // The scrape's name goes in the SCRAPE slot: there is no driver here to name, which is the
73
94
  // whole failure.
@@ -79,6 +100,21 @@ export async function runScrape<I, Row>(
79
100
  driver: driver.name,
80
101
  });
81
102
  const secrets = createSecretBag(definition.secrets ?? []);
103
+ // The run's exit, resolved HERE — in the worker, under the job's tenant — so what the queue row
104
+ // carries is the id of whatever holds it. An empty answer is "none", the same as absent: a
105
+ // driver handed `''` would dial `--proxy-server=` and call that a decision.
106
+ const declared = await definition.egress?.(args.input, args.ctx);
107
+ const egress = declared === undefined || declared === '' ? undefined : declared;
108
+ if (egress !== undefined && hasCredentials(egress)) {
109
+ // Concealed HERE and not only in the driver that dials it: an offline or third-party driver
110
+ // never parses the exit, and the page artifact is redacted from this one bag either way.
111
+ for (const value of urlSecretValues(egress)) secrets.conceal(value);
112
+ // Before the browser opens: the credential is in `x_jobs` already, and running would only add
113
+ // a session to an exposure somebody has to rotate.
114
+ if (credentialInPayload(egress, args.input)) {
115
+ throw egressInPayload({ scrape: definition.name, egress });
116
+ }
117
+ }
82
118
  const rules = { allowHosts: definition.allowHosts, block: definition.block };
83
119
  // Screened here as well as in `scrape()`, and the two are not one check written twice: that one
84
120
  // refuses the DECLARATION and never sees a definition assembled by hand, which `runScrape` is
@@ -113,13 +149,19 @@ export async function runScrape<I, Row>(
113
149
  // opening a session first would already have spent an identity on a run that cannot succeed.
114
150
  const restored = await restorableSession(plan);
115
151
  const pageTimeoutMs = toMillis(definition.pageTimeout, DEFAULT_PAGE_TIMEOUT_MS, 'pageTimeout');
116
- // The exit the session dials, readable only AFTER `driver.open()` — the proxy is a driver
117
- // option and the gate below is an argument to `open()`, so the gate asks for it per read
118
- // instead of being handed a value that cannot exist yet. Every read happens during a
119
- // navigation, which is after this is assigned.
152
+ // The exit the session dials, readable only AFTER `driver.open()` — a driver with no run exit
153
+ // falls back to its own option, and the gate below is an argument to `open()`, so the gate asks
154
+ // for it per read instead of being handed a value that cannot exist yet. Every read happens
155
+ // during a navigation, which is after this is assigned.
120
156
  let sessionProxy: string | undefined;
157
+ // Before `open()`: the time a rented browser takes to arrive is time it was held.
158
+ const usage = createUsageMeter(clock);
121
159
  const session = await driver.open({
122
160
  name: definition.name,
161
+ logger,
162
+ runId: args.runId,
163
+ ...(egress === undefined ? {} : { proxy: egress }),
164
+ usage,
123
165
  rules,
124
166
  clock,
125
167
  timeoutMs: pageTimeoutMs,
@@ -151,10 +193,21 @@ export async function runScrape<I, Row>(
151
193
  ensureAuthenticated({
152
194
  ...plan,
153
195
  input: args.input,
196
+ runId: args.runId,
154
197
  page: session.page,
155
198
  secrets,
156
199
  restored,
157
- prompt: createPrompt(definition.name, definition.prompt, session.page),
200
+ prompt: createPrompt({
201
+ scrape: definition.name,
202
+ handler: definition.prompt,
203
+ input: args.input,
204
+ page: session.page,
205
+ runId: args.runId,
206
+ clock,
207
+ signal: args.ctx.signal,
208
+ secrets,
209
+ onAnswered: () => usage.promptAnswered(),
210
+ }),
158
211
  }),
159
212
  );
160
213
  // Persisted after a LOGIN only, never after a reuse: rewriting the record on every run
@@ -181,16 +234,23 @@ export async function runScrape<I, Row>(
181
234
  // alone made a run that blocked 5,000 images print 200 and discarded the one number
182
235
  // (`Ring.dropped`) that exists to say "you are not seeing it all".
183
236
  const networkDropped = session.page.networkDropped();
184
- logger.info('scrape.ok', { rows: rows.length, refused });
237
+ const used = usage.snapshot(session.browserCost);
238
+ logger.info('scrape.ok', { rows: rows.length, refused, ...usageFields(used) });
185
239
  return {
186
240
  scrape: definition.name,
187
241
  rows,
188
242
  artifacts: artifact.saved.map((ref) => ref.key),
189
243
  refused,
190
244
  networkDropped,
245
+ usage: used,
191
246
  };
192
247
  } catch (thrown) {
193
- logger.error('scrape.failed', { code: errorCode(thrown) });
248
+ // A failed run has no report, and it was billed all the same: the counts ride the one line
249
+ // every failure already writes.
250
+ const used = usage.snapshot(session.browserCost);
251
+ logger.error('scrape.failed', { code: errorCode(thrown), ...usageFields(used) });
252
+ // Kept for `onSettled`, and only when somebody declared one to read it.
253
+ if (definition.onSettled !== undefined) rememberFailedUsage(args.runId, used);
194
254
  if (errorCode(thrown) === 'X_SCRAPE_AUTH_FAILED') {
195
255
  await recordSessionOutcome('session.refuse', logger, () => markRefused(plan));
196
256
  } else if (burnsSession(thrown)) {
@@ -204,6 +264,15 @@ export async function runScrape<I, Row>(
204
264
  }
205
265
  }
206
266
 
267
+ /** The counts as log fields. `browserCost` stays off the line: it is on the report, as `Money`. */
268
+ const usageFields = (used: ScrapeUsage): ScrapeEventFields => ({
269
+ browserMs: used.browserMs,
270
+ navigations: used.navigations,
271
+ httpRequests: used.httpRequests,
272
+ bytesIn: used.bytesIn,
273
+ promptsAnswered: used.promptsAnswered,
274
+ });
275
+
207
276
  /**
208
277
  * The tombstone or the burn, on the way out — best effort, and it may NEVER replace the failure
209
278
  * that caused it. `markRefused` reaches `store.save()` reaches `storage.put()`, so an S3 503 or an
@@ -250,11 +319,13 @@ async function bodyWithRecovery<I, Row>(
250
319
  secrets,
251
320
  artifact,
252
321
  attempt: args.attempt,
322
+ finalAttempt: args.finalAttempt,
323
+ progress: args.progress,
253
324
  runId: args.runId,
254
325
  });
255
326
  try {
256
327
  return await withStepEvent(
257
- { name: 'body', logger, clock: definition.clock ?? systemScrapeClock, attempt: args.attempt },
328
+ { name: 'body', logger, clock: definition.clock ?? scrapeClock(), attempt: args.attempt },
258
329
  body,
259
330
  );
260
331
  } catch (thrown) {
package/src/scrape.ts CHANGED
@@ -11,7 +11,16 @@
11
11
 
12
12
  import type { Ctx } from '@ultimat3/core';
13
13
  import { assert } from '@ultimat3/core';
14
- import type { JobHandle, JobTenant, RetryPolicy, StepApi } from '@ultimat3/jobs';
14
+ import type {
15
+ JobCompleted,
16
+ JobConcurrency,
17
+ JobFailed,
18
+ JobHandle,
19
+ JobTenant,
20
+ ProgressFn,
21
+ RetryPolicy,
22
+ StepApi,
23
+ } from '@ultimat3/jobs';
15
24
  import { DEFAULT_RETRY, job } from '@ultimat3/jobs';
16
25
  import type { StandardSchemaV1 } from '@ultimat3/schema';
17
26
  import type { StorageDriver } from '@ultimat3/storage';
@@ -29,6 +38,8 @@ import type { ResourceType } from './rings';
29
38
  import type { RobotsPolicy } from './robots';
30
39
  import { runScrape } from './scrape-run';
31
40
  import type { ScrapeSecrets } from './secrets';
41
+ import type { ScrapeUsage } from './usage';
42
+ import { takeFailedUsage } from './usage';
32
43
 
33
44
  export interface ScrapeRunArgs<I> {
34
45
  readonly input: I;
@@ -47,11 +58,16 @@ export interface ScrapeRunArgs<I> {
47
58
  readonly secrets: ScrapeSecrets;
48
59
  readonly artifact: ArtifactWriter;
49
60
  readonly attempt: number;
61
+ /** The job's own: true when a failure of this attempt is not retried for want of attempts. */
62
+ readonly finalAttempt: boolean;
63
+ /** The job's own `progress(done, total, note?)` — what `x jobs show` and a dashboard read. */
64
+ readonly progress: ProgressFn;
50
65
  readonly runId: string;
51
66
  }
52
67
 
53
68
  export interface ScrapeArtifacts {
54
- readonly storage?: StorageDriver | undefined;
69
+ /** A thunk, read per write — `() => disk('artifacts')`: the app's disk exists only after boot. */
70
+ readonly storage?: (() => StorageDriver) | undefined;
55
71
  /** Save the page's HTML when the run fails. On by default — it is the only forensic left. */
56
72
  readonly onFailure?: boolean | undefined;
57
73
  readonly prefix?: string | undefined;
@@ -93,9 +109,26 @@ export interface ScrapeDefinition<I, Row> {
93
109
  * primary case, so this is declared rather than hand-rolled per app. See `auth.ts`.
94
110
  */
95
111
  readonly auth?: ScrapeAuth<I> | undefined;
96
- /** Where an out-of-band code comes from, for a site that asks for one after the password. */
97
- readonly prompt?: PromptHandler | undefined;
112
+ /**
113
+ * Where an out-of-band code comes from, for a site that asks for one after the password. The
114
+ * request carries the run's `input` and `runId`: what a handler ties the prompt to.
115
+ */
116
+ readonly prompt?: PromptHandler<I> | undefined;
98
117
  readonly driver?: ScrapeDriver | undefined;
118
+ /**
119
+ * The exit THIS run leaves through — a proxy URL, credentials included — RESOLVED IN THE WORKER,
120
+ * under the job's tenant: `async ({ connectionId }) => (await repo.connectionById(connectionId))
121
+ * ?.exit`. The input names the row; the row (a `.sealed()` column) holds the exit. An exit whose
122
+ * credential is ALSO in the input rode the queue payload and sits in `x_jobs` in the clear, and
123
+ * is refused before the browser opens (`X_SCRAPE_EGRESS_IN_PAYLOAD`).
124
+ *
125
+ * It reaches the driver as `SessionInit.proxy` and wins over the driver's own `proxy`; both legs
126
+ * and the robots read dial it. `undefined` leaves the driver's exit in force. A driver that
127
+ * cannot dial it refuses with `X_SCRAPE_EGRESS_UNSUPPORTED` rather than dialling another.
128
+ */
129
+ readonly egress?:
130
+ | ((input: I, ctx: Ctx) => string | undefined | Promise<string | undefined>)
131
+ | undefined;
99
132
  readonly retry?: RetryPolicy | undefined;
100
133
  /** Per attempt, whole-run. `'5m'` or ms. */
101
134
  readonly timeout?: string | number | undefined;
@@ -103,12 +136,33 @@ export interface ScrapeDefinition<I, Row> {
103
136
  readonly pageTimeout?: string | number | undefined;
104
137
  /** Kill the browser after this much silence from it. See `watchdog.ts`. */
105
138
  readonly watchdog?: { readonly idleMs?: number; readonly graceMs?: number } | undefined;
106
- readonly concurrency?: number | undefined;
139
+ /** The job's own field, unchanged: a number, or `{ key, limit, whenBusy }` for a cap per key. */
140
+ readonly concurrency?: JobConcurrency<I> | undefined;
107
141
  readonly queue?: string | undefined;
142
+ /**
143
+ * Pins this definition's clock. Omit it: a run waits on the process's (`scrapeClock()`), which
144
+ * is the system clock and what a test replaces with `setScrapeClock()`.
145
+ */
108
146
  readonly clock?: ScrapeClock | undefined;
109
147
  run(args: ScrapeRunArgs<I>): Promise<readonly unknown[]>;
148
+ /**
149
+ * The job's own `onSettled`, with what a scrape adds: a `completed` run hands over its
150
+ * `ScrapeReport` — rows, artifacts and `usage` — and a run that ended any other way carries the
151
+ * `usage` of its last attempt (`undefined` for a `refused` one, whose body never ran). Same
152
+ * guarantees as the job's: after the row is settled, under the job's tenant, AT MOST ONCE
153
+ * across a crash, and a hook that throws changes nothing.
154
+ */
155
+ onSettled?(settled: ScrapeSettled<I, Row>): Promise<void>;
110
156
  }
111
157
 
158
+ /** How one scrape run ended, as `ScrapeDefinition.onSettled` is told. */
159
+ export type ScrapeSettled<I, Row> =
160
+ | JobCompleted<I, ScrapeReport<Row>>
161
+ | (JobFailed<I> & {
162
+ /** What the last attempt used. A failed run has no report and was billed all the same. */
163
+ readonly usage: ScrapeUsage | undefined;
164
+ });
165
+
112
166
  /** What one completed scrape reports — bounded, so `x jobs show` can print it. */
113
167
  export interface ScrapeReport<Row> {
114
168
  readonly scrape: string;
@@ -124,6 +178,8 @@ export interface ScrapeReport<Row> {
124
178
  * "you are not seeing it all": `refused` was counted from what survived the bound.
125
179
  */
126
180
  readonly networkDropped: number;
181
+ /** What this run used. A measurement: no quota is read from it and none is enforced. */
182
+ readonly usage: ScrapeUsage;
127
183
  }
128
184
 
129
185
  export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<I> {
@@ -145,7 +201,8 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
145
201
  if (definition.expect?.maxDrop !== undefined && definition.history === undefined) {
146
202
  throw yieldHistoryMissing(definition.name);
147
203
  }
148
- return job<I>({
204
+ const onSettled = definition.onSettled?.bind(definition);
205
+ return job<I, ScrapeReport<Row>>({
149
206
  name: definition.name,
150
207
  input: definition.input,
151
208
  idempotencyKey: definition.idempotencyKey,
@@ -155,5 +212,15 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
155
212
  ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
156
213
  ...(definition.concurrency === undefined ? {} : { concurrency: definition.concurrency }),
157
214
  run: (args) => runScrape(definition, args),
215
+ ...(onSettled === undefined
216
+ ? {}
217
+ : {
218
+ onSettled: (settled) => {
219
+ // Read on EVERY ending, the completed one too: a run that failed an attempt and then
220
+ // completed must not leave that attempt's counts behind in this process.
221
+ const usage = takeFailedUsage(settled.runId);
222
+ return onSettled(settled.outcome === 'completed' ? settled : { ...settled, usage });
223
+ },
224
+ }),
158
225
  });
159
226
  }
@@ -0,0 +1,17 @@
1
+ // Whether a secret occurs in a piece of text, without a search that stops at the first differing
2
+ // byte. `.includes(secret)` answers sooner the more of a window matches the secret's prefix — the
3
+ // oracle `scripts/secret-compare.ts` refuses — so every window is compared whole, in constant time.
4
+
5
+ import { timingSafeEqual } from '@ultimat3/core';
6
+
7
+ /** True when `secret` occurs in `text`. Time depends on the two lengths, never on their bytes. */
8
+ export function containsSecret(text: string, secret: string): boolean {
9
+ const width = secret.length;
10
+ if (width === 0) return false;
11
+ let found = false;
12
+ for (let start = 0; start + width <= text.length; start += 1) {
13
+ // `timingSafeEqual` first, so a window after a hit is still compared rather than skipped.
14
+ found = timingSafeEqual(text.slice(start, start + width), secret) || found;
15
+ }
16
+ return found;
17
+ }
package/src/secrets.ts CHANGED
@@ -14,6 +14,14 @@ export interface ScrapeSecrets {
14
14
  readonly names: readonly string[];
15
15
  /** The boxed value. Reading it out is `revealSecret()`, one greppable call site. */
16
16
  get(name: string): Secret;
17
+ /**
18
+ * Add a value this run learned MID-RUN to the redaction set: a prompt answer, the credentials
19
+ * of the exit it dials, the connect URL of the browser it rented, a bearer the body read off a
20
+ * page. It has no name and cannot be read back — it exists only so `page.html()`, the rings and
21
+ * the failure artifact never carry it. The same floor applies: shorter than
22
+ * `MIN_REDACTABLE_LENGTH` is not redacted.
23
+ */
24
+ conceal(value: string): void;
17
25
  }
18
26
 
19
27
  /**
@@ -30,6 +38,7 @@ export function createSecretBag(
30
38
  resolve: SecretResolver = fromEnvironment,
31
39
  ): ScrapeSecrets {
32
40
  const boxed = new Map<string, Secret>();
41
+ const learned = new Set<string>();
33
42
  for (const name of names) {
34
43
  const value = resolve(name);
35
44
  if (value === undefined || value === '') {
@@ -42,8 +51,11 @@ export function createSecretBag(
42
51
  }
43
52
  boxed.set(name, secret(value, name));
44
53
  }
45
- return {
54
+ const bag: ScrapeSecrets = {
46
55
  names: [...names],
56
+ conceal(value: string): void {
57
+ if (value !== '') learned.add(value);
58
+ },
47
59
  get(name: string): Secret {
48
60
  const found = boxed.get(name);
49
61
  if (found === undefined) {
@@ -57,8 +69,17 @@ export function createSecretBag(
57
69
  return found;
58
70
  },
59
71
  };
72
+ concealed.set(bag, learned);
73
+ return bag;
60
74
  }
61
75
 
76
+ /**
77
+ * The values `conceal()` was handed, by bag — module-private, so the only way out of a bag is
78
+ * still `revealSecret(secrets.get(name))` for a DECLARED name. A `ScrapeSecrets` built by hand has
79
+ * no entry here and redacts its declared names alone.
80
+ */
81
+ const concealed = new WeakMap<ScrapeSecrets, ReadonlySet<string>>();
82
+
62
83
  export const SECRET_PLACEHOLDER = '[redacted]';
63
84
 
64
85
  /**
@@ -93,9 +114,13 @@ export const MIN_REDACTABLE_LENGTH = 4;
93
114
  * Longest first, so a secret that contains another one does not leave its tail behind.
94
115
  */
95
116
  export function redactSecrets(text: string, secrets: ScrapeSecrets | undefined): string {
96
- if (secrets === undefined || secrets.names.length === 0) return text;
97
- const values = secrets.names
98
- .map((name) => revealSecret(secrets.get(name)))
117
+ if (secrets === undefined) return text;
118
+ const learned = concealed.get(secrets);
119
+ if (secrets.names.length === 0 && (learned === undefined || learned.size === 0)) return text;
120
+ const values = [
121
+ ...secrets.names.map((name) => revealSecret(secrets.get(name))),
122
+ ...(learned ?? []),
123
+ ]
99
124
  .filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
100
125
  .sort((a, b) => b.length - a.length);
101
126
  let out = text;