@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
package/README.md
CHANGED
|
@@ -6,10 +6,8 @@ no ninth primitive, and no ordinal here to go stale when the next factory lands.
|
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { t } from '@ultimat3/schema';
|
|
9
|
-
import type { StorageDriver } from '@ultimat3/storage';
|
|
10
9
|
import { scrape, storageSessionStore } from '@ultimat3/scraping';
|
|
11
|
-
|
|
12
|
-
declare const disk: StorageDriver;
|
|
10
|
+
import { disk } from '@ultimat3/storage';
|
|
13
11
|
|
|
14
12
|
const orderPage = t.object({
|
|
15
13
|
rows: t.array(t.object({ id: t.string, total: t.number })),
|
|
@@ -29,7 +27,8 @@ export const dailyOrders = scrape({
|
|
|
29
27
|
secrets: ['SHOP_PASSWORD'],
|
|
30
28
|
expect: { minRows: 1, maxDrop: 0.5 },
|
|
31
29
|
auth: {
|
|
32
|
-
|
|
30
|
+
// A thunk: the app's disks exist once boot ran `defineStorage()`, after this module loaded.
|
|
31
|
+
store: storageSessionStore(() => disk('sessions')),
|
|
33
32
|
login: async ({ page, secrets, prompt }) => {
|
|
34
33
|
await page.goto('https://shop.example.com/login');
|
|
35
34
|
await page.fill('#user', 'ops@example.com');
|
|
@@ -85,6 +84,168 @@ Both legs replay from **one** fixture directory (`fixtureBrowser(dir)`), so a hy
|
|
|
85
84
|
login, session handoff, HTTP bulk fetch — is tested end to end. Both legs apply the same robots
|
|
86
85
|
gate, offline included.
|
|
87
86
|
|
|
87
|
+
## A session a service is built on
|
|
88
|
+
|
|
89
|
+
One connection, one run at a time, on its own exit, in a browser rented for that run — with a
|
|
90
|
+
person or an OTP source answering the site mid-run and a `usage` block on the result. Every piece
|
|
91
|
+
is a field on `scrape()` or on its driver; there is no second factory.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { t } from '@ultimat3/schema';
|
|
95
|
+
import type {
|
|
96
|
+
CdpLauncherLike,
|
|
97
|
+
CdpResolver,
|
|
98
|
+
PromptRequest,
|
|
99
|
+
ScrapeDriver,
|
|
100
|
+
ScrapeUsage,
|
|
101
|
+
} from '@ultimat3/scraping';
|
|
102
|
+
import {
|
|
103
|
+
answerPrompt,
|
|
104
|
+
eventPrompt,
|
|
105
|
+
fixtureBrowser,
|
|
106
|
+
remoteBrowser,
|
|
107
|
+
scrape,
|
|
108
|
+
storageSessionStore,
|
|
109
|
+
} from '@ultimat3/scraping';
|
|
110
|
+
import { disk } from '@ultimat3/storage';
|
|
111
|
+
|
|
112
|
+
declare const puppeteer: CdpLauncherLike;
|
|
113
|
+
/** Set where recordings stand in for the browser: `bun test`, `x dev`, the app's own gate. */
|
|
114
|
+
declare const fixtures: string | undefined;
|
|
115
|
+
/** The app's own provider client, and its own "a prompt is pending" row. Neither ships. */
|
|
116
|
+
declare const provider: {
|
|
117
|
+
create(request: {
|
|
118
|
+
tag: string | undefined;
|
|
119
|
+
proxy: string | undefined;
|
|
120
|
+
signal: AbortSignal | undefined;
|
|
121
|
+
}): Promise<{ id: string; connectUrl: string; priceCents: number }>;
|
|
122
|
+
end(id: string): Promise<void>;
|
|
123
|
+
};
|
|
124
|
+
declare function promptPending(request: PromptRequest): Promise<void>;
|
|
125
|
+
/** The app's own rows: the exit is a `.sealed()` column on the connection, read by id. */
|
|
126
|
+
declare function exitOf(connectionId: string): Promise<string | undefined>;
|
|
127
|
+
declare function recordRun(run: {
|
|
128
|
+
runId: string;
|
|
129
|
+
connectionId: string;
|
|
130
|
+
outcome: string;
|
|
131
|
+
code?: string | undefined;
|
|
132
|
+
usage?: ScrapeUsage | undefined;
|
|
133
|
+
}): Promise<void>;
|
|
134
|
+
|
|
135
|
+
// A browser rented per session. `release` runs exactly once, whatever the run did.
|
|
136
|
+
const rent: CdpResolver = async ({ runId, egress, signal }) => {
|
|
137
|
+
const rented = await provider.create({ tag: runId, proxy: egress, signal });
|
|
138
|
+
return {
|
|
139
|
+
cdpUrl: rented.connectUrl,
|
|
140
|
+
release: () => provider.end(rented.id),
|
|
141
|
+
cost: { minor: rented.priceCents, currency: 'USD' },
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const browser: ScrapeDriver =
|
|
146
|
+
fixtures === undefined
|
|
147
|
+
? remoteBrowser({ launcher: puppeteer, cdpUrl: rent })
|
|
148
|
+
: fixtureBrowser(fixtures);
|
|
149
|
+
|
|
150
|
+
const waitForAnswer = eventPrompt({ timeout: 300_000 });
|
|
151
|
+
|
|
152
|
+
export const syncAccounts = scrape({
|
|
153
|
+
name: 'accounts.sync',
|
|
154
|
+
// IDS only. A proxy URL carries its account, and the input is the queue row.
|
|
155
|
+
input: t.object({ connectionId: t.uuid, orgId: t.uuid }),
|
|
156
|
+
extract: t.object({ id: t.string }),
|
|
157
|
+
idempotencyKey: ({ connectionId }) => `accounts:${connectionId}`,
|
|
158
|
+
tenant: ({ orgId }) => orgId,
|
|
159
|
+
allowHosts: ['bank.example'],
|
|
160
|
+
driver: browser,
|
|
161
|
+
// The exit THIS run leaves through, looked up IN THE WORKER under the job's tenant. Both legs
|
|
162
|
+
// and the robots read dial it.
|
|
163
|
+
egress: ({ connectionId }) => exitOf(connectionId),
|
|
164
|
+
// One session per connection: a second run of the same one is `failed`, X_JOB_KEY_BUSY.
|
|
165
|
+
concurrency: { key: ({ connectionId }) => connectionId, limit: 1, whenBusy: 'fail' },
|
|
166
|
+
auth: {
|
|
167
|
+
// Declaring a store IS what persists the session — sealed under the app's master key.
|
|
168
|
+
store: storageSessionStore(() => disk('sessions')),
|
|
169
|
+
key: ({ connectionId }) => connectionId,
|
|
170
|
+
login: async ({ page, prompt }) => {
|
|
171
|
+
await page.goto('https://bank.example/login');
|
|
172
|
+
await page.fill('#otp', await prompt('sms code'));
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
// Say a prompt is pending, then wait for its answer on the job event bus — browser open.
|
|
176
|
+
prompt: async (request) => {
|
|
177
|
+
await promptPending(request);
|
|
178
|
+
return waitForAnswer(request);
|
|
179
|
+
},
|
|
180
|
+
async run({ page, http }) {
|
|
181
|
+
await page.goto('https://bank.example/accounts');
|
|
182
|
+
const body = await (await http.request('https://bank.example/api/accounts')).parse(
|
|
183
|
+
t.object({ rows: t.array(t.object({ id: t.string })) }),
|
|
184
|
+
);
|
|
185
|
+
return body.rows;
|
|
186
|
+
},
|
|
187
|
+
// How the run ENDED, told once: the report with its usage, or the code it failed with.
|
|
188
|
+
async onSettled(settled) {
|
|
189
|
+
if (settled.input === undefined) return;
|
|
190
|
+
const run = { runId: settled.runId, connectionId: settled.input.connectionId };
|
|
191
|
+
await recordRun(
|
|
192
|
+
settled.outcome === 'completed'
|
|
193
|
+
? { ...run, outcome: settled.outcome, usage: settled.result.usage }
|
|
194
|
+
: { ...run, outcome: settled.outcome, code: settled.code, usage: settled.usage },
|
|
195
|
+
);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** The body of the app's own policy-checked action: the event name is not an authorization. */
|
|
200
|
+
export const answer = (input: { runId: string; index: number; answer: string }) =>
|
|
201
|
+
answerPrompt(input);
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
| Field | Rule |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `egress` | `(input, ctx) => string \| undefined`, sync or async, called in the WORKER under the job's tenant — so the input carries an id and a row carries the exit. An exit whose password is also in the input rode the queue payload and is refused before the browser opens: `X_SCRAPE_EGRESS_IN_PAYLOAD`, terminal. Reaches the driver as `SessionInit.proxy` and wins over the driver's `proxy`. A URL with credentials never reaches the process arguments: a launched browser answers the proxy through `page.authenticate()`, and a launcher without it is `X_SCRAPE_EGRESS_UNSUPPORTED` |
|
|
207
|
+
| `remoteBrowser({ cdpUrl })` | a fixed URL, or a `CdpResolver`: called once per `open()` with `{ scrape, runId, egress, signal }`; `release()` runs once — in `close()` after the browser is quit, or at once when the attach fails. A `release()` that throws is logged `scrape.browser.release_failed` (`code`, `driver`, `origin` as scheme and host) through `SessionInit.logger`, which every driver now requires. A fixed URL handed a different exit is `X_SCRAPE_EGRESS_UNSUPPORTED` |
|
|
208
|
+
| `concurrency` | the job's own field, passed through: a number, or `{ key, limit, whenBusy }` |
|
|
209
|
+
| `auth.store` | `storageSessionStore(() => disk('sessions'), { prefix?, keySource? })` — a THUNK, read per call: a scrape is declared when its module loads and the app's disks exist after boot. Seals with core's `seal()`, purpose `SESSION_SEAL_PURPOSE`. Anything in the bucket that is not its own sealed record for that key is burned; no master key is `X_SEAL_KEY_MISSING` before the browser opens. `artifacts.storage` is the same thunk |
|
|
210
|
+
| `onSettled` | the job's own hook (`@ultimat3/jobs`), with what a scrape adds: `completed` hands over the `ScrapeReport`; `dead-lettered` / `dropped` / `refused` carry `code` and the last attempt's `usage` (`undefined` for `refused` — the body never ran). After the row is settled, under the job's tenant, **at most once** across a crash, and a hook that throws changes nothing |
|
|
211
|
+
| `run({ progress, finalAttempt })` | the job's own, passed through |
|
|
212
|
+
| `prompt` | `eventPrompt({ timeout, pollMs?, bus?, keySource?, env? })` polls the event bus in process — never `step.waitForEvent`, which would close the browser. An in-memory bus (`stored: false`) is refused outside development and test (`X_DRIVER_UNAVAILABLE`): the answer comes from another process. Event `promptEventName(runId, index)`; answered by `answerPrompt({ runId, index, answer })`, sealed for that one name. Timeout is `X_SCRAPE_PROMPT_UNANSWERED` |
|
|
213
|
+
| the bus | the **stored** one (`createPgEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it |
|
|
214
|
+
|
|
215
|
+
`PromptRequest` carries the run's `input` and `runId` — what ties a prompt to the app's own row
|
|
216
|
+
for the run — and `index`, `clock`, `signal` and `keepAlive()` beside `label`, `scrape` and `url`:
|
|
217
|
+
what any handler that waits needs: wait on the run's clock, stop on the run's
|
|
218
|
+
signal, and call `keepAlive()` between polls so the wedge watchdog and a rented browser both keep
|
|
219
|
+
seeing activity. `auth.login` and `auth.validate` are handed `runId` beside `input`.
|
|
220
|
+
|
|
221
|
+
`ScrapeReport.usage` is `{ browserMs, navigations, httpRequests, bytesIn, promptsAnswered,
|
|
222
|
+
browserCost? }`. `bytesIn` is the HTTP leg's response bodies; `browserCost` is the resolver's
|
|
223
|
+
`cost`, a `Money` value parsed at the boundary, so a float is refused. The counts ride the
|
|
224
|
+
`scrape.ok` and `scrape.failed` log lines too — a failed run has no report and was billed anyway,
|
|
225
|
+
which is why `onSettled` hands a failed run its last attempt's `usage`. No quota reads them.
|
|
226
|
+
|
|
227
|
+
### The clock, and a test's
|
|
228
|
+
|
|
229
|
+
A run waits on the PROCESS's clock — `scrapeClock()`, the system's — unless its definition pins
|
|
230
|
+
one with `clock:`. A test replaces it the way it replaces the queue driver:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { noWaitClock, resetScrapeClock, setScrapeClock } from '@ultimat3/scraping';
|
|
234
|
+
|
|
235
|
+
setScrapeClock(noWaitClock); // in a beforeEach
|
|
236
|
+
resetScrapeClock(); // in the afterEach
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
| Clock | Sleeping | For |
|
|
240
|
+
|---|---|---|
|
|
241
|
+
| `systemScrapeClock` | waits | production |
|
|
242
|
+
| `noWaitClock` | one turn of the event loop, whatever was asked; `now()` and deadlines are real | a test whose run waits on something that really happens in the same process — an answer the test publishes |
|
|
243
|
+
| `testClock()` | advances virtual time and yields a microtask | a test of a timeout: thirty seconds pass in none |
|
|
244
|
+
|
|
245
|
+
A third-party driver takes part by reading three `SessionInit` fields: dial `proxy` or throw
|
|
246
|
+
`egressUnsupported()`, pass `usage` to `pageOverTarget` and its HTTP transport, and answer
|
|
247
|
+
`browserCost` on the session it returns.
|
|
248
|
+
|
|
88
249
|
## Reading elements, frames, and the network condition
|
|
89
250
|
|
|
90
251
|
| Read | Answers |
|
|
@@ -152,8 +313,9 @@ bound threw away, so a count taken from one is never quietly a floor.
|
|
|
152
313
|
All three are **redacted by value** on the way out, the same pass `page.html()` makes: a login
|
|
153
314
|
endpoint fetched with the password in its query string, or a site that logs the credential it
|
|
154
315
|
rejected, would otherwise put the value in `page.network()`/`page.console()` verbatim and from
|
|
155
|
-
there into the stored failure artifact
|
|
156
|
-
|
|
316
|
+
there into the stored failure artifact — percent-encoded, form-encoded or HTML-escaped as much as
|
|
317
|
+
raw, since a password in a query string is never the raw string. The HTTP leg's
|
|
318
|
+
`X_SCRAPE_HTTP_FAILED` cause is redacted too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
|
|
157
319
|
a substring of ordinary prose — and pixels never can be, which is why a typed secret TAINTS the
|
|
158
320
|
page and `screenshot()`/`pdf()` are refused outright.
|
|
159
321
|
|
|
@@ -199,14 +361,17 @@ on a machine with no browser — the case CI is.
|
|
|
199
361
|
| `page.ts` / `page-over-target.ts` | the driver-blind vocabulary, implemented once |
|
|
200
362
|
| `target.ts` / `driver.ts` | the two seams: what a driver answers, and what a session is |
|
|
201
363
|
| `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
|
|
202
|
-
| `driver-fake.ts` / `driver-
|
|
364
|
+
| `driver-fake.ts` / `driver-recorded.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
|
|
203
365
|
| `http.ts` / `http-recorded.ts` | the second transport, live and replayed |
|
|
204
|
-
| `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions |
|
|
205
|
-
| `
|
|
366
|
+
| `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions. A stored session is sealed. `burnSession(plan, seen)` takes `recordVersion(record)` of the record the run used, so a record another run saved since is not burned |
|
|
367
|
+
| `event-prompt.ts` | `eventPrompt()` and `answerPrompt()`: a prompt answered from another process, over the job event bus, with the browser open |
|
|
368
|
+
| `cdp-resolver.ts` | a browser rented per session: acquired once, released once |
|
|
369
|
+
| `usage.ts` | what one run used, counted where the work happens |
|
|
370
|
+
| `secrets.ts` / `url-secrets.ts` / `browser-record.ts` | what may leave this package — declared secrets, values learned mid-run (`conceal`), the credential-bearing parts of a URL — and how a browser's own string map is read |
|
|
206
371
|
| `expect.ts` | the silent-green alarm |
|
|
207
372
|
| `watchdog.ts` | the wedge and zombie discipline |
|
|
208
373
|
| `capture-clip.ts` | the one framing rule — crop, whole page, or a refusal — checked before any driver sees it |
|
|
209
|
-
| `errors.ts` / `error-throws.ts` | this package's `X_*` codes and their retry classification |
|
|
374
|
+
| `errors.ts` / `error-throws.ts` / `error-throws-session.ts` | this package's `X_*` codes and their retry classification |
|
|
210
375
|
|
|
211
376
|
## Extending it — there is no plugin API, and none is needed
|
|
212
377
|
|
|
@@ -247,6 +412,7 @@ export const myBrowser = (options: MyOptions): ScrapeDriver => ({
|
|
|
247
412
|
secrets: init.secrets,
|
|
248
413
|
robots: init.robots,
|
|
249
414
|
signal: init.signal,
|
|
415
|
+
usage: init.usage,
|
|
250
416
|
}),
|
|
251
417
|
http: myHttp(target, init),
|
|
252
418
|
close: () => target.close(),
|
|
@@ -260,10 +426,8 @@ export const myBrowser = (options: MyOptions): ScrapeDriver => ({
|
|
|
260
426
|
```ts
|
|
261
427
|
// apps/web/shared/base/bank-scrape.ts — the app's convention, written once
|
|
262
428
|
import type { JobHandle } from '@ultimat3/jobs';
|
|
263
|
-
import type { StorageDriver } from '@ultimat3/storage';
|
|
264
429
|
import { scrape, type ScrapeDefinition } from '@ultimat3/scraping';
|
|
265
|
-
|
|
266
|
-
declare const disk: StorageDriver;
|
|
430
|
+
import { disk } from '@ultimat3/storage';
|
|
267
431
|
|
|
268
432
|
export const bankScrape = <I, R>(over: ScrapeDefinition<I, R>): JobHandle<I> =>
|
|
269
433
|
scrape<I, R>({
|
|
@@ -271,7 +435,7 @@ export const bankScrape = <I, R>(over: ScrapeDefinition<I, R>): JobHandle<I> =>
|
|
|
271
435
|
rate: 0.5,
|
|
272
436
|
block: ['image', 'media', 'font'],
|
|
273
437
|
expect: { minRows: 1, maxDrop: 0.5 },
|
|
274
|
-
artifacts: { storage: disk },
|
|
438
|
+
artifacts: { storage: () => disk('artifacts') },
|
|
275
439
|
...over,
|
|
276
440
|
});
|
|
277
441
|
```
|
|
@@ -298,6 +462,15 @@ Every code carries a cause, a runnable `fix:` and a retry classification — see
|
|
|
298
462
|
`X_SCRAPE_YIELD_COLLAPSED`, `X_SCRAPE_AUTH_FAILED`, `X_SCRAPE_BLOCKED` and `X_SCRAPE_PAGE_CRASHED`
|
|
299
463
|
are the four worth knowing by heart.
|
|
300
464
|
|
|
465
|
+
`X_SCRAPE_EGRESS_IN_PAYLOAD` is the one that is about the DECLARATION rather than the site: the
|
|
466
|
+
exit's password was readable out of the run's own input, so it is in `x_jobs` in the clear.
|
|
467
|
+
Enqueue the id of the row that holds the exit, resolve it in `egress`, and rotate the credential.
|
|
468
|
+
|
|
469
|
+
`X_SCRAPE_CDP_ATTACH_FAILED` names the endpoint by **scheme and host only**, for a fixed `cdpUrl` as
|
|
470
|
+
for a resolved one, and cuts every credential-bearing part of the URL out of the launcher's own
|
|
471
|
+
message: a provider's connect URL is its access token, and this cause is written to the dead-letter
|
|
472
|
+
row.
|
|
473
|
+
|
|
301
474
|
### Error classes
|
|
302
475
|
|
|
303
476
|
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "Browser automation as a job: scrape() returns a JobHandle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,9 +31,9 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/jobs": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
36
|
-
"@ultimat3/storage": "
|
|
34
|
+
"@ultimat3/core": "24.0.0",
|
|
35
|
+
"@ultimat3/jobs": "24.0.0",
|
|
36
|
+
"@ultimat3/schema": "24.0.0",
|
|
37
|
+
"@ultimat3/storage": "24.0.0"
|
|
37
38
|
}
|
|
38
39
|
}
|
package/src/artifacts.ts
CHANGED
|
@@ -21,7 +21,8 @@ export interface ArtifactWriter {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface ArtifactWriterInit {
|
|
24
|
-
|
|
24
|
+
/** A thunk, read per save: the app's disk exists only after boot (`storageSessionStore`). */
|
|
25
|
+
readonly storage: (() => StorageDriver) | undefined;
|
|
25
26
|
readonly scrape: string;
|
|
26
27
|
readonly runId: string;
|
|
27
28
|
/** Optional prefix, so an app can keep scrape artifacts out of its user-upload namespace. */
|
|
@@ -69,7 +70,7 @@ export function createArtifactWriter(init: ArtifactWriterInit): ArtifactWriter {
|
|
|
69
70
|
contentType: contentType ?? contentTypeFor(name),
|
|
70
71
|
};
|
|
71
72
|
if (init.storage !== undefined) {
|
|
72
|
-
await init.storage.put(ref.key, bytes, { contentType: ref.contentType });
|
|
73
|
+
await init.storage().put(ref.key, bytes, { contentType: ref.contentType });
|
|
73
74
|
}
|
|
74
75
|
saved.push(ref);
|
|
75
76
|
return ref;
|
package/src/auth.ts
CHANGED
|
@@ -23,23 +23,49 @@ import type { ScrapeClock } from './clock';
|
|
|
23
23
|
import { authFailed, promptUnanswered, sessionExpired } from './error-throws';
|
|
24
24
|
import type { ScrapePage } from './page';
|
|
25
25
|
import type { ScrapeSecrets } from './secrets';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
recordVersion,
|
|
28
|
+
type ScrapeSessionStore,
|
|
29
|
+
type SessionState,
|
|
30
|
+
sessionDigest,
|
|
31
|
+
} from './session-state';
|
|
27
32
|
|
|
28
|
-
export interface PromptRequest {
|
|
33
|
+
export interface PromptRequest<I = unknown> {
|
|
34
|
+
/**
|
|
35
|
+
* The run's own input — what ties this prompt to the app's identity for the run (a connection
|
|
36
|
+
* id, an org). A handler typed on the definition reads it typed; a shared one reads `unknown`.
|
|
37
|
+
*/
|
|
38
|
+
readonly input: I;
|
|
29
39
|
/** What the site is asking for: `'sms code'`, `'authenticator code'`, `'security question 3'`. */
|
|
30
40
|
readonly label: string;
|
|
31
41
|
readonly scrape: string;
|
|
32
42
|
readonly url: string;
|
|
43
|
+
/** The job run asking. With `index` it names this one prompt: `promptEventName(runId, index)`. */
|
|
44
|
+
readonly runId: string;
|
|
45
|
+
/** 1 for the first prompt of this attempt, 2 for the next. */
|
|
46
|
+
readonly index: number;
|
|
47
|
+
/** The run's clock. A handler that waits, waits on this — never on a timer of its own. */
|
|
48
|
+
readonly clock: ScrapeClock;
|
|
49
|
+
/** The run's cancellation. A handler that waits past it holds a browser nobody is using. */
|
|
50
|
+
readonly signal: AbortSignal | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* One cheap round trip to the browser. A handler that waits calls it between polls: the site is
|
|
53
|
+
* waiting INSIDE this session, so the wedge watchdog must keep seeing activity and a rented
|
|
54
|
+
* browser must not idle out — and a browser that died is found now, not after the answer.
|
|
55
|
+
*/
|
|
56
|
+
keepAlive(): Promise<void>;
|
|
33
57
|
}
|
|
34
58
|
|
|
35
59
|
/**
|
|
36
60
|
* Where an out-of-band code comes from. A declared callback field on the definition — typed,
|
|
37
61
|
* discoverable, one per concern — and never a global "register a plugin" call.
|
|
38
62
|
*/
|
|
39
|
-
export type PromptHandler = (request: PromptRequest) => Promise<string> | string;
|
|
63
|
+
export type PromptHandler<I = unknown> = (request: PromptRequest<I>) => Promise<string> | string;
|
|
40
64
|
|
|
41
65
|
export interface AuthContext<I> {
|
|
42
66
|
readonly input: I;
|
|
67
|
+
/** The job run logging in — the id the run's events, steps and prompts are keyed by. */
|
|
68
|
+
readonly runId: string;
|
|
43
69
|
readonly page: ScrapePage;
|
|
44
70
|
/**
|
|
45
71
|
* The declared secrets, boxed. A login body needs the credential it is about to type, and
|
|
@@ -78,15 +104,50 @@ export interface ScrapeAuth<I> {
|
|
|
78
104
|
readonly maxAge?: number | undefined;
|
|
79
105
|
}
|
|
80
106
|
|
|
81
|
-
export
|
|
82
|
-
scrape: string
|
|
83
|
-
handler: PromptHandler | undefined
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
export interface PromptInit<I = unknown> {
|
|
108
|
+
readonly scrape: string;
|
|
109
|
+
readonly handler: PromptHandler<I> | undefined;
|
|
110
|
+
readonly input: I;
|
|
111
|
+
readonly page: ScrapePage;
|
|
112
|
+
readonly runId: string;
|
|
113
|
+
readonly clock: ScrapeClock;
|
|
114
|
+
readonly signal?: AbortSignal | undefined;
|
|
115
|
+
/** The run's secret set. Every answer is concealed in it before the body sees the answer. */
|
|
116
|
+
readonly secrets?: ScrapeSecrets | undefined;
|
|
117
|
+
/** Called once per answered prompt — `ScrapeReport.usage.promptsAnswered`. */
|
|
118
|
+
readonly onAnswered?: (() => void) | undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Matches no element on any page, which is the point: the read costs one round trip and carries
|
|
123
|
+
* nothing back. It goes through the page vocabulary, so it reports activity like any other verb.
|
|
124
|
+
*/
|
|
125
|
+
const KEEPALIVE_SELECTOR = 'ultimate-keepalive';
|
|
126
|
+
|
|
127
|
+
export function createPrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
|
|
128
|
+
let asked = 0;
|
|
86
129
|
return async (label: string): Promise<string> => {
|
|
130
|
+
const { scrape, handler, page } = init;
|
|
87
131
|
if (handler === undefined) throw promptUnanswered(scrape, label);
|
|
88
|
-
|
|
132
|
+
asked += 1;
|
|
133
|
+
const answer = await handler({
|
|
134
|
+
input: init.input,
|
|
135
|
+
label,
|
|
136
|
+
scrape,
|
|
137
|
+
url: page.url(),
|
|
138
|
+
runId: init.runId,
|
|
139
|
+
index: asked,
|
|
140
|
+
clock: init.clock,
|
|
141
|
+
signal: init.signal,
|
|
142
|
+
keepAlive: async (): Promise<void> => {
|
|
143
|
+
await page.count(KEEPALIVE_SELECTOR);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
89
146
|
if (typeof answer !== 'string' || answer === '') throw promptUnanswered(scrape, label);
|
|
147
|
+
// Before the body can type it: an OTP echoed into the page it was typed on is in `page.html()`,
|
|
148
|
+
// and the failure artifact is written from exactly that.
|
|
149
|
+
init.secrets?.conceal(answer);
|
|
150
|
+
init.onAnswered?.();
|
|
90
151
|
return answer;
|
|
91
152
|
};
|
|
92
153
|
}
|
|
@@ -111,10 +172,30 @@ export interface AuthPlanInput<I> {
|
|
|
111
172
|
export async function restorableSession<I>(
|
|
112
173
|
plan: AuthPlanInput<I>,
|
|
113
174
|
): Promise<SessionState | undefined> {
|
|
175
|
+
return (await observeSession(plan)).restorable;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What `observeSession` answers: the session to restore, and the version of what was found. */
|
|
179
|
+
export interface ObservedSession {
|
|
180
|
+
readonly restorable: SessionState | undefined;
|
|
181
|
+
/**
|
|
182
|
+
* `recordVersion` of the record found — restorable or NOT. A `reuse: false` run, or one whose
|
|
183
|
+
* record is past `maxAge`, still saw that record: a refused login must tombstone over it, and a
|
|
184
|
+
* run that compared against "nothing" let the rejected credential through again.
|
|
185
|
+
*/
|
|
186
|
+
readonly seen: string | undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `restorableSession`, plus the version of the record found, for the run's later writes. */
|
|
190
|
+
export async function observeSession<I>(plan: AuthPlanInput<I>): Promise<ObservedSession> {
|
|
114
191
|
const store = plan.auth?.store;
|
|
115
|
-
if (store === undefined) return undefined;
|
|
192
|
+
if (store === undefined) return { restorable: undefined, seen: undefined };
|
|
116
193
|
const found = await store.load(plan.key);
|
|
117
|
-
if (found === undefined) return undefined;
|
|
194
|
+
if (found === undefined) return { restorable: undefined, seen: undefined };
|
|
195
|
+
return { restorable: restorableFrom(plan, found), seen: recordVersion(found) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function restorableFrom<I>(plan: AuthPlanInput<I>, found: SessionState): SessionState | undefined {
|
|
118
199
|
// The tombstone is read BEFORE `reuse` is honoured. `reuse: false` says "do not restore this
|
|
119
200
|
// session"; it does not say "present the rejected credential again", and reading it first meant
|
|
120
201
|
// a `reuse: false` scrape walked a refused password back to the login form on every requeue.
|
|
@@ -138,13 +219,42 @@ export async function restorableSession<I>(
|
|
|
138
219
|
return found;
|
|
139
220
|
}
|
|
140
221
|
|
|
141
|
-
/**
|
|
142
|
-
|
|
222
|
+
/**
|
|
223
|
+
* True when the stored record is still the one this run last saw — `seen` is the `recordVersion`
|
|
224
|
+
* it found or persisted, `undefined` when it found none. The store has no compare-and-set (nor has
|
|
225
|
+
* `StorageDriver` a conditional write to build one from), so a
|
|
226
|
+
* burn or a tombstone is a blind write: two runs on one key, and the one that failed destroyed the
|
|
227
|
+
* session the other had just persisted. Compared before the write, which narrows the race to the
|
|
228
|
+
* gap between this read and that write rather than the whole run. A record moved on is left alone.
|
|
229
|
+
*/
|
|
230
|
+
async function stillSeen<I>(
|
|
231
|
+
plan: AuthPlanInput<I>,
|
|
232
|
+
store: ScrapeSessionStore,
|
|
233
|
+
seen: string | undefined,
|
|
234
|
+
step: 'session.burn' | 'session.refuse',
|
|
235
|
+
): Promise<boolean> {
|
|
236
|
+
const current = await store.load(plan.key);
|
|
237
|
+
if (current === undefined || recordVersion(current) === seen) return true;
|
|
238
|
+
plan.logger.warn('scrape.session.superseded', { step });
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Written down so the next attempt cannot reach the login form with the same credentials — unless
|
|
244
|
+
* another run saved a session after this one read the record: that login SUCCEEDED, so this
|
|
245
|
+
* refusal is older news than the record it would overwrite.
|
|
246
|
+
*/
|
|
247
|
+
export async function markRefused<I>(
|
|
248
|
+
plan: AuthPlanInput<I>,
|
|
249
|
+
seen: string | undefined,
|
|
250
|
+
): Promise<void> {
|
|
143
251
|
const store = plan.auth?.store;
|
|
144
252
|
if (store === undefined) return;
|
|
253
|
+
if (!(await stillSeen(plan, store, seen, 'session.refuse'))) return;
|
|
145
254
|
await store.save({
|
|
146
255
|
key: plan.key,
|
|
147
256
|
savedAt: plan.clock.now().toISOString(),
|
|
257
|
+
version: crypto.randomUUID(),
|
|
148
258
|
refusedAt: plan.clock.now().toISOString(),
|
|
149
259
|
cookies: [],
|
|
150
260
|
headers: {},
|
|
@@ -154,15 +264,25 @@ export async function markRefused<I>(plan: AuthPlanInput<I>): Promise<void> {
|
|
|
154
264
|
});
|
|
155
265
|
}
|
|
156
266
|
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
267
|
+
/**
|
|
268
|
+
* New identity, from scratch. One call, because a flagged profile is unusable and must go — the
|
|
269
|
+
* profile THIS run used: `seen` is that record's `recordVersion` (`observeSession`'s, or the one
|
|
270
|
+
* `persistSession` answered), and a record another run saved since is not burned.
|
|
271
|
+
*/
|
|
272
|
+
export async function burnSession<I>(
|
|
273
|
+
plan: AuthPlanInput<I>,
|
|
274
|
+
seen: string | undefined,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const store = plan.auth?.store;
|
|
277
|
+
if (store === undefined) return;
|
|
278
|
+
if (!(await stillSeen(plan, store, seen, 'session.burn'))) return;
|
|
279
|
+
await store.burn(plan.key);
|
|
161
280
|
plan.logger.warn('scrape.session.burned', { burned: true });
|
|
162
281
|
}
|
|
163
282
|
|
|
164
283
|
export interface EnsureAuthInput<I> extends AuthPlanInput<I> {
|
|
165
284
|
readonly input: I;
|
|
285
|
+
readonly runId: string;
|
|
166
286
|
readonly page: ScrapePage;
|
|
167
287
|
readonly secrets: ScrapeSecrets;
|
|
168
288
|
readonly restored: SessionState | undefined;
|
|
@@ -182,6 +302,7 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
|
|
|
182
302
|
if (auth === undefined) return false;
|
|
183
303
|
const context: AuthContext<I> = {
|
|
184
304
|
input: args.input,
|
|
305
|
+
runId: args.runId,
|
|
185
306
|
page: args.page,
|
|
186
307
|
secrets: args.secrets,
|
|
187
308
|
prompt: args.prompt,
|
|
@@ -193,18 +314,31 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
|
|
|
193
314
|
return false;
|
|
194
315
|
}
|
|
195
316
|
args.logger.info('scrape.session.expired', { reused: false });
|
|
196
|
-
await burnSession(args);
|
|
317
|
+
await burnSession(args, recordVersion(args.restored));
|
|
197
318
|
}
|
|
198
319
|
if (auth.login === undefined) throw sessionExpired(args.scrape, args.key);
|
|
199
320
|
await auth.login(context);
|
|
200
321
|
return true;
|
|
201
322
|
}
|
|
202
323
|
|
|
203
|
-
/**
|
|
204
|
-
|
|
324
|
+
/**
|
|
325
|
+
* After a login: keep what it produced. A 2FA code somebody typed is worth exactly this. Answers
|
|
326
|
+
* the `version` it wrote — the record this run now owns, for a later burn to compare against.
|
|
327
|
+
*/
|
|
328
|
+
export async function persistSession<I>(
|
|
329
|
+
plan: AuthPlanInput<I>,
|
|
330
|
+
page: ScrapePage,
|
|
331
|
+
): Promise<string | undefined> {
|
|
205
332
|
const store = plan.auth?.store;
|
|
206
|
-
if (store === undefined) return;
|
|
333
|
+
if (store === undefined) return undefined;
|
|
207
334
|
const snapshot = await page.session();
|
|
208
|
-
|
|
335
|
+
const version = crypto.randomUUID();
|
|
336
|
+
await store.save({
|
|
337
|
+
...snapshot,
|
|
338
|
+
key: plan.key,
|
|
339
|
+
savedAt: plan.clock.now().toISOString(),
|
|
340
|
+
version,
|
|
341
|
+
});
|
|
209
342
|
plan.logger.info('scrape.session.saved', sessionDigest(snapshot));
|
|
343
|
+
return version;
|
|
210
344
|
}
|
package/src/cdp-arm.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// three verbs `press`/`focus`/`accessibility` did not fit under it. The extraction is VERBATIM —
|
|
5
5
|
// the request, console, `pageerror` and `error` handlers are the same code, one file over.
|
|
6
6
|
|
|
7
|
-
import type { CdpPageLike, CdpRequestLike } from './cdp-port';
|
|
7
|
+
import type { CdpBrowserLike, CdpPageLike, CdpRequestLike } from './cdp-port';
|
|
8
8
|
import type { ScrapeClock } from './clock';
|
|
9
9
|
import type { InterceptRules } from './intercept';
|
|
10
10
|
import { interceptVerdict, refusalEntry } from './intercept';
|
|
@@ -174,3 +174,67 @@ export async function arm(init: CdpArmInit, sinks: CdpSinks): Promise<void> {
|
|
|
174
174
|
crashed.value = readStringFrom(payload, 'message') ?? 'renderer crashed';
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
|
+
|
|
178
|
+
/** A `Fetch.requestPaused` payload, read structurally — the fields the verdict needs, or nothing. */
|
|
179
|
+
interface PausedRequest {
|
|
180
|
+
readonly requestId: string;
|
|
181
|
+
readonly resourceType: string;
|
|
182
|
+
readonly url: string;
|
|
183
|
+
readonly method: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const asPaused = (payload: unknown): PausedRequest | undefined => {
|
|
187
|
+
if (typeof payload !== 'object' || payload === null) return undefined;
|
|
188
|
+
const event = payload as Record<string, unknown>;
|
|
189
|
+
const request = event['request'];
|
|
190
|
+
if (typeof request !== 'object' || request === null) return undefined;
|
|
191
|
+
const { url, method } = request as Record<string, unknown>;
|
|
192
|
+
const { requestId, resourceType } = event;
|
|
193
|
+
if (typeof requestId !== 'string' || typeof url !== 'string') return undefined;
|
|
194
|
+
return {
|
|
195
|
+
requestId,
|
|
196
|
+
url,
|
|
197
|
+
resourceType: typeof resourceType === 'string' ? resourceType : 'Other',
|
|
198
|
+
method: typeof method === 'string' ? method : 'GET',
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `allowHosts` and `block` on EVERY target the browser opens. Page-level interception (`arm`)
|
|
204
|
+
* covers the one page this package created; a popup — `window.open`, a `target=_blank` link — is
|
|
205
|
+
* a new target it never sees, and on Chrome 150 a scraped page's `window.open` to an off-list host
|
|
206
|
+
* reached that host. Browser-level `Fetch` pauses those requests too, and the same
|
|
207
|
+
* `interceptVerdict` decides them. The page's own requests are decided at the page first, so an
|
|
208
|
+
* allowed one is continued here as well and a refused one never arrives: one ring entry each.
|
|
209
|
+
*
|
|
210
|
+
* Enabled BEFORE the first navigation; a browser that refuses `Fetch.enable` rejects here, and
|
|
211
|
+
* `driver-cdp.ts`'s `opened()` closes it — a session whose rule would hold on one tab is refused.
|
|
212
|
+
*/
|
|
213
|
+
export async function armBrowser(
|
|
214
|
+
init: {
|
|
215
|
+
readonly browser: CdpBrowserLike;
|
|
216
|
+
readonly rules: InterceptRules;
|
|
217
|
+
readonly clock: ScrapeClock;
|
|
218
|
+
},
|
|
219
|
+
network: NetworkRing,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const session = await init.browser.target().createCDPSession();
|
|
222
|
+
session.on('Fetch.requestPaused', (payload) => {
|
|
223
|
+
const paused = asPaused(payload);
|
|
224
|
+
if (paused === undefined) return;
|
|
225
|
+
const type = asResourceType(paused.resourceType.toLowerCase());
|
|
226
|
+
const verdict = interceptVerdict(paused.url, type, init.rules);
|
|
227
|
+
const requestId = { requestId: paused.requestId };
|
|
228
|
+
// Caught, never floated, for `arm`'s reason: the target may be gone by the time this answers.
|
|
229
|
+
if (verdict === 'allow') {
|
|
230
|
+
session.send('Fetch.continueRequest', requestId).catch(() => undefined);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const at = init.clock.now().getTime();
|
|
234
|
+
network.push(refusalEntry(paused.url, type, verdict, at, paused.method));
|
|
235
|
+
session
|
|
236
|
+
.send('Fetch.failRequest', { ...requestId, errorReason: 'BlockedByClient' })
|
|
237
|
+
.catch(() => undefined);
|
|
238
|
+
});
|
|
239
|
+
await session.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
|
|
240
|
+
}
|