@ultimat3/scraping 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,9 +8,14 @@
8
8
  // The exit is a RESOLVER, not a string: `scrape-run.ts` builds this gate as an argument to
9
9
  // `driver.open()`, and the proxy is a driver option the session only reports on the way back out.
10
10
 
11
- import { finiteCount, readWithinLimit } from '@ultimat3/core';
11
+ import type { HostRule } from '@ultimat3/core';
12
+ import { finiteCount, isUltimateError, readWithinLimit } from '@ultimat3/core';
12
13
  import type { ScrapeFetch } from './http';
13
- import type { RobotsFetch } from './robots';
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';
18
+ import type { RobotsAnswer, RobotsFetch } from './robots';
14
19
 
15
20
  /**
16
21
  * A deadline is applied ALWAYS, proxy or no proxy, session or no session: the failure it prevents
@@ -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,20 +66,56 @@ 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
+ /**
84
+ * RFC 9309 §2.3.1.4 names server errors; a 429 is the same condition spelled as a client status —
85
+ * the origin is shedding load, not reporting a missing file — and every major crawler reads it so.
86
+ */
87
+ const isUnreachableStatus = (status: number): boolean => status >= 500 || status === 429;
88
+
89
+ /** The final hop's body under the cap, unreachable for a 5xx/429, `undefined` for another non-2xx. */
90
+ const answerOf = async (response: Response, limit: number): Promise<RobotsAnswer> => {
91
+ if (isUnreachableStatus(response.status)) {
92
+ await response.body?.cancel().catch(() => undefined);
93
+ return { unreachable: `status ${String(response.status)}` };
94
+ }
95
+ if (!response.ok) return undefined;
96
+ // Counted as it arrives rather than `.text()`, which materialises the whole body first: a
97
+ // multi-gigabyte robots.txt is a heap the worker never gets back.
98
+ const capped = await readWithinLimit(response.body, limit);
99
+ return 'over' in capped ? undefined : new TextDecoder().decode(capped.bytes);
100
+ };
101
+
52
102
  /**
53
- * 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.
103
+ * Reads `robotsUrl`. Three answers, after RFC 9309 §2.3.1: the text; `undefined` when the file is
104
+ * UNAVAILABLE — a 4xx, a body past the cap, a redirect off `allowHosts` or past the hop cap, a
105
+ * host the pin refuses — which the gate reads as no rules; and `{ unreachable }` for a 5xx, a 429,
106
+ * a deadline, a cancellation or a network error, which the gate reads as complete disallow. A
107
+ * server that cannot answer is not a server that has no rules.
56
108
  */
57
109
  export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
58
110
  const call: ScrapeFetch = init.fetch ?? fetch;
59
- // Both bounds are screened HERE, at construction, and both floors are 1 — because every way this
60
- // read can fail is the same answer, `undefined`, which the gate reads as "no restrictions". A
111
+ const resolve = init.resolve ?? resolveHost;
112
+ // Both bounds are screened HERE, at construction, and both floors are 1 — because a bad bound
113
+ // turns into a failed read on every origin, and a read that fails is never evidence of a rule. A
61
114
  // `NaN` deadline throws a bare `TypeError` out of `AbortSignal.timeout` (measured: "Value NaN is
62
115
  // outside the range [0, 9007199254740991]") straight into the gate's own `.catch`, and a `NaN`
63
116
  // cap makes `readWithinLimit` refuse after the request already left. A zero of either is the
64
- // same outcome spelled deliberately: an expired deadline and a cap every file is over. Robots
65
- // enforcement off, for the whole run, with nothing in the log.
117
+ // same outcome spelled deliberately: an expired deadline and a cap every file is over — every
118
+ // origin refused, or (the cap) every origin unrestricted, for the whole run.
66
119
  const limit = finiteCount(
67
120
  'robotsFetcher',
68
121
  'maxBytes',
@@ -75,7 +128,7 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
75
128
  init.timeoutMs ?? DEFAULT_ROBOTS_TIMEOUT_MS,
76
129
  1,
77
130
  );
