@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.
- package/README.md +23 -5
- package/package.json +6 -6
- package/src/artifacts.ts +2 -4
- package/src/auth.ts +93 -17
- package/src/cdp-arm.ts +65 -1
- package/src/cdp-port.ts +21 -0
- package/src/cdp-snapshot.ts +11 -3
- package/src/cdp-target.ts +82 -58
- package/src/clock.ts +4 -9
- package/src/driver-cdp.ts +12 -3
- package/src/driver.ts +1 -1
- package/src/error-throws-session.ts +38 -1
- package/src/error-throws.ts +6 -16
- package/src/errors.ts +8 -2
- package/src/event-prompt.ts +3 -3
- package/src/expect.ts +21 -4
- package/src/html-target.ts +14 -19
- package/src/http-recorded.ts +3 -1
- package/src/http.ts +47 -10
- package/src/index.ts +22 -17
- package/src/intercept.ts +2 -2
- package/src/launch-args.ts +51 -0
- package/src/page-over-target.ts +14 -6
- package/src/pinned-host.ts +65 -0
- package/src/rate.ts +1 -1
- package/src/recover.ts +4 -3
- package/src/rings.ts +3 -3
- package/src/robots-fetch.ts +105 -21
- package/src/robots.ts +50 -12
- package/src/scrape-run.ts +42 -25
- package/src/scrape.ts +5 -4
- package/src/secrets.ts +61 -9
- package/src/session-state.ts +11 -0
- package/src/target.ts +14 -2
- package/src/url-secrets.ts +68 -7
- package/src/usage.ts +1 -1
- package/src/cdp-fake.ts +0 -420
- package/src/hosts.ts +0 -4
package/src/robots-fetch.ts
CHANGED
|
@@ -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 {
|
|
11
|
+
import type { HostRule } from '@ultimat3/core';
|
|
12
|
+
import { finiteCount, isUltimateError, readWithinLimit } from '@ultimat3/core';
|
|
12
13
|
import type { ScrapeFetch } from './http';
|
|
13
|
-
import
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
|
|
60
|
-
//
|
|
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
|
|
65
|
-
//
|
|
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<
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
143
|
-
*
|
|
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
|
|
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<
|
|
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
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
34
|
+
import { DEFAULT_NAVIGATION_RATE, scrapePacer } from './rate';
|
|
35
35
|
import { runRecovery } from './recover';
|
|
36
|
-
import {
|
|
36
|
+
import { robotsGate } from './robots';
|
|
37
37
|
import type { ScrapeDefinition, ScrapeReport } from './scrape';
|
|
38
38
|
import { containsSecret } from './secret-scan';
|
|
39
|
-
import {
|
|
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 {
|
|
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 =
|
|
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 =
|
|
123
|
+
const pace = scrapePacer(
|
|
124
124
|
finiteOption('the scrape definition', 'rate', definition.rate ?? DEFAULT_NAVIGATION_RATE),
|
|
125
125
|
clock,
|
|
126
126
|
);
|
|
127
|
-
const artifact =
|
|
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
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
278
|
-
*
|
|
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:
|
|
294
|
-
//
|
|
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
|
|
310
|
-
secrets: ReturnType<typeof
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (`& < >`, what a browser's serialiser emits), as an attribute (`& "`),
|
|
110
|
+
* or through a template that escapes all five (`'` or `'` 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("'", '''),
|
|
124
|
+
full.replaceAll("'", '''),
|
|
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
|
-
*
|
|
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
|
-
...
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
179
|
+
for (const value of values) out = out.replace(spellingPattern(value), SECRET_PLACEHOLDER);
|
|
128
180
|
return out;
|
|
129
181
|
}
|
|
130
182
|
|
package/src/session-state.ts
CHANGED
|
@@ -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;
|