@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.
@@ -0,0 +1,65 @@
1
+ // The DNS half of `allowHosts`, for the two legs this package dials itself — the HTTP leg and the
2
+ // robots read. Core's `hostDecision` is synchronous and judges NAMES: a wildcard never admits an
3
+ // address literal inside the network, but `allowHosts: ['*']` still admits a hostname that
4
+ // resolves inward. So a name admitted by a wildcard alone is resolved here, every address
5
+ // classified by core's one classifier, and the connection PINNED to the approved address — the
6
+ // shape `@ultimat3/jobs`' `webhook-target.ts` takes for a webhook.
7
+
8
+ import type { HostRule } from '@ultimat3/core';
9
+ import { classifyAddress, hostMatches } from '@ultimat3/core';
10
+ import { hostResolvesInward } from './error-throws-session';
11
+
12
+ /** Every address a hostname answers. Injected in tests: the network is sealed in this repo. */
13
+ export type HostResolve = (hostname: string) => Promise<readonly string[]>;
14
+
15
+ /** `Bun.dns.lookup` — the runtime's own resolver, every family, every address. */
16
+ export const resolveHost: HostResolve = async (hostname) =>
17
+ (await Bun.dns.lookup(hostname)).map((entry) => entry.address);
18
+
19
+ /** What the transport connects to: the URL to dial, and the name it proves when pinned. */
20
+ export interface DialTarget {
21
+ readonly url: string;
22
+ /** The original `host[:port]`, sent as the `Host` header of a pinned request. */
23
+ readonly host?: string | undefined;
24
+ /** For `https:` — the certificate is verified against the NAME, not the address. */
25
+ readonly serverName?: string | undefined;
26
+ }
27
+
28
+ const isWildcard = (rule: HostRule): boolean => {
29
+ const cleaned = rule.trim();
30
+ return cleaned === '*' || cleaned.startsWith('*.');
31
+ };
32
+
33
+ /**
34
+ * The connection for `url`, which `interceptVerdict` has ALREADY allowed. Unchanged when an exact
35
+ * rule names the host — the app said that host, internal or not, in a line a reviewer can see —
36
+ * or when the host is an address literal, which core's floor already judged. Otherwise resolved:
37
+ * one address that is not public refuses the request (`X_SCRAPE_HOST_BLOCKED`), and the dial is
38
+ * pinned to the first approved address, so a name that answers public here and private to the
39
+ * socket (DNS rebinding) is never reached. A resolver failure propagates: nothing unscreened is
40
+ * dialled, and the same name would not have resolved for `fetch` either.
41
+ */
42
+ export async function dialTarget(
43
+ url: string,
44
+ allowHosts: readonly HostRule[],
45
+ resolve: HostResolve,
46
+ ): Promise<DialTarget> {
47
+ const parsed = new URL(url);
48
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { url };
49
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
50
+ if (classifyAddress(hostname) !== undefined) return { url };
51
+ if (allowHosts.some((rule) => !isWildcard(rule) && hostMatches(hostname, rule))) return { url };
52
+ const addresses = await resolve(hostname);
53
+ const inward = addresses.map((address) => classifyAddress(address) ?? 'unclassifiable');
54
+ const refused =
55
+ addresses.length === 0 ? 'unresolvable' : inward.find((kind) => kind !== 'public');
56
+ if (refused !== undefined) throw hostResolvesInward(url, hostname, refused);
57
+ const pinned = addresses[0] ?? hostname;
58
+ const target = new URL(parsed.href);
59
+ target.hostname = pinned.includes(':') ? `[${pinned}]` : pinned;
60
+ return {
61
+ url: target.href,
62
+ host: parsed.host,
63
+ ...(parsed.protocol === 'https:' ? { serverName: hostname } : {}),
64
+ };
65
+ }
package/src/recover.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  // `X_NOT_IMPLEMENTED` today, in the shape `packages/jobs/src/driver-redis.ts` uses: correct types
7
7
  // so an app can be written against it, one labelled throw, no silent no-op.
8
8
 
9
- import { recoverRefused, scrapeNotImplemented } from './error-throws';
9
+ import { NotImplementedError } from '@ultimat3/core';
10
+ import { recoverRefused } from './error-throws';
10
11
  import type { ScrapePage } from './page';
11
12
 