78
- return async (robotsUrl: string): Promise<string | undefined> => {
131
+ return async (robotsUrl: string): Promise<RobotsAnswer> => {
79
132
  // Armed per read, not per gate: the gate is long-lived and reads once per origin, so a
80
133
  // deadline created alongside it would already have expired by the second origin.
81
134
  const deadline = AbortSignal.timeout(timeoutMs);
@@ -83,18 +136,49 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
83
136
  // Resolved here, at the read, because the session that owns the exit did not exist when this
84
137
  // fetcher was built. An empty string is not an exit and is dropped with the absent one.
85
138
  const proxy = init.proxy?.();
139
+ const dialled = proxy === undefined || proxy === '' ? undefined : proxy;
86
140
  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);
96
- } catch {
97
- return undefined;
141
+ let url = robotsUrl;
142
+ for (let hops = 0; ; hops += 1) {
143
+ if (init.allowHosts !== undefined && !onList(url, init.allowHosts)) return undefined;
144
+ // Pinned exactly as the HTTP leg pins (`pinned-host.ts`), and for its reason skipped
145
+ // through a proxy. A name resolving inward throws, which this read answers as "no robots".
146
+ const dial =
147
+ init.allowHosts === undefined || dialled !== undefined
148
+ ? { url }
149
+ : await dialTarget(url, init.allowHosts, resolve);
150
+ // `manual`, never the platform default: `follow` dials wherever the site points before
151
+ // anything here can ask whether that host is on the list.
152
+ const response = await call(dial.url, {
153
+ signal,
154
+ redirect: 'manual',
155
+ ...(dialled === undefined ? {} : { proxy: dialled }),
156
+ ...(dial.host === undefined ? {} : { headers: { host: dial.host } }),
157
+ ...(dial.serverName === undefined ? {} : { tls: { serverName: dial.serverName } }),
158
+ });
159
+ const next = redirectHop(
160
+ response.status,
161
+ response.headers.get('location'),
162
+ url,
163
+ 'GET',
164
+ undefined,
165
+ );
166
+ if (next === undefined) return await answerOf(response, limit);
167
+ await response.body?.cancel().catch(() => undefined);
168
+ if (hops >= MAX_ROBOTS_REDIRECTS) return undefined;
169
+ // With no list to ask, only a hop that stays on the host the read started on is
170
+ // followed — an `http:` → `https:` upgrade or a moved path. Another host is a host
171
+ // nobody screened, and is never dialled.
172
+ if (init.allowHosts === undefined && !sameHost(robotsUrl, next.url)) return undefined;
173
+ url = next.url;
174
+ }
175
+ } catch (error) {
176
+ // The pin refusing an inward name is a POLICY answer about the host, and the navigation is
177
+ // refused by the same rule — it is not evidence the origin is down.
178
+ if (isUltimateError(error) && error.code === 'X_SCRAPE_HOST_BLOCKED') return undefined;
179
+ if (deadline.aborted) return { unreachable: 'timeout' };
180
+ if (init.signal?.aborted === true) return { unreachable: 'cancelled' };
181
+ return { unreachable: 'network' };
98
182
  }
99
183
  };
100
184
  }
package/src/robots.ts CHANGED
@@ -126,7 +126,20 @@ export interface RobotsGate {
126
126
  readonly ignoredBecause: string | undefined;
127
127
  }
128
128
 
