@ultimat3/scraping 22.15.0 → 24.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 +187 -14
- package/package.json +6 -5
- package/src/artifacts.ts +3 -2
- package/src/auth.ts +156 -22
- package/src/cdp-arm.ts +65 -1
- package/src/cdp-fake-target.ts +67 -0
- package/src/cdp-fake.ts +9 -1
- package/src/cdp-port.ts +31 -0
- package/src/cdp-resolver.ts +81 -0
- package/src/cdp-target.ts +70 -48
- package/src/clock.ts +34 -0
- package/src/driver-cdp.ts +175 -20
- package/src/driver.ts +26 -2
- package/src/error-throws-session.ts +111 -0
- package/src/error-throws.ts +40 -17
- package/src/errors.ts +18 -2
- package/src/event-prompt.ts +133 -0
- package/src/events.ts +6 -0
- package/src/expect.ts +21 -4
- package/src/html-target.ts +10 -15
- package/src/http-recorded.ts +7 -1
- package/src/http.ts +56 -11
- package/src/index.ts +43 -5
- package/src/launch-args.ts +51 -0
- package/src/offline-session.ts +6 -0
- package/src/page-over-target.ts +4 -0
- package/src/pinned-host.ts +65 -0
- package/src/recover.ts +3 -2
- package/src/robots-fetch.ts +79 -11
- package/src/scrape-run.ts +109 -21
- package/src/scrape.ts +77 -8
- package/src/secret-scan.ts +17 -0
- package/src/secrets.ts +86 -9
- package/src/session-state.ts +84 -10
- package/src/url-secrets.ts +139 -0
- package/src/usage.ts +101 -0
- /package/src/{driver-fixture.ts → driver-recorded.ts} +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The DNS half of `allowHosts`, for the two legs this package dials itself — the HTTP leg and the
|
|
2
|
+
// robots read. Core's `hostDecision` is synchronous and judges NAMES: a wildcard never admits an
|
|
3
|
+
// address literal inside the network, but `allowHosts: ['*']` still admits a hostname that
|
|
4
|
+
// resolves inward. So a name admitted by a wildcard alone is resolved here, every address
|
|
5
|
+
// classified by core's one classifier, and the connection PINNED to the approved address — the
|
|
6
|
+
// shape `@ultimat3/jobs`' `webhook-target.ts` takes for a webhook.
|
|
7
|
+
|
|
8
|
+
import type { HostRule } from '@ultimat3/core';
|
|
9
|
+
import { classifyAddress, hostMatches } from '@ultimat3/core';
|
|
10
|
+
import { hostResolvesInward } from './error-throws-session';
|
|
11
|
+
|
|
12
|
+
/** Every address a hostname answers. Injected in tests: the network is sealed in this repo. */
|
|
13
|
+
export type HostResolve = (hostname: string) => Promise<readonly string[]>;
|
|
14
|
+
|
|
15
|
+
/** `Bun.dns.lookup` — the runtime's own resolver, every family, every address. */
|
|
16
|
+
export const resolveHost: HostResolve = async (hostname) =>
|
|
17
|
+
(await Bun.dns.lookup(hostname)).map((entry) => entry.address);
|
|
18
|
+
|
|
19
|
+
/** What the transport connects to: the URL to dial, and the name it proves when pinned. */
|
|
20
|
+
export interface DialTarget {
|
|
21
|
+
readonly url: string;
|
|
22
|
+
/** The original `host[:port]`, sent as the `Host` header of a pinned request. */
|
|
23
|
+
readonly host?: string | undefined;
|
|
24
|
+
/** For `https:` — the certificate is verified against the NAME, not the address. */
|
|
25
|
+
readonly serverName?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const isWildcard = (rule: HostRule): boolean => {
|
|
29
|
+
const cleaned = rule.trim();
|
|
30
|
+
return cleaned === '*' || cleaned.startsWith('*.');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The connection for `url`, which `interceptVerdict` has ALREADY allowed. Unchanged when an exact
|
|
35
|
+
* rule names the host — the app said that host, internal or not, in a line a reviewer can see —
|
|
36
|
+
* or when the host is an address literal, which core's floor already judged. Otherwise resolved:
|
|
37
|
+
* one address that is not public refuses the request (`X_SCRAPE_HOST_BLOCKED`), and the dial is
|
|
38
|
+
* pinned to the first approved address, so a name that answers public here and private to the
|
|
39
|
+
* socket (DNS rebinding) is never reached. A resolver failure propagates: nothing unscreened is
|
|
40
|
+
* dialled, and the same name would not have resolved for `fetch` either.
|
|
41
|
+
*/
|
|
42
|
+
export async function dialTarget(
|
|
43
|
+
url: string,
|
|
44
|
+
allowHosts: readonly HostRule[],
|
|
45
|
+
resolve: HostResolve,
|
|
46
|
+
): Promise<DialTarget> {
|
|
47
|
+
const parsed = new URL(url);
|
|
48
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { url };
|
|
49
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
50
|
+
if (classifyAddress(hostname) !== undefined) return { url };
|
|
51
|
+
if (allowHosts.some((rule) => !isWildcard(rule) && hostMatches(hostname, rule))) return { url };
|
|
52
|
+
const addresses = await resolve(hostname);
|
|
53
|
+
const inward = addresses.map((address) => classifyAddress(address) ?? 'unclassifiable');
|
|
54
|
+
const refused =
|
|
55
|
+
addresses.length === 0 ? 'unresolvable' : inward.find((kind) => kind !== 'public');
|
|
56
|
+
if (refused !== undefined) throw hostResolvesInward(url, hostname, refused);
|
|
57
|
+
const pinned = addresses[0] ?? hostname;
|
|
58
|
+
const target = new URL(parsed.href);
|
|
59
|
+
target.hostname = pinned.includes(':') ? `[${pinned}]` : pinned;
|
|
60
|
+
return {
|
|
61
|
+
url: target.href,
|
|
62
|
+
host: parsed.host,
|
|
63
|
+
...(parsed.protocol === 'https:' ? { serverName: hostname } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/recover.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// `X_NOT_IMPLEMENTED` today, in the shape `packages/jobs/src/driver-redis.ts` uses: correct types
|
|
7
7
|
// so an app can be written against it, one labelled throw, no silent no-op.
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
10
|
+
import { recoverRefused } from './error-throws';
|
|
10
11
|
import type { ScrapePage } from './page';
|
|
11
12
|
|
|
12
13
|
export interface RecoveryAttempt {
|
|
@@ -41,7 +42,7 @@ export async function runRecovery(recovery: Recovery, attempt: RecoveryAttempt):
|
|
|
41
42
|
// The agent path lands with `@ultimat3/ai`'s `llm()` behind it: page HTML in, a selector out,
|
|
42
43
|
// re-run once. Until it does, this throws rather than answering `false` — a recovery that
|
|
43
44
|
// silently declines is indistinguishable from one that was never configured.
|
|
44
|
-
throw
|
|
45
|
+
throw new NotImplementedError({ cause: "recover: 'agent' is not implemented", fix: AGENT_FIX });
|
|
45
46
|
}
|
|
46
47
|
const verdict: unknown = await recovery(attempt);
|
|
47
48
|
if (typeof verdict !== 'boolean') {
|
package/src/robots-fetch.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
// `driver.open()`, and the proxy is a driver option the session only reports on the way back out.
|
|
10
10
|
|
|
11
11
|
import { finiteCount, readWithinLimit } from '@ultimat3/core';
|
|
12
|
+
import type { HostRule } from './hosts';
|
|
12
13
|
import type { ScrapeFetch } from './http';
|
|
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';
|
|
13
18
|
import type { RobotsFetch } from './robots';
|
|
14
19
|
|
|
15
20
|
/**
|
|
@@ -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,13 +66,38 @@ 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
|
+
/** The final hop's body under the cap, or `undefined` for a non-2xx answer. */
|
|
84
|
+
const answerOf = async (response: Response, limit: number): Promise<string | undefined> => {
|
|
85
|
+
if (!response.ok) return undefined;
|
|
86
|
+
// Counted as it arrives rather than `.text()`, which materialises the whole body first: a
|
|
87
|
+
// multi-gigabyte robots.txt is a heap the worker never gets back.
|
|
88
|
+
const capped = await readWithinLimit(response.body, limit);
|
|
89
|
+
return 'over' in capped ? undefined : new TextDecoder().decode(capped.bytes);
|
|
90
|
+
};
|
|
91
|
+
|
|
52
92
|
/**
|
|
53
93
|
* Reads `robotsUrl`, or answers `undefined` — which the gate reads as "no restrictions", the
|
|
54
|
-
* standard's own answer for a file it cannot obtain. A deadline that fires, a body past the cap
|
|
55
|
-
* and a
|
|
94
|
+
* standard's own answer for a file it cannot obtain. A deadline that fires, a body past the cap,
|
|
95
|
+
* a 404 and a redirect off `allowHosts` are all the same answer on purpose: none of them is
|
|
96
|
+
* evidence of a rule.
|
|
56
97
|
*/
|
|
57
98
|
export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
|
|
58
99
|
const call: ScrapeFetch = init.fetch ?? fetch;
|
|
100
|
+
const resolve = init.resolve ?? resolveHost;
|
|
59
101
|
// Both bounds are screened HERE, at construction, and both floors are 1 — because every way this
|
|
60
102
|
// read can fail is the same answer, `undefined`, which the gate reads as "no restrictions". A
|
|
61
103
|
// `NaN` deadline throws a bare `TypeError` out of `AbortSignal.timeout` (measured: "Value NaN is
|
|
@@ -83,16 +125,42 @@ export function robotsFetcher(init: RobotsFetchInit = {}): RobotsFetch {
|
|
|
83
125
|
// Resolved here, at the read, because the session that owns the exit did not exist when this
|
|
84
126
|
// fetcher was built. An empty string is not an exit and is dropped with the absent one.
|
|
85
127
|
const proxy = init.proxy?.();
|
|
128
|
+
const dialled = proxy === undefined || proxy === '' ? undefined : proxy;
|
|
86
129
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
130
|
+
let url = robotsUrl;
|
|
131
|
+
for (let hops = 0; ; hops += 1) {
|
|
132
|
+
if (init.allowHosts !== undefined && !onList(url, init.allowHosts)) return undefined;
|
|
133
|
+
// Pinned exactly as the HTTP leg pins (`pinned-host.ts`), and for its reason skipped
|
|
134
|
+
// through a proxy. A name resolving inward throws, which this read answers as "no robots".
|
|
135
|
+
const dial =
|
|
136
|
+
init.allowHosts === undefined || dialled !== undefined
|
|
137
|
+
? { url }
|
|
138
|
+
: await dialTarget(url, init.allowHosts, resolve);
|
|
139
|
+
// `manual`, never the platform default: `follow` dials wherever the site points before
|
|
140
|
+
// anything here can ask whether that host is on the list.
|
|
141
|
+
const response = await call(dial.url, {
|
|
142
|
+
signal,
|
|
143
|
+
redirect: 'manual',
|
|
144
|
+
...(dialled === undefined ? {} : { proxy: dialled }),
|
|
145
|
+
...(dial.host === undefined ? {} : { headers: { host: dial.host } }),
|
|
146
|
+
...(dial.serverName === undefined ? {} : { tls: { serverName: dial.serverName } }),
|
|
147
|
+
});
|
|
148
|
+
const next = redirectHop(
|
|
149
|
+
response.status,
|
|
150
|
+
response.headers.get('location'),
|
|
151
|
+
url,
|
|
152
|
+
'GET',
|
|
153
|
+
undefined,
|
|
154
|
+
);
|
|
155
|
+
if (next === undefined) return await answerOf(response, limit);
|
|
156
|
+
await response.body?.cancel().catch(() => undefined);
|
|
157
|
+
if (hops >= MAX_ROBOTS_REDIRECTS) return undefined;
|
|
158
|
+
// With no list to ask, only a hop that stays on the host the read started on is
|
|
159
|
+
// followed — an `http:` → `https:` upgrade or a moved path. Another host is a host
|
|
160
|
+
// nobody screened, and is never dialled.
|
|
161
|
+
if (init.allowHosts === undefined && !sameHost(robotsUrl, next.url)) return undefined;
|
|
162
|
+
url = next.url;
|
|
163
|
+
}
|
|
96
164
|
} catch {
|
|
97
165
|
return undefined;
|
|
98
166
|
}
|
package/src/scrape-run.ts
CHANGED
|
@@ -19,13 +19,15 @@ import {
|
|
|
19
19
|
createPrompt,
|
|
20
20
|
ensureAuthenticated,
|
|
21
21
|
markRefused,
|
|
22
|
+
observeSession,
|
|
22
23
|
persistSession,
|
|
23
|
-
restorableSession,
|
|
24
24
|
} from './auth';
|
|
25
|
-
import {
|
|
25
|
+
import { scrapeClock } from './clock';
|
|
26
26
|
import type { ScrapeSession } from './driver';
|
|
27
27
|
import { scrapeDriver } from './driver';
|
|
28
28
|
import { driverUnknown, outputInvalid } from './error-throws';
|
|
29
|
+
import { egressInPayload } from './error-throws-session';
|
|
30
|
+
import type { ScrapeEventFields } from './events';
|
|
29
31
|
import { scrapeLogger, withStepEvent } from './events';
|
|
30
32
|
import { guardYield } from './expect';
|
|
31
33
|
import { burnsSession, errorCode, neverRetried } from './failures';
|
|
@@ -33,8 +35,12 @@ import { createPacer, DEFAULT_NAVIGATION_RATE } from './rate';
|
|
|
33
35
|
import { runRecovery } from './recover';
|
|
34
36
|
import { createRobotsGate } from './robots';
|
|
35
37
|
import type { ScrapeDefinition, ScrapeReport } from './scrape';
|
|
36
|
-
import {
|
|
38
|
+
import { containsSecret } from './secret-scan';
|
|
39
|
+
import { createSecretBag, MIN_REDACTABLE_LENGTH } from './secrets';
|
|
37
40
|
import { sessionKeyFor } from './session-state';
|
|
41
|
+
import { hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
|
|
42
|
+
import type { ScrapeUsage } from './usage';
|
|
43
|
+
import { createUsageMeter, rememberFailedUsage } from './usage';
|
|
38
44
|
|
|
39
45
|
/** `ctx.actor` is a structural read: this package never imports the auth types (tier 2). */
|
|
40
46
|
const orgOf = (ctx: unknown): string | undefined => {
|
|
@@ -63,11 +69,26 @@ const toMillis = (value: string | number | undefined, fallback: number, option:
|
|
|
63
69
|
|
|
64
70
|
export const DEFAULT_PAGE_TIMEOUT_MS = 30_000;
|
|
65
71
|
|
|
72
|
+
/**
|
|
73
|
+
* True when the exit's password is readable out of the run's own input — which is the queue row.
|
|
74
|
+
* Both spellings, because a password with a quote or a backslash is stored JSON-escaped; and
|
|
75
|
+
* nothing shorter than `MIN_REDACTABLE_LENGTH`, which is a substring of ordinary payload text.
|
|
76
|
+
*/
|
|
77
|
+
function credentialInPayload(egress: string, input: unknown): boolean {
|
|
78
|
+
const { password } = splitCredentials(egress);
|
|
79
|
+
if (password.length < MIN_REDACTABLE_LENGTH) return false;
|
|
80
|
+
const payload = JSON.stringify(input ?? null);
|
|
81
|
+
const escaped = JSON.stringify(password).slice(1, -1);
|
|
82
|
+
// Both scans always run: an `||` would skip the second exactly when the first found the secret.
|
|
83
|
+
const plain = containsSecret(payload, password);
|
|
84
|
+
return containsSecret(payload, escaped) || plain;
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
export async function runScrape<I, Row>(
|
|
67
88
|
definition: ScrapeDefinition<I, Row>,
|
|
68
89
|
args: JobRunArgs<I>,
|
|
69
90
|
): Promise<ScrapeReport<Row>> {
|
|
70
|
-
const clock = definition.clock ??
|
|
91
|
+
const clock = definition.clock ?? scrapeClock();
|
|
71
92
|
const driver = definition.driver ?? scrapeDriver();
|
|
72
93
|
// The scrape's name goes in the SCRAPE slot: there is no driver here to name, which is the
|
|
73
94
|
// whole failure.
|
|
@@ -79,6 +100,21 @@ export async function runScrape<I, Row>(
|
|
|
79
100
|
driver: driver.name,
|
|
80
101
|
});
|
|
81
102
|
const secrets = createSecretBag(definition.secrets ?? []);
|
|
103
|
+
// The run's exit, resolved HERE — in the worker, under the job's tenant — so what the queue row
|
|
104
|
+
// carries is the id of whatever holds it. An empty answer is "none", the same as absent: a
|
|
105
|
+
// driver handed `''` would dial `--proxy-server=` and call that a decision.
|
|
106
|
+
const declared = await definition.egress?.(args.input, args.ctx);
|
|
107
|
+
const egress = declared === undefined || declared === '' ? undefined : declared;
|
|
108
|
+
if (egress !== undefined && hasCredentials(egress)) {
|
|
109
|
+
// Concealed HERE and not only in the driver that dials it: an offline or third-party driver
|
|
110
|
+
// never parses the exit, and the page artifact is redacted from this one bag either way.
|
|
111
|
+
for (const value of urlSecretValues(egress)) secrets.conceal(value);
|
|
112
|
+
// Before the browser opens: the credential is in `x_jobs` already, and running would only add
|
|
113
|
+
// a session to an exposure somebody has to rotate.
|
|
114
|
+
if (credentialInPayload(egress, args.input)) {
|
|
115
|
+
throw egressInPayload({ scrape: definition.name, egress });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
82
118
|
const rules = { allowHosts: definition.allowHosts, block: definition.block };
|
|
83
119
|
// Screened here as well as in `scrape()`, and the two are not one check written twice: that one
|
|
84
120
|
// refuses the DECLARATION and never sees a definition assembled by hand, which `runScrape` is
|
|
@@ -111,15 +147,22 @@ export async function runScrape<I, Row>(
|
|
|
111
147
|
|
|
112
148
|
// Read BEFORE the browser opens: a refused credential must not reach a login form again, and
|
|
113
149
|
// opening a session first would already have spent an identity on a run that cannot succeed.
|
|
114
|
-
const
|
|
150
|
+
const observed = await observeSession(plan);
|
|
151
|
+
const restored = observed.restorable;
|
|
115
152
|
const pageTimeoutMs = toMillis(definition.pageTimeout, DEFAULT_PAGE_TIMEOUT_MS, 'pageTimeout');
|
|
116
|
-
// The exit the session dials, readable only AFTER `driver.open()` —
|
|
117
|
-
// option and the gate below is an argument to `open()`, so the gate asks
|
|
118
|
-
// instead of being handed a value that cannot exist yet. Every read happens
|
|
119
|
-
// navigation, which is after this is assigned.
|
|
153
|
+
// The exit the session dials, readable only AFTER `driver.open()` — a driver with no run exit
|
|
154
|
+
// falls back to its own option, and the gate below is an argument to `open()`, so the gate asks
|
|
155
|
+
// for it per read instead of being handed a value that cannot exist yet. Every read happens
|
|
156
|
+
// during a navigation, which is after this is assigned.
|
|
120
157
|
let sessionProxy: string | undefined;
|
|
158
|
+
// Before `open()`: the time a rented browser takes to arrive is time it was held.
|
|
159
|
+
const usage = createUsageMeter(clock);
|
|
121
160
|
const session = await driver.open({
|
|
122
161
|
name: definition.name,
|
|
162
|
+
logger,
|
|
163
|
+
runId: args.runId,
|
|
164
|
+
...(egress === undefined ? {} : { proxy: egress }),
|
|
165
|
+
usage,
|
|
123
166
|
rules,
|
|
124
167
|
clock,
|
|
125
168
|
timeoutMs: pageTimeoutMs,
|
|
@@ -132,6 +175,8 @@ export async function runScrape<I, Row>(
|
|
|
132
175
|
// this gate reads as "no restrictions".
|
|
133
176
|
robots: createRobotsGate({
|
|
134
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,
|
|
135
180
|
timeoutMs: pageTimeoutMs,
|
|
136
181
|
signal: args.ctx.signal,
|
|
137
182
|
proxy: () => sessionProxy,
|
|
@@ -142,6 +187,11 @@ export async function runScrape<I, Row>(
|
|
|
142
187
|
watchdog: definition.watchdog,
|
|
143
188
|
});
|
|
144
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;
|
|
145
195
|
|
|
146
196
|
try {
|
|
147
197
|
if (definition.auth !== undefined) {
|
|
@@ -151,15 +201,34 @@ export async function runScrape<I, Row>(
|
|
|
151
201
|
ensureAuthenticated({
|
|
152
202
|
...plan,
|
|
153
203
|
input: args.input,
|
|
204
|
+
runId: args.runId,
|
|
154
205
|
page: session.page,
|
|
155
206
|
secrets,
|
|
156
207
|
restored,
|
|
157
|
-
prompt: createPrompt(
|
|
208
|
+
prompt: createPrompt({
|
|
209
|
+
scrape: definition.name,
|
|
210
|
+
handler: definition.prompt,
|
|
211
|
+
input: args.input,
|
|
212
|
+
page: session.page,
|
|
213
|
+
runId: args.runId,
|
|
214
|
+
clock,
|
|
215
|
+
signal: args.ctx.signal,
|
|
216
|
+
secrets,
|
|
217
|
+
onAnswered: () => usage.promptAnswered(),
|
|
218
|
+
}),
|
|
158
219
|
}),
|
|
159
220
|
);
|
|
160
221
|
// Persisted after a LOGIN only, never after a reuse: rewriting the record on every run
|
|
161
222
|
// refreshes `savedAt` without refreshing the session, so `maxAge` would never expire it.
|
|
162
|
-
|
|
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
|
+
}
|
|
163
232
|
}
|
|
164
233
|
|
|
165
234
|
const raw = await bodyWithRecovery(definition, args, session, logger, artifact, secrets);
|
|
@@ -181,20 +250,27 @@ export async function runScrape<I, Row>(
|
|
|
181
250
|
// alone made a run that blocked 5,000 images print 200 and discarded the one number
|
|
182
251
|
// (`Ring.dropped`) that exists to say "you are not seeing it all".
|
|
183
252
|
const networkDropped = session.page.networkDropped();
|
|
184
|
-
|
|
253
|
+
const used = usage.snapshot(session.browserCost);
|
|
254
|
+
logger.info('scrape.ok', { rows: rows.length, refused, ...usageFields(used) });
|
|
185
255
|
return {
|
|
186
256
|
scrape: definition.name,
|
|
187
257
|
rows,
|
|
188
258
|
artifacts: artifact.saved.map((ref) => ref.key),
|
|
189
259
|
refused,
|
|
190
260
|
networkDropped,
|
|
261
|
+
usage: used,
|
|
191
262
|
};
|
|
192
263
|
} catch (thrown) {
|
|
193
|
-
|
|
264
|
+
// A failed run has no report, and it was billed all the same: the counts ride the one line
|
|
265
|
+
// every failure already writes.
|
|
266
|
+
const used = usage.snapshot(session.browserCost);
|
|
267
|
+
logger.error('scrape.failed', { code: errorCode(thrown), ...usageFields(used) });
|
|
268
|
+
// Kept for `onSettled`, and only when somebody declared one to read it.
|
|
269
|
+
if (definition.onSettled !== undefined) rememberFailedUsage(args.runId, used);
|
|
194
270
|
if (errorCode(thrown) === 'X_SCRAPE_AUTH_FAILED') {
|
|
195
|
-
await recordSessionOutcome('session.refuse', logger, () => markRefused(plan));
|
|
271
|
+
await recordSessionOutcome('session.refuse', logger, () => markRefused(plan, seen));
|
|
196
272
|
} else if (burnsSession(thrown)) {
|
|
197
|
-
await recordSessionOutcome('session.burn', logger, () => burnSession(plan));
|
|
273
|
+
await recordSessionOutcome('session.burn', logger, () => burnSession(plan, seen));
|
|
198
274
|
}
|
|
199
275
|
if (definition.artifacts?.onFailure !== false) await saveFailureArtifact(session, artifact);
|
|
200
276
|
throw thrown;
|
|
@@ -204,9 +280,18 @@ export async function runScrape<I, Row>(
|
|
|
204
280
|
}
|
|
205
281
|
}
|
|
206
282
|
|
|
283
|
+
/** The counts as log fields. `browserCost` stays off the line: it is on the report, as `Money`. */
|
|
284
|
+
const usageFields = (used: ScrapeUsage): ScrapeEventFields => ({
|
|
285
|
+
browserMs: used.browserMs,
|
|
286
|
+
navigations: used.navigations,
|
|
287
|
+
httpRequests: used.httpRequests,
|
|
288
|
+
bytesIn: used.bytesIn,
|
|
289
|
+
promptsAnswered: used.promptsAnswered,
|
|
290
|
+
});
|
|
291
|
+
|
|
207
292
|
/**
|
|
208
|
-
* The tombstone or the burn
|
|
209
|
-
*
|
|
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
|
|
210
295
|
* `X_STORAGE_PATH_UNSAFE` from a tenant whose key sanitises to nothing used to propagate out of
|
|
211
296
|
* the catch and REPLACE a terminal `X_SCRAPE_AUTH_FAILED` with a retryable one. Attempt 2 then
|
|
212
297
|
* found no tombstone — the save is what failed — and walked the same rejected password back to
|
|
@@ -214,15 +299,16 @@ export async function runScrape<I, Row>(
|
|
|
214
299
|
* reason: the run's own error is the one the reader needs.
|
|
215
300
|
*/
|
|
216
301
|
async function recordSessionOutcome(
|
|
217
|
-
step: 'session.refuse' | 'session.burn',
|
|
302
|
+
step: 'session.save' | 'session.refuse' | 'session.burn',
|
|
218
303
|
logger: ReturnType<typeof scrapeLogger>,
|
|
219
304
|
write: () => Promise<void>,
|
|
220
305
|
): Promise<void> {
|
|
221
306
|
try {
|
|
222
307
|
await write();
|
|
223
308
|
} catch (thrown) {
|
|
224
|
-
// Logged rather than swallowed silently:
|
|
225
|
-
//
|
|
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.
|
|
226
312
|
logger.error('scrape.session.write_failed', { step, code: errorCode(thrown) });
|
|
227
313
|
}
|
|
228
314
|
}
|
|
@@ -250,11 +336,13 @@ async function bodyWithRecovery<I, Row>(
|
|
|
250
336
|
secrets,
|
|
251
337
|
artifact,
|
|
252
338
|
attempt: args.attempt,
|
|
339
|
+
finalAttempt: args.finalAttempt,
|
|
340
|
+
progress: args.progress,
|
|
253
341
|
runId: args.runId,
|
|
254
342
|
});
|
|
255
343
|
try {
|
|
256
344
|
return await withStepEvent(
|
|
257
|
-
{ name: 'body', logger, clock: definition.clock ??
|
|
345
|
+
{ name: 'body', logger, clock: definition.clock ?? scrapeClock(), attempt: args.attempt },
|
|
258
346
|
body,
|
|
259
347
|
);
|
|
260
348
|
} catch (thrown) {
|
package/src/scrape.ts
CHANGED
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
import type { Ctx } from '@ultimat3/core';
|
|
13
13
|
import { assert } from '@ultimat3/core';
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
JobCompleted,
|
|
16
|
+
JobConcurrency,
|
|
17
|
+
JobFailed,
|
|
18
|
+
JobHandle,
|
|
19
|
+
JobTenant,
|
|
20
|
+
ProgressFn,
|
|
21
|
+
RetryPolicy,
|
|
22
|
+
StepApi,
|
|
23
|
+
} from '@ultimat3/jobs';
|
|
15
24
|
import { DEFAULT_RETRY, job } from '@ultimat3/jobs';
|
|
16
25
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
17
26
|
import type { StorageDriver } from '@ultimat3/storage';
|
|
@@ -21,6 +30,7 @@ import type { ScrapeClock } from './clock';
|
|
|
21
30
|
import type { ScrapeDriver } from './driver';
|
|
22
31
|
import { yieldHistoryMissing } from './error-throws';
|
|
23
32
|
import type { YieldExpectation, YieldHistory } from './expect';
|
|
33
|
+
import { maxDropFraction } from './expect';
|
|
24
34
|
import type { HostRule } from './hosts';
|
|
25
35
|
import type { ScrapeHttp } from './http';
|
|
26
36
|
import type { ScrapePage } from './page';
|
|
@@ -29,6 +39,8 @@ import type { ResourceType } from './rings';
|
|
|
29
39
|
import type { RobotsPolicy } from './robots';
|
|
30
40
|
import { runScrape } from './scrape-run';
|
|
31
41
|
import type { ScrapeSecrets } from './secrets';
|
|
42
|
+
import type { ScrapeUsage } from './usage';
|
|
43
|
+
import { takeFailedUsage } from './usage';
|
|
32
44
|
|
|
33
45
|
export interface ScrapeRunArgs<I> {
|
|
34
46
|
readonly input: I;
|
|
@@ -47,11 +59,16 @@ export interface ScrapeRunArgs<I> {
|
|
|
47
59
|
readonly secrets: ScrapeSecrets;
|
|
48
60
|
readonly artifact: ArtifactWriter;
|
|
49
61
|
readonly attempt: number;
|
|
62
|
+
/** The job's own: true when a failure of this attempt is not retried for want of attempts. */
|
|
63
|
+
readonly finalAttempt: boolean;
|
|
64
|
+
/** The job's own `progress(done, total, note?)` — what `x jobs show` and a dashboard read. */
|
|
65
|
+
readonly progress: ProgressFn;
|
|
50
66
|
readonly runId: string;
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
export interface ScrapeArtifacts {
|
|
54
|
-
|
|
70
|
+
/** A thunk, read per write — `() => disk('artifacts')`: the app's disk exists only after boot. */
|
|
71
|
+
readonly storage?: (() => StorageDriver) | undefined;
|
|
55
72
|
/** Save the page's HTML when the run fails. On by default — it is the only forensic left. */
|
|
56
73
|
readonly onFailure?: boolean | undefined;
|
|
57
74
|
readonly prefix?: string | undefined;
|
|
@@ -93,9 +110,26 @@ export interface ScrapeDefinition<I, Row> {
|
|
|
93
110
|
* primary case, so this is declared rather than hand-rolled per app. See `auth.ts`.
|
|
94
111
|
*/
|
|
95
112
|
readonly auth?: ScrapeAuth<I> | undefined;
|
|
96
|
-
/**
|
|
97
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Where an out-of-band code comes from, for a site that asks for one after the password. The
|
|
115
|
+
* request carries the run's `input` and `runId`: what a handler ties the prompt to.
|
|
116
|
+
*/
|
|
117
|
+
readonly prompt?: PromptHandler<I> | undefined;
|
|
98
118
|
readonly driver?: ScrapeDriver | undefined;
|
|
119
|
+
/**
|
|
120
|
+
* The exit THIS run leaves through — a proxy URL, credentials included — RESOLVED IN THE WORKER,
|
|
121
|
+
* under the job's tenant: `async ({ connectionId }) => (await repo.connectionById(connectionId))
|
|
122
|
+
* ?.exit`. The input names the row; the row (a `.sealed()` column) holds the exit. An exit whose
|
|
123
|
+
* credential is ALSO in the input rode the queue payload and sits in `x_jobs` in the clear, and
|
|
124
|
+
* is refused before the browser opens (`X_SCRAPE_EGRESS_IN_PAYLOAD`).
|
|
125
|
+
*
|
|
126
|
+
* It reaches the driver as `SessionInit.proxy` and wins over the driver's own `proxy`; both legs
|
|
127
|
+
* and the robots read dial it. `undefined` leaves the driver's exit in force. A driver that
|
|
128
|
+
* cannot dial it refuses with `X_SCRAPE_EGRESS_UNSUPPORTED` rather than dialling another.
|
|
129
|
+
*/
|
|
130
|
+
readonly egress?:
|
|
131
|
+
| ((input: I, ctx: Ctx) => string | undefined | Promise<string | undefined>)
|
|
132
|
+
| undefined;
|
|
99
133
|
readonly retry?: RetryPolicy | undefined;
|
|
100
134
|
/** Per attempt, whole-run. `'5m'` or ms. */
|
|
101
135
|
readonly timeout?: string | number | undefined;
|
|
@@ -103,12 +137,33 @@ export interface ScrapeDefinition<I, Row> {
|
|
|
103
137
|
readonly pageTimeout?: string | number | undefined;
|
|
104
138
|
/** Kill the browser after this much silence from it. See `watchdog.ts`. */
|
|
105
139
|
readonly watchdog?: { readonly idleMs?: number; readonly graceMs?: number } | undefined;
|
|
106
|
-
|
|
140
|
+
/** The job's own field, unchanged: a number, or `{ key, limit, whenBusy }` for a cap per key. */
|
|
141
|
+
readonly concurrency?: JobConcurrency<I> | undefined;
|
|
107
142
|
readonly queue?: string | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Pins this definition's clock. Omit it: a run waits on the process's (`scrapeClock()`), which
|
|
145
|
+
* is the system clock and what a test replaces with `setScrapeClock()`.
|
|
146
|
+
*/
|
|
108
147
|
readonly clock?: ScrapeClock | undefined;
|
|
109
148
|
run(args: ScrapeRunArgs<I>): Promise<readonly unknown[]>;
|
|
149
|
+
/**
|
|
150
|
+
* The job's own `onSettled`, with what a scrape adds: a `completed` run hands over its
|
|
151
|
+
* `ScrapeReport` — rows, artifacts and `usage` — and a run that ended any other way carries the
|
|
152
|
+
* `usage` of its last attempt (`undefined` for a `refused` one, whose body never ran). Same
|
|
153
|
+
* guarantees as the job's: after the row is settled, under the job's tenant, AT MOST ONCE
|
|
154
|
+
* across a crash, and a hook that throws changes nothing.
|
|
155
|
+
*/
|
|
156
|
+
onSettled?(settled: ScrapeSettled<I, Row>): Promise<void>;
|
|
110
157
|
}
|
|
111
158
|
|
|
159
|
+
/** How one scrape run ended, as `ScrapeDefinition.onSettled` is told. */
|
|
160
|
+
export type ScrapeSettled<I, Row> =
|
|
161
|
+
| JobCompleted<I, ScrapeReport<Row>>
|
|
162
|
+
| (JobFailed<I> & {
|
|
163
|
+
/** What the last attempt used. A failed run has no report and was billed all the same. */
|
|
164
|
+
readonly usage: ScrapeUsage | undefined;
|
|
165
|
+
});
|
|
166
|
+
|
|
112
167
|
/** What one completed scrape reports — bounded, so `x jobs show` can print it. */
|
|
113
168
|
export interface ScrapeReport<Row> {
|
|
114
169
|
readonly scrape: string;
|
|
@@ -124,6 +179,8 @@ export interface ScrapeReport<Row> {
|
|
|
124
179
|
* "you are not seeing it all": `refused` was counted from what survived the bound.
|
|
125
180
|
*/
|
|
126
181
|
readonly networkDropped: number;
|
|
182
|
+
/** What this run used. A measurement: no quota is read from it and none is enforced. */
|
|
183
|
+
readonly usage: ScrapeUsage;
|
|
127
184
|
}
|
|
128
185
|
|
|
129
186
|
export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<I> {
|
|
@@ -142,10 +199,12 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
|
|
|
142
199
|
// Two halves that must be set together. `maxDrop` is a fraction of a trailing median and only
|
|
143
200
|
// `history:` can supply one, so declaring it alone is an alarm that cannot fire — refused here,
|
|
144
201
|
// where it is written, rather than discovered as a scrape that never once went red.
|
|
145
|
-
if (definition.expect?.maxDrop !== undefined
|
|
146
|
-
throw yieldHistoryMissing(definition.name);
|
|
202
|
+
if (definition.expect?.maxDrop !== undefined) {
|
|
203
|
+
if (definition.history === undefined) throw yieldHistoryMissing(definition.name);
|
|
204
|
+
maxDropFraction(definition.name, definition.expect.maxDrop);
|
|
147
205
|
}
|
|
148
|
-
|
|
206
|
+
const onSettled = definition.onSettled?.bind(definition);
|
|
207
|
+
return job<I, ScrapeReport<Row>>({
|
|
149
208
|
name: definition.name,
|
|
150
209
|
input: definition.input,
|
|
151
210
|
idempotencyKey: definition.idempotencyKey,
|
|
@@ -155,5 +214,15 @@ export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<
|
|
|
155
214
|
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
156
215
|
...(definition.concurrency === undefined ? {} : { concurrency: definition.concurrency }),
|
|
157
216
|
run: (args) => runScrape(definition, args),
|
|
217
|
+
...(onSettled === undefined
|
|
218
|
+
? {}
|
|
219
|
+
: {
|
|
220
|
+
onSettled: (settled) => {
|
|
221
|
+
// Read on EVERY ending, the completed one too: a run that failed an attempt and then
|
|
222
|
+
// completed must not leave that attempt's counts behind in this process.
|
|
223
|
+
const usage = takeFailedUsage(settled.runId);
|
|
224
|
+
return onSettled(settled.outcome === 'completed' ? settled : { ...settled, usage });
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
158
227
|
});
|
|
159
228
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Whether a secret occurs in a piece of text, without a search that stops at the first differing
|
|
2
|
+
// byte. `.includes(secret)` answers sooner the more of a window matches the secret's prefix — the
|
|
3
|
+
// oracle `scripts/secret-compare.ts` refuses — so every window is compared whole, in constant time.
|
|
4
|
+
|
|
5
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/** True when `secret` occurs in `text`. Time depends on the two lengths, never on their bytes. */
|
|
8
|
+
export function containsSecret(text: string, secret: string): boolean {
|
|
9
|
+
const width = secret.length;
|
|
10
|
+
if (width === 0) return false;
|
|
11
|
+
let found = false;
|
|
12
|
+
for (let start = 0; start + width <= text.length; start += 1) {
|
|
13
|
+
// `timingSafeEqual` first, so a window after a hit is still compared rather than skipped.
|
|
14
|
+
found = timingSafeEqual(text.slice(start, start + width), secret) || found;
|
|
15
|
+
}
|
|
16
|
+
return found;
|
|
17
|
+
}
|