12
13
  export interface RecoveryAttempt {
@@ -41,7 +42,7 @@ export async function runRecovery(recovery: Recovery, attempt: RecoveryAttempt):
41
42
  // The agent path lands with `@ultimat3/ai`'s `llm()` behind it: page HTML in, a selector out,
42
43
  // re-run once. Until it does, this throws rather than answering `false` — a recovery that
43
44
  // silently declines is indistinguishable from one that was never configured.
44
- throw scrapeNotImplemented("recover: 'agent'", AGENT_FIX);
45
+ throw new NotImplementedError({ cause: "recover: 'agent' is not implemented", fix: AGENT_FIX });
45
46
  }
46
47
  const verdict: unknown = await recovery(attempt);
47
48
  if (typeof verdict !== 'boolean') {
@@ -9,7 +9,12 @@
9
9
  // `driver.open()`, and the proxy is a driver option the session only reports on the way back out.
10
10
 
11
11
  import { finiteCount, readWithinLimit } from '@ultimat3/core';
12
+ import type { HostRule } from './hosts';
12
13
  import type { ScrapeFetch } from './http';
14
+ import { redirectHop } from './http-redirect';
15
+ import { interceptVerdict } from './intercept';
16
+ import type { HostResolve } from './pinned-host';
17
+ import { dialTarget, resolveHost } from './pinned-host';
13
18
  import type { RobotsFetch } from './robots';
14
19
 
15
20
  /**
@@ -23,6 +28,9 @@ export const DEFAULT_ROBOTS_TIMEOUT_MS = 10_000;
23
28
  /** Google's own documented ceiling for the file, and generous for a list of path prefixes. */
24
29
  export const DEFAULT_ROBOTS_MAX_BYTES = 500 * 1024;
25
30
 
31
+ /** RFC 9309 §2.3.1.2: a crawler SHOULD follow at least five consecutive redirects for robots.txt. */
32
+ export const MAX_ROBOTS_REDIRECTS = 5;
33
+
26
34
  export interface RobotsFetchInit {
27
35
  /** Per-read wall clock. Defaults to `DEFAULT_ROBOTS_TIMEOUT_MS`. */
28
36
  readonly timeoutMs?: number | undefined;
@@ -41,6 +49,15 @@ export interface RobotsFetchInit {
41
49
  */
42
50
  readonly proxy?: (() => string | undefined) | undefined;
43
51
  readonly maxBytes?: number | undefined;
52
+ /**
53
+ * The run's `allowHosts`, asked about EVERY hop — the first URL included — through
54
+ * `interceptVerdict`, the one host rule. A hop off the list is never requested and the read
55
+ * answers "no robots". With none given, only a redirect that stays on the starting HOSTNAME is
56
+ * followed (a scheme upgrade, a moved path), still capped; another host is never dialled.
57
+ */
58
+ readonly allowHosts?: readonly HostRule[] | undefined;
59
+ /** The resolver a wildcard-admitted name is checked and pinned with. Defaults to `Bun.dns`. */
60
+ readonly resolve?: HostResolve | undefined;
44
61
  /**
45
62
  * The platform `fetch`, injectable so the default path itself is testable. `ScrapeFetch` and not
46
63
  * `typeof fetch`: the latter also carries `preconnect`, so nothing a caller can write satisfies
@@ -49,13 +66,38 @@ export interface RobotsFetchInit {
49
66
  readonly fetch?: ScrapeFetch | undefined;
50
67
  }
51
68
 
69
+ const onList = (url: string, allowHosts: readonly HostRule[]): boolean =>
70
+ interceptVerdict(url, 'fetch', { allowHosts }) === 'allow';
71
+
72
+ /** Same hostname over http(s) — the port may change with the scheme. Unparseable is not same. */
73
+ const sameHost = (from: string, to: string): boolean => {
74
+ try {
75
+ const next = new URL(to);
76
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') return false;
77
+ return new URL(from).hostname === next.hostname;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ /** The final hop's body under the cap, or `undefined` for a non-2xx answer. */
84
+ const answerOf = async (response: Response, limit: number): Promise<string | undefined> => {
85
+ if (!response.ok) return undefined;
86
+ // Counted as it arrives rather than `.text()`, which materialises the whole body first: a
87
+ // multi-gigabyte robots.txt is a heap the worker never gets back.
88
+ const capped = await readWithinLimit(response.body, limit);
89
+ return 'over' in capped ? undefined : new TextDecoder().decode(capped.bytes);
90
+ };
91
+
52
92
  /**
53
93
  * Reads `robotsUrl`, or answers `undefined` — which the gate reads as "no restrictions", the
54
- * standard's own answer for a file it cannot obtain. A deadline that fires, a body past the cap
55
- * and a 404 are all the same answer on purpose: none of them is evidence of a rule.
94
+ * standard's own answer for a file it cannot obtain. A deadline that fires, a body past the cap,
95
+ * a 404 and a redirect off `allowHosts` are all the same answer on purpose: none of them is
96
+ * evidence of a rule.
56
97
  */
57
98
  export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
58
99
  const call: ScrapeFetch = init.fetch ?? fetch;
100
+ const resolve = init.resolve ?? resolveHost;
59
101
  // Both bounds are screened HERE, at construction, and both floors are 1 — because every way this
60
102
  // read can fail is the same answer, `undefined`, which the gate reads as "no restrictions". A
61
103
  // `NaN` deadline throws a bare `TypeError` out of `AbortSignal.timeout` (measured: "Value NaN is
@@ -83,16 +125,42 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
83
125
  // Resolved here, at the read, because the session that owns the exit did not exist when this
84
126
  // fetcher was built. An empty string is not an exit and is dropped with the absent one.
85
127
  const proxy = init.proxy?.();
128
+ const dialled = proxy === undefined || proxy === '' ? undefined : proxy;
86
129
  try {
87
- const response = await call(robotsUrl, {
88
- signal,
89
- ...(proxy === undefined || proxy === '' ? {} : { proxy }),
90
- });
91
- if (!response.ok) return undefined;
92
- // Counted as it arrives rather than `.text()`, which materialises the whole body first: a
93
- // multi-gigabyte robots.txt is a heap the worker never gets back.
94
- const capped = await readWithinLimit(response.body, limit);
95
- return 'over' in capped ? undefined : new TextDecoder().decode(capped.bytes);
130
+ let url = robotsUrl;
131
+ for (let hops = 0; ; hops += 1) {
132
+ if (init.allowHosts !== undefined && !onList(url, init.allowHosts)) return undefined;
133
+ // Pinned exactly as the HTTP leg pins (`pinned-host.ts`), and for its reason skipped
134
+ // through a proxy. A name resolving inward throws, which this read answers as "no robots".
135
+ const dial =
136
+ init.allowHosts === undefined || dialled !== undefined
137
+ ? { url }
138
+ : await dialTarget(url, init.allowHosts, resolve);
139
+ // `manual`, never the platform default: `follow` dials wherever the site points before
140
+ // anything here can ask whether that host is on the list.
141
+ const response = await call(dial.url, {
142
+ signal,
143
+ redirect: 'manual',
144
+ ...(dialled === undefined ? {} : { proxy: dialled }),
145
+ ...(dial.host === undefined ? {} : { headers: { host: dial.host } }),
146
+ ...(dial.serverName === undefined ? {} : { tls: { serverName: dial.serverName } }),
147
+ });
148
+ const next = redirectHop(
149
+ response.status,
150
+ response.headers.get('location'),
151
+ url,
152
+ 'GET',
153
+ undefined,
154
+ );
155
+ if (next === undefined) return await answerOf(response, limit);
156
+ await response.body?.cancel().catch(() => undefined);
157
+ if (hops >= MAX_ROBOTS_REDIRECTS) return undefined;
158
+ // With no list to ask, only a hop that stays on the host the read started on is
159
+ // followed — an `http:` → `https:` upgrade or a moved path. Another host is a host
160
+ // nobody screened, and is never dialled.
161
+ if (init.allowHosts === undefined && !sameHost(robotsUrl, next.url)) return undefined;
162
+ url = next.url;
163
+ }
96
164
  } catch {
97
165
  return undefined;
98
166
  }
package/src/scrape-run.ts CHANGED
@@ -19,13 +19,15 @@ import {
19
19
  createPrompt,
20
20
  ensureAuthenticated,
21
21
  markRefused,
22
+ observeSession,
22
23
  persistSession,
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
@@ -111,15 +147,22 @@ export async function runScrape<I, Row>(
111
147
 
112
148
  // Read BEFORE the browser opens: a refused credential must not reach a login form again, and
113
149
  // opening a session first would already have spent an identity on a run that cannot succeed.
114
- const restored = await restorableSession(plan);
150
+ const observed = await observeSession(plan);
151
+ const restored = observed.restorable;
115
152
  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.
153
+ // The exit the session dials, readable only AFTER `driver.open()` — a driver with no run exit
154
+ // falls back to its own option, and the gate below is an argument to `open()`, so the gate asks
155
+ // for it per read instead of being handed a value that cannot exist yet. Every read happens
156
+ // during a navigation, which is after this is assigned.
120
157
  let sessionProxy: string | undefined;
158
+ // Before `open()`: the time a rented browser takes to arrive is time it was held.
159
+ const usage = createUsageMeter(clock);
121
160
  const session = await driver.open({
122
161
  name: definition.name,
162
+ logger,
163
+ runId: args.runId,
164
+ ...(egress === undefined ? {} : { proxy: egress }),
165
+ usage,
123
166
  rules,
124
167
  clock,
125
168
  timeoutMs: pageTimeoutMs,
@@ -132,6 +175,8 @@ export async function runScrape<I, Row>(
132
175
  // this gate reads as "no restrictions".
133
176
  robots: createRobotsGate({
134
177
  policy: definition.robots ?? 'obey',
178
+ // Every hop of the read screened by the run's own host rule, as the HTTP leg's are.
179
+ allowHosts: rules.allowHosts,
135
180
  timeoutMs: pageTimeoutMs,
136
181
  signal: args.ctx.signal,
137
182
  proxy: () => sessionProxy,
@@ -142,6 +187,11 @@ export async function runScrape<I, Row>(
142
187
  watchdog: definition.watchdog,
143
188
  });
144
189
  sessionProxy = session.proxy;
190
+ // The VERSION of the stored record this run is on — the one it found (restorable or not), or the
191
+ // one it persisted after its own login. A burn or a tombstone on the way out compares against
192
+ // it, so a record another run on the key saved meanwhile is not destroyed by this run's failure
193
+ // (`auth.ts`'s `stillSeen`).
194
+ let seen = observed.seen;
145
195
 
146
196
  try {
147
197
  if (definition.auth !== undefined) {
@@ -151,15 +201,34 @@ export async function runScrape<I, Row>(
151
201
  ensureAuthenticated({
152
202
  ...plan,
153
203
  input: args.input,
204
+ runId: args.runId,
154
205
  page: session.page,
155
206
  secrets,
156
207
  restored,
157
- prompt: createPrompt(definition.name, definition.prompt, session.page),
208
+ prompt: createPrompt({
209
+ scrape: definition.name,
210
+ handler: definition.prompt,
211
+ input: args.input,
212
+ page: session.page,
213
+ runId: args.runId,
214
+ clock,
215
+ signal: args.ctx.signal,
216
+ secrets,
217
+ onAnswered: () => usage.promptAnswered(),
218
+ }),
158
219
  }),
159
220
  );
160
221
  // Persisted after a LOGIN only, never after a reuse: rewriting the record on every run
161
222
  // refreshes `savedAt` without refreshing the session, so `maxAge` would never expire it.
162
- if (loggedIn) await persistSession(plan, session.page);
223
+ //
224
+ // And a failure to SAVE never fails the attempt: the login happened, the body can run, and
225
+ // failing here made the retry log in again — one more arrival at the login form, and on a
226
+ // store that stays down, one per attempt. Logged, like the burn and the tombstone below.
227
+ if (loggedIn) {
228
+ await recordSessionOutcome('session.save', logger, async () => {
229
+ seen = await persistSession(plan, session.page);
230
+ });
231
+ }
163
232
  }
164
233
 
165
234
  const raw = await bodyWithRecovery(definition, args, session, logger, artifact, secrets);
@@ -181,20 +250,27 @@ export async function runScrape<I, Row>(
181
250
  // alone made a run that blocked 5,000 images print 200 and discarded the one number
182
251
  // (`Ring.dropped`) that exists to say "you are not seeing it all".
183
252
  const networkDropped = session.page.networkDropped();
184
- logger.info('scrape.ok', { rows: rows.length, refused });
253
+ const used = usage.snapshot(session.browserCost);
254
+ logger.info('scrape.ok', { rows: rows.length, refused, ...usageFields(used) });
185
255
  return {
186
256
  scrape: definition.name,
187
257
  rows,
188
258
  artifacts: artifact.saved.map((ref) => ref.key),
189
259
  refused,
190
260
  networkDropped,
261
+ usage: used,
191
262
  };
192
263
  } catch (thrown) {
193
- logger.error('scrape.failed', { code: errorCode(thrown) });
264
+ // A failed run has no report, and it was billed all the same: the counts ride the one line
265
+ // every failure already writes.
266
+ const used = usage.snapshot(session.browserCost);
267
+ logger.error('scrape.failed', { code: errorCode(thrown), ...usageFields(used) });
268
+ // Kept for `onSettled`, and only when somebody declared one to read it.
269
+ if (definition.onSettled !== undefined) rememberFailedUsage(args.runId, used);
194
270
  if (errorCode(thrown) === 'X_SCRAPE_AUTH_FAILED') {
195
- await recordSessionOutcome('session.refuse', logger, () => markRefused(plan));
271
+ await recordSessionOutcome('session.refuse', logger, () => markRefused(plan, seen));
196
272
  } else if (burnsSession(thrown)) {
197
- await recordSessionOutcome('session.burn', logger, () => burnSession(plan));
273
+ await recordSessionOutcome('session.burn', logger, () => burnSession(plan, seen));
198
274
  }
199
275
  if (definition.artifacts?.onFailure !== false) await saveFailureArtifact(session, artifact);
200
276
  throw thrown;
@@ -204,9 +280,18 @@ export async function runScrape<I, Row>(
204
280
  }
205
281
  }
206
282
 
283
+ /** The counts as log fields. `browserCost` stays off the line: it is on the report, as `Money`. */
284
+ const usageFields = (used: ScrapeUsage): ScrapeEventFields => ({
285
+ browserMs: used.browserMs,
286
+ navigations: used.navigations,
287
+ httpRequests: used.httpRequests,
288
+ bytesIn: used.bytesIn,
289
+ promptsAnswered: used.promptsAnswered,
290
+ });
291
+
207
292
  /**
208
- * The tombstone or the burn, on the way out — best effort, and it may NEVER replace the failure
209
- * that caused it. `markRefused` reaches `store.save()` reaches `storage.put()`, so an S3 503 or an
293
+ * The save after a login, and the tombstone or the burn on the way out — best effort, and none
294
+ * may ever replace the run's own outcome. `markRefused` reaches `store.save()` reaches `storage.put()`, so an S3 503 or an
210
295
  * `X_STORAGE_PATH_UNSAFE` from a tenant whose key sanitises to nothing used to propagate out of
211
296
  * the catch and REPLACE a terminal `X_SCRAPE_AUTH_FAILED` with a retryable one. Attempt 2 then
212
297
  * found no tombstone — the save is what failed — and walked the same rejected password back to
@@ -214,15 +299,16 @@ export async function runScrape<I, Row>(
214
299
  * reason: the run's own error is the one the reader needs.
215
300
  */
216
301
  async function recordSessionOutcome(
217
- step: 'session.refuse' | 'session.burn',
302
+ step: 'session.save' | 'session.refuse' | 'session.burn',
218
303
  logger: ReturnType<typeof scrapeLogger>,
219
304
  write: () => Promise<void>,
220
305
  ): Promise<void> {
221
306
  try {
222
307
  await write();
223
308
  } catch (thrown) {
224
- // Logged rather than swallowed silently: the tombstone is missing, so the NEXT attempt will
225
- // re-probe rather than refuse cheaply, and that is a fact an operator has to be able to see.
309
+ // Logged rather than swallowed silently: a missing tombstone means the NEXT attempt re-probes
310
+ // rather than refusing cheaply, a missing save means it logs in again, and an operator has to
311
+ // be able to see either.
226
312
  logger.error('scrape.session.write_failed', { step, code: errorCode(thrown) });
227
313
  }
228
314
  }
@@ -250,11 +336,13 @@ async function bodyWithRecovery<I, Row>(
250
336
  secrets,
251
337
  artifact,
252
338
  attempt: args.attempt,
339
+ finalAttempt: args.finalAttempt,
340
+ progress: args.progress,
253
341
  runId: args.runId,
254
342
  });
255
343
  try {
256
344
  return await withStepEvent(
257
- { name: 'body', logger, clock: definition.clock ?? systemScrapeClock, attempt: args.attempt },
345
+ { name: 'body', logger, clock: definition.clock ?? scrapeClock(), attempt: args.attempt },
258
346
  body,
259
347
  );
260
348
  } 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';
@@ -21,6 +30,7 @@ import type { ScrapeClock } from './clock';
21
30
  import type { ScrapeDriver } from './driver';
22
31
  import { yieldHistoryMissing } from './error-throws';
23
32
  import type { YieldExpectation, YieldHistory } from './expect';
33
+ import { maxDropFraction } from './expect';
24
34
  import type { HostRule } from './hosts';
25
35
  import type { ScrapeHttp } from './http';
26
36
  import type { ScrapePage } from './page';
@@ -29,6 +39,8 @@ import type { ResourceType } from './rings';
29
39
  import type { RobotsPolicy } from './robots';
30
40
  import { runScrape } from './scrape-run';
31
41
  import type { ScrapeSecrets } from './secrets';
42
+ import type { ScrapeUsage } from './usage';
43
+ import { takeFailedUsage } from './usage';
32
44
 
33
45
  export interface ScrapeRunArgs<I> {
34
46
  readonly input: I;
@@ -47,11 +59,16 @@ export interface ScrapeRunArgs<I> {
47
59
  readonly secrets: ScrapeSecrets;
48
60
  readonly artifact: ArtifactWriter;
49
61
  readonly attempt: number;
62
+ /** The job's own: true when a failure of this attempt is not retried for want of attempts. */
63
+ readonly finalAttempt: boolean;
64
+ /** The job's own `progress(done, total, note?)` — what `x jobs show` and a dashboard read. */
65
+ readonly progress: ProgressFn;
50
66
  readonly runId: string;
51
67
  }
52
68
 
53
69
  export interface ScrapeArtifacts {
54
- readonly storage?: StorageDriver | undefined;
70
+ /** A thunk, read per write — `() => disk('artifacts')`: the app's disk exists only after boot. */
71
+ readonly storage?: (() => StorageDriver) | undefined;
55
72
  /** Save the page's HTML when the run fails. On by default — it is the only forensic left. */
56
73
  readonly onFailure?: boolean | undefined;
57
74
  readonly prefix?: string | undefined;
@@ -93,9 +110,26 @@ export interface ScrapeDefinition<I, Row> {
93
110
  * primary case, so this is declared rather than hand-rolled per app. See `auth.ts`.
94
111
  */
95
112
  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;
113
+ /**
114
+ * Where an out-of-band code comes from, for a site that asks for one after the password. The
115
+ * request carries the run's `input` and `runId`: what a handler ties the prompt to.
116
+ */
117
+ readonly prompt?: PromptHandler<I> | undefined;
98
118
  readonly driver?: ScrapeDriver | undefined;
119
+ /**
120
+ * The exit THIS run leaves through — a proxy URL, credentials included — RESOLVED IN THE WORKER,
121
+ * under the job's tenant: `async ({ connectionId }) => (await repo.connectionById(connectionId))
122
+ * ?.exit`. The input names the row; the row (a `.sealed()` column) holds the exit. An exit whose
123
+ * credential is ALSO in the input rode the queue payload and sits in `x_jobs` in the clear, and
124
+ * is refused before the browser opens (`X_SCRAPE_EGRESS_IN_PAYLOAD`).
125
+ *
126
+ * It reaches the driver as `SessionInit.proxy` and wins over the driver's own `proxy`; both legs
127
+ * and the robots read dial it. `undefined` leaves the driver's exit in force. A driver that
128
+ * cannot dial it refuses with `X_SCRAPE_EGRESS_UNSUPPORTED` rather than dialling another.
129
+ */
130
+ readonly egress?:
131
+ | ((input: I, ctx: Ctx) => string | undefined | Promise<string | undefined>)
132
+ | undefined;
99
133
  readonly retry?: RetryPolicy | undefined;
100
134
  /** Per attempt, whole-run. `'5m'` or ms. */
101
135
  readonly timeout?: string | number | undefined;
@@ -103,12 +137,33 @@ export interface ScrapeDefinition<I, Row> {
103
137
  readonly pageTimeout?: string | number | undefined;
104
138
  /** Kill the browser after this much silence from it. See `watchdog.ts`. */
105
139
  readonly watchdog?: { readonly idleMs?: number; readonly graceMs?: number } | undefined;
106
- readonly concurrency?: number | undefined;
140
+ /** The job's own field, unchanged: a number, or `{ key, limit, whenBusy }` for a cap per key. */
141
+ readonly concurrency?: JobConcurrency<I> | undefined;
107
142
  readonly queue?: string | undefined;
143
+ /**
144
+ * Pins this definition's clock. Omit it: a run waits on the process's (`scrapeClock()`), which
145
+ * is the system clock and what a test replaces with `setScrapeClock()`.
146
+ */
108
147
  readonly clock?: ScrapeClock | undefined;
109
148
  run(args: ScrapeRunArgs<I>): Promise<readonly unknown[]>;
149
+ /**
150
+ * The job's own `onSettled`, with what a scrape adds: a `completed` run hands over its
151
+ * `ScrapeReport` — rows, artifacts and `usage` — and a run that ended any other way carries the
152
+ * `usage` of its last attempt (`undefined` for a `refused` one, whose body never ran). Same
153
+ * guarantees as the job's: after the row is settled, under the job's tenant, AT MOST ONCE
154
+ * across a crash, and a hook that throws changes nothing.
155
+ */
156
+ onSettled?(settled: ScrapeSettled<I, Row>): Promise<void>;
110
157
  }
111
158
 
159
+ /** How one scrape run ended, as `ScrapeDefinition.onSettled` is told. */
160
+ export type ScrapeSettled<I, Row> =
161
+ | JobCompleted<I, ScrapeReport<Row>>
162
+ | (JobFailed<I> & {
163
+ /** What the last attempt used. A failed run has no report and was billed all the same. */
164
+ readonly usage: ScrapeUsage | undefined;
165
+ });
166
+
112
167
  /** What one completed scrape reports — bounded, so `x jobs show` can print it. */
113
168
  export interface ScrapeReport<Row> {
114
169
  readonly scrape: string;
@@ -124,6 +179,8 @@ export interface ScrapeReport<Row> {
124
179
  * "you are not seeing it all": `refused` was counted from what survived the bound.
125
180
  */
126
181
  readonly networkDropped: number;
182
+ /** What this run used. A measurement: no quota is read from it and none is enforced. */
183
+ readonly usage: ScrapeUsage;
127
184
  }
128
185
 
129
186
  export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<I> {
@@ -142,10 +199,12 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
142
199
  // Two halves that must be set together. `maxDrop` is a fraction of a trailing median and only
143
200
  // `history:` can supply one, so declaring it alone is an alarm that cannot fire — refused here,
144
201
  // where it is written, rather than discovered as a scrape that never once went red.
145
- if (definition.expect?.maxDrop !== undefined && definition.history === undefined) {
146
- throw yieldHistoryMissing(definition.name);
202
+ if (definition.expect?.maxDrop !== undefined) {
203
+ if (definition.history === undefined) throw yieldHistoryMissing(definition.name);
204
+ maxDropFraction(definition.name, definition.expect.maxDrop);
147
205
  }
148
- return job<I>({
206
+ const onSettled = definition.onSettled?.bind(definition);
207
+ return job<I, ScrapeReport<Row>>({
149
208
  name: definition.name,
150
209
  input: definition.input,
151
210
  idempotencyKey: definition.idempotencyKey,
@@ -155,5 +214,15 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
155
214
  ...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
156
215
  ...(definition.concurrency === undefined ? {} : { concurrency: definition.concurrency }),
157
216
  run: (args) => runScrape(definition, args),
217
+ ...(onSettled === undefined
218
+ ? {}
219
+ : {
220
+ onSettled: (settled) => {
221
+ // Read on EVERY ending, the completed one too: a run that failed an attempt and then
222
+ // completed must not leave that attempt's counts behind in this process.
223
+ const usage = takeFailedUsage(settled.runId);
224
+ return onSettled(settled.outcome === 'completed' ? settled : { ...settled, usage });
225
+ },
226
+ }),
158
227
  });
159
228
  }
@@ -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
+ }