@ultimat3/scraping 29.3.1 → 29.3.2
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/cdp-arm.ts +4 -3
- package/src/clock.ts +15 -0
- package/src/deadline-signal.ts +46 -0
- package/src/http.ts +9 -5
- package/src/intercept.ts +2 -1
- package/src/rings.ts +4 -1
- package/src/robots-fetch.ts +14 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "29.3.
|
|
3
|
+
"version": "29.3.2",
|
|
4
4
|
"description": "Browser automation as a job: scrape() returns a JobHandle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "29.3.
|
|
35
|
-
"@ultimat3/jobs": "29.3.
|
|
36
|
-
"@ultimat3/schema": "29.3.
|
|
37
|
-
"@ultimat3/storage": "29.3.
|
|
34
|
+
"@ultimat3/core": "29.3.2",
|
|
35
|
+
"@ultimat3/jobs": "29.3.2",
|
|
36
|
+
"@ultimat3/schema": "29.3.2",
|
|
37
|
+
"@ultimat3/storage": "29.3.2"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/cdp-arm.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
PageErrorRing,
|
|
17
17
|
ResourceType,
|
|
18
18
|
} from './rings';
|
|
19
|
-
import { pageErrorEntry, RESOURCE_TYPES } from './rings';
|
|
19
|
+
import { clamped, pageErrorEntry, RESOURCE_TYPES } from './rings';
|
|
20
20
|
|
|
21
21
|
/** What `arm()` reads: the page it subscribes to, the rules it judges by, the clock it stamps with. */
|
|
22
22
|
export interface CdpArmInit {
|
|
@@ -134,7 +134,7 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
|
|
|
134
134
|
const verdict = interceptVerdict(url, type, init.rules);
|
|
135
135
|
const at = init.clock.now().getTime();
|
|
136
136
|
if (verdict === 'allow') {
|
|
137
|
-
network.push({ method, url, resourceType: type, at });
|
|
137
|
+
network.push({ method, url: clamped(url), resourceType: type, at });
|
|
138
138
|
// Caught, never floated: both reject when the target closed mid-request (and on any other
|
|
139
139
|
// protocol error), and a floated rejection is one Bun ends the process on. The request is
|
|
140
140
|
// already recorded; there is nothing left to do for a target that is gone.
|
|
@@ -147,7 +147,8 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
|
|
|
147
147
|
init.page.on('console', (payload) => {
|
|
148
148
|
console_.push({
|
|
149
149
|
level: consoleLevel(readStringFrom(payload, 'type') ?? ''),
|
|
150
|
-
|
|
150
|
+
// Clamped: the ring bounds the count, and one `console.log(JSON.stringify(state))` is megabytes.
|
|
151
|
+
text: clamped(readStringFrom(payload, 'text') ?? ''),
|
|
151
152
|
at: init.clock.now().getTime(),
|
|
152
153
|
});
|
|
153
154
|
});
|
package/src/clock.ts
CHANGED
|
@@ -131,3 +131,18 @@ export function deadline(clock: Clock, totalMs: number, subject = 'a scrape wait
|
|
|
131
131
|
const remainingMs = (): number => Math.max(0, budgetMs - (clock.monotonic() - startedAt));
|
|
132
132
|
return { totalMs: budgetMs, remainingMs, expired: () => remainingMs() <= 0 };
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The timer a request DEADLINE is armed on (`deadline-signal.ts`) — not a wait this package
|
|
137
|
+
* performs, but a timer all the same, so it lives here. Cleared when the request settles, where
|
|
138
|
+
* `AbortSignal.timeout` stayed armed for its whole span after the answer had landed.
|
|
139
|
+
*/
|
|
140
|
+
export interface DeadlineTimer {
|
|
141
|
+
set(fire: () => void, ms: number): unknown;
|
|
142
|
+
clear(handle: unknown): void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const platformDeadlineTimer: DeadlineTimer = Object.freeze({
|
|
146
|
+
set: (fire: () => void, ms: number): unknown => setTimeout(fire, ms),
|
|
147
|
+
clear: (handle: unknown): void => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
148
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// One request's signal: a deadline plus the caller's own signal, undone when the request settles.
|
|
2
|
+
// `AbortSignal.any([AbortSignal.timeout(ms), outer])` composed the same thing and could not be
|
|
3
|
+
// undone — every read hung one listener (and one composite) on a session-long `outer` and armed a
|
|
4
|
+
// timer that outlived the answer. The shape is `@ultimat3/jobs`' `createRunSignal`.
|
|
5
|
+
|
|
6
|
+
import { type DeadlineTimer, platformDeadlineTimer } from './clock';
|
|
7
|
+
|
|
8
|
+
export type { DeadlineTimer } from './clock';
|
|
9
|
+
|
|
10
|
+
export interface DeadlineSignal extends Disposable {
|
|
11
|
+
/** Aborts at the deadline (a `TimeoutError`, as `AbortSignal.timeout`'s) or with `outer`. */
|
|
12
|
+
readonly signal: AbortSignal;
|
|
13
|
+
/** Whether the DEADLINE fired — not the caller cancelling. What the timeout code is chosen by. */
|
|
14
|
+
timedOut(): boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `ms` is the caller's already-screened deadline (a positive finite count). `[Symbol.dispose]`
|
|
19
|
+
* clears the timer and detaches from `outer`; idempotent, and it never aborts.
|
|
20
|
+
*/
|
|
21
|
+
export function deadlineSignal(
|
|
22
|
+
ms: number,
|
|
23
|
+
outer?: AbortSignal,
|
|
24
|
+
timer: DeadlineTimer = platformDeadlineTimer,
|
|
25
|
+
): DeadlineSignal {
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
let fired = false;
|
|
28
|
+
const handle = timer.set(() => {
|
|
29
|
+
fired = true;
|
|
30
|
+
controller.abort(new DOMException('The operation timed out.', 'TimeoutError'));
|
|
31
|
+
}, ms);
|
|
32
|
+
const forward = (): void => controller.abort(outer?.reason);
|
|
33
|
+
if (outer?.aborted === true) forward();
|
|
34
|
+
else outer?.addEventListener('abort', forward, { once: true });
|
|
35
|
+
let open = true;
|
|
36
|
+
return {
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
timedOut: () => fired,
|
|
39
|
+
[Symbol.dispose]: () => {
|
|
40
|
+
if (!open) return;
|
|
41
|
+
open = false;
|
|
42
|
+
timer.clear(handle);
|
|
43
|
+
outer?.removeEventListener('abort', forward);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
|
15
15
|
import { parse } from '@ultimat3/schema';
|
|
16
16
|
import type { ScrapeClock } from './clock';
|
|
17
17
|
import { cookieHeaderFor } from './cookie-scope';
|
|
18
|
+
import { type DeadlineTimer, deadlineSignal } from './deadline-signal';
|
|
18
19
|
import { bodyTooLarge, hostBlocked, httpFailed, redirectLoop, scrapeTimeout } from './error-throws';
|
|
19
20
|
import type { RedirectHop } from './http-redirect';
|
|
20
21
|
import { MAX_REDIRECT_HOPS, redirectHop } from './http-redirect';
|
|
@@ -123,6 +124,8 @@ export interface HttpTransportInit {
|
|
|
123
124
|
readonly fetch?: ScrapeFetch | undefined;
|
|
124
125
|
/** The resolver a wildcard-admitted name is checked and pinned with. Defaults to `Bun.dns`. */
|
|
125
126
|
readonly resolve?: HostResolve | undefined;
|
|
127
|
+
/** The deadline's timer. Defaults to the platform's; a test hands its own (`deadline-signal.ts`). */
|
|
128
|
+
readonly timer?: DeadlineTimer | undefined;
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
/**
|
|
@@ -320,15 +323,16 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
|
|
|
320
323
|
init.onActivity?.();
|
|
321
324
|
let dial = await screen(url);
|
|
322
325
|
const session = await init.session();
|
|
323
|
-
//
|
|
326
|
+
// A platform timer and NOT `clock.sleep`: this is a deadline handed to the platform's
|
|
324
327
|
// own fetch, not a wait this package performs — and under a test clock a slept deadline
|
|
325
328
|
// would fire on the microtask after it was armed, cancelling every request instantly.
|
|
326
329
|
// The offline transport (`http-recorded.ts`) is what a test runs, and it has no deadline.
|
|
327
330
|
//
|
|
328
331
|
// ONE deadline for the whole chain, not one per hop: the caller declared a budget for
|
|
329
332
|
// getting an answer, and ten hops each allowed the full budget is ten times the wait.
|
|
330
|
-
|
|
331
|
-
|
|
333
|
+
// Disposed when the request settles: the timer cleared and the session-long `init.signal`
|
|
334
|
+
// let go — `AbortSignal.any` per hop hung a listener on it for every hop of every request.
|
|
335
|
+
using deadline = deadlineSignal(timeoutMs, init.signal, init.timer);
|
|
332
336
|
try {
|
|
333
337
|
let hop: RedirectHop = {
|
|
334
338
|
url,
|
|
@@ -365,7 +369,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
|
|
|
365
369
|
),
|
|
366
370
|
...(dial.serverName === undefined ? {} : { tls: { serverName: dial.serverName } }),
|
|
367
371
|
...(hop.body === undefined ? {} : { body: hop.body }),
|
|
368
|
-
signal:
|
|
372
|
+
signal: deadline.signal,
|
|
369
373
|
// The whole point: the platform's own `follow` is what made the four lines above
|
|
370
374
|
// decorative, because it dials the target itself and hands back one Response.
|
|
371
375
|
redirect: 'manual',
|
|
@@ -404,7 +408,7 @@ export function httpOverFetch(init: HttpTransportInit): ScrapeHttp {
|
|
|
404
408
|
} catch (thrown) {
|
|
405
409
|
// A deadline that fired is this package's own timeout, with its own code and fix — never
|
|
406
410
|
// the platform's bare `TimeoutError` reaching a job's retry classifier unclassified.
|
|
407
|
-
if (
|
|
411
|
+
if (deadline.timedOut()) throw scrapeTimeout(`http ${url}`, timeoutMs);
|
|
408
412
|
throw thrown;
|
|
409
413
|
}
|
|
410
414
|
},
|
package/src/intercept.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { HostRule } from '@ultimat3/core';
|
|
9
9
|
import { hostDecision } from '@ultimat3/core';
|
|
10
10
|
import type { NetworkEntry, ResourceType } from './rings';
|
|
11
|
+
import { clamped } from './rings';
|
|
11
12
|
|
|
12
13
|
export interface InterceptRules {
|
|
13
14
|
readonly allowHosts: readonly HostRule[];
|
|
@@ -38,4 +39,4 @@ export const refusalEntry = (
|
|
|
38
39
|
verdict: Exclude<InterceptVerdict, 'allow'>,
|
|
39
40
|
at: number,
|
|
40
41
|
method = 'GET',
|
|
41
|
-
): NetworkEntry => ({ method, url, resourceType, at, refused: verdict });
|
|
42
|
+
): NetworkEntry => ({ method, url: clamped(url), resourceType, at, refused: verdict });
|
package/src/rings.ts
CHANGED
|
@@ -44,10 +44,13 @@ export interface PageError {
|
|
|
44
44
|
* The per-entry cap, which the ring itself cannot give: a ring bounds the COUNT, and one
|
|
45
45
|
* `RangeError: Maximum call stack size exceeded` carries thousands of frames — 200 of those is
|
|
46
46
|
* megabytes held per page, which is the same OOM this file exists to prevent, one level down.
|
|
47
|
+
* The same cap holds a console line's text and a network entry's URL: a page that logs a
|
|
48
|
+
* serialised state blob, or requests a `data:` URL, is the same megabytes per entry.
|
|
47
49
|
*/
|
|
48
50
|
export const MAX_PAGE_ERROR_CHARS = 4_000;
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
/** One entry's text, held to `MAX_PAGE_ERROR_CHARS` — every ring entry a page controls goes through it. */
|
|
53
|
+
export const clamped = (text: string): string =>
|
|
51
54
|
text.length <= MAX_PAGE_ERROR_CHARS ? text : `${text.slice(0, MAX_PAGE_ERROR_CHARS - 1)}…`;
|
|
52
55
|
|
|
53
56
|
/**
|
package/src/robots-fetch.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { HostRule } from '@ultimat3/core';
|
|
12
12
|
import { finiteCount, isUltimateError, readWithinLimit } from '@ultimat3/core';
|
|
13
|
+
import { type DeadlineTimer, deadlineSignal } from './deadline-signal';
|
|
13
14
|
import type { ScrapeFetch } from './http';
|
|
14
15
|
import { redirectHop } from './http-redirect';
|
|
15
16
|
import { interceptVerdict } from './intercept';
|
|
@@ -58,6 +59,8 @@ export interface RobotsFetchInit {
|
|
|
58
59
|
readonly allowHosts?: readonly HostRule[] | undefined;
|
|
59
60
|
/** The resolver a wildcard-admitted name is checked and pinned with. Defaults to `Bun.dns`. */
|
|
60
61
|
readonly resolve?: HostResolve | undefined;
|
|
62
|
+
/** The deadline's timer. Defaults to the platform's; a test hands its own (`deadline-signal.ts`). */
|
|
63
|
+
readonly timer?: DeadlineTimer | undefined;
|
|
61
64
|
/**
|
|
62
65
|
* The platform `fetch`, injectable so the default path itself is testable. `ScrapeFetch` and not
|
|
63
66
|
* `typeof fetch`: the latter also carries `preconnect`, so nothing a caller can write satisfies
|
|
@@ -92,7 +95,12 @@ const answerOf = async (response: Response, limit: number): Promise<RobotsAnswer
|
|
|
92
95
|
await response.body?.cancel().catch(() => undefined);
|
|
93
96
|
return { unreachable: `status ${String(response.status)}` };
|
|
94
97
|
}
|
|
95
|
-
if (!response.ok)
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
// A 4xx's body is never read: cancelled, so its connection is not held until collection (the
|
|
100
|
+
// deadline that used to abort it is disposed with the read).
|
|
101
|
+
await response.body?.cancel().catch(() => undefined);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
96
104
|
// Counted as it arrives rather than `.text()`, which materialises the whole body first: a
|
|
97
105
|
// multi-gigabyte robots.txt is a heap the worker never gets back.
|
|
98
106
|
const capped = await readWithinLimit(response.body, limit);
|
|
@@ -131,8 +139,10 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
|
|
|
131
139
|
return async (robotsUrl: string): Promise<RobotsAnswer> => {
|
|
132
140
|
// Armed per read, not per gate: the gate is long-lived and reads once per origin, so a
|
|
133
141
|
// deadline created alongside it would already have expired by the second origin.
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
// Disposed when the read settles: the timer cleared and the run-long `init.signal` let go.
|
|
143
|
+
// `AbortSignal.any` here hung one listener on it per origin read, for the life of the run.
|
|
144
|
+
using deadline = deadlineSignal(timeoutMs, init.signal, init.timer);
|
|
145
|
+
const signal = deadline.signal;
|
|
136
146
|
// Resolved here, at the read, because the session that owns the exit did not exist when this
|
|
137
147
|
// fetcher was built. An empty string is not an exit and is dropped with the absent one.
|
|
138
148
|
const proxy = init.proxy?.();
|
|
@@ -176,7 +186,7 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
|
|
|
176
186
|
// The pin refusing an inward name is a POLICY answer about the host, and the navigation is
|
|
177
187
|
// refused by the same rule — it is not evidence the origin is down.
|
|
178
188
|
if (isUltimateError(error) && error.code === 'X_SCRAPE_HOST_BLOCKED') return undefined;
|
|
179
|
-
if (deadline.
|
|
189
|
+
if (deadline.timedOut()) return { unreachable: 'timeout' };
|
|
180
190
|
if (init.signal?.aborted === true) return { unreachable: 'cancelled' };
|
|
181
191
|
return { unreachable: 'network' };
|
|
182
192
|
}
|