129
- export type RobotsFetch = (robotsUrl: string) => Promise<string | undefined>;
129
+ /**
130
+ * RFC 9309 §2.3.1.4: the file could not be obtained because of a server or network error — a 5xx,
131
+ * a 429, a refused connection, a deadline. The standard's answer is COMPLETE DISALLOW, and it is
132
+ * a different answer from `undefined` (a 4xx: the file is unavailable, so there are no rules).
133
+ * `unreachable` says why, for the log; nothing branches on its text.
134
+ */
135
+ export interface RobotsUnreachable {
136
+ readonly unreachable: string;
137
+ }
138
+
139
+ /** The file's text, `undefined` for no file (no rules), or unreachable (no access). */
140
+ export type RobotsAnswer = string | undefined | RobotsUnreachable;
141
+
142
+ export type RobotsFetch = (robotsUrl: string) => Promise<RobotsAnswer>;
130
143
 
131
144
  export interface RobotsGateInit extends RobotsFetchInit {
132
145
  readonly policy: RobotsPolicy;
@@ -135,20 +148,32 @@ export interface RobotsGateInit extends RobotsFetchInit {
135
148
  readonly fetchText?: RobotsFetch | undefined;
136
149
  }
137
150
 
151
+ /** What the gate holds per origin: the rules, or the standard's complete disallow. */
152
+ type Verdict = RobotsRules | 'unreachable';
153
+
154
+ const verdictOf = (answer: RobotsAnswer, agent: string): Verdict => {
155
+ if (answer === undefined) return { rules: [] };
156
+ if (typeof answer === 'string') return parseRobots(answer, agent);
157
+ return 'unreachable';
158
+ };
159
+
138
160
  /**
139
161
  * One fetch of `/robots.txt` per ORIGIN per run, cached. A gate that re-fetched per navigation
140
162
  * would triple the request count of every scrape it protects.
141
163
  *
142
- * An origin whose robots.txt cannot be read is ALLOWED. That is the standard's own answer — a
143
- * missing file means no restrictions — and the alternative fails every run behind a flaky CDN.
164
+ * A MISSING robots.txt (a 4xx, a redirect chain past the cap) is no restrictions — the standard's
165
+ * own answer. An UNREACHABLE one (5xx, 429, a network error, a deadline, a read that rejects) is
166
+ * complete disallow (RFC 9309 §2.3.1.4), and is NOT cached: the next navigation to the origin asks
167
+ * again, so a run behind a flaky CDN recovers the moment the file answers instead of failing for
168
+ * the rest of the run — and an origin cannot shed its own rules by answering 503 once.
144
169
  */
145
- export function createRobotsGate(init: RobotsGateInit): RobotsGate {
170
+ export function robotsGate(init: RobotsGateInit): RobotsGate {
146
171
  if (init.policy !== 'obey') {
147
172
  return { assertAllowed: () => Promise.resolve(), ignoredBecause: init.policy.ignore };
148
173
  }
149
174
  const agent = init.agent ?? DEFAULT_ROBOTS_AGENT;
150
175
  const read = init.fetchText ?? robotsFetcher(init);
151
- const cache = new Map<string, Promise<RobotsRules>>();
176
+ const cache = new Map<string, Promise<Verdict>>();
152
177
  return {
153
178
  ignoredBecause: undefined,
154
179
  async assertAllowed(url: string): Promise<void> {
@@ -160,14 +185,27 @@ export function createRobotsGate(init: RobotsGateInit): RobotsGate {
160
185
  }
161
186
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return;
162
187
  const origin = parsed.origin;
163
- let rules = cache.get(origin);
164
- if (rules === undefined) {
165
- rules = read(`${origin}/robots.txt`)
166
- .then((text) => (text === undefined ? { rules: [] } : parseRobots(text, agent)))
167
- .catch(() => ({ rules: [] }));
168
- cache.set(origin, rules);
188
+ let pending = cache.get(origin);
189
+ if (pending === undefined) {
190
+ // A caller-supplied read that rejects has not produced a file, and "could not ask" is
191
+ // the unreachable case, not the missing one.
192
+ const fresh = read(`${origin}/robots.txt`).then(
193
+ (answer) => verdictOf(answer, agent),
194
+ (): Verdict => 'unreachable',
195
+ );
196
+ pending = fresh;
197
+ cache.set(origin, fresh);
198
+ // Concurrent navigations still share the one in-flight read; only a settled unreachable
199
+ // verdict is evicted, and only if no later read has replaced it.
200
+ void fresh.then((verdict) => {
201
+ if (verdict === 'unreachable' && cache.get(origin) === fresh) cache.delete(origin);
202
+ });
169
203
  }
170
- if (!robotsAllows(await rules, `${parsed.pathname}${parsed.search}`)) {
204
+ const verdict = await pending;
205
+ if (
206
+ verdict === 'unreachable' ||
207
+ !robotsAllows(verdict, `${parsed.pathname}${parsed.search}`)
208
+ ) {
171
209
  throw robotsDisallowed(url, agent);
172
210
  }
173
211
  },
package/src/scrape-run.ts CHANGED
@@ -12,15 +12,15 @@
12
12
  import { finiteCount, finiteOption } from '@ultimat3/core';
13
13
  import type { JobRunArgs } from '@ultimat3/jobs';
14
14
  import { parse } from '@ultimat3/schema';
15
- import { createArtifactWriter } from './artifacts';
15
+ import { artifactWriter } from './artifacts';
16
16
  import type { AuthPlanInput } from './auth';
17
17
  import {
18
18
  burnSession,
19
- createPrompt,
20
19
  ensureAuthenticated,
21
20
  markRefused,
21
+ observeSession,
22
22
  persistSession,
23
- restorableSession,
23
+ scrapePrompt,
24
24
  } from './auth';
25
25
  import { scrapeClock } from './clock';
26
26
  import type { ScrapeSession } from './driver';
@@ -31,16 +31,16 @@ import type { ScrapeEventFields } from './events';
31
31
  import { scrapeLogger, withStepEvent } from './events';
32
32
  import { guardYield } from './expect';
33
33
  import { burnsSession, errorCode, neverRetried } from './failures';
34
- import { createPacer, DEFAULT_NAVIGATION_RATE } from './rate';
34
+ import { DEFAULT_NAVIGATION_RATE, scrapePacer } from './rate';
35
35
  import { runRecovery } from './recover';
36
- import { createRobotsGate } from './robots';
36
+ import { robotsGate } from './robots';
37
37
  import type { ScrapeDefinition, ScrapeReport } from './scrape';
38
38
  import { containsSecret } from './secret-scan';
39
- import { createSecretBag, MIN_REDACTABLE_LENGTH } from './secrets';
39
+ import { MIN_REDACTABLE_LENGTH, secretBag } from './secrets';
40
40
  import { sessionKeyFor } from './session-state';
41
41
  import { hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
42
42
  import type { ScrapeUsage } from './usage';
43
- import { createUsageMeter, rememberFailedUsage } from './usage';
43
+ import { rememberFailedUsage, usageMeter } from './usage';
44
44
 
45
45
  /** `ctx.actor` is a structural read: this package never imports the auth types (tier 2). */
46
46
  const orgOf = (ctx: unknown): string | undefined => {
@@ -99,7 +99,7 @@ export async function runScrape<I, Row>(
99
99
  attempt: args.attempt,
100
100
  driver: driver.name,
101
101
  });
102
- const secrets = createSecretBag(definition.secrets ?? []);
102
+ const secrets = secretBag(definition.secrets ?? []);
103
103
  // The run's exit, resolved HERE — in the worker, under the job's tenant — so what the queue row
104
104
  // carries is the id of whatever holds it. An empty answer is "none", the same as absent: a
105
105
  // driver handed `''` would dial `--proxy-server=` and call that a decision.
@@ -120,11 +120,11 @@ export async function runScrape<I, Row>(
120
120
  // refuses the DECLARATION and never sees a definition assembled by hand, which `runScrape` is
121
121
  // exported to accept. `finiteOption` and not `finiteCount` — a rate of 0.5 is one navigation
122
122
  // every two seconds, and `scrape()` owns the "greater than zero" half.
123
- const pace = createPacer(
123
+ const pace = scrapePacer(
124
124
  finiteOption('the scrape definition', 'rate', definition.rate ?? DEFAULT_NAVIGATION_RATE),
125
125
  clock,
126
126
  );
127
- const artifact = createArtifactWriter({
127
+ const artifact = artifactWriter({
128
128
  storage: definition.artifacts?.storage,
129
129
  scrape: definition.name,
130
130
  runId: args.runId,
@@ -147,7 +147,8 @@ export async function runScrape<I, Row>(
147
147
 
148
148
  // Read BEFORE the browser opens: a refused credential must not reach a login form again, and
149
149
  // opening a session first would already have spent an identity on a run that cannot succeed.
150
- const restored = await restorableSession(plan);
150
+ const observed = await observeSession(plan);
151
+ const restored = observed.restorable;
151
152
  const pageTimeoutMs = toMillis(definition.pageTimeout, DEFAULT_PAGE_TIMEOUT_MS, 'pageTimeout');
152
153
  // The exit the session dials, readable only AFTER `driver.open()` — a driver with no run exit
153
154
  // falls back to its own option, and the gate below is an argument to `open()`, so the gate asks
@@ -155,7 +156,7 @@ export async function runScrape<I, Row>(
155
156
  // during a navigation, which is after this is assigned.
156
157
  let sessionProxy: string | undefined;
157
158
  // Before `open()`: the time a rented browser takes to arrive is time it was held.
158
- const usage = createUsageMeter(clock);
159
+ const usage = usageMeter(clock);
159
160
  const session = await driver.open({
160
161
  name: definition.name,
161
162
  logger,
@@ -172,8 +173,10 @@ export async function runScrape<I, Row>(
172
173
  // unreachable by `ctx.signal`; without the third the read leaves from a different IP than
173
174
  // every page load, and an origin reachable only through the proxy answers nothing — which
174
175
  // this gate reads as "no restrictions".
175
- robots: createRobotsGate({
176
+ robots: robotsGate({
176
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,
177
180
  timeoutMs: pageTimeoutMs,
178
181
  signal: args.ctx.signal,
179
182
  proxy: () => sessionProxy,
@@ -184,6 +187,11 @@ export async function runScrape<I, Row>(
184
187
  watchdog: definition.watchdog,
185
188
  });
186
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;
187
195
 
188
196
  try {
189
197
  if (definition.auth !== undefined) {
@@ -197,7 +205,7 @@ export async function runScrape<I, Row>(
197
205
  page: session.page,
198
206
  secrets,
199
207
  restored,
200
- prompt: createPrompt({
208
+ prompt: scrapePrompt({
201
209
  scrape: definition.name,
202
210
  handler: definition.prompt,
203
211
  input: args.input,
@@ -212,7 +220,15 @@ export async function runScrape<I, Row>(
212
220
  );
213
221
  // Persisted after a LOGIN only, never after a reuse: rewriting the record on every run
214
222
  // refreshes `savedAt` without refreshing the session, so `maxAge` would never expire it.
215
- 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
+ }
216
232
  }
217
233
 
218
234
  const raw = await bodyWithRecovery(definition, args, session, logger, artifact, secrets);
@@ -252,9 +268,9 @@ export async function runScrape<I, Row>(
252
268
  // Kept for `onSettled`, and only when somebody declared one to read it.
253
269
  if (definition.onSettled !== undefined) rememberFailedUsage(args.runId, used);
254
270
  if (errorCode(thrown) === 'X_SCRAPE_AUTH_FAILED') {
255
- await recordSessionOutcome('session.refuse', logger, () => markRefused(plan));
271
+ await recordSessionOutcome('session.refuse', logger, () => markRefused(plan, seen));
256
272
  } else if (burnsSession(thrown)) {
257
- await recordSessionOutcome('session.burn', logger, () => burnSession(plan));
273
+ await recordSessionOutcome('session.burn', logger, () => burnSession(plan, seen));
258
274
  }
259
275
  if (definition.artifacts?.onFailure !== false) await saveFailureArtifact(session, artifact);
260
276
  throw thrown;
@@ -274,8 +290,8 @@ const usageFields = (used: ScrapeUsage): ScrapeEventFields => ({
274
290
  });
275
291
 
276
292
  /**
277
- * The tombstone or the burn, on the way out — best effort, and it may NEVER replace the failure
278
- * 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
279
295
  * `X_STORAGE_PATH_UNSAFE` from a tenant whose key sanitises to nothing used to propagate out of
280
296
  * the catch and REPLACE a terminal `X_SCRAPE_AUTH_FAILED` with a retryable one. Attempt 2 then
281
297
  * found no tombstone — the save is what failed — and walked the same rejected password back to
@@ -283,15 +299,16 @@ const usageFields = (used: ScrapeUsage): ScrapeEventFields => ({
283
299
  * reason: the run's own error is the one the reader needs.
284
300
  */
285
301
  async function recordSessionOutcome(
286
- step: 'session.refuse' | 'session.burn',
302
+ step: 'session.save' | 'session.refuse' | 'session.burn',
287
303
  logger: ReturnType<typeof scrapeLogger>,
288
304
  write: () => Promise<void>,
289
305
  ): Promise<void> {
290
306
  try {
291
307
  await write();
292
308
  } catch (thrown) {
293
- // Logged rather than swallowed silently: the tombstone is missing, so the NEXT attempt will
294
- // 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.
295
312
  logger.error('scrape.session.write_failed', { step, code: errorCode(thrown) });
296
313
  }
297
314
  }
@@ -306,8 +323,8 @@ async function bodyWithRecovery<I, Row>(
306
323
  args: JobRunArgs<I>,
307
324
  session: ScrapeSession,
308
325
  logger: ReturnType<typeof scrapeLogger>,
309
- artifact: ReturnType<typeof createArtifactWriter>,
310
- secrets: ReturnType<typeof createSecretBag>,
326
+ artifact: ReturnType<typeof artifactWriter>,
327
+ secrets: ReturnType<typeof secretBag>,
311
328
  ): Promise<readonly unknown[]> {
312
329
  const body = (): Promise<readonly unknown[]> =>
313
330
  definition.run({
@@ -348,7 +365,7 @@ async function bodyWithRecovery<I, Row>(
348
365
  */
349
366
  async function saveFailureArtifact(
350
367
  session: ScrapeSession,
351
- artifact: ReturnType<typeof createArtifactWriter>,
368
+ artifact: ReturnType<typeof artifactWriter>,
352
369
  ): Promise<void> {
353
370
  try {
354
371
  await artifact.save('page.html', await session.page.html());
package/src/scrape.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // the account. So this file is a FACTORY over `job()`, and a scrape inherits `.enqueue()`, the
10
10
  // worker's cancellation, the dead-letter path, `x jobs show` and its manifest row for free.
11
11
 
12
- import type { Ctx } from '@ultimat3/core';
12
+ import type { Ctx, HostRule } from '@ultimat3/core';
13
13
  import { assert } from '@ultimat3/core';
14
14
  import type {
15
15
  JobCompleted,
@@ -30,7 +30,7 @@ import type { ScrapeClock } from './clock';
30
30
  import type { ScrapeDriver } from './driver';
31
31
  import { yieldHistoryMissing } from './error-throws';
32
32
  import type { YieldExpectation, YieldHistory } from './expect';
33
- import type { HostRule } from './hosts';
33
+ import { maxDropFraction } from './expect';
34
34
  import type { ScrapeHttp } from './http';
35
35
  import type { ScrapePage } from './page';
36
36
  import type { Recovery } from './recover';
@@ -198,8 +198,9 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
198
198
  // Two halves that must be set together. `maxDrop` is a fraction of a trailing median and only
199
199
  // `history:` can supply one, so declaring it alone is an alarm that cannot fire — refused here,
200
200
  // where it is written, rather than discovered as a scrape that never once went red.
201
- if (definition.expect?.maxDrop !== undefined && definition.history === undefined) {
202
- throw yieldHistoryMissing(definition.name);
201
+ if (definition.expect?.maxDrop !== undefined) {
202
+ if (definition.history === undefined) throw yieldHistoryMissing(definition.name);
203
+ maxDropFraction(definition.name, definition.expect.maxDrop);
203
204
  }
204
205
  const onSettled = definition.onSettled?.bind(definition);
205
206
  return job<I, ScrapeReport<Row>>({
package/src/secrets.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // logger. The third is not, because pixels cannot be redacted after the fact.
8
8
 
9
9
  import type { Secret } from '@ultimat3/core';
10
- import { revealSecret, secret, UltimateError } from '@ultimat3/core';
10
+ import { escapeHtml, revealSecret, secret, UltimateError } from '@ultimat3/core';
11
11
  import type { ConsoleLine, NetworkEntry, PageError } from './rings';
12
12
 
13
13
  export interface ScrapeSecrets {
@@ -33,7 +33,7 @@ export type SecretResolver = (name: string) => string | undefined;
33
33
 
34
34
  const fromEnvironment: SecretResolver = (name) => Bun.env[name];
35
35
 
36
- export function createSecretBag(
36
+ export function secretBag(
37
37
  names: readonly string[],
38
38
  resolve: SecretResolver = fromEnvironment,
39
39
  ): ScrapeSecrets {
@@ -95,6 +95,56 @@ export const SECRET_PLACEHOLDER = '[redacted]';
95
95
  */
96
96
  export const MIN_REDACTABLE_LENGTH = 4;
97
97
 
98
+ /**
99
+ * The SUBSET a serialiser escaped, each character spelt by core's one table — a page escapes fewer
100
+ * characters than `escapeHtml` does (a browser's text serialiser leaves quotes alone), so the
101
+ * spelling is the table applied to that serialiser's set, never a second table.
102
+ */
103
+ const escapeOnly = (value: string, chars: RegExp): string => value.replace(chars, escapeHtml);
104
+
105
+ /**
106
+ * Every spelling a page or a URL writes one value in. The raw value alone missed the case
107
+ * `safeNetwork` exists for: a password in a query string arrives percent-encoded, or form-encoded
108
+ * (`+` for a space, `'` and `(` escaped), and a server-rendered page writes it HTML-escaped — as
109
+ * text (`&amp; &lt; &gt;`, what a browser's serialiser emits), as an attribute (`&amp; &quot;`),
110
+ * or through a template that escapes all five (`&#39;` or `&#x27;` for the apostrophe).
111
+ * Each spelling is at least as long as the value, so the length floor is decided on the value.
112
+ */
113
+ function spellingsOf(value: string): readonly string[] {
114
+ const text = escapeOnly(value, /[&<>]/g);
115
+ const attribute = escapeOnly(value, /[&"]/g);
116
+ const full = escapeOnly(value, /[&<>"]/g);
117
+ return [
118
+ value,
119
+ encodeURIComponent(value),
120
+ new URLSearchParams([['', value]]).toString().slice(1),
121
+ text,
122
+ attribute,
123
+ full.replaceAll("'", '&#39;'),
124
+ full.replaceAll("'", '&#x27;'),
125
+ ];
126
+ }
127
+
128
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\/]/g;
129
+
130
+ /**
131
+ * One spelling as a pattern: literal throughout, except that the hex digits of a `%XX` escape
132
+ * match in either case — `%2F` and `%2f` are the same byte, and clients emit both. The value's own
133
+ * letters stay case-sensitive: `Hunter2` is not `hunter2`.
134
+ */
135
+ const spellingPattern = (spelling: string): RegExp =>
136
+ new RegExp(
137
+ spelling
138
+ .split(/(%[0-9A-Fa-f]{2})/)
139
+ .map((part, index) =>
140
+ index % 2 === 1
141
+ ? part.replace(/[A-Fa-f]/g, (digit) => `[${digit.toUpperCase()}${digit.toLowerCase()}]`)
142
+ : part.replace(REGEX_SPECIAL, '\\$&'),
143
+ )
144
+ .join(''),
145
+ 'g',
146
+ );
147
+
98
148
  /**
99
149
  * Redaction BY VALUE, over the text this package hands back or persists. Name-based redaction only
100
150
  * catches a secret travelling under a name somebody remembered to list, and a password pasted into
@@ -111,20 +161,22 @@ export const MIN_REDACTABLE_LENGTH = 4;
111
161
  * `MIN_REDACTABLE_LENGTH`, and pixels — which is why a secret TAINTS the page and captures are
112
162
  * refused outright.
113
163
  *
114
- * Longest first, so a secret that contains another one does not leave its tail behind.
164
+ * Every spelling of every value (`spellingsOf`), longest first, so a secret that contains another
165
+ * one does not leave its tail behind.
115
166
  */
116
167
  export function redactSecrets(text: string, secrets: ScrapeSecrets | undefined): string {
117
168
  if (secrets === undefined) return text;
118
169
  const learned = concealed.get(secrets);
119
170
  if (secrets.names.length === 0 && (learned === undefined || learned.size === 0)) return text;
120
171
  const values = [
121
- ...secrets.names.map((name) => revealSecret(secrets.get(name))),
122
- ...(learned ?? []),
123
- ]
124
- .filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
125
- .sort((a, b) => b.length - a.length);
172
+ ...new Set(
173
+ [...secrets.names.map((name) => revealSecret(secrets.get(name))), ...(learned ?? [])]
174
+ .filter((value) => value.length >= MIN_REDACTABLE_LENGTH)
175
+ .flatMap(spellingsOf),
176
+ ),
177
+ ].sort((a, b) => b.length - a.length);
126
178
  let out = text;
127
- for (const value of values) out = out.split(value).join(SECRET_PLACEHOLDER);
179
+ for (const value of values) out = out.replace(spellingPattern(value), SECRET_PLACEHOLDER);
128
180
  return out;
129
181
  }
130
182
 
@@ -38,6 +38,13 @@ export interface SessionState extends SessionSnapshot {
38
38
  readonly key: string;
39
39
  /** ISO 8601. What a `maxAge` on reuse is measured against. */
40
40
  readonly savedAt: string;
41
+ /**
42
+ * Unique per save — what a burn or a tombstone compares to decide whether the record is still
43
+ * the one a run saw (`auth.ts`'s `stillSeen`). Never `savedAt`: two saves in one millisecond, or
44
+ * on one frozen clock, share it. Absent on a record written before it existed; `recordVersion`
45
+ * reads `savedAt` for that one.
46
+ */
47
+ readonly version?: string | undefined;
41
48
  /**
42
49
  * ISO 8601, set when this site REFUSED these credentials — and the reason the record survives
43
50
  * the failure instead of being deleted with it.
@@ -309,6 +316,7 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und
309
316
  return {
310
317
  key,
311
318
  savedAt: value.savedAt,
319
+ ...(typeof value.version === 'string' ? { version: value.version } : {}),
312
320
  ...(typeof value.refusedAt === 'string' ? { refusedAt: value.refusedAt } : {}),
313
321
  cookies: value.cookies.flatMap((cookie: unknown) => toCookie(cookie) ?? []),
314
322
  headers: value.headers ?? {},
@@ -317,3 +325,6 @@ export function parseSessionState(raw: unknown, key: string): SessionState | und
317
325
  origin: value.origin ?? '',
318
326
  };
319
327
  }
328
+
329
+ /** The identity a conditional burn or tombstone compares: the record's version, else its `savedAt`. */
330
+ export const recordVersion = (state: SessionState): string => state.version ?? state.savedAt;