@ultimat3/scraping 24.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/package.json +6 -6
- package/src/artifacts.ts +2 -4
- package/src/auth.ts +3 -3
- package/src/cdp-snapshot.ts +11 -3
- package/src/cdp-target.ts +12 -10
- package/src/clock.ts +4 -9
- package/src/error-throws-session.ts +1 -1
- package/src/error-throws.ts +1 -1
- package/src/event-prompt.ts +3 -3
- package/src/html-target.ts +4 -4
- package/src/index.ts +19 -16
- package/src/intercept.ts +2 -2
- package/src/page-over-target.ts +14 -6
- package/src/rate.ts +1 -1
- package/src/recover.ts +1 -1
- package/src/rings.ts +3 -3
- package/src/robots-fetch.ts +32 -16
- package/src/robots.ts +50 -12
- package/src/scrape-run.ts +15 -15
- package/src/scrape.ts +1 -2
- package/src/secrets.ts +1 -1
- package/src/target.ts +14 -2
- package/src/usage.ts +1 -1
- package/src/cdp-fake-target.ts +0 -67
- package/src/cdp-fake.ts +0 -428
- package/src/hosts.ts +0 -4
package/README.md
CHANGED
|
@@ -84,6 +84,15 @@ Both legs replay from **one** fixture directory (`fixtureBrowser(dir)`), so a hy
|
|
|
84
84
|
login, session handoff, HTTP bulk fetch — is tested end to end. Both legs apply the same robots
|
|
85
85
|
gate, offline included.
|
|
86
86
|
|
|
87
|
+
The robots gate follows RFC 9309 §2.3.1: a `4xx` robots.txt is **unavailable** (no rules); a `5xx`,
|
|
88
|
+
a network error or the deadline is **unreachable**, which refuses every path on the origin
|
|
89
|
+
(`X_SCRAPE_ROBOTS_DISALLOWED`) and is not cached. A `429` is a `4xx` the RFC calls unavailable; this
|
|
90
|
+
gate treats it as unreachable anyway — our stricter policy, since a server that is rate-limiting
|
|
91
|
+
the crawler has not said "no rules". A hand-written `fetchText` answers the text,
|
|
92
|
+
`undefined` (no file) or `{ unreachable: '<why>' }` — `RobotsAnswer`. An offline scrape declares
|
|
93
|
+
`robots: { ignore: 'offline fixture' }`: under the sealed test network its robots read is
|
|
94
|
+
unreachable.
|
|
95
|
+
|
|
87
96
|
## A session a service is built on
|
|
88
97
|
|
|
89
98
|
One connection, one run at a time, on its own exit, in a browser rented for that run — with a
|
|
@@ -210,7 +219,7 @@ export const answer = (input: { runId: string; index: number; answer: string })
|
|
|
210
219
|
| `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
220
|
| `run({ progress, finalAttempt })` | the job's own, passed through |
|
|
212
221
|
| `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 (`
|
|
222
|
+
| the bus | the **stored** one (`postgresEventBus`) wherever the worker and the answering process differ. Every `x` boot installs it |
|
|
214
223
|
|
|
215
224
|
`PromptRequest` carries the run's `input` and `runId` — what ties a prompt to the app's own row
|
|
216
225
|
for the run — and `index`, `clock`, `signal` and `keepAlive()` beside `label`, `scrape` and `url`:
|
|
@@ -240,7 +249,7 @@ resetScrapeClock(); // in the afterEach
|
|
|
240
249
|
|---|---|---|
|
|
241
250
|
| `systemScrapeClock` | waits | production |
|
|
242
251
|
| `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
|
-
| `
|
|
252
|
+
| `testScrapeClock()` | advances virtual time and yields a microtask | a test of a timeout: thirty seconds pass in none |
|
|
244
253
|
|
|
245
254
|
A third-party driver takes part by reading three `SessionInit` fields: dial `proxy` or throw
|
|
246
255
|
`egressUnsupported()`, pass `usage` to `pageOverTarget` and its HTTP transport, and answer
|
|
@@ -261,6 +270,14 @@ the port's. A caller that had to compute its own wrote
|
|
|
261
270
|
`display !== 'none' && visibility !== 'hidden' && opacity !== '0'` a second time, which is the
|
|
262
271
|
copy axiom 1 forbids.
|
|
263
272
|
|
|
273
|
+
**An act scrolls its element into view; a read does not.** The `actionable` wait (every `click`,
|
|
274
|
+
`type`, `fill`, `select`, `focus`, and `waitFor` at its default state) calls
|
|
275
|
+
`ScrapeTarget.query(selector, { reveal: true })`, which scrolls the first match to the viewport's
|
|
276
|
+
centre before measuring — `elementFromPoint` answers nothing off-screen, so an element below the
|
|
277
|
+
fold read as covered. `text()`, `count()`, `values()` and `query()` never move the page. A target
|
|
278
|
+
with no layout engine ignores `QueryOptions` (`cdp-target-reveal.test.ts`,
|
|
279
|
+
`cdp-snapshot.e2e.test.ts`).
|
|
280
|
+
|
|
264
281
|
**A frame verb reaches the FRAME.** `fill`, `type`, `select`, `clear`, `click` and `query` through
|
|
265
282
|
a `ScrapeFrame` handle all address that frame's own document — never the parent's, even when the
|
|
266
283
|
two carry the same ids, which is what an iframe'd SSO login looks like. `driver-parity-frames.test.ts`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "Browser automation as a job: scrape() returns a JobHandle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,16 +24,16 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"engines": {
|
|
27
|
-
"bun": ">=1.4.
|
|
27
|
+
"bun": ">=1.4.2"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/jobs": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/storage": "
|
|
34
|
+
"@ultimat3/core": "25.0.0",
|
|
35
|
+
"@ultimat3/jobs": "25.0.0",
|
|
36
|
+
"@ultimat3/schema": "25.0.0",
|
|
37
|
+
"@ultimat3/storage": "25.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/artifacts.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Written through `@ultimat3/storage`'s driver, so the same call lands on a local disk in
|
|
6
6
|
// development and on S3 in production, and this package owns no upload path of its own.
|
|
7
7
|
|
|
8
|
-
import type
|
|
8
|
+
import { DEFAULT_CONTENT_TYPE, type StorageDriver } from '@ultimat3/storage';
|
|
9
9
|
|
|
10
10
|
export interface ArtifactRef {
|
|
11
11
|
readonly key: string;
|
|
@@ -31,8 +31,6 @@ export interface ArtifactWriterInit {
|
|
|
31
31
|
|
|
32
32
|
export const DEFAULT_ARTIFACT_PREFIX = 'scrape';
|
|
33
33
|
|
|
34
|
-
export const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
|
35
|
-
|
|
36
34
|
/**
|
|
37
35
|
* A `Map`, not an object literal — the same choice `failures.ts` makes, for the same reason. The
|
|
38
36
|
* extension comes off a caller-supplied filename, and on a download that filename came off the
|
|
@@ -57,7 +55,7 @@ export const contentTypeFor = (name: string): string =>
|
|
|
57
55
|
* that has not configured storage should still be able to run a scrape; losing the artifact is a
|
|
58
56
|
* cost, and refusing the run over it is a bigger one.
|
|
59
57
|
*/
|
|
60
|
-
export function
|
|
58
|
+
export function artifactWriter(init: ArtifactWriterInit): ArtifactWriter {
|
|
61
59
|
const saved: ArtifactRef[] = [];
|
|
62
60
|
const prefix = `${init.prefix ?? DEFAULT_ARTIFACT_PREFIX}/${init.scrape}/${init.runId}`;
|
|
63
61
|
return {
|
package/src/auth.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import type { Logger } from '@ultimat3/core';
|
|
21
21
|
import { finiteCount } from '@ultimat3/core';
|
|
22
22
|
import type { ScrapeClock } from './clock';
|
|
23
|
-
import { authFailed, promptUnanswered,
|
|
23
|
+
import { authFailed, promptUnanswered, scrapeSessionExpired } from './error-throws';
|
|
24
24
|
import type { ScrapePage } from './page';
|
|
25
25
|
import type { ScrapeSecrets } from './secrets';
|
|
26
26
|
import {
|
|
@@ -124,7 +124,7 @@ export interface PromptInit<I = unknown> {
|
|
|
124
124
|
*/
|
|
125
125
|
const KEEPALIVE_SELECTOR = 'ultimate-keepalive';
|
|
126
126
|
|
|
127
|
-
export function
|
|
127
|
+
export function scrapePrompt<I>(init: PromptInit<I>): (label: string) => Promise<string> {
|
|
128
128
|
let asked = 0;
|
|
129
129
|
return async (label: string): Promise<string> => {
|
|
130
130
|
const { scrape, handler, page } = init;
|
|
@@ -316,7 +316,7 @@ export async function ensureAuthenticated<I>(args: EnsureAuthInput<I>): Promise<
|
|
|
316
316
|
args.logger.info('scrape.session.expired', { reused: false });
|
|
317
317
|
await burnSession(args, recordVersion(args.restored));
|
|
318
318
|
}
|
|
319
|
-
if (auth.login === undefined) throw
|
|
319
|
+
if (auth.login === undefined) throw scrapeSessionExpired(args.scrape, args.key);
|
|
320
320
|
await auth.login(context);
|
|
321
321
|
return true;
|
|
322
322
|
}
|
package/src/cdp-snapshot.ts
CHANGED
|
@@ -8,16 +8,24 @@
|
|
|
8
8
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
9
|
import { parse, t } from '@ultimat3/schema';
|
|
10
10
|
import { browserRecord } from './browser-record';
|
|
11
|
-
import type { ElementSnapshot } from './target';
|
|
11
|
+
import type { ElementSnapshot, QueryOptions } from './target';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Returns JSON TEXT, not an object: a CDP round trip serialises the result anyway, and a string
|
|
15
15
|
* has one deserialiser here — `parse` against the schema below — instead of an implicit one in
|
|
16
16
|
* the library plus a cast.
|
|
17
|
+
*
|
|
18
|
+
* `reveal` scrolls BEFORE the loop, so every box below is measured where the page now is — the
|
|
19
|
+
* centre a click must land on, and the point `elementFromPoint` is asked about (`QueryOptions`).
|
|
17
20
|
*/
|
|
18
|
-
export const snapshotExpression = (
|
|
21
|
+
export const snapshotExpression = (
|
|
22
|
+
selector: string,
|
|
23
|
+
options: QueryOptions = {},
|
|
24
|
+
): string => `(() => {
|
|
19
25
|
const out = [];
|
|
20
|
-
|
|
26
|
+
const matches = document.querySelectorAll(${JSON.stringify(selector)});
|
|
27
|
+
${options.reveal === true ? "matches[0]?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });" : ''}
|
|
28
|
+
for (const el of matches) {
|
|
21
29
|
const style = getComputedStyle(el);
|
|
22
30
|
const rect = el.getBoundingClientRect();
|
|
23
31
|
const attrs = {};
|
package/src/cdp-target.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
|
|
|
19
19
|
import { browserUnreachable, pageCrashed } from './error-throws';
|
|
20
20
|
import { parseKeyChord } from './key-chord';
|
|
21
21
|
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
22
|
-
import {
|
|
22
|
+
import { boundedRing } from './rings';
|
|
23
23
|
import type { SessionSnapshot } from './session-state';
|
|
24
24
|
import type {
|
|
25
25
|
AxNode,
|
|
@@ -94,9 +94,9 @@ export interface CdpTargetInit extends CdpArmInit {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
97
|
-
const console_ =
|
|
98
|
-
const network =
|
|
99
|
-
const pageErrors =
|
|
97
|
+
const console_ = boundedRing<ConsoleLine>();
|
|
98
|
+
const network = boundedRing<NetworkEntry>();
|
|
99
|
+
const pageErrors = boundedRing<PageError>();
|
|
100
100
|
const crashed: { value: string | undefined } = { value: undefined };
|
|
101
101
|
await arm(init, { network, console: console_, pageErrors, crashed });
|
|
102
102
|
await armBrowser(init, network);
|
|
@@ -125,8 +125,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
125
125
|
if (pending === undefined) return;
|
|
126
126
|
if (originOf(init.page.url()) !== pending.origin || pending.origin === '') return;
|
|
127
127
|
pendingStorage = undefined;
|
|
128
|
+
// PAIRS, never an object literal: `{"__proto__": "…"}` in the page sets the literal's
|
|
129
|
+
// prototype and files no key, so a token stored under that legal name was never restored.
|
|
128
130
|
await init.page.evaluate(
|
|
129
|
-
`(() => { const
|
|
131
|
+
`(() => { for (const [key, value] of ${JSON.stringify(Object.entries(pending.storage))}) localStorage.setItem(key, value); })()`,
|
|
130
132
|
);
|
|
131
133
|
};
|
|
132
134
|
|
|
@@ -149,9 +151,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
149
151
|
...parent,
|
|
150
152
|
url: () => frame.url(),
|
|
151
153
|
content: () => guard('content', () => frame.content()),
|
|
152
|
-
query: (selector) =>
|
|
154
|
+
query: (selector, options) =>
|
|
153
155
|
guard('query', async () =>
|
|
154
|
-
parseSnapshots(await frame.evaluate(snapshotExpression(selector))),
|
|
156
|
+
parseSnapshots(await frame.evaluate(snapshotExpression(selector, options))),
|
|
155
157
|
),
|
|
156
158
|
click: (selector) => guard('click', () => frame.click(selector)),
|
|
157
159
|
type: (selector, text) => guard('type', () => frame.type(selector, text)),
|
|
@@ -206,9 +208,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
206
208
|
await applyPendingStorage();
|
|
207
209
|
}),
|
|
208
210
|
content: () => guard('content', () => init.page.content()),
|
|
209
|
-
query: (selector) =>
|
|
211
|
+
query: (selector, options) =>
|
|
210
212
|
guard('query', async () =>
|
|
211
|
-
parseSnapshots(await init.page.evaluate(snapshotExpression(selector))),
|
|
213
|
+
parseSnapshots(await init.page.evaluate(snapshotExpression(selector, options))),
|
|
212
214
|
),
|
|
213
215
|
click: (selector) => guard('click', () => init.page.click(selector)),
|
|
214
216
|
type: (selector, text) => guard('type', () => init.page.type(selector, text)),
|
|
@@ -347,7 +349,7 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
347
349
|
}
|
|
348
350
|
return parse(cookieSchema, await source.cookies());
|
|
349
351
|
}),
|
|
350
|
-
// Honest stub
|
|
352
|
+
// Honest stub: correct types and one labelled `X_NOT_IMPLEMENTED`. A real one needs
|
|
351
353
|
// `Browser.setDownloadBehavior` over a raw CDP session plus a directory watch, and a
|
|
352
354
|
// half-written version that silently returned empty bytes is worse than this line.
|
|
353
355
|
//
|
package/src/clock.ts
CHANGED
|
@@ -28,7 +28,7 @@ export const systemScrapeClock: ScrapeClock = Object.freeze({
|
|
|
28
28
|
await Bun.sleep(ms);
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
-
throwIfAborted(
|
|
31
|
+
signal.throwIfAborted();
|
|
32
32
|
// A timer raced against the signal, never `Bun.sleep(ms).then(check)`: the watchdog aborts a
|
|
33
33
|
// wedged run precisely so nothing waits out the rest of a five-minute budget, and a sleep
|
|
34
34
|
// that only notices the abort when it expires would wait out every one of them.
|
|
@@ -52,7 +52,7 @@ export const systemScrapeClock: ScrapeClock = Object.freeze({
|
|
|
52
52
|
* The system clock with the waiting taken out: `now()` and `monotonic()` are real, and every
|
|
53
53
|
* `sleep` is ONE turn of the event loop whatever was asked for. For a test whose run waits on
|
|
54
54
|
* something that really happens in the same process — an answer the test publishes, a job another
|
|
55
|
-
* worker holds — where `
|
|
55
|
+
* worker holds — where `testScrapeClock()`'s "sleeping is advancing" would run a five-minute budget out
|
|
56
56
|
* in microtasks before the test's next line. A deadline is still a real one.
|
|
57
57
|
*/
|
|
58
58
|
export const noWaitClock: ScrapeClock = Object.freeze({
|
|
@@ -82,11 +82,6 @@ export function resetScrapeClock(): void {
|
|
|
82
82
|
ambient = undefined;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** The abort reason, unwrapped — a caller's `AbortSignal.reason` is whatever they put there. */
|
|
86
|
-
export function throwIfAborted(signal: AbortSignal): void {
|
|
87
|
-
if (signal.aborted) throw signal.reason;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
85
|
/**
|
|
91
86
|
* A clock in which sleeping IS advancing: `await clock.sleep(30_000)` returns on the next
|
|
92
87
|
* microtask with thirty seconds elapsed. This is what makes a poll-until-deadline test instant
|
|
@@ -97,7 +92,7 @@ export interface TestScrapeClock extends ScrapeClock {
|
|
|
97
92
|
advance(ms: number): void;
|
|
98
93
|
}
|
|
99
94
|
|
|
100
|
-
export function
|
|
95
|
+
export function testScrapeClock(at: Date | number = 0): TestScrapeClock {
|
|
101
96
|
let epochMs = at instanceof Date ? at.getTime() : at;
|
|
102
97
|
let mono = 0;
|
|
103
98
|
const advance = (ms: number): void => {
|
|
@@ -113,7 +108,7 @@ export function testClock(at: Date | number = 0): TestScrapeClock {
|
|
|
113
108
|
// Yield the microtask queue anyway: a loop that never awaits anything real starves whatever
|
|
114
109
|
// the test armed to cancel it, and the wedge tests arm exactly that.
|
|
115
110
|
await Promise.resolve();
|
|
116
|
-
|
|
111
|
+
signal?.throwIfAborted();
|
|
117
112
|
},
|
|
118
113
|
};
|
|
119
114
|
}
|
|
@@ -51,7 +51,7 @@ export const promptTimedOut = (input: {
|
|
|
51
51
|
new ScrapeError({
|
|
52
52
|
code: 'X_SCRAPE_PROMPT_UNANSWERED',
|
|
53
53
|
cause: `scrape "${input.scrape}" asked for "${input.label}" and no answer was published to ${input.event} within ${String(input.waitedMs)}ms`,
|
|
54
|
-
fix: 'call answerPrompt({ runId, index, answer }) with the run id and index the event name in the cause ends with, from a process on the same STORED event bus — setEventBus(
|
|
54
|
+
fix: 'call answerPrompt({ runId, index, answer }) with the run id and index the event name in the cause ends with, from a process on the same STORED event bus — setEventBus(postgresEventBus({ executor })) — or raise eventPrompt({ timeout })',
|
|
55
55
|
meta: {
|
|
56
56
|
scrape: input.scrape,
|
|
57
57
|
label: input.label,
|
package/src/error-throws.ts
CHANGED
|
@@ -401,7 +401,7 @@ export const authFailed = (scrape: string, detail: string): ScrapeError =>
|
|
|
401
401
|
meta: { scrape },
|
|
402
402
|
});
|
|
403
403
|
|
|
404
|
-
export const
|
|
404
|
+
export const scrapeSessionExpired = (scrape: string, key: string): ScrapeError =>
|
|
405
405
|
new ScrapeError({
|
|
406
406
|
code: 'X_SCRAPE_SESSION_EXPIRED',
|
|
407
407
|
cause: `the stored session ${key} for scrape "${scrape}" failed its validate() probe and the definition declares no auth.login`,
|
package/src/event-prompt.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { finiteCount, isLocal, isSealed, openText, seal } from '@ultimat3/core';
|
|
|
15
15
|
import type { EventBus, JobEvent } from '@ultimat3/jobs';
|
|
16
16
|
import { DriverUnavailableError, eventBus } from '@ultimat3/jobs';
|
|
17
17
|
import type { PromptHandler, PromptRequest } from './auth';
|
|
18
|
-
import { deadline
|
|
18
|
+
import { deadline } from './clock';
|
|
19
19
|
import { promptAnswerInvalid, promptTimedOut } from './error-throws-session';
|
|
20
20
|
|
|
21
21
|
/** How often the bus is asked. One indexed read and one browser round trip per interval. */
|
|
@@ -73,7 +73,7 @@ export function eventPrompt(options: EventPromptOptions): PromptHandler {
|
|
|
73
73
|
driver: 'event bus',
|
|
74
74
|
cause:
|
|
75
75
|
'eventPrompt() was asked on the in-memory event bus — the answer is published by another process and can never reach this one',
|
|
76
|
-
fix: 'call setEventBus(
|
|
76
|
+
fix: 'call setEventBus(postgresEventBus({ executor })) at boot — x dev and the served roles do — or pass bus: postgresEventBus({ executor }) to eventPrompt()',
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
79
|
const event = promptEventName(request.runId, request.index);
|
|
@@ -83,7 +83,7 @@ export function eventPrompt(options: EventPromptOptions): PromptHandler {
|
|
|
83
83
|
const askedAt = await bus.now();
|
|
84
84
|
const budget = deadline(request.clock, timeoutMs, 'eventPrompt');
|
|
85
85
|
for (;;) {
|
|
86
|
-
|
|
86
|
+
request.signal?.throwIfAborted();
|
|
87
87
|
const found = await bus.find(event, undefined, askedAt);
|
|
88
88
|
if (found !== undefined) {
|
|
89
89
|
if (!isSealed(found.payload)) {
|
package/src/html-target.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { parseKeyChord } from './key-chord';
|
|
|
18
18
|
import type { PageRecording } from './recording';
|
|
19
19
|
import { splitDownload } from './recording';
|
|
20
20
|
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
21
|
-
import {
|
|
21
|
+
import { boundedRing } from './rings';
|
|
22
22
|
import type { SessionSnapshot } from './session-state';
|
|
23
23
|
import { EMPTY_SESSION } from './session-state';
|
|
24
24
|
import type {
|
|
@@ -119,14 +119,14 @@ interface OfflineDocument {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
|
|
122
|
-
const consoleRing =
|
|
123
|
-
const networkRing =
|
|
122
|
+
const consoleRing = boundedRing<ConsoleLine>();
|
|
123
|
+
const networkRing = boundedRing<NetworkEntry>();
|
|
124
124
|
// Built and never pushed to, deliberately: this target parses markup and executes none of it, so
|
|
125
125
|
// there is no uncaught exception for it to have. It ANSWERS rather than omitting the ring —
|
|
126
126
|
// `page.pageErrors()` returning `[]` here is the honest "nothing threw, and nothing could",
|
|
127
127
|
// where a missing ring would be a page method that throws on two of the three drivers.
|
|
128
128
|
// `driver-parity.test.ts` pins the divergence, beside the box/hit-target one it already carries.
|
|
129
|
-
const pageErrorRing =
|
|
129
|
+
const pageErrorRing = boundedRing<PageError>();
|
|
130
130
|
const overlay = new Map<string, string>();
|
|
131
131
|
/**
|
|
132
132
|
* One overlay per FRAME, held here rather than in the frame target, because `frames()` builds a
|
package/src/index.ts
CHANGED
|
@@ -5,10 +5,7 @@
|
|
|
5
5
|
export type { ActionabilityState, ActionabilityWait } from './actionability';
|
|
6
6
|
export { awaitActionable } from './actionability';
|
|
7
7
|
export type { ArtifactRef, ArtifactWriter, ArtifactWriterInit } from './artifacts';
|
|
8
|
-
export {
|
|
9
|
-
createArtifactWriter,
|
|
10
|
-
DEFAULT_CONTENT_TYPE,
|
|
11
|
-
} from './artifacts';
|
|
8
|
+
export { artifactWriter } from './artifacts';
|
|
12
9
|
export type {
|
|
13
10
|
AuthContext,
|
|
14
11
|
PromptHandler,
|
|
@@ -16,7 +13,7 @@ export type {
|
|
|
16
13
|
PromptRequest,
|
|
17
14
|
ScrapeAuth,
|
|
18
15
|
} from './auth';
|
|
19
|
-
export { burnSession,
|
|
16
|
+
export { burnSession, ensureAuthenticated, restorableSession, scrapePrompt } from './auth';
|
|
20
17
|
export { browserRecord } from './browser-record';
|
|
21
18
|
export type { CaptureClip, CaptureFraming } from './capture-clip';
|
|
22
19
|
export { assertCaptureFraming } from './capture-clip';
|
|
@@ -44,8 +41,7 @@ export {
|
|
|
44
41
|
scrapeClock,
|
|
45
42
|
setScrapeClock,
|
|
46
43
|
systemScrapeClock,
|
|
47
|
-
|
|
48
|
-
throwIfAborted,
|
|
44
|
+
testScrapeClock,
|
|
49
45
|
} from './clock';
|
|
50
46
|
export type { ColorScheme } from './color-scheme';
|
|
51
47
|
export { COLOR_SCHEMES, isColorScheme } from './color-scheme';
|
|
@@ -81,10 +77,10 @@ export {
|
|
|
81
77
|
redirectLoop,
|
|
82
78
|
remoteRequired,
|
|
83
79
|
robotsDisallowed,
|
|
80
|
+
scrapeSessionExpired,
|
|
84
81
|
scrapeTimeout,
|
|
85
82
|
secretExposed,
|
|
86
83
|
selectorMissing,
|
|
87
|
-
sessionExpired,
|
|
88
84
|
wedged,
|
|
89
85
|
yieldCollapsed,
|
|
90
86
|
} from './error-throws';
|
|
@@ -122,8 +118,6 @@ export {
|
|
|
122
118
|
memoryYieldHistory,
|
|
123
119
|
} from './expect';
|
|
124
120
|
export { BURNS_SESSION, errorCode, NEVER_RETRIED } from './failures';
|
|
125
|
-
export type { HostDecision, HostRule } from './hosts';
|
|
126
|
-
export { ANY_HOST, hostDecision, hostMatches } from './hosts';
|
|
127
121
|
export { queryHtml } from './html-query';
|
|
128
122
|
export type { MarkupRequest } from './html-requests';
|
|
129
123
|
export type { HtmlTargetInit, RecordingLookup } from './html-target';
|
|
@@ -152,7 +146,7 @@ export type {
|
|
|
152
146
|
export type { PageContext } from './page-over-target';
|
|
153
147
|
export { pageOverTarget } from './page-over-target';
|
|
154
148
|
export type { Pacer } from './rate';
|
|
155
|
-
export {
|
|
149
|
+
export { scrapePacer } from './rate';
|
|
156
150
|
export type { HttpRecording, PageRecording } from './recording';
|
|
157
151
|
export {
|
|
158
152
|
httpRecordingSchema,
|
|
@@ -173,13 +167,21 @@ export type {
|
|
|
173
167
|
Ring,
|
|
174
168
|
} from './rings';
|
|
175
169
|
export {
|
|
176
|
-
|
|
170
|
+
boundedRing,
|
|
177
171
|
MAX_PAGE_ERROR_CHARS,
|
|
178
172
|
pageErrorEntry,
|
|
179
173
|
RESOURCE_TYPES,
|
|
180
174
|
} from './rings';
|
|
181
|
-
export type {
|
|
182
|
-
|
|
175
|
+
export type {
|
|
176
|
+
RobotsAnswer,
|
|
177
|
+
RobotsFetch,
|
|
178
|
+
RobotsGate,
|
|
179
|
+
RobotsGateInit,
|
|
180
|
+
RobotsPolicy,
|
|
181
|
+
RobotsRules,
|
|
182
|
+
RobotsUnreachable,
|
|
183
|
+
} from './robots';
|
|
184
|
+
export { parseRobots, robotsAllows, robotsGate } from './robots';
|
|
183
185
|
export type { RobotsFetchInit } from './robots-fetch';
|
|
184
186
|
export {
|
|
185
187
|
DEFAULT_ROBOTS_MAX_BYTES,
|
|
@@ -197,13 +199,13 @@ export { scrape } from './scrape';
|
|
|
197
199
|
export { DEFAULT_PAGE_TIMEOUT_MS, runScrape } from './scrape-run';
|
|
198
200
|
export type { ScrapeSecrets, SecretResolver } from './secrets';
|
|
199
201
|
export {
|
|
200
|
-
createSecretBag,
|
|
201
202
|
MIN_REDACTABLE_LENGTH,
|
|
202
203
|
redactSecrets,
|
|
203
204
|
safeConsole,
|
|
204
205
|
safeHtml,
|
|
205
206
|
safeNetwork,
|
|
206
207
|
safePageErrors,
|
|
208
|
+
secretBag,
|
|
207
209
|
} from './secrets';
|
|
208
210
|
export type {
|
|
209
211
|
ScrapeSessionStore,
|
|
@@ -227,12 +229,13 @@ export type {
|
|
|
227
229
|
ElementSnapshot,
|
|
228
230
|
FrameRef,
|
|
229
231
|
GotoOptions,
|
|
232
|
+
QueryOptions,
|
|
230
233
|
ScrapeCookie,
|
|
231
234
|
ScrapeDownloadFile,
|
|
232
235
|
ScrapeTarget,
|
|
233
236
|
} from './target';
|
|
234
237
|
export { endpointLabel, urlSecretValues } from './url-secrets';
|
|
235
238
|
export type { RunUsageMeter, ScrapeUsage, UsageMeter } from './usage';
|
|
236
|
-
export {
|
|
239
|
+
export { usageMeter } from './usage';
|
|
237
240
|
export type { WedgeGuard, WedgeGuardInit } from './watchdog';
|
|
238
241
|
export { DEFAULT_GRACE_MS } from './watchdog';
|
package/src/intercept.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// differently — `driver-parity.test.ts` asserts the same refusal on all three, and a rule
|
|
6
6
|
// re-implemented per driver is a rule that holds only on the driver nobody ships.
|
|
7
7
|
|
|
8
|
-
import type { HostRule } from '
|
|
9
|
-
import { hostDecision } from '
|
|
8
|
+
import type { HostRule } from '@ultimat3/core';
|
|
9
|
+
import { hostDecision } from '@ultimat3/core';
|
|
10
10
|
import type { NetworkEntry, ResourceType } from './rings';
|
|
11
11
|
|
|
12
12
|
export interface InterceptRules {
|
package/src/page-over-target.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// driver a test uses and the driver production uses.
|
|
5
5
|
|
|
6
6
|
import type { Secret } from '@ultimat3/core';
|
|
7
|
-
import { finiteCount, isSecret, revealSecret } from '@ultimat3/core';
|
|
7
|
+
import { finiteCount, hostDecision, isSecret, revealSecret } from '@ultimat3/core';
|
|
8
8
|
import type { ActionabilityState } from './actionability';
|
|
9
9
|
import { awaitActionable } from './actionability';
|
|
10
10
|
import { assertCaptureFraming } from './capture-clip';
|
|
@@ -12,7 +12,6 @@ import type { ScrapeClock } from './clock';
|
|
|
12
12
|
import { deadline } from './clock';
|
|
13
13
|
import type { ColorScheme } from './color-scheme';
|
|
14
14
|
import { hostBlocked, secretExposed, selectorMissing } from './error-throws';
|
|
15
|
-
import { hostDecision } from './hosts';
|
|
16
15
|
import type {
|
|
17
16
|
AccessibilityOptions,
|
|
18
17
|
CaptureRequest,
|
|
@@ -30,6 +29,7 @@ import type {
|
|
|
30
29
|
AxNode,
|
|
31
30
|
CaptureOptions,
|
|
32
31
|
ElementSnapshot,
|
|
32
|
+
QueryOptions,
|
|
33
33
|
ScrapeCookie,
|
|
34
34
|
ScrapeDownloadFile,
|
|
35
35
|
ScrapeTarget,
|
|
@@ -80,9 +80,13 @@ const toValue = (snapshot: ElementSnapshot): ElementValue => ({
|
|
|
80
80
|
attrs: snapshot.attrs,
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
async function first(
|
|
83
|
+
async function first(
|
|
84
|
+
resolve: Resolve,
|
|
85
|
+
selector: string,
|
|
86
|
+
options?: QueryOptions,
|
|
87
|
+
): Promise<ElementSnapshot | undefined> {
|
|
84
88
|
const target = await resolve();
|
|
85
|
-
return (await target.query(selector))[0];
|
|
89
|
+
return (await target.query(selector, options))[0];
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
function frameOver(
|
|
@@ -103,14 +107,18 @@ function frameOver(
|
|
|
103
107
|
): Promise<ElementSnapshot> => {
|
|
104
108
|
const target = await resolve();
|
|
105
109
|
lastUrl = target.url();
|
|
110
|
+
const state = options?.state ?? fallback;
|
|
111
|
+
// Only `actionable` reads `hitTarget`, so only it pays for moving the page — on every poll,
|
|
112
|
+
// because a layout shift between two can carry the element back out of view.
|
|
113
|
+
const query: QueryOptions = { reveal: state === 'actionable' };
|
|
106
114
|
return awaitActionable({
|
|
107
115
|
selector,
|
|
108
116
|
url: lastUrl,
|
|
109
|
-
state
|
|
117
|
+
state,
|
|
110
118
|
timeoutMs: timeoutFor(options),
|
|
111
119
|
clock: ctx.clock,
|
|
112
120
|
signal: ctx.signal,
|
|
113
|
-
snapshot: () => first(resolve, selector),
|
|
121
|
+
snapshot: () => first(resolve, selector, query),
|
|
114
122
|
});
|
|
115
123
|
};
|
|
116
124
|
|
package/src/rate.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const DEFAULT_NAVIGATION_RATE = 1;
|
|
|
9
9
|
|
|
10
10
|
export type Pacer = (signal?: AbortSignal) => Promise<void>;
|
|
11
11
|
|
|
12
|
-
export function
|
|
12
|
+
export function scrapePacer(rate: number, clock: ScrapeClock): Pacer {
|
|
13
13
|
const intervalMs = 1_000 / rate;
|
|
14
14
|
let nextAt = 0;
|
|
15
15
|
return async (signal?: AbortSignal): Promise<void> => {
|
package/src/recover.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Two shapes. A FUNCTION is complete and shipped — an app writes its own fallback, gets the page
|
|
4
4
|
// and the failure, and answers whether the attempt should be retried. `'agent'` is the seam for
|
|
5
5
|
// letting a model re-derive the selector from the page, and it is an honest
|
|
6
|
-
// `X_NOT_IMPLEMENTED` today
|
|
6
|
+
// `X_NOT_IMPLEMENTED` today: correct types
|
|
7
7
|
// so an app can be written against it, one labelled throw, no silent no-op.
|
|
8
8
|
|
|
9
9
|
import { NotImplementedError } from '@ultimat3/core';
|
package/src/rings.ts
CHANGED
|
@@ -107,18 +107,18 @@ export type PageErrorRing = Ring<PageError>;
|
|
|
107
107
|
*
|
|
108
108
|
* Both wrong values fail in opposite directions and neither is survivable. A NEGATIVE capacity
|
|
109
109
|
* makes `while (items.length > capacity) items.shift()` spin forever on an already-empty array
|
|
110
|
-
* (`0 > -1`): `
|
|
110
|
+
* (`0 > -1`): `boundedRing(-1).push(1)` never returns, and it is a synchronous loop on the worker's
|
|
111
111
|
* only thread, inside a `page.on('request')` handler — past `ctx.signal`, past the wedge watchdog
|
|
112
112
|
* and past the job timeout, which is incident #1 in this file's header. `NaN` fails the other way:
|
|
113
113
|
* the comparison is false forever, the ring is UNBOUNDED, and the OOM the bound exists to prevent
|
|
114
114
|
* arrives two hours in. Zero is a ring that discards everything silently, and a fraction is a
|
|
115
115
|
* bound that is not the number anybody asked for.
|
|
116
116
|
*/
|
|
117
|
-
export function
|
|
117
|
+
export function boundedRing<T>(capacity: number = DEFAULT_RING_CAPACITY): Ring<T> {
|
|
118
118
|
assert(
|
|
119
119
|
Number.isSafeInteger(capacity) && capacity > 0,
|
|
120
120
|
`a ring capacity must be a positive integer, got ${String(capacity)}: a negative one spins forever on push() and NaN makes the ring unbounded`,
|
|
121
|
-
`pass a positive capacity —
|
|
121
|
+
`pass a positive capacity — boundedRing(${String(DEFAULT_RING_CAPACITY)}) — or omit it and take DEFAULT_RING_CAPACITY`,
|
|
122
122
|
);
|
|
123
123
|
const items: T[] = [];
|
|
124
124
|
let dropped = 0;
|