@ultimat3/scraping 16.0.0 → 17.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/scraping",
3
- "version": "16.0.0",
3
+ "version": "17.0.0",
4
4
  "description": "Browser automation as a job: scrape() returns a JobHandle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,9 +30,9 @@
30
30
  "test": "bun test"
31
31
  },
32
32
  "dependencies": {
33
- "@ultimat3/core": "16.0.0",
34
- "@ultimat3/jobs": "16.0.0",
35
- "@ultimat3/schema": "16.0.0",
36
- "@ultimat3/storage": "16.0.0"
33
+ "@ultimat3/core": "17.0.0",
34
+ "@ultimat3/jobs": "17.0.0",
35
+ "@ultimat3/schema": "17.0.0",
36
+ "@ultimat3/storage": "17.0.0"
37
37
  }
38
38
  }
@@ -4,6 +4,7 @@
4
4
  // `waitForSelector('#aceptar:not([disabled])')`, which is this rule, spelled once, by hand, at
5
5
  // one call site out of forty.
6
6
 
7
+ import { finiteCount } from '@ultimat3/core';
7
8
  import type { ScrapeClock } from './clock';
8
9
  import { deadline } from './clock';
9
10
  import { notActionable, selectorMissing } from './error-throws';
@@ -85,8 +86,12 @@ export interface ActionabilityWait {
85
86
  * a scraper failure take an afternoon.
86
87
  */
87
88
  export async function awaitActionable(wait: ActionabilityWait): Promise<ElementSnapshot> {
88
- const budget = deadline(wait.clock, wait.timeoutMs);
89
- const pollMs = wait.pollMs ?? DEFAULT_POLL_MS;
89
+ const budget = deadline(wait.clock, wait.timeoutMs, 'page.waitFor');
90
+ // At least 1ms, and the floor is the point: `Math.min(0, remainingMs())` is 0, `clock.sleep(0)`
91
+ // returns on the next turn, and the poll is then one full round trip to the browser per
92
+ // event-loop turn for the whole budget. `NaN` is the same loop by a different route —
93
+ // `Math.min(NaN, x)` is `NaN` and `setTimeout(fn, NaN)` is `setTimeout(fn, 0)`.
94
+ const pollMs = finiteCount('page.waitFor', 'pollMs', wait.pollMs ?? DEFAULT_POLL_MS, 1);
90
95
  let previous: ElementSnapshot | undefined;
91
96
  let lastProblem: string | undefined;
92
97
  let everSeen = false;
package/src/auth.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  // is written into the session record so the NEXT attempt fails before reaching the login form.
19
19
 
20
20
  import type { Logger } from '@ultimat3/core';
21
+ import { finiteCount } from '@ultimat3/core';
21
22
  import type { ScrapeClock } from './clock';
22
23
  import { authFailed, promptUnanswered, sessionExpired } from './error-throws';
23
24
  import type { ScrapePage } from './page';
@@ -121,8 +122,17 @@ export async function restorableSession<I>(
121
122
  if (plan.auth?.reuse === false) return undefined;
122
123
  const maxAge = plan.auth?.maxAge;
123
124
  if (maxAge !== undefined) {
125
+ // Screened, because `age > NaN` is false and false here means RESTORED: a `NaN` maxAge hands
126
+ // back a session of any age, with no re-login and nothing in the report. `0` is legal — it
127
+ // means "restore nothing stored before now" — so the floor stays there.
128
+ const limit = finiteCount('the scrape auth', 'maxAge', maxAge);
124
129
  const age = plan.clock.now().getTime() - new Date(found.savedAt).getTime();
125
- if (age > maxAge) return undefined;
130
+ // `!(age <= limit)` and not `age > limit`, which is the same test for every finite age and the
131
+ // OPPOSITE one for a `NaN`. `savedAt` is data, not configuration: it comes back off a bucket
132
+ // and `parseSessionState` only asks that it is a string, so an edited or half-written record
133
+ // produces a `NaN` age against a perfectly good `maxAge`. Failing closed costs one re-login;
134
+ // failing open acts as somebody else, indefinitely.
135
+ if (!(age <= limit)) return undefined;
126
136
  }
127
137
  plan.logger.debug('scrape.session.restored', sessionDigest(found));
128
138
  return found;
package/src/clock.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // `clock-discipline.test.ts` fails the build if a second file reaches for a timer directly.
6
6
 
7
7
  import type { Clock } from '@ultimat3/core';
8
+ import { finiteCount } from '@ultimat3/core';
8
9
 
9
10
  export interface ScrapeClock extends Clock {
10
11
  /**
@@ -90,8 +91,14 @@ export interface Deadline {
90
91
  expired(): boolean;
91
92
  }
92
93
 
93
- export function deadline(clock: Clock, totalMs: number): Deadline {
94
+ export function deadline(clock: Clock, totalMs: number, subject = 'a scrape wait'): Deadline {
95
+ // Screened HERE because this is the one constructor of a budget, and every `for (;;)` in this
96
+ // package asks `expired()` to leave it. `Math.max(0, NaN - elapsed)` is `NaN` and `NaN <= 0` is
97
+ // false, so a budget that is not a number never expires: the loop in `awaitActionable` and the
98
+ // one in `page-over-target.ts`'s `frame()` both become unbounded, re-reading a live browser once
99
+ // per `Math.min(pollMs, NaN)` — which is `NaN`, which `setTimeout` reads as 0.
100
+ const budgetMs = finiteCount(subject, 'timeoutMs', totalMs);
94
101
  const startedAt = clock.monotonic();
95
- const remainingMs = (): number => Math.max(0, totalMs - (clock.monotonic() - startedAt));
96
- return { totalMs, remainingMs, expired: () => remainingMs() <= 0 };
102
+ const remainingMs = (): number => Math.max(0, budgetMs - (clock.monotonic() - startedAt));
103
+ return { totalMs: budgetMs, remainingMs, expired: () => remainingMs() <= 0 };
97
104
  }
@@ -5,6 +5,7 @@
5
5
  // It covers BOTH legs. `fakeBrowser({ pages, http })` replays the browser walk and the JSON
6
6
  // endpoints behind it from one declaration, so a hybrid scrape is tested the way it runs.
7
7
 
8
+ import { finiteCount } from '@ultimat3/core';
8
9
  import type { ScrapeClock } from './clock';
9
10
  import { systemScrapeClock } from './clock';
10
11
  import type { ScrapeDriver, ScrapeSession, SessionInit } from './driver';
@@ -113,7 +114,10 @@ export function fakePage(dom: string, options: FakePageOptions = {}): ScrapePage
113
114
  return pageOverTarget(target, {
114
115
  clock,
115
116
  allowHosts,
116
- defaultTimeoutMs: options.timeoutMs ?? 1_000,
117
+ // The default budget for every wait AND every navigation on this page, so the floor is 1: a
118
+ // session default of 0 is already out of time everywhere, which a per-call `{ timeout: 0 }` —
119
+ // "is it there right now" — is not. Non-finite is the loop that never leaves.
120
+ defaultTimeoutMs: finiteCount('fakePage', 'timeoutMs', options.timeoutMs ?? 1_000, 1),
117
121
  ...options.context,
118
122
  });
119
123
  }
package/src/expect.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // where the data went. `expect` turns that into a red run: a yield under `minRows`, or under
4
4
  // `maxDrop` of what this scrape normally returns, throws.
5
5
 
6
+ import { finiteCount, finiteOption } from '@ultimat3/core';
6
7
  import { yieldCollapsed } from './error-throws';
7
8
  import type { ScrapeError } from './errors';
8
9
 
@@ -61,7 +62,23 @@ export interface YieldCheck {
61
62
  * fifty histories without a queue, a browser or a clock.
62
63
  */
63
64
  export function yieldProblem(check: YieldCheck): ScrapeError | undefined {
64
- const { minRows, maxDrop } = check.expect;
65
+ // Screened where the rule is, because this is the alarm and both directions are silent. A `NaN`
66
+ // minRows makes `rows < minRows` false for every yield, so the floor never fires and the scrape
67
+ // is green on zero rows forever — the exact failure this file exists to prevent. A `NaN` maxDrop
68
+ // makes `rows >= baseline * (1 - NaN)` false for every yield, which fires the alarm on every run
69
+ // instead, and an alarm that always fires is an alarm somebody turns off.
70
+ //
71
+ // `minRows: 0` is legal and stays legal: this file asks an author whose answer is legitimately
72
+ // sometimes zero to declare exactly that. `maxDrop` is a FRACTION of a median, so `finiteOption`
73
+ // and not `finiteCount` — `0.5` is the documented value.
74
+ const minRows =
75
+ check.expect.minRows === undefined
76
+ ? undefined
77
+ : finiteCount('the scrape expect', 'minRows', check.expect.minRows);
78
+ const maxDrop =
79
+ check.expect.maxDrop === undefined
80
+ ? undefined
81
+ : finiteOption('the scrape expect', 'maxDrop', check.expect.maxDrop);
65
82
  if (minRows !== undefined && check.rows < minRows) {
66
83
  return yieldCollapsed({ scrape: check.scrape, rows: check.rows, reason: 'min-rows', minRows });
67
84
  }
@@ -101,7 +118,15 @@ export async function guardYield(input: YieldGuardInput): Promise<void> {
101
118
  // `maxDrop` needs `MIN_BASELINE_RUNS` runs after it is declared before it can fire — a delay,
102
119
  // not a hole. `expect.test.ts` pins both halves.
103
120
  if (input.expect === undefined) return;
104
- const window = input.expect.window ?? DEFAULT_YIELD_WINDOW;
121
+ // At least 1: `[…].slice(-0)` is `slice(0)`, the WHOLE history, so a zero window is the largest
122
+ // baseline rather than no baseline — and the number is handed to an app's own `recent()`, where
123
+ // it is usually a SQL `limit`.
124
+ const window = finiteCount(
125
+ 'the scrape expect',
126
+ 'window',
127
+ input.expect.window ?? DEFAULT_YIELD_WINDOW,
128
+ 1,
129
+ );
105
130
  const history =
106
131
  input.history === undefined ? [] : await input.history.recent(input.scrape, window);
107
132
  const problem = yieldProblem({
package/src/http.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // guarantee the page vocabulary makes — and a different exit IP mid-session is exactly what
11
11
  // anti-bot systems look for.
12
12
 
13
- import { readWithinLimit } from '@ultimat3/core';
13
+ import { finiteCount, readWithinLimit } from '@ultimat3/core';
14
14
  import type { StandardSchemaV1 } from '@ultimat3/schema';
15
15
  import { parse } from '@ultimat3/schema';
16
16
  import type { ScrapeClock } from './clock';
@@ -172,6 +172,29 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
172
172
  const call: ScrapeFetch = init.fetch ?? fetch;
173
173
  return {
174
174
  async request(url: string, request: HttpRequestInit = {}): Promise<ScrapeResponse> {
175
+ // Screened FIRST — before the activity touch, before the robots read this method performs
176
+ // and before a byte leaves. `AbortSignal.timeout(NaN)` THROWS, and it throws a bare
177
+ // `TypeError` ("Value NaN is outside the range [0, 9007199254740991]"), which is the one
178
+ // thing the deadline below exists to prevent: an unclassified platform error reaching a
179
+ // job's retry classifier instead of this package's own `X_SCRAPE_TIMEOUT`. And the cap is
180
+ // the only thing between a hostile stream and the worker's heap, so `readWithinLimit`'s own
181
+ // refusal is too late: it arrives once the request — a POST included — has been performed.
182
+ //
183
+ // Both floors are 1. A zero deadline aborts on the tick it is armed and a zero cap puts
184
+ // every response over, so either one makes every request on this leg fail; neither is a
185
+ // caller declining a feature the way `watchdog.graceMs: 0` is.
186
+ const timeoutMs = finiteCount(
187
+ 'http.request',
188
+ 'timeout',
189
+ request.timeout ?? init.timeoutMs,
190
+ 1,
191
+ );
192
+ const maxBytes = finiteCount(
193
+ 'http.request',
194
+ 'maxBytes',
195
+ request.maxBytes ?? DEFAULT_HTTP_MAX_BYTES,
196
+ 1,
197
+ );
175
198
  init.onActivity?.();
176
199
  if (interceptVerdict(url, 'fetch', init.rules) !== 'allow') {
177
200
  throw hostBlocked(url, init.rules.allowHosts);
@@ -180,7 +203,6 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
180
203
  await init.pace?.(init.signal);
181
204
  const session = await init.session();
182
205
  const cookies = cookieHeaderFor(session.cookies, url);
183
- const timeoutMs = request.timeout ?? init.timeoutMs;
184
206
  // `AbortSignal.timeout` and NOT `clock.sleep`: this is a deadline handed to the platform's
185
207
  // own fetch, not a wait this package performs — and under a test clock a slept deadline
186
208
  // would fire on the microtask after it was armed, cancelling every request instantly.
@@ -210,7 +232,6 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
210
232
  // Counted as it arrives rather than `.text()`, which materialises first and checks never:
211
233
  // a 30s stream at 50MB/s is a 1.5GB allocation the worker does not get back, and it takes
212
234
  // every other job on that worker with it. The same read `robots-fetch.ts` performs.
213
- const maxBytes = request.maxBytes ?? DEFAULT_HTTP_MAX_BYTES;
214
235
  const capped = await readWithinLimit(response.body, maxBytes);
215
236
  if ('over' in capped) throw bodyTooLarge(url, capped.over, maxBytes);
216
237
  const body = new TextDecoder().decode(capped.bytes);
@@ -8,7 +8,7 @@
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 { readWithinLimit } from '@ultimat3/core';
11
+ import { finiteCount, readWithinLimit } from '@ultimat3/core';
12
12
  import type { ScrapeFetch } from './http';
13
13
  import type { RobotsFetch } from './robots';
14
14
 
@@ -56,11 +56,29 @@ export interface RobotsFetchInit {
56
56
  */
57
57
  export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
58
58
  const call: ScrapeFetch = init.fetch ?? fetch;
59
- const limit = init.maxBytes ?? DEFAULT_ROBOTS_MAX_BYTES;
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
61
+ // `NaN` deadline throws a bare `TypeError` out of `AbortSignal.timeout` (measured: "Value NaN is
62
+ // outside the range [0, 9007199254740991]") straight into the gate's own `.catch`, and a `NaN`
63
+ // 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.
66
+ const limit = finiteCount(
67
+ 'robotsFetcher',
68
+ 'maxBytes',
69
+ init.maxBytes ?? DEFAULT_ROBOTS_MAX_BYTES,
70
+ 1,
71
+ );
72
+ const timeoutMs = finiteCount(
73
+ 'robotsFetcher',
74
+ 'timeoutMs',
75
+ init.timeoutMs ?? DEFAULT_ROBOTS_TIMEOUT_MS,
76
+ 1,
77
+ );
60
78
  return async (robotsUrl: string): Promise<string | undefined> => {
61
79
  // Armed per read, not per gate: the gate is long-lived and reads once per origin, so a
62
80
  // deadline created alongside it would already have expired by the second origin.
63
- const deadline = AbortSignal.timeout(init.timeoutMs ?? DEFAULT_ROBOTS_TIMEOUT_MS);
81
+ const deadline = AbortSignal.timeout(timeoutMs);
64
82
  const signal = init.signal === undefined ? deadline : AbortSignal.any([deadline, init.signal]);
65
83
  // Resolved here, at the read, because the session that owns the exit did not exist when this
66
84
  // fetcher was built. An empty string is not an exit and is dropped with the absent one.
package/src/scrape-run.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // A step record saying "logged in" would be a checkpoint asserting something about a session that
10
10
  // may have expired an hour ago.
11
11
 
12
+ import { finiteCount, finiteOption } from '@ultimat3/core';
12
13
  import type { JobRunArgs } from '@ultimat3/jobs';
13
14
  import { parse } from '@ultimat3/schema';
14
15
  import { createArtifactWriter } from './artifacts';
@@ -41,13 +42,23 @@ const orgOf = (ctx: unknown): string | undefined => {
41
42
  return typeof actor?.orgId === 'string' ? actor.orgId : undefined;
42
43
  };
43
44
 
44
- const toMillis = (value: string | number | undefined, fallback: number): number => {
45
+ /**
46
+ * `'30s'` | `30_000` | absent, as milliseconds — screened under the name the DEFINITION uses.
47
+ *
48
+ * The number branch is the one that needs it: the string branch can only ever produce digits, and
49
+ * a number a definition declares is whatever the app computed. What it lands on is the reason the
50
+ * refusal is here rather than downstream — this one value becomes the robots read's deadline
51
+ * (where a non-finite one turns robots enforcement off silently, because every failure of that
52
+ * read answers "no restrictions"), the session's `timeoutMs`, and through it every actionability
53
+ * budget in the run, where `NaN <= 0` is false so the poll loop never leaves.
54
+ */
55
+ const toMillis = (value: string | number | undefined, fallback: number, option: string): number => {
45
56
  if (value === undefined) return fallback;
46
- if (typeof value === 'number') return value;
57
+ if (typeof value === 'number') return finiteCount('the scrape definition', option, value, 1);
47
58
  const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(value.trim());
48
59
  if (match === null) return fallback;
49
60
  const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[match[2] ?? 'ms'] ?? 1;
50
- return Number(match[1]) * scale;
61
+ return finiteCount('the scrape definition', option, Number(match[1]) * scale, 1);
51
62
  };
52
63
 
53
64
  export const DEFAULT_PAGE_TIMEOUT_MS = 30_000;
@@ -69,7 +80,14 @@ export async function runScrape<I, Row>(
69
80
  });
70
81
  const secrets = createSecretBag(definition.secrets ?? []);
71
82
  const rules = { allowHosts: definition.allowHosts, block: definition.block };
72
- const pace = createPacer(definition.rate ?? DEFAULT_NAVIGATION_RATE, clock);
83
+ // Screened here as well as in `scrape()`, and the two are not one check written twice: that one
84
+ // refuses the DECLARATION and never sees a definition assembled by hand, which `runScrape` is
85
+ // exported to accept. `finiteOption` and not `finiteCount` — a rate of 0.5 is one navigation
86
+ // every two seconds, and `scrape()` owns the "greater than zero" half.
87
+ const pace = createPacer(
88
+ finiteOption('the scrape definition', 'rate', definition.rate ?? DEFAULT_NAVIGATION_RATE),
89
+ clock,
90
+ );
73
91
  const artifact = createArtifactWriter({
74
92
  storage: definition.artifacts?.storage,
75
93
  scrape: definition.name,
@@ -94,7 +112,7 @@ export async function runScrape<I, Row>(
94
112
  // Read BEFORE the browser opens: a refused credential must not reach a login form again, and
95
113
  // opening a session first would already have spent an identity on a run that cannot succeed.
96
114
  const restored = await restorableSession(plan);
97
- const pageTimeoutMs = toMillis(definition.pageTimeout, DEFAULT_PAGE_TIMEOUT_MS);
115
+ const pageTimeoutMs = toMillis(definition.pageTimeout, DEFAULT_PAGE_TIMEOUT_MS, 'pageTimeout');
98
116
  // The exit the session dials, readable only AFTER `driver.open()` — the proxy is a driver
99
117
  // option and the gate below is an argument to `open()`, so the gate asks for it per read
100
118
  // instead of being handed a value that cannot exist yet. Every read happens during a
package/src/watchdog.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  // socket die, which is what turns an infinite await into a catchable `X_SCRAPE_WEDGED` — and a
11
11
  // graceful-quit CEILING on shutdown, past which the same kill runs.
12
12
 
13
+ import { finiteCount } from '@ultimat3/core';
13
14
  import type { ScrapeClock } from './clock';
14
15
  import { watchdogStopped, wedged } from './error-throws';
15
16
 
@@ -49,8 +50,19 @@ export interface WedgeGuard {
49
50
  }
50
51
 
51
52
  export function createWedgeGuard(init: WedgeGuardInit): WedgeGuard {
52
- const idleMs = init.idleMs ?? DEFAULT_IDLE_MS;
53
- const graceMs = init.graceMs ?? DEFAULT_GRACE_MS;
53
+ // Screened before the watch loop starts, because both failures are silent in the expensive
54
+ // direction. `elapsed < NaN` is FALSE, so a `NaN` idleMs fires on the first 250ms poll and every
55
+ // run dies as `X_SCRAPE_WEDGED` against a browser that answered; `elapsed < Infinity` is always
56
+ // true, so the loop never fires and the guard is incident #1 with nothing armed. A `NaN` graceMs
57
+ // is `clock.sleep(NaN)`, which `setTimeout` reads as 0: `browser.close()` cannot win the race, so
58
+ // `kill()` runs instead — and on `remoteBrowser()`, where `process()` is `null`, that reaches
59
+ // nothing and the paid remote session survives the run.
60
+ //
61
+ // The floors differ because zero means two different things. `idleMs: 0` is "kill at the first
62
+ // poll", which is the NaN outcome spelled deliberately; `graceMs: 0` is "do not wait for the
63
+ // polite close", which is a policy a caller may hold.
64
+ const idleMs = finiteCount('the scrape watchdog', 'idleMs', init.idleMs ?? DEFAULT_IDLE_MS, 1);
65
+ const graceMs = finiteCount('the scrape watchdog', 'graceMs', init.graceMs ?? DEFAULT_GRACE_MS);
54
66
  const controller = new AbortController();
55
67
  let lastTouch = init.clock.monotonic();
56
68
  // TWO latches, not one. `stopped` ends the watch loop; `shuttingDown` guards the teardown. They