@ultimat3/scraping 2.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/LICENSE +21 -0
- package/README.md +194 -0
- package/package.json +38 -0
- package/src/actionability.ts +106 -0
- package/src/artifacts.ts +69 -0
- package/src/auth.ts +200 -0
- package/src/cdp-fake.ts +150 -0
- package/src/cdp-port.ts +75 -0
- package/src/cdp-snapshot.ts +63 -0
- package/src/cdp-target.ts +320 -0
- package/src/clock.ts +97 -0
- package/src/cookie-scope.ts +97 -0
- package/src/driver-cdp.ts +184 -0
- package/src/driver-fake.ts +119 -0
- package/src/driver-fixture.ts +65 -0
- package/src/driver.ts +88 -0
- package/src/error-throws.ts +258 -0
- package/src/errors.ts +180 -0
- package/src/events.ts +74 -0
- package/src/expect.ts +133 -0
- package/src/failures.ts +47 -0
- package/src/hosts.ts +56 -0
- package/src/html-query.ts +119 -0
- package/src/html-requests.ts +45 -0
- package/src/html-target.ts +229 -0
- package/src/http-recorded.ts +85 -0
- package/src/http.ts +158 -0
- package/src/index.ts +178 -0
- package/src/intercept.ts +41 -0
- package/src/offline-session.ts +67 -0
- package/src/page-over-target.ts +215 -0
- package/src/page.ts +103 -0
- package/src/rate.ts +23 -0
- package/src/recording.ts +68 -0
- package/src/recover.ts +51 -0
- package/src/rings.ts +73 -0
- package/src/robots.ts +144 -0
- package/src/scrape-run.ts +226 -0
- package/src/scrape.ts +151 -0
- package/src/secrets.ts +91 -0
- package/src/session-state.ts +181 -0
- package/src/target.ts +118 -0
- package/src/watchdog.ts +100 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// One attempt, assembled: driver, secrets, robots gate, pacing, session lifecycle, the body, the
|
|
2
|
+
// extract schema, the yield alarm, the artifacts, the teardown.
|
|
3
|
+
//
|
|
4
|
+
// RESTARTABILITY, stated once because it is the property everything here is arranged around: a
|
|
5
|
+
// killed attempt resumes from the caller's own `step.run` checkpoints, and what a step may persist
|
|
6
|
+
// is a CURSOR — a page number, an id, an offset. Never a page, never a live handle, never a
|
|
7
|
+
// session. The session lives in the session store and is re-probed on every attempt, which is what
|
|
8
|
+
// makes a resumed run skip the login when the session is still good and re-login when it is not.
|
|
9
|
+
// A step record saying "logged in" would be a checkpoint asserting something about a session that
|
|
10
|
+
// may have expired an hour ago.
|
|
11
|
+
|
|
12
|
+
import type { JobRunArgs } from '@ultimat3/jobs';
|
|
13
|
+
import { parse } from '@ultimat3/schema';
|
|
14
|
+
import { createArtifactWriter } from './artifacts';
|
|
15
|
+
import type { AuthPlanInput } from './auth';
|
|
16
|
+
import {
|
|
17
|
+
burnSession,
|
|
18
|
+
createPrompt,
|
|
19
|
+
ensureAuthenticated,
|
|
20
|
+
markRefused,
|
|
21
|
+
persistSession,
|
|
22
|
+
restorableSession,
|
|
23
|
+
} from './auth';
|
|
24
|
+
import { systemScrapeClock } from './clock';
|
|
25
|
+
import type { ScrapeSession } from './driver';
|
|
26
|
+
import { scrapeDriver } from './driver';
|
|
27
|
+
import { driverUnknown, outputInvalid } from './error-throws';
|
|
28
|
+
import { scrapeLogger, withStepEvent } from './events';
|
|
29
|
+
import { guardYield } from './expect';
|
|
30
|
+
import { burnsSession, errorCode, neverRetried } from './failures';
|
|
31
|
+
import { createPacer, DEFAULT_NAVIGATION_RATE } from './rate';
|
|
32
|
+
import { runRecovery } from './recover';
|
|
33
|
+
import { createRobotsGate } from './robots';
|
|
34
|
+
import type { ScrapeDefinition, ScrapeReport } from './scrape';
|
|
35
|
+
import { createSecretBag } from './secrets';
|
|
36
|
+
import { sessionKeyFor } from './session-state';
|
|
37
|
+
|
|
38
|
+
/** `ctx.actor` is a structural read: this package never imports the auth types (tier 2). */
|
|
39
|
+
const orgOf = (ctx: unknown): string | undefined => {
|
|
40
|
+
const actor = (ctx as { actor?: { orgId?: unknown } }).actor;
|
|
41
|
+
return typeof actor?.orgId === 'string' ? actor.orgId : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const toMillis = (value: string | number | undefined, fallback: number): number => {
|
|
45
|
+
if (value === undefined) return fallback;
|
|
46
|
+
if (typeof value === 'number') return value;
|
|
47
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(value.trim());
|
|
48
|
+
if (match === null) return fallback;
|
|
49
|
+
const scale = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[match[2] ?? 'ms'] ?? 1;
|
|
50
|
+
return Number(match[1]) * scale;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_PAGE_TIMEOUT_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
export async function runScrape<I, Row>(
|
|
56
|
+
definition: ScrapeDefinition<I, Row>,
|
|
57
|
+
args: JobRunArgs<I>,
|
|
58
|
+
): Promise<ScrapeReport<Row>> {
|
|
59
|
+
const clock = definition.clock ?? systemScrapeClock;
|
|
60
|
+
const driver = definition.driver ?? scrapeDriver();
|
|
61
|
+
// The scrape's name goes in the SCRAPE slot: there is no driver here to name, which is the
|
|
62
|
+
// whole failure.
|
|
63
|
+
if (driver === undefined) throw driverUnknown(undefined, [], definition.name);
|
|
64
|
+
const logger = scrapeLogger(args.ctx.logger, {
|
|
65
|
+
scrape: definition.name,
|
|
66
|
+
runId: args.runId,
|
|
67
|
+
attempt: args.attempt,
|
|
68
|
+
driver: driver.name,
|
|
69
|
+
});
|
|
70
|
+
const secrets = createSecretBag(definition.secrets ?? []);
|
|
71
|
+
const rules = { allowHosts: definition.allowHosts, block: definition.block };
|
|
72
|
+
const pace = createPacer(definition.rate ?? DEFAULT_NAVIGATION_RATE, clock);
|
|
73
|
+
const artifact = createArtifactWriter({
|
|
74
|
+
storage: definition.artifacts?.storage,
|
|
75
|
+
scrape: definition.name,
|
|
76
|
+
runId: args.runId,
|
|
77
|
+
prefix: definition.artifacts?.prefix,
|
|
78
|
+
});
|
|
79
|
+
const plan: AuthPlanInput<I> = {
|
|
80
|
+
scrape: definition.name,
|
|
81
|
+
auth: definition.auth,
|
|
82
|
+
// `auth.key` DISCRIMINATES inside the tenant's key space; it never replaces it. Letting it
|
|
83
|
+
// replace the key gave two tenants declaring the same account name one authenticated session,
|
|
84
|
+
// and skipped the sanitising `sessionKeyFor` does to a value that is also a storage path.
|
|
85
|
+
key: sessionKeyFor({
|
|
86
|
+
scrape: definition.name,
|
|
87
|
+
tenant: orgOf(args.ctx),
|
|
88
|
+
discriminator: definition.auth?.key?.(args.input),
|
|
89
|
+
}),
|
|
90
|
+
clock,
|
|
91
|
+
logger,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Read BEFORE the browser opens: a refused credential must not reach a login form again, and
|
|
95
|
+
// opening a session first would already have spent an identity on a run that cannot succeed.
|
|
96
|
+
const restored = await restorableSession(plan);
|
|
97
|
+
const session = await driver.open({
|
|
98
|
+
name: definition.name,
|
|
99
|
+
rules,
|
|
100
|
+
clock,
|
|
101
|
+
timeoutMs: toMillis(definition.pageTimeout, DEFAULT_PAGE_TIMEOUT_MS),
|
|
102
|
+
secrets,
|
|
103
|
+
robots: createRobotsGate({ policy: definition.robots ?? 'obey' }),
|
|
104
|
+
signal: args.ctx.signal,
|
|
105
|
+
restore: restored,
|
|
106
|
+
pace: (signal) => pace(signal),
|
|
107
|
+
watchdog: definition.watchdog,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
if (definition.auth !== undefined) {
|
|
112
|
+
const loggedIn = await withStepEvent(
|
|
113
|
+
{ name: 'auth', logger, clock, attempt: args.attempt },
|
|
114
|
+
() =>
|
|
115
|
+
ensureAuthenticated({
|
|
116
|
+
...plan,
|
|
117
|
+
input: args.input,
|
|
118
|
+
page: session.page,
|
|
119
|
+
secrets,
|
|
120
|
+
restored,
|
|
121
|
+
prompt: createPrompt(definition.name, definition.prompt, session.page),
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
// Persisted after a LOGIN only, never after a reuse: rewriting the record on every run
|
|
125
|
+
// refreshes `savedAt` without refreshing the session, so `maxAge` would never expire it.
|
|
126
|
+
if (loggedIn) await persistSession(plan, session.page);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const raw = await bodyWithRecovery(definition, args, session, logger, artifact, secrets);
|
|
130
|
+
const rows = raw.map((row): Row => {
|
|
131
|
+
try {
|
|
132
|
+
return parse(definition.extract, row);
|
|
133
|
+
} catch (thrown) {
|
|
134
|
+
throw outputInvalid(definition.name, errorCode(thrown) ?? 'the row did not parse');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
await guardYield({
|
|
138
|
+
scrape: definition.name,
|
|
139
|
+
rows: rows.length,
|
|
140
|
+
expect: definition.expect,
|
|
141
|
+
history: definition.history,
|
|
142
|
+
});
|
|
143
|
+
const refused = session.page.network().filter((entry) => entry.refused !== undefined).length;
|
|
144
|
+
// The ring is bounded, so `refused` is a FLOOR and this is what says so. Reporting the count
|
|
145
|
+
// alone made a run that blocked 5,000 images print 200 and discarded the one number
|
|
146
|
+
// (`Ring.dropped`) that exists to say "you are not seeing it all".
|
|
147
|
+
const networkDropped = session.page.networkDropped();
|
|
148
|
+
logger.info('scrape.ok', { rows: rows.length, refused });
|
|
149
|
+
return {
|
|
150
|
+
scrape: definition.name,
|
|
151
|
+
rows,
|
|
152
|
+
artifacts: artifact.saved.map((ref) => ref.key),
|
|
153
|
+
refused,
|
|
154
|
+
networkDropped,
|
|
155
|
+
};
|
|
156
|
+
} catch (thrown) {
|
|
157
|
+
logger.error('scrape.failed', { code: errorCode(thrown) });
|
|
158
|
+
if (errorCode(thrown) === 'X_SCRAPE_AUTH_FAILED') await markRefused(plan);
|
|
159
|
+
else if (burnsSession(thrown)) await burnSession(plan);
|
|
160
|
+
if (definition.artifacts?.onFailure !== false) await saveFailureArtifact(session, artifact);
|
|
161
|
+
throw thrown;
|
|
162
|
+
} finally {
|
|
163
|
+
// Always, and it never throws: `close()` ends the local connection AND the remote browser.
|
|
164
|
+
await session.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The body, with at most ONE recovery pass. `recover` never runs for a failure that must not be
|
|
170
|
+
* retried — a rejected credential is the case, and asking a model to "fix" a wrong password is
|
|
171
|
+
* how an account gets locked.
|
|
172
|
+
*/
|
|
173
|
+
async function bodyWithRecovery<I, Row>(
|
|
174
|
+
definition: ScrapeDefinition<I, Row>,
|
|
175
|
+
args: JobRunArgs<I>,
|
|
176
|
+
session: ScrapeSession,
|
|
177
|
+
logger: ReturnType<typeof scrapeLogger>,
|
|
178
|
+
artifact: ReturnType<typeof createArtifactWriter>,
|
|
179
|
+
secrets: ReturnType<typeof createSecretBag>,
|
|
180
|
+
): Promise<readonly unknown[]> {
|
|
181
|
+
const body = (): Promise<readonly unknown[]> =>
|
|
182
|
+
definition.run({
|
|
183
|
+
input: args.input,
|
|
184
|
+
page: session.page,
|
|
185
|
+
http: session.http,
|
|
186
|
+
step: args.step,
|
|
187
|
+
ctx: args.ctx,
|
|
188
|
+
secrets,
|
|
189
|
+
artifact,
|
|
190
|
+
attempt: args.attempt,
|
|
191
|
+
runId: args.runId,
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
return await withStepEvent(
|
|
195
|
+
{ name: 'body', logger, clock: definition.clock ?? systemScrapeClock, attempt: args.attempt },
|
|
196
|
+
body,
|
|
197
|
+
);
|
|
198
|
+
} catch (thrown) {
|
|
199
|
+
if (definition.recover === undefined || neverRetried(thrown)) throw thrown;
|
|
200
|
+
const recovered = await runRecovery(definition.recover, {
|
|
201
|
+
scrape: definition.name,
|
|
202
|
+
page: session.page,
|
|
203
|
+
failure: thrown,
|
|
204
|
+
attempt: args.attempt,
|
|
205
|
+
});
|
|
206
|
+
if (!recovered) throw thrown;
|
|
207
|
+
logger.warn('scrape.recovered', { code: errorCode(thrown) });
|
|
208
|
+
return await body();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The page HTML, on failure — redacted by value, password fields blanked. It is the only forensic
|
|
214
|
+
* left once the browser is gone, and by the time somebody looks the page has changed.
|
|
215
|
+
*/
|
|
216
|
+
async function saveFailureArtifact(
|
|
217
|
+
session: ScrapeSession,
|
|
218
|
+
artifact: ReturnType<typeof createArtifactWriter>,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
try {
|
|
221
|
+
await artifact.save('page.html', await session.page.html());
|
|
222
|
+
} catch {
|
|
223
|
+
// A failed artifact must never replace the failure that caused it. The run's own error is the
|
|
224
|
+
// one the reader needs; this is a best effort on the way out.
|
|
225
|
+
}
|
|
226
|
+
}
|
package/src/scrape.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// `scrape()` — a browser run, declared as a `job` and NOT as a ninth primitive. The rule's fourth
|
|
2
|
+
// instance after `llm()` (an action factory) and `backfill()` (a job factory).
|
|
3
|
+
//
|
|
4
|
+
// It is a job by every field of the definition, not by analogy: a scrape has an input schema, a
|
|
5
|
+
// tenant, a retry policy, a timeout, a concurrency cap, a queue, and — decisively — a REQUIRED
|
|
6
|
+
// idempotency key and `step.run` checkpoints. Logging into a bank twice because a worker was
|
|
7
|
+
// killed between the login and its checkpoint is not a hypothetical; three wrong attempts locks
|
|
8
|
+
// the account. So this file is a FACTORY over `job()`, and a scrape inherits `.enqueue()`, the
|
|
9
|
+
// worker's cancellation, the dead-letter path, `x jobs show` and its manifest row for free.
|
|
10
|
+
|
|
11
|
+
import type { Ctx } from '@ultimat3/core';
|
|
12
|
+
import { assert } from '@ultimat3/core';
|
|
13
|
+
import type { JobHandle, JobTenant, RetryPolicy, StepApi } from '@ultimat3/jobs';
|
|
14
|
+
import { DEFAULT_RETRY, job } from '@ultimat3/jobs';
|
|
15
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
16
|
+
import type { StorageDriver } from '@ultimat3/storage';
|
|
17
|
+
import type { ArtifactWriter } from './artifacts';
|
|
18
|
+
import type { PromptHandler, ScrapeAuth } from './auth';
|
|
19
|
+
import type { ScrapeClock } from './clock';
|
|
20
|
+
import type { ScrapeDriver } from './driver';
|
|
21
|
+
import type { YieldExpectation, YieldHistory } from './expect';
|
|
22
|
+
import type { HostRule } from './hosts';
|
|
23
|
+
import type { ScrapeHttp } from './http';
|
|
24
|
+
import type { ScrapePage } from './page';
|
|
25
|
+
import type { Recovery } from './recover';
|
|
26
|
+
import type { ResourceType } from './rings';
|
|
27
|
+
import type { RobotsPolicy } from './robots';
|
|
28
|
+
import { runScrape } from './scrape-run';
|
|
29
|
+
import type { ScrapeSecrets } from './secrets';
|
|
30
|
+
|
|
31
|
+
export interface ScrapeRunArgs<I> {
|
|
32
|
+
readonly input: I;
|
|
33
|
+
/** Driver-blind. The same body runs on a browser, on a recording and on a string of HTML. */
|
|
34
|
+
readonly page: ScrapePage;
|
|
35
|
+
/**
|
|
36
|
+
* The same session, over HTTP. Drive the browser through login and navigation, then pull the
|
|
37
|
+
* bulk off the site's own JSON endpoints: the cookies, headers, proxy, host allow list, rate
|
|
38
|
+
* limit and cancellation are the page's, so the authenticated browser session simply continues.
|
|
39
|
+
*/
|
|
40
|
+
readonly http: ScrapeHttp;
|
|
41
|
+
/** The job's own step api: one `step.run` per page, so a kill resumes where it stopped. */
|
|
42
|
+
readonly step: StepApi;
|
|
43
|
+
readonly ctx: Ctx;
|
|
44
|
+
/** Declared names, resolved in the worker. A value never enters the queue row. */
|
|
45
|
+
readonly secrets: ScrapeSecrets;
|
|
46
|
+
readonly artifact: ArtifactWriter;
|
|
47
|
+
readonly attempt: number;
|
|
48
|
+
readonly runId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ScrapeArtifacts {
|
|
52
|
+
readonly storage?: StorageDriver | undefined;
|
|
53
|
+
/** Save the page's HTML when the run fails. On by default — it is the only forensic left. */
|
|
54
|
+
readonly onFailure?: boolean | undefined;
|
|
55
|
+
readonly prefix?: string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ScrapeDefinition<I, Row> {
|
|
59
|
+
/** REQUIRED, exactly as a backfill's is: the name is a durable queue key, never an export name. */
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly input: StandardSchemaV1<unknown, I>;
|
|
62
|
+
/**
|
|
63
|
+
* Every row, parsed. A scrape's output is somebody else's HTML, so "it came back" and "it came
|
|
64
|
+
* back in the shape this app stores" are different questions and this is the second one.
|
|
65
|
+
* A row the schema rejects is `X_SCRAPE_OUTPUT_INVALID`, never a silently stored partial.
|
|
66
|
+
*/
|
|
67
|
+
readonly extract: StandardSchemaV1<unknown, Row>;
|
|
68
|
+
/** REQUIRED by the type, like every job's. */
|
|
69
|
+
readonly idempotencyKey: (input: I) => string;
|
|
70
|
+
/** REQUIRED by the type, like every job's. */
|
|
71
|
+
readonly tenant: JobTenant<I>;
|
|
72
|
+
/**
|
|
73
|
+
* REQUIRED, and the field this package is most opinionated about. A headless browser with no
|
|
74
|
+
* host list is the widest SSRF surface an app can own. `['*']` is the explicit escape hatch —
|
|
75
|
+
* spelled out, visible in review — and there is no way to omit the decision.
|
|
76
|
+
*/
|
|
77
|
+
readonly allowHosts: readonly HostRule[];
|
|
78
|
+
readonly block?: readonly ResourceType[] | undefined;
|
|
79
|
+
/** Navigations per second. Defaults to `DEFAULT_NAVIGATION_RATE`; there is no unpaced mode. */
|
|
80
|
+
readonly rate?: number | undefined;
|
|
81
|
+
readonly robots?: RobotsPolicy | undefined;
|
|
82
|
+
/** The silent-green alarm. See `expect.ts` — this is the most valuable field here. */
|
|
83
|
+
readonly expect?: YieldExpectation | undefined;
|
|
84
|
+
readonly history?: YieldHistory | undefined;
|
|
85
|
+
readonly artifacts?: ScrapeArtifacts | undefined;
|
|
86
|
+
/** NAMES, never values. */
|
|
87
|
+
readonly secrets?: readonly string[] | undefined;
|
|
88
|
+
readonly recover?: Recovery | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* Session lifecycle: acquire, persist, reuse, validate, burn. Authenticated scraping is the
|
|
91
|
+
* primary case, so this is declared rather than hand-rolled per app. See `auth.ts`.
|
|
92
|
+
*/
|
|
93
|
+
readonly auth?: ScrapeAuth<I> | undefined;
|
|
94
|
+
/** Where an out-of-band code comes from, for a site that asks for one after the password. */
|
|
95
|
+
readonly prompt?: PromptHandler | undefined;
|
|
96
|
+
readonly driver?: ScrapeDriver | undefined;
|
|
97
|
+
readonly retry?: RetryPolicy | undefined;
|
|
98
|
+
/** Per attempt, whole-run. `'5m'` or ms. */
|
|
99
|
+
readonly timeout?: string | number | undefined;
|
|
100
|
+
/** Per browser operation. `'30s'` or ms. */
|
|
101
|
+
readonly pageTimeout?: string | number | undefined;
|
|
102
|
+
/** Kill the browser after this much silence from it. See `watchdog.ts`. */
|
|
103
|
+
readonly watchdog?: { readonly idleMs?: number; readonly graceMs?: number } | undefined;
|
|
104
|
+
readonly concurrency?: number | undefined;
|
|
105
|
+
readonly queue?: string | undefined;
|
|
106
|
+
readonly clock?: ScrapeClock | undefined;
|
|
107
|
+
run(args: ScrapeRunArgs<I>): Promise<readonly unknown[]>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** What one completed scrape reports — bounded, so `x jobs show` can print it. */
|
|
111
|
+
export interface ScrapeReport<Row> {
|
|
112
|
+
readonly scrape: string;
|
|
113
|
+
readonly rows: readonly Row[];
|
|
114
|
+
readonly artifacts: readonly string[];
|
|
115
|
+
/**
|
|
116
|
+
* Requests interception refused, by reason. A zero-row run usually explains itself here — and it
|
|
117
|
+
* is a FLOOR, not a total, whenever `networkDropped` is non-zero.
|
|
118
|
+
*/
|
|
119
|
+
readonly refused: number;
|
|
120
|
+
/**
|
|
121
|
+
* Entries the bounded network ring dropped to stay bounded (`rings.ts`). Non-zero is the honest
|
|
122
|
+
* "you are not seeing it all": `refused` was counted from what survived the bound.
|
|
123
|
+
*/
|
|
124
|
+
readonly networkDropped: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function scrape<I, Row>(definition: ScrapeDefinition<I, Row>): JobHandle<I> {
|
|
128
|
+
// Refused where it is written, in the voice `backfill()` uses: a scrape with an empty host list
|
|
129
|
+
// can never navigate anywhere, and finding that out is otherwise a dead-lettered job.
|
|
130
|
+
assert(
|
|
131
|
+
definition.allowHosts.length > 0,
|
|
132
|
+
`scrape "${definition.name}" declares allowHosts: [] — nothing can be navigated to`,
|
|
133
|
+
`list the hosts on scrape("${definition.name}") — allowHosts: ['example.com'] — or state the decision with allowHosts: ['*']`,
|
|
134
|
+
);
|
|
135
|
+
assert(
|
|
136
|
+
definition.rate === undefined || (Number.isFinite(definition.rate) && definition.rate > 0),
|
|
137
|
+
`scrape "${definition.name}" declares rate: ${String(definition.rate)} — a rate is navigations per second, greater than zero`,
|
|
138
|
+
`set rate: 1 on scrape("${definition.name}"), or leave it out — to go faster raise the number, there is no unpaced mode`,
|
|
139
|
+
);
|
|
140
|
+
return job<I>({
|
|
141
|
+
name: definition.name,
|
|
142
|
+
input: definition.input,
|
|
143
|
+
idempotencyKey: definition.idempotencyKey,
|
|
144
|
+
tenant: definition.tenant,
|
|
145
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
146
|
+
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
147
|
+
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
148
|
+
...(definition.concurrency === undefined ? {} : { concurrency: definition.concurrency }),
|
|
149
|
+
run: (args) => runScrape(definition, args),
|
|
150
|
+
});
|
|
151
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Secrets in a browser run, and the one place this package decides what may leave it.
|
|
2
|
+
//
|
|
3
|
+
// A scrape logs in. That means a password crosses this package on its way into a form field, and
|
|
4
|
+
// there are exactly three ways it gets out: an event stream, a log line, and — the one nobody
|
|
5
|
+
// remembers — a SCREENSHOT of the filled form. The first two are solved by carrying the value in
|
|
6
|
+
// core's `Secret` box, which renders `[redacted]` through `String`, `JSON.stringify` and the
|
|
7
|
+
// logger. The third is not, because pixels cannot be redacted after the fact.
|
|
8
|
+
|
|
9
|
+
import type { Secret } from '@ultimat3/core';
|
|
10
|
+
import { revealSecret, secret, UltimateError } from '@ultimat3/core';
|
|
11
|
+
|
|
12
|
+
export interface ScrapeSecrets {
|
|
13
|
+
readonly names: readonly string[];
|
|
14
|
+
/** The boxed value. Reading it out is `revealSecret()`, one greppable call site. */
|
|
15
|
+
get(name: string): Secret;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Names only ever reach a job payload — the values are resolved in the worker, per attempt, from
|
|
20
|
+
* the environment. A queue row that carried the value would put a bank password in a durable
|
|
21
|
+
* table, in the outbox, and in every `x jobs show` an operator runs.
|
|
22
|
+
*/
|
|
23
|
+
export type SecretResolver = (name: string) => string | undefined;
|
|
24
|
+
|
|
25
|
+
const fromEnvironment: SecretResolver = (name) => Bun.env[name];
|
|
26
|
+
|
|
27
|
+
export function createSecretBag(
|
|
28
|
+
names: readonly string[],
|
|
29
|
+
resolve: SecretResolver = fromEnvironment,
|
|
30
|
+
): ScrapeSecrets {
|
|
31
|
+
const boxed = new Map<string, Secret>();
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
const value = resolve(name);
|
|
34
|
+
if (value === undefined || value === '') {
|
|
35
|
+
throw new UltimateError({
|
|
36
|
+
code: 'X_ENV_MISSING',
|
|
37
|
+
cause: `scrape secret "${name}" is declared and the environment has no value for it`,
|
|
38
|
+
fix: `add ${name}= to .env.local, or drop "${name}" from secrets: on the scrape() definition`,
|
|
39
|
+
meta: { name },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
boxed.set(name, secret(value, name));
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
names: [...names],
|
|
46
|
+
get(name: string): Secret {
|
|
47
|
+
const found = boxed.get(name);
|
|
48
|
+
if (found === undefined) {
|
|
49
|
+
throw new UltimateError({
|
|
50
|
+
code: 'X_ENV_MISSING',
|
|
51
|
+
cause: `scrape secret "${name}" was read and never declared`,
|
|
52
|
+
fix: `add "${name}" to secrets: on the scrape() definition — a run resolves only what it declares`,
|
|
53
|
+
meta: { name },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return found;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const SECRET_PLACEHOLDER = '[redacted]';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Redaction BY VALUE, over any text this package is about to persist: page HTML, a console line,
|
|
65
|
+
* a request URL, an error cause. Name-based redaction only catches a secret travelling under a
|
|
66
|
+
* name somebody remembered to list, and a password pasted into a query string travels under none.
|
|
67
|
+
*
|
|
68
|
+
* Longest first, so a secret that contains another one does not leave its tail behind.
|
|
69
|
+
*/
|
|
70
|
+
export function redactSecrets(text: string, secrets: ScrapeSecrets | undefined): string {
|
|
71
|
+
if (secrets === undefined || secrets.names.length === 0) return text;
|
|
72
|
+
const values = secrets.names
|
|
73
|
+
.map((name) => revealSecret(secrets.get(name)))
|
|
74
|
+
.filter((value) => value.length >= 4)
|
|
75
|
+
.sort((a, b) => b.length - a.length);
|
|
76
|
+
let out = text;
|
|
77
|
+
for (const value of values) out = out.split(value).join(SECRET_PLACEHOLDER);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `<input type="password" value="hunter2">` -> `value=""`, whatever the value happened to be. */
|
|
82
|
+
export function blankPasswordFields(html: string): string {
|
|
83
|
+
return html.replaceAll(
|
|
84
|
+
/(<input\b[^>]*\btype\s*=\s*['"]?password['"]?[^>]*?)\bvalue\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi,
|
|
85
|
+
'$1value=""',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Both passes, in the order that matters: value redaction first, then the structural blank. */
|
|
90
|
+
export const safeHtml = (html: string, secrets: ScrapeSecrets | undefined): string =>
|
|
91
|
+
blankPasswordFields(redactSecrets(html, secrets));
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The session: what an authenticated browser carries, as a value that can be persisted, restored,
|
|
2
|
+
// validated and BURNED.
|
|
3
|
+
//
|
|
4
|
+
// Reuse is both the fast path and the safe path — logging in on every run is slow and is itself
|
|
5
|
+
// the signal anti-bot systems look for. Burning is the counter-intuitive half and it is
|
|
6
|
+
// evidence-backed: a persisted profile that has been flagged stays flagged, so a retry that
|
|
7
|
+
// reloads it re-trips the same block, forever. "New identity, from scratch" has to be one call.
|
|
8
|
+
//
|
|
9
|
+
// Session material is CREDENTIAL material. It is tenant-scoped, it never reaches a log line, an
|
|
10
|
+
// event field, an artifact or a screenshot, and it is summarised for logs by `sessionDigest()` —
|
|
11
|
+
// counts and an origin, never a value.
|
|
12
|
+
|
|
13
|
+
import type { StorageDriver } from '@ultimat3/storage';
|
|
14
|
+
import { assertSafeKey } from '@ultimat3/storage';
|
|
15
|
+
import type { ScrapeCookie } from './target';
|
|
16
|
+
|
|
17
|
+
export interface SessionSnapshot {
|
|
18
|
+
readonly cookies: readonly ScrapeCookie[];
|
|
19
|
+
/**
|
|
20
|
+
* Headers the HTTP leg must send to stay the same client — user-agent, language, site tokens.
|
|
21
|
+
*
|
|
22
|
+
* Only an OFFLINE driver can fill it: CDP exposes no read for "the headers this site now
|
|
23
|
+
* expects", so the puppeteer driver answers `{}` and `driver-parity.test.ts` pins the
|
|
24
|
+
* divergence. A token the HTTP leg must carry belongs on the request (`http.request(url, {
|
|
25
|
+
* headers })`), not here — a fixture that proves otherwise proves it only offline.
|
|
26
|
+
*/
|
|
27
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
28
|
+
/** `localStorage`, flattened. Many sites keep the bearer token here and not in a cookie. */
|
|
29
|
+
readonly storage: Readonly<Record<string, string>>;
|
|
30
|
+
readonly userAgent: string;
|
|
31
|
+
/** The origin this session belongs to. A session is never replayed against another one. */
|
|
32
|
+
readonly origin: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SessionState extends SessionSnapshot {
|
|
36
|
+
readonly key: string;
|
|
37
|
+
/** ISO 8601. What a `maxAge` on reuse is measured against. */
|
|
38
|
+
readonly savedAt: string;
|
|
39
|
+
/**
|
|
40
|
+
* ISO 8601, set when this site REFUSED these credentials — and the reason the record survives
|
|
41
|
+
* the failure instead of being deleted with it.
|
|
42
|
+
*
|
|
43
|
+
* A site that locks an account after three wrong attempts makes a retrying framework the thing
|
|
44
|
+
* that destroys the user's account. Two mechanisms stop that, at two different distances, and
|
|
45
|
+
* this is the near one:
|
|
46
|
+
*
|
|
47
|
+
* | Mechanism | Stops | Cost still paid |
|
|
48
|
+
* |---|---|---|
|
|
49
|
+
* | `X_SCRAPE_AUTH_FAILED` registered `terminal` (`errors.ts`) | the NEXT attempt — `executeJob` reads the thrown code's classification and dead-letters on the attempt that failed | this attempt ran in full: process, CDP attach, navigation, one wrong password at the site |
|
|
50
|
+
* | this field | THIS attempt, before `driver.open()` — `restorableSession()` reads it first | nothing; the run refuses without a browser and without a request |
|
|
51
|
+
*
|
|
52
|
+
* So the queue-level classification is what makes the failure terminal, and this is what makes
|
|
53
|
+
* the failure CHEAP: a replay after a worker restart, a manual `x jobs retry`, or any second
|
|
54
|
+
* enqueue of the same input never reaches a login form at all. Clearing it is deliberate —
|
|
55
|
+
* `burn()` — because the thing that fixed it was a human changing the credential.
|
|
56
|
+
*/
|
|
57
|
+
readonly refusedAt?: string | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const EMPTY_SESSION: SessionSnapshot = Object.freeze({
|
|
61
|
+
cookies: [],
|
|
62
|
+
headers: {},
|
|
63
|
+
storage: {},
|
|
64
|
+
userAgent: '',
|
|
65
|
+
origin: '',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Per tenant, per scrape, per site. The tenant is FIRST because the key is also a storage path,
|
|
70
|
+
* and a prefix that starts with the tenant is one an object-store policy can scope.
|
|
71
|
+
*/
|
|
72
|
+
export function sessionKeyFor(input: {
|
|
73
|
+
readonly scrape: string;
|
|
74
|
+
readonly tenant: string | undefined;
|
|
75
|
+
readonly discriminator?: string | undefined;
|
|
76
|
+
}): string {
|
|
77
|
+
const parts = [input.tenant ?? 'no-tenant', input.scrape, input.discriminator ?? 'default'];
|
|
78
|
+
return parts.map((part) => part.replaceAll(/[^a-zA-Z0-9._-]+/g, '-')).join('/');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What a log line may say about a session: shape, never content. */
|
|
82
|
+
export const sessionDigest = (
|
|
83
|
+
state: SessionSnapshot | undefined,
|
|
84
|
+
): Readonly<Record<string, unknown>> =>
|
|
85
|
+
state === undefined
|
|
86
|
+
? { session: 'none' }
|
|
87
|
+
: {
|
|
88
|
+
session: 'present',
|
|
89
|
+
origin: state.origin,
|
|
90
|
+
cookies: state.cookies.length,
|
|
91
|
+
storageKeys: Object.keys(state.storage).length,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export interface ScrapeSessionStore {
|
|
95
|
+
load(key: string): Promise<SessionState | undefined>;
|
|
96
|
+
save(state: SessionState): Promise<void>;
|
|
97
|
+
/** Delete it. Called on a block, and by an author who knows the identity is spent. */
|
|
98
|
+
burn(key: string): Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function memorySessionStore(
|
|
102
|
+
seed: Readonly<Record<string, SessionState>> = {},
|
|
103
|
+
): ScrapeSessionStore {
|
|
104
|
+
const states = new Map<string, SessionState>(Object.entries(seed));
|
|
105
|
+
return {
|
|
106
|
+
load: (key) => Promise.resolve(states.get(key)),
|
|
107
|
+
save: (state) => {
|
|
108
|
+
states.set(state.key, state);
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
},
|
|
111
|
+
burn: (key) => {
|
|
112
|
+
states.delete(key);
|
|
113
|
+
return Promise.resolve();
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const DEFAULT_SESSION_PREFIX = 'scrape-session';
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Sessions on the app's own disk, through `@ultimat3/storage` — the seam that already knows about
|
|
122
|
+
* tenant-scoped keys and refuses one that escapes its prefix. This package owns no upload path.
|
|
123
|
+
*
|
|
124
|
+
* NOT encrypted at rest: the bytes are as sensitive as the bucket they sit in, so the bucket must
|
|
125
|
+
* be private. Sealing them under core's secrets envelope is the obvious next step and is
|
|
126
|
+
* deliberately not guessed at here — it needs a key the app declares, and a wrong guess about
|
|
127
|
+
* where that key lives is worse than the honest note.
|
|
128
|
+
*/
|
|
129
|
+
export function storageSessionStore(
|
|
130
|
+
storage: StorageDriver,
|
|
131
|
+
options: { readonly prefix?: string | undefined } = {},
|
|
132
|
+
): ScrapeSessionStore {
|
|
133
|
+
const keyFor = (key: string): string => {
|
|
134
|
+
const path = `${options.prefix ?? DEFAULT_SESSION_PREFIX}/${key}.json`;
|
|
135
|
+
assertSafeKey(path);
|
|
136
|
+
return path;
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
async load(key: string): Promise<SessionState | undefined> {
|
|
140
|
+
try {
|
|
141
|
+
const read = await storage.get(keyFor(key));
|
|
142
|
+
return parseSessionState(JSON.parse(new TextDecoder().decode(read.bytes)) as unknown, key);
|
|
143
|
+
} catch {
|
|
144
|
+
// A session that cannot be read is a session that does not exist. Refusing the run over a
|
|
145
|
+
// missing cache would make reuse — the fast path — the fragile path.
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
async save(state: SessionState): Promise<void> {
|
|
150
|
+
await storage.put(keyFor(state.key), new TextEncoder().encode(JSON.stringify(state)), {
|
|
151
|
+
contentType: 'application/json',
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
async burn(key: string): Promise<void> {
|
|
155
|
+
await storage.delete(keyFor(key));
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isCookie = (value: unknown): value is ScrapeCookie =>
|
|
161
|
+
typeof value === 'object' &&
|
|
162
|
+
value !== null &&
|
|
163
|
+
typeof (value as { name?: unknown }).name === 'string' &&
|
|
164
|
+
typeof (value as { value?: unknown }).value === 'string';
|
|
165
|
+
|
|
166
|
+
/** Stored JSON is `unknown`. Read structurally, and answer `undefined` rather than half a session. */
|
|
167
|
+
export function parseSessionState(raw: unknown, key: string): SessionState | undefined {
|
|
168
|
+
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
169
|
+
const value = raw as Partial<SessionState>;
|
|
170
|
+
if (!Array.isArray(value.cookies) || typeof value.savedAt !== 'string') return undefined;
|
|
171
|
+
return {
|
|
172
|
+
key,
|
|
173
|
+
savedAt: value.savedAt,
|
|
174
|
+
...(typeof value.refusedAt === 'string' ? { refusedAt: value.refusedAt } : {}),
|
|
175
|
+
cookies: value.cookies.filter((cookie): cookie is ScrapeCookie => isCookie(cookie)),
|
|
176
|
+
headers: value.headers ?? {},
|
|
177
|
+
storage: value.storage ?? {},
|
|
178
|
+
userAgent: value.userAgent ?? '',
|
|
179
|
+
origin: value.origin ?? '',
|
|
180
|
+
};
|
|
181
|
+
}
|