@ultimat3/scraping 22.15.0 → 23.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 +184 -12
- package/package.json +6 -5
- package/src/artifacts.ts +3 -2
- package/src/auth.ts +66 -8
- package/src/cdp-port.ts +10 -0
- package/src/cdp-resolver.ts +81 -0
- package/src/clock.ts +34 -0
- package/src/driver-cdp.ts +164 -18
- package/src/driver.ts +25 -1
- package/src/error-throws-session.ts +74 -0
- package/src/error-throws.ts +35 -2
- package/src/errors.ts +10 -0
- package/src/event-prompt.ts +133 -0
- package/src/events.ts +6 -0
- package/src/http-recorded.ts +4 -0
- package/src/http.ts +9 -1
- package/src/index.ts +40 -4
- package/src/offline-session.ts +6 -0
- package/src/page-over-target.ts +4 -0
- package/src/scrape-run.ts +82 -11
- package/src/scrape.ts +73 -6
- package/src/secret-scan.ts +17 -0
- package/src/secrets.ts +29 -4
- package/src/session-state.ts +73 -10
- package/src/url-secrets.ts +78 -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 |
|
|
@@ -199,14 +360,17 @@ on a machine with no browser — the case CI is.
|
|
|
199
360
|
| `page.ts` / `page-over-target.ts` | the driver-blind vocabulary, implemented once |
|
|
200
361
|
| `target.ts` / `driver.ts` | the two seams: what a driver answers, and what a session is |
|
|
201
362
|
| `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
|
|
202
|
-
| `driver-fake.ts` / `driver-
|
|
363
|
+
| `driver-fake.ts` / `driver-recorded.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
|
|
203
364
|
| `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
|
-
| `
|
|
365
|
+
| `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 |
|
|
366
|
+
| `event-prompt.ts` | `eventPrompt()` and `answerPrompt()`: a prompt answered from another process, over the job event bus, with the browser open |
|
|
367
|
+
| `cdp-resolver.ts` | a browser rented per session: acquired once, released once |
|
|
368
|
+
| `usage.ts` | what one run used, counted where the work happens |
|
|
369
|
+
| `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
370
|
| `expect.ts` | the silent-green alarm |
|
|
207
371
|
| `watchdog.ts` | the wedge and zombie discipline |
|
|
208
372
|
| `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 |
|
|
373
|
+
| `errors.ts` / `error-throws.ts` / `error-throws-session.ts` | this package's `X_*` codes and their retry classification |
|
|
210
374
|
|
|
211
375
|
## Extending it — there is no plugin API, and none is needed
|
|
212
376
|
|
|
@@ -247,6 +411,7 @@ export const myBrowser = (options: MyOptions): ScrapeDriver => ({
|
|
|
247
411
|
secrets: init.secrets,
|
|
248
412
|
robots: init.robots,
|
|
249
413
|
signal: init.signal,
|
|
414
|
+
usage: init.usage,
|
|
250
415
|
}),
|
|
251
416
|
http: myHttp(target, init),
|
|
252
417
|
close: () => target.close(),
|
|
@@ -260,10 +425,8 @@ export const myBrowser = (options: MyOptions): ScrapeDriver => ({
|
|
|
260
425
|
```ts
|
|
261
426
|
// apps/web/shared/base/bank-scrape.ts — the app's convention, written once
|
|
262
427
|
import type { JobHandle } from '@ultimat3/jobs';
|
|
263
|
-
import type { StorageDriver } from '@ultimat3/storage';
|
|
264
428
|
import { scrape, type ScrapeDefinition } from '@ultimat3/scraping';
|
|
265
|
-
|
|
266
|
-
declare const disk: StorageDriver;
|
|
429
|
+
import { disk } from '@ultimat3/storage';
|
|
267
430
|
|
|
268
431
|
export const bankScrape = <I, R>(over: ScrapeDefinition<I, R>): JobHandle<I> =>
|
|
269
432
|
scrape<I, R>({
|
|
@@ -271,7 +434,7 @@ export const bankScrape = <I, R>(over: ScrapeDefinition<I, R>): JobHandle<I> =>
|
|
|
271
434
|
rate: 0.5,
|
|
272
435
|
block: ['image', 'media', 'font'],
|
|
273
436
|
expect: { minRows: 1, maxDrop: 0.5 },
|
|
274
|
-
artifacts: { storage: disk },
|
|
437
|
+
artifacts: { storage: () => disk('artifacts') },
|
|
275
438
|
...over,
|
|
276
439
|
});
|
|
277
440
|
```
|
|
@@ -298,6 +461,15 @@ Every code carries a cause, a runnable `fix:` and a retry classification — see
|
|
|
298
461
|
`X_SCRAPE_YIELD_COLLAPSED`, `X_SCRAPE_AUTH_FAILED`, `X_SCRAPE_BLOCKED` and `X_SCRAPE_PAGE_CRASHED`
|
|
299
462
|
are the four worth knowing by heart.
|
|
300
463
|
|
|
464
|
+
`X_SCRAPE_EGRESS_IN_PAYLOAD` is the one that is about the DECLARATION rather than the site: the
|
|
465
|
+
exit's password was readable out of the run's own input, so it is in `x_jobs` in the clear.
|
|
466
|
+
Enqueue the id of the row that holds the exit, resolve it in `egress`, and rotate the credential.
|
|
467
|
+
|
|
468
|
+
`X_SCRAPE_CDP_ATTACH_FAILED` names the endpoint by **scheme and host only**, for a fixed `cdpUrl` as
|
|
469
|
+
for a resolved one, and cuts every credential-bearing part of the URL out of the launcher's own
|
|
470
|
+
message: a provider's connect URL is its access token, and this cause is written to the dead-letter
|
|
471
|
+
row.
|
|
472
|
+
|
|
301
473
|
### Error classes
|
|
302
474
|
|
|
303
475
|
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": "23.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": "23.0.0",
|
|
35
|
+
"@ultimat3/jobs": "23.0.0",
|
|
36
|
+
"@ultimat3/schema": "23.0.0",
|
|
37
|
+
"@ultimat3/storage": "23.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
|
@@ -25,21 +25,42 @@ import type { ScrapePage } from './page';
|
|
|
25
25
|
import type { ScrapeSecrets } from './secrets';
|
|
26
26
|
import { type ScrapeSessionStore, type SessionState, sessionDigest } from './session-state';
|
|
27
27
|
|
|
28
|
-
export interface PromptRequest {
|
|
28
|
+
export interface PromptRequest<I = unknown> {
|
|
29
|
+
/**
|
|
30
|
+
* The run's own input — what ties this prompt to the app's identity for the run (a connection
|
|
31
|
+
* id, an org). A handler typed on the definition reads it typed; a shared one reads `unknown`.
|
|
32
|
+
*/
|
|
33
|
+
readonly input: I;
|
|
29
34
|
/** What the site is asking for: `'sms code'`, `'authenticator code'`, `'security question 3'`. */
|
|
30
35
|
readonly label: string;
|
|
31
36
|
readonly scrape: string;
|
|
32
37
|
readonly url: string;
|
|
38
|
+
/** The job run asking. With `index` it names this one prompt: `promptEventName(runId, index)`. */
|
|
39
|
+
readonly runId: string;
|
|
40
|
+
/** 1 for the first prompt of this attempt, 2 for the next. */
|
|
41
|
+
readonly index: number;
|
|
42
|
+
/** The run's clock. A handler that waits, waits on this — never on a timer of its own. */
|
|
43
|
+
readonly clock: ScrapeClock;
|
|
44
|
+
/** The run's cancellation. A handler that waits past it holds a browser nobody is using. */
|
|
45
|
+
readonly signal: AbortSignal | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* One cheap round trip to the browser. A handler that waits calls it between polls: the site is
|
|
48
|
+
* waiting INSIDE this session, so the wedge watchdog must keep seeing activity and a rented
|
|
49
|
+
* browser must not idle out — and a browser that died is found now, not after the answer.
|
|
50
|
+
*/
|
|
51
|
+
keepAlive(): Promise<void>;
|
|
33
52
|
}
|
|
34
53
|
|
|
35
54
|
/**
|
|
36
55
|
* Where an out-of-band code comes from. A declared callback field on the definition — typed,
|
|
37
56
|
* discoverable, one per concern — and never a global "register a plugin" call.
|
|
38
57
|
*/
|
|
39
|
-
export type PromptHandler = (request: PromptRequest) => Promise<string> | string;
|
|
58
|
+
export type PromptHandler<I = unknown> = (request: PromptRequest<I>) => Promise<string> | string;
|
|
40
59
|
|
|
41
60
|
export interface AuthContext<I> {
|
|
42
61
|
readonly input: I;
|
|
62
|
+
/** The job run logging in — the id the run's events, steps and prompts are keyed by. */
|
|
63
|
+
readonly runId: string;
|
|
43
64
|
readonly page: ScrapePage;
|
|
44
65
|
/**
|
|
45
66
|
* The declared secrets, boxed. A login body needs the credential it is about to type, and
|
|
@@ -78,15 +99,50 @@ export interface ScrapeAuth<I> {
|
|
|
78
99
|
readonly maxAge?: number | undefined;
|
|
79
100
|
}
|
|
80
101
|
|
|
81
|
-
export
|
|
82
|
-
scrape: string
|
|
83
|
-
handler: PromptHandler | undefined
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
export interface PromptInit<I = unknown> {
|
|
103
|
+
readonly scrape: string;
|
|
104
|
+
readonly handler: PromptHandler<I> | undefined;
|
|
105
|
+
readonly input: I;
|
|
106
|
+
readonly page: ScrapePage;
|
|
107
|
+
readonly runId: string;
|
|
108
|
+
readonly clock: ScrapeClock;
|
|
109
|
+
readonly signal?: AbortSignal | undefined;
|
|
110
|
+
/** The run's secret set. Every answer is concealed in it before the body sees the answer. */
|
|
111
|
+
readonly secrets?: ScrapeSecrets | undefined;
|
|
112
|
+
/** Called once per answered prompt — `ScrapeReport.usage.promptsAnswered`. */
|
|
113
|
+
readonly onAnswered?: (() => void) | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Matches no element on any page, which is the point: the read costs one round trip and carries
|
|
118
|
+
* nothing back. It goes through the page vocabulary, so it reports activity like any other verb.
|
|
119
|
+
*/
|
|
120
|
+
const KEEPALIVE_SELECTOR = 'ultimate-keepalive';
|
|
121
|
+
|
|
122
|
+
export function createPrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
|
|
123
|
+
let asked = 0;
|
|
86
124
|
return async (label: string): Promise<string> => {
|
|
125
|
+
const { scrape, handler, page } = init;
|
|
87
126
|
if (handler === undefined) throw promptUnanswered(scrape, label);
|
|
88
|
-
|
|
127
|
+
asked += 1;
|
|
128
|
+
const answer = await handler({
|
|
129
|
+
input: init.input,
|
|
130
|
+
label,
|
|
131
|
+
scrape,
|
|
132
|
+
url: page.url(),
|
|
133
|
+
runId: init.runId,
|
|
134
|
+
index: asked,
|
|
135
|
+
clock: init.clock,
|
|
136
|
+
signal: init.signal,
|
|
137
|
+
keepAlive: async (): Promise<void> => {
|
|
138
|
+
await page.count(KEEPALIVE_SELECTOR);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
89
141
|
if (typeof answer !== 'string' || answer === '') throw promptUnanswered(scrape, label);
|
|
142
|
+
// Before the body can type it: an OTP echoed into the page it was typed on is in `page.html()`,
|
|
143
|
+
// and the failure artifact is written from exactly that.
|
|
144
|
+
init.secrets?.conceal(answer);
|
|
145
|
+
init.onAnswered?.();
|
|
90
146
|
return answer;
|
|
91
147
|
};
|
|
92
148
|
}
|
|
@@ -163,6 +219,7 @@ export async function burnSession<I>(plan: AuthPlanInput<I>): Promise<void> {
|
|
|
163
219
|
|
|
164
220
|
export interface EnsureAuthInput<I> extends AuthPlanInput<I> {
|
|
165
221
|
readonly input: I;
|
|
222
|
+
readonly runId: string;
|
|
166
223
|
readonly page: ScrapePage;
|
|
167
224
|
readonly secrets: ScrapeSecrets;
|
|
168
225
|
readonly restored: SessionState | undefined;
|
|
@@ -182,6 +239,7 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
|
|
|
182
239
|
if (auth === undefined) return false;
|
|
183
240
|
const context: AuthContext<I> = {
|
|
184
241
|
input: args.input,
|
|
242
|
+
runId: args.runId,
|
|
185
243
|
page: args.page,
|
|
186
244
|
secrets: args.secrets,
|
|
187
245
|
prompt: args.prompt,
|
package/src/cdp-port.ts
CHANGED
|
@@ -143,6 +143,16 @@ export interface CdpPageLike {
|
|
|
143
143
|
* reason, and refused by name when absent.
|
|
144
144
|
*/
|
|
145
145
|
createCDPSession?(): Promise<CdpSessionLike>;
|
|
146
|
+
/**
|
|
147
|
+
* `page.authenticate(credentials)`: answers the PROXY's own auth challenge. It is the only way a
|
|
148
|
+
* launched browser dials a credentialed exit — `--proxy-server` takes no userinfo, and one
|
|
149
|
+
* written there would sit in the process list. OPTIONAL for `keyboard`'s reason; a credentialed
|
|
150
|
+
* exit on a launcher without it is `X_SCRAPE_EGRESS_UNSUPPORTED`, never a direct connection.
|
|
151
|
+
*/
|
|
152
|
+
authenticate?(credentials: {
|
|
153
|
+
readonly username: string;
|
|
154
|
+
readonly password: string;
|
|
155
|
+
}): Promise<void>;
|
|
146
156
|
frames(): readonly CdpFrameLike[];
|
|
147
157
|
close(): Promise<void>;
|
|
148
158
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A browser RENTED per session: `remoteBrowser({ cdpUrl })` takes a function instead of a string,
|
|
2
|
+
// and what the function hands back is acquired once and released exactly once. The framework
|
|
3
|
+
// ships no vendor — a provider wrapper is about twenty lines of app code that returns this shape.
|
|
4
|
+
|
|
5
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { MoneyValue } from '@ultimat3/schema';
|
|
7
|
+
import { parse, t } from '@ultimat3/schema';
|
|
8
|
+
import { cdpResolveFailed } from './error-throws';
|
|
9
|
+
|
|
10
|
+
export interface CdpResolverRequest {
|
|
11
|
+
/** The scrape's name. */
|
|
12
|
+
readonly scrape: string;
|
|
13
|
+
/** The job run asking — what a provider session is tagged with. Absent outside a job. */
|
|
14
|
+
readonly runId: string | undefined;
|
|
15
|
+
/** The exit this run dials, when it declared one: rent the browser ON it. May carry credentials. */
|
|
16
|
+
readonly egress: string | undefined;
|
|
17
|
+
/** The run's cancellation. A rental still in flight when it aborts is the resolver's to cancel. */
|
|
18
|
+
readonly signal: AbortSignal | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CdpResolution {
|
|
22
|
+
/** The connect URL. Usually carries the provider's access token: it is redacted, never printed. */
|
|
23
|
+
readonly cdpUrl: string;
|
|
24
|
+
/**
|
|
25
|
+
* Hand the browser back. Runs ONCE — in the session's `close()` on success, failure and abort,
|
|
26
|
+
* and straight away when the attach itself fails. A rejection is swallowed: it runs on the way
|
|
27
|
+
* out of a run whose own outcome is the one the reader needs.
|
|
28
|
+
*/
|
|
29
|
+
release?(): Promise<void>;
|
|
30
|
+
/** What the rental cost, when the provider says so up front. `Money`, never a float. */
|
|
31
|
+
readonly cost?: MoneyValue | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CdpResolver = (request: CdpResolverRequest) => Promise<CdpResolution>;
|
|
35
|
+
|
|
36
|
+
export interface AcquiredCdp {
|
|
37
|
+
readonly cdpUrl: string;
|
|
38
|
+
readonly cost: MoneyValue | undefined;
|
|
39
|
+
/** The resolution's `release`, latched: the second call does nothing. Never throws. */
|
|
40
|
+
release(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A fixed URL and a resolver, as one shape — so the driver has one attach path, not two. */
|
|
44
|
+
export async function acquireCdp(
|
|
45
|
+
source: string | CdpResolver,
|
|
46
|
+
request: CdpResolverRequest,
|
|
47
|
+
): Promise<AcquiredCdp> {
|
|
48
|
+
if (typeof source === 'string') {
|
|
49
|
+
return { cdpUrl: source, cost: undefined, release: () => Promise.resolve() };
|
|
50
|
+
}
|
|
51
|
+
let resolution: CdpResolution;
|
|
52
|
+
try {
|
|
53
|
+
resolution = await source(request);
|
|
54
|
+
} catch (thrown) {
|
|
55
|
+
// The resolver's own coded failure keeps its code and its classification; anything else is
|
|
56
|
+
// "no browser to attach to", which is this package's retryable attach failure.
|
|
57
|
+
throw isUltimateError(thrown) ? thrown : cdpResolveFailed(request.scrape, thrown);
|
|
58
|
+
}
|
|
59
|
+
let released = false;
|
|
60
|
+
const release = async (): Promise<void> => {
|
|
61
|
+
if (released) return;
|
|
62
|
+
released = true;
|
|
63
|
+
try {
|
|
64
|
+
await resolution.release?.();
|
|
65
|
+
} catch {
|
|
66
|
+
// See `CdpResolution.release`: a failed hand-back never replaces the run's own outcome.
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
return {
|
|
71
|
+
cdpUrl: resolution.cdpUrl,
|
|
72
|
+
// Parsed, not trusted: the type says `minor: number` and `0.35` satisfies it. A float cost
|
|
73
|
+
// is refused here, before it is added to anything.
|
|
74
|
+
cost: resolution.cost === undefined ? undefined : parse(t.money, resolution.cost),
|
|
75
|
+
release,
|
|
76
|
+
};
|
|
77
|
+
} catch (thrown) {
|
|
78
|
+
await release();
|
|
79
|
+
throw thrown;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/clock.ts
CHANGED
|
@@ -48,6 +48,40 @@ export const systemScrapeClock: ScrapeClock = Object.freeze({
|
|
|
48
48
|
},
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The system clock with the waiting taken out: `now()` and `monotonic()` are real, and every
|
|
53
|
+
* `sleep` is ONE turn of the event loop whatever was asked for. For a test whose run waits on
|
|
54
|
+
* something that really happens in the same process — an answer the test publishes, a job another
|
|
55
|
+
* worker holds — where `testClock()`'s "sleeping is advancing" would run a five-minute budget out
|
|
56
|
+
* in microtasks before the test's next line. A deadline is still a real one.
|
|
57
|
+
*/
|
|
58
|
+
export const noWaitClock: ScrapeClock = Object.freeze({
|
|
59
|
+
now: () => systemScrapeClock.now(),
|
|
60
|
+
monotonic: () => systemScrapeClock.monotonic(),
|
|
61
|
+
sleep: (_ms: number, signal?: AbortSignal) => systemScrapeClock.sleep(0, signal),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let ambient: ScrapeClock | undefined;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The clock every run in this process waits on, unless its definition pins one — the counterpart
|
|
68
|
+
* of `setScrapeDriver()`, and the seam a TEST takes: `setScrapeClock(noWaitClock)` in a
|
|
69
|
+
* `beforeEach`, and a declaration under test needs no `clock:` field written for the test's sake.
|
|
70
|
+
*/
|
|
71
|
+
export function setScrapeClock(clock: ScrapeClock): void {
|
|
72
|
+
ambient = clock;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The process's scrape clock: the installed one, or the system's. */
|
|
76
|
+
export function scrapeClock(): ScrapeClock {
|
|
77
|
+
return ambient ?? systemScrapeClock;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Test seam: back to the system clock, so the next file runs on real time again. */
|
|
81
|
+
export function resetScrapeClock(): void {
|
|
82
|
+
ambient = undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
/** The abort reason, unwrapped — a caller's `AbortSignal.reason` is whatever they put there. */
|
|
52
86
|
export function throwIfAborted(signal: AbortSignal): void {
|
|
53
87
|
if (signal.aborted) throw signal.reason;
|