@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 +5 -5
- package/src/actionability.ts +7 -2
- package/src/auth.ts +11 -1
- package/src/clock.ts +10 -3
- package/src/driver-fake.ts +5 -1
- package/src/expect.ts +27 -2
- package/src/http.ts +24 -3
- package/src/robots-fetch.ts +21 -3
- package/src/scrape-run.ts +23 -5
- package/src/watchdog.ts +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "
|
|
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": "
|
|
34
|
-
"@ultimat3/jobs": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
36
|
-
"@ultimat3/storage": "
|
|
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
|
}
|
package/src/actionability.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
package/src/driver-fake.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/robots-fetch.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|