@ultimat3/scraping 23.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 +23 -5
- package/package.json +6 -6
- package/src/artifacts.ts +2 -4
- package/src/auth.ts +93 -17
- package/src/cdp-arm.ts +65 -1
- package/src/cdp-port.ts +21 -0
- package/src/cdp-snapshot.ts +11 -3
- package/src/cdp-target.ts +82 -58
- package/src/clock.ts +4 -9
- package/src/driver-cdp.ts +12 -3
- package/src/driver.ts +1 -1
- package/src/error-throws-session.ts +38 -1
- package/src/error-throws.ts +6 -16
- package/src/errors.ts +8 -2
- package/src/event-prompt.ts +3 -3
- package/src/expect.ts +21 -4
- package/src/html-target.ts +14 -19
- package/src/http-recorded.ts +3 -1
- package/src/http.ts +47 -10
- package/src/index.ts +22 -17
- package/src/intercept.ts +2 -2
- package/src/launch-args.ts +51 -0
- package/src/page-over-target.ts +14 -6
- package/src/pinned-host.ts +65 -0
- package/src/rate.ts +1 -1
- package/src/recover.ts +4 -3
- package/src/rings.ts +3 -3
- package/src/robots-fetch.ts +105 -21
- package/src/robots.ts +50 -12
- package/src/scrape-run.ts +42 -25
- package/src/scrape.ts +5 -4
- package/src/secrets.ts +61 -9
- package/src/session-state.ts +11 -0
- package/src/target.ts +14 -2
- package/src/url-secrets.ts +68 -7
- package/src/usage.ts +1 -1
- package/src/cdp-fake.ts +0 -420
- package/src/hosts.ts +0 -4
package/src/cdp-target.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// `ScrapeTarget` over a real browser, through the structural CDP port. Everything driver-specific
|
|
2
2
|
// in this package lives here and in `driver-cdp.ts`; the vocabulary above it does not change.
|
|
3
3
|
|
|
4
|
-
import { isUltimateError } from '@ultimat3/core';
|
|
4
|
+
import { isUltimateError, NotImplementedError } from '@ultimat3/core';
|
|
5
5
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
6
6
|
import { parse, t } from '@ultimat3/schema';
|
|
7
7
|
import { browserRecord } from './browser-record';
|
|
8
8
|
import { axNodesFor } from './cdp-a11y';
|
|
9
9
|
import type { CdpArmInit } from './cdp-arm';
|
|
10
|
-
import { arm } from './cdp-arm';
|
|
10
|
+
import { arm, armBrowser } from './cdp-arm';
|
|
11
11
|
import type { CdpBrowserLike, CdpFrameLike } from './cdp-port';
|
|
12
12
|
import {
|
|
13
13
|
clearExpression,
|
|
@@ -16,10 +16,10 @@ import {
|
|
|
16
16
|
snapshotExpression,
|
|
17
17
|
} from './cdp-snapshot';
|
|
18
18
|
import { type ColorScheme, colorSchemeFeatures } from './color-scheme';
|
|
19
|
-
import { browserUnreachable, pageCrashed
|
|
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,
|
|
@@ -54,7 +54,7 @@ const cookieSchema = t.array(
|
|
|
54
54
|
*
|
|
55
55
|
* | Code | Why a retry cannot change it |
|
|
56
56
|
* |---|---|
|
|
57
|
-
* | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's `
|
|
57
|
+
* | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's own `NotImplementedError` refusals can, and the method is still missing on attempt five |
|
|
58
58
|
* | `X_VALIDATION_FAILED` | the browser ANSWERED, and the answer did not match the shape this driver reads it with. The page is what it is; attempt five reads the same DOM |
|
|
59
59
|
*
|
|
60
60
|
* Both were re-labelled `X_SCRAPE_BROWSER_UNREACHABLE`, which `errors.ts` registers `retryable`,
|
|
@@ -94,11 +94,12 @@ 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
|
+
await armBrowser(init, network);
|
|
102
103
|
let pendingStorage: SessionSnapshot | undefined;
|
|
103
104
|
|
|
104
105
|
const originOf = (url: string): string => {
|
|
@@ -124,8 +125,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
124
125
|
if (pending === undefined) return;
|
|
125
126
|
if (originOf(init.page.url()) !== pending.origin || pending.origin === '') return;
|
|
126
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.
|
|
127
130
|
await init.page.evaluate(
|
|
128
|
-
`(() => { const
|
|
131
|
+
`(() => { for (const [key, value] of ${JSON.stringify(Object.entries(pending.storage))}) localStorage.setItem(key, value); })()`,
|
|
129
132
|
);
|
|
130
133
|
};
|
|
131
134
|
|
|
@@ -148,9 +151,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
148
151
|
...parent,
|
|
149
152
|
url: () => frame.url(),
|
|
150
153
|
content: () => guard('content', () => frame.content()),
|
|
151
|
-
query: (selector) =>
|
|
154
|
+
query: (selector, options) =>
|
|
152
155
|
guard('query', async () =>
|
|
153
|
-
parseSnapshots(await frame.evaluate(snapshotExpression(selector))),
|
|
156
|
+
parseSnapshots(await frame.evaluate(snapshotExpression(selector, options))),
|
|
154
157
|
),
|
|
155
158
|
click: (selector) => guard('click', () => frame.click(selector)),
|
|
156
159
|
type: (selector, text) => guard('type', () => frame.type(selector, text)),
|
|
@@ -183,10 +186,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
183
186
|
// rather than answered from the PARENT document, which is what the spread alone would do.
|
|
184
187
|
accessibility: (_selector, _max): Promise<readonly AxNode[]> =>
|
|
185
188
|
Promise.reject(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
new NotImplementedError({
|
|
190
|
+
cause:
|
|
191
|
+
'accessibility() on a frame of the puppeteer driver is not implemented by this driver',
|
|
192
|
+
fix: 'read the accessibility tree through the page — page.accessibility(selector) — which covers the top-level document only',
|
|
193
|
+
}),
|
|
190
194
|
),
|
|
191
195
|
});
|
|
192
196
|
|
|
@@ -204,9 +208,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
204
208
|
await applyPendingStorage();
|
|
205
209
|
}),
|
|
206
210
|
content: () => guard('content', () => init.page.content()),
|
|
207
|
-
query: (selector) =>
|
|
211
|
+
query: (selector, options) =>
|
|
208
212
|
guard('query', async () =>
|
|
209
|
-
parseSnapshots(await init.page.evaluate(snapshotExpression(selector))),
|
|
213
|
+
parseSnapshots(await init.page.evaluate(snapshotExpression(selector, options))),
|
|
210
214
|
),
|
|
211
215
|
click: (selector) => guard('click', () => init.page.click(selector)),
|
|
212
216
|
type: (selector, text) => guard('type', () => init.page.type(selector, text)),
|
|
@@ -228,10 +232,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
228
232
|
await guard('press', async () => {
|
|
229
233
|
const keyboard = init.page.keyboard;
|
|
230
234
|
if (keyboard === undefined) {
|
|
231
|
-
throw
|
|
232
|
-
'press() on a CDP page with no keyboard',
|
|
233
|
-
'upgrade the launcher to a puppeteer-core that exposes page.keyboard, or dispatch the key from the app under test instead of the browser',
|
|
234
|
-
);
|
|
235
|
+
throw new NotImplementedError({
|
|
236
|
+
cause: 'press() on a CDP page with no keyboard is not implemented by this driver',
|
|
237
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.keyboard, or dispatch the key from the app under test instead of the browser',
|
|
238
|
+
});
|
|
235
239
|
}
|
|
236
240
|
// Down in the order written, the key, then up in REVERSE — the order a hand releases
|
|
237
241
|
// them, and the order the browser's own `press` with modifiers performs. `.call`-free:
|
|
@@ -248,10 +252,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
248
252
|
guard('focus', async () => {
|
|
249
253
|
const focus = init.page.focus;
|
|
250
254
|
if (typeof focus !== 'function') {
|
|
251
|
-
throw
|
|
252
|
-
'focus() on a CDP page with no focus() method',
|
|
253
|
-
'upgrade the launcher to a puppeteer-core that exposes page.focus(), or click the element instead',
|
|
254
|
-
);
|
|
255
|
+
throw new NotImplementedError({
|
|
256
|
+
cause: 'focus() on a CDP page with no focus() method is not implemented by this driver',
|
|
257
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.focus(), or click the element instead',
|
|
258
|
+
});
|
|
255
259
|
}
|
|
256
260
|
// `.call`, for `setColorScheme`'s reason: the member is read off the object.
|
|
257
261
|
await focus.call(init.page, selector);
|
|
@@ -260,10 +264,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
260
264
|
guard('accessibility', async () => {
|
|
261
265
|
const createSession = init.page.createCDPSession;
|
|
262
266
|
if (typeof createSession !== 'function') {
|
|
263
|
-
throw
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
throw new NotImplementedError({
|
|
268
|
+
cause:
|
|
269
|
+
'accessibility() on a CDP page with no createCDPSession() method is not implemented by this driver',
|
|
270
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.createCDPSession(), or assert on the markup with page.query() — which reads attributes, not what the browser computed',
|
|
271
|
+
});
|
|
267
272
|
}
|
|
268
273
|
const session = await createSession.call(init.page);
|
|
269
274
|
try {
|
|
@@ -276,10 +281,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
276
281
|
guard('setOfflineMode', async () => {
|
|
277
282
|
const source = init.page as { setOfflineMode?: (value: boolean) => Promise<void> };
|
|
278
283
|
if (typeof source.setOfflineMode !== 'function') {
|
|
279
|
-
throw
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
284
|
+
throw new NotImplementedError({
|
|
285
|
+
cause:
|
|
286
|
+
'setOfflineMode() on a CDP page with no setOfflineMode() method is not implemented by this driver',
|
|
287
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.setOfflineMode(), or drive the condition from the app under test instead of the browser',
|
|
288
|
+
});
|
|
283
289
|
}
|
|
284
290
|
await source.setOfflineMode(enabled);
|
|
285
291
|
}),
|
|
@@ -291,10 +297,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
291
297
|
// older spelling.
|
|
292
298
|
const emulate = init.page.emulateMediaFeatures;
|
|
293
299
|
if (typeof emulate !== 'function') {
|
|
294
|
-
throw
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
throw new NotImplementedError({
|
|
301
|
+
cause:
|
|
302
|
+
'setColorScheme() on a CDP page with no emulateMediaFeatures() method is not implemented by this driver',
|
|
303
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.emulateMediaFeatures(), or have the page under test set its own theme',
|
|
304
|
+
});
|
|
298
305
|
}
|
|
299
306
|
// `.call`, because the member is read off the object: an unbound method loses `this` and
|
|
300
307
|
// puppeteer's own `Page` needs it.
|
|
@@ -305,10 +312,11 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
305
312
|
// Read off the object and called through it, for `setColorScheme`'s reason.
|
|
306
313
|
const evaluateOnNewDocument = init.page.evaluateOnNewDocument;
|
|
307
314
|
if (typeof evaluateOnNewDocument !== 'function') {
|
|
308
|
-
throw
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
315
|
+
throw new NotImplementedError({
|
|
316
|
+
cause:
|
|
317
|
+
'prepare() on a CDP page with no evaluateOnNewDocument() method is not implemented by this driver',
|
|
318
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes page.evaluateOnNewDocument(), or seed the state from the app under test instead of the browser',
|
|
319
|
+
});
|
|
312
320
|
}
|
|
313
321
|
await evaluateOnNewDocument.call(init.page, expression);
|
|
314
322
|
}),
|
|
@@ -333,14 +341,15 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
333
341
|
guard('cookies', async () => {
|
|
334
342
|
const source = init.browser as { cookies?: () => Promise<unknown> };
|
|
335
343
|
if (typeof source.cookies !== 'function') {
|
|
336
|
-
throw
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
344
|
+
throw new NotImplementedError({
|
|
345
|
+
cause:
|
|
346
|
+
'cookies() on a CDP browser with no cookies() method is not implemented by this driver',
|
|
347
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes browser.cookies(), or read them with page.evaluate("document.cookie")',
|
|
348
|
+
});
|
|
340
349
|
}
|
|
341
350
|
return parse(cookieSchema, await source.cookies());
|
|
342
351
|
}),
|
|
343
|
-
// Honest stub
|
|
352
|
+
// Honest stub: correct types and one labelled `X_NOT_IMPLEMENTED`. A real one needs
|
|
344
353
|
// `Browser.setDownloadBehavior` over a raw CDP session plus a directory watch, and a
|
|
345
354
|
// half-written version that silently returned empty bytes is worse than this line.
|
|
346
355
|
//
|
|
@@ -349,10 +358,10 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
349
358
|
// `try` — a synchronous `throw` jumps over the first of those entirely.
|
|
350
359
|
download: (_options): Promise<ScrapeDownloadFile> =>
|
|
351
360
|
Promise.reject(
|
|
352
|
-
|
|
353
|
-
'download() on the puppeteer driver',
|
|
354
|
-
'fetch the file inside the page — page.evaluate("fetch(url).then(r => r.text())") — or run this scrape on fixtureBrowser(), whose download() is complete',
|
|
355
|
-
),
|
|
361
|
+
new NotImplementedError({
|
|
362
|
+
cause: 'download() on the puppeteer driver is not implemented by this driver',
|
|
363
|
+
fix: 'fetch the file inside the page — page.evaluate("fetch(url).then(r => r.text())") — or run this scrape on fixtureBrowser(), whose download() is complete',
|
|
364
|
+
}),
|
|
356
365
|
),
|
|
357
366
|
frames: () =>
|
|
358
367
|
guard('frames', () =>
|
|
@@ -378,10 +387,19 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
378
387
|
} catch {
|
|
379
388
|
origin = '';
|
|
380
389
|
}
|
|
381
|
-
|
|
390
|
+
// Refused, never `[]`: the HTTP leg reads its cookies from here on every request and
|
|
391
|
+
// `persistSession` stores what this answers, so an empty jar went out logged-out and was
|
|
392
|
+
// saved as the session — both silently, while every other missing port method refuses.
|
|
393
|
+
if (typeof init.browser.cookies !== 'function') {
|
|
394
|
+
throw new NotImplementedError({
|
|
395
|
+
cause:
|
|
396
|
+
'session() on a CDP browser with no cookies() method is not implemented by this driver',
|
|
397
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes browser.cookies() — the HTTP leg and a persisted session both read the jar through it',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
const jar = parse(cookieSchema, await init.browser.cookies());
|
|
382
401
|
return {
|
|
383
|
-
cookies:
|
|
384
|
-
typeof source.cookies === 'function' ? parse(cookieSchema, await source.cookies()) : [],
|
|
402
|
+
cookies: jar,
|
|
385
403
|
headers: {},
|
|
386
404
|
// `browserRecord` and not `t.record()`: a page may legitimately hold a key called
|
|
387
405
|
// `constructor`, and the schema refuses that name outright — see `browser-record.ts`.
|
|
@@ -394,11 +412,17 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
394
412
|
guard('restore', async () => {
|
|
395
413
|
// Two halves, because they belong to two different moments: cookies are the browser's and
|
|
396
414
|
// can be put back now, storage is an ORIGIN's and cannot exist until one is loaded.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
415
|
+
if (session.cookies.length > 0) {
|
|
416
|
+
// Refused rather than skipped: a restore that dropped the cookies opened the session
|
|
417
|
+
// logged-out while the run believed it restored one.
|
|
418
|
+
if (typeof init.browser.setCookie !== 'function') {
|
|
419
|
+
throw new NotImplementedError({
|
|
420
|
+
cause:
|
|
421
|
+
'restore() of cookies on a CDP browser with no setCookie() method is not implemented by this driver',
|
|
422
|
+
fix: 'upgrade the launcher to a puppeteer-core that exposes browser.setCookie(), or set auth: { reuse: false } on the scrape() definition so no session is restored',
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
await init.browser.setCookie(...session.cookies);
|
|
402
426
|
}
|
|
403
427
|
pendingStorage = Object.keys(session.storage).length > 0 ? session : undefined;
|
|
404
428
|
await applyPendingStorage();
|
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
|
}
|
package/src/driver-cdp.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { isScrapeError } from './errors';
|
|
|
23
23
|
import type { ScrapeEventFields } from './events';
|
|
24
24
|
import { errorCode } from './failures';
|
|
25
25
|
import { httpOverFetch } from './http';
|
|
26
|
+
import { launchArgs } from './launch-args';
|
|
26
27
|
import { pageOverTarget } from './page-over-target';
|
|
27
28
|
import type { ScrapeTarget } from './target';
|
|
28
29
|
import { endpointLabel, hasCredentials, splitCredentials, urlSecretValues } from './url-secrets';
|
|
@@ -38,7 +39,10 @@ export interface BrowserOptions {
|
|
|
38
39
|
* `egress(input)` — `SessionInit.proxy` — wins over it, per session.
|
|
39
40
|
*/
|
|
40
41
|
readonly proxy?: string | undefined;
|
|
41
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Extra launch/connect options, passed through — except `args`, which `localBrowser()` puts
|
|
44
|
+
* BEFORE the exit's `--proxy-server` and screens for a second route (`launch-args.ts`).
|
|
45
|
+
*/
|
|
42
46
|
readonly options?: Record<string, unknown> | undefined;
|
|
43
47
|
/** How long a graceful `close()` may take before the process is killed. */
|
|
44
48
|
readonly graceMs?: number | undefined;
|
|
@@ -267,6 +271,11 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
|
|
|
267
271
|
// The userinfo never reaches the argument list: Chrome ignores it there, and an argument is
|
|
268
272
|
// readable by every process on the box. `opened()` answers the challenge instead.
|
|
269
273
|
const proxyArg = proxy !== undefined && authenticate ? splitCredentials(proxy).bare : proxy;
|
|
274
|
+
// The caller's args FIRST and the exit appended after them, never a spread that lets one
|
|
275
|
+
// replace the other: `{ args: ['--no-sandbox'] }` — every container deploy's — used to
|
|
276
|
+
// overwrite the proxy flag, and the browser dialled direct while the session reported the exit.
|
|
277
|
+
const { args: callerArgs, ...passthrough } = options.options ?? {};
|
|
278
|
+
const args = launchArgs(callerArgs, proxyArg);
|
|
270
279
|
let browser: CdpBrowserLike;
|
|
271
280
|
try {
|
|
272
281
|
browser = await launch.call(options.launcher, {
|
|
@@ -275,8 +284,8 @@ export function localBrowser(options: LocalBrowserOptions): ScrapeDriver {
|
|
|
275
284
|
? {}
|
|
276
285
|
: { executablePath: options.executablePath }),
|
|
277
286
|
...(options.profileDir === undefined ? {} : { userDataDir: options.profileDir }),
|
|
278
|
-
...
|
|
279
|
-
...
|
|
287
|
+
...passthrough,
|
|
288
|
+
...(args === undefined ? {} : { args }),
|
|
280
289
|
});
|
|
281
290
|
} catch (thrown) {
|
|
282
291
|
throw browserUnreachable(CDP_DRIVER, thrown);
|
package/src/driver.ts
CHANGED
|
@@ -102,7 +102,7 @@ export interface ScrapeDriver {
|
|
|
102
102
|
|
|
103
103
|
let ambient: ScrapeDriver | undefined;
|
|
104
104
|
|
|
105
|
-
/** Set once at boot
|
|
105
|
+
/** Set once by the app at boot — no config key names it. A scrape's own `driver:` overrides it. */
|
|
106
106
|
export function setScrapeDriver(driver: ScrapeDriver): void {
|
|
107
107
|
ambient = driver;
|
|
108
108
|
}
|
|
@@ -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,
|
|
@@ -72,3 +72,40 @@ export const promptAnswerInvalid = (input: {
|
|
|
72
72
|
fix: 'publish the answer with answerPrompt({ runId, index, answer }) and never publishEvent() by hand — the answer is sealed for this one prompt, so a plain payload is refused rather than typed into the site',
|
|
73
73
|
meta: { scrape: input.scrape, label: input.label, event: input.event },
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
/** Lists every `localBrowser(` call site and every proxy switch in the app — where the edit goes. */
|
|
77
|
+
export const LAUNCH_ARGS_FIX =
|
|
78
|
+
"grep -rnE --include='*.ts' -e 'localBrowser\\(' -e '--(no-)?proxy-' .";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The caller's launch args set the exit themselves, or are not a list a launcher can take. The
|
|
82
|
+
* switch NAME only, never its value: `--proxy-server=http://user:pass@…` is a credential a caller
|
|
83
|
+
* may well have written, and this cause is written to the dead-letter row.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
export const launchArgsInvalid = (reason: string, route: boolean): ScrapeError =>
|
|
87
|
+
new ScrapeError({
|
|
88
|
+
code: 'X_SCRAPE_LAUNCH_ARGS_INVALID',
|
|
89
|
+
cause: `localBrowser({ options: { args } }) ${reason} — ${
|
|
90
|
+
route
|
|
91
|
+
? 'delete the switch from args and pass the exit as localBrowser({ proxy }) or scrape({ egress })'
|
|
92
|
+
: "pass args as a list of strings, e.g. options: { args: ['--no-sandbox'] }"
|
|
93
|
+
}`,
|
|
94
|
+
// One command, the same for both shapes: it lists every call site and every proxy switch.
|
|
95
|
+
fix: LAUNCH_ARGS_FIX,
|
|
96
|
+
meta: { reason },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A name a WILDCARD admitted, resolved to an address inside the network. The address CLASS and
|
|
101
|
+
* never the address: the cause reaches the dead-letter row, and the hostname is enough to act on.
|
|
102
|
+
*/
|
|
103
|
+
export const hostResolvesInward = (url: string, hostname: string, kind: string): ScrapeError =>
|
|
104
|
+
new ScrapeError({
|
|
105
|
+
code: 'X_SCRAPE_HOST_BLOCKED',
|
|
106
|
+
cause: `${url} is admitted by a wildcard in allowHosts, and ${hostname} resolves to ${
|
|
107
|
+
kind === 'unresolvable' ? 'no address' : `a ${kind} address`
|
|
108
|
+
} — a wildcard admits public addresses only`,
|
|
109
|
+
fix: `name the host exactly on the scrape() definition — allowHosts: [${JSON.stringify(hostname)}] — if it is meant to be reached inside your network`,
|
|
110
|
+
meta: { url, host: hostname, addressClass: kind },
|
|
111
|
+
});
|
package/src/error-throws.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
isRetryableStatus,
|
|
9
9
|
renderFixShellArg,
|
|
10
10
|
renderThrowable,
|
|
11
|
-
UltimateError,
|
|
12
11
|
} from '@ultimat3/core';
|
|
13
12
|
import type { CaptureClip } from './capture-clip';
|
|
14
13
|
import { ScrapeError } from './errors';
|
|
@@ -303,7 +302,11 @@ export const fixtureMissing = (url: string, dir: string): ScrapeError =>
|
|
|
303
302
|
export const fixtureStale = (url: string, ageMs: number, maxAgeMs: number): ScrapeError =>
|
|
304
303
|
new ScrapeError({
|
|
305
304
|
code: 'X_SCRAPE_FIXTURE_STALE',
|
|
306
|
-
cause: `the recording of ${url}
|
|
305
|
+
cause: `the recording of ${url} ${
|
|
306
|
+
Number.isFinite(ageMs)
|
|
307
|
+
? `is ${String(Math.round(ageMs / 86_400_000))} days old`
|
|
308
|
+
: 'has a recordedAt that is not a date'
|
|
309
|
+
} and fixtureBrowser declares maxAge ${String(Math.round(maxAgeMs / 86_400_000))} days`,
|
|
307
310
|
fix: 're-record the fixture directory, or raise maxAge on fixtureBrowser({ maxAge }) with the reason an old recording still proves something',
|
|
308
311
|
meta: { url, ageMs, maxAgeMs },
|
|
309
312
|
});
|
|
@@ -398,7 +401,7 @@ export const authFailed = (scrape: string, detail: string): ScrapeError =>
|
|
|
398
401
|
meta: { scrape },
|
|
399
402
|
});
|
|
400
403
|
|
|
401
|
-
export const
|
|
404
|
+
export const scrapeSessionExpired = (scrape: string, key: string): ScrapeError =>
|
|
402
405
|
new ScrapeError({
|
|
403
406
|
code: 'X_SCRAPE_SESSION_EXPIRED',
|
|
404
407
|
cause: `the stored session ${key} for scrape "${scrape}" failed its validate() probe and the definition declares no auth.login`,
|
|
@@ -426,19 +429,6 @@ export const blocked = (scrape: string, url: string, detail: string): ScrapeErro
|
|
|
426
429
|
meta: { scrape, url },
|
|
427
430
|
});
|
|
428
431
|
|
|
429
|
-
/**
|
|
430
|
-
* The honest stub, in the shape `packages/jobs/src/driver-redis.ts` uses: correct types so an app
|
|
431
|
-
* can be written against the seam, and one labelled throw so nobody discovers the gap from a
|
|
432
|
-
* silently-skipped recovery.
|
|
433
|
-
*/
|
|
434
|
-
export const scrapeNotImplemented = (feature: string, fix: string): UltimateError =>
|
|
435
|
-
new UltimateError({
|
|
436
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
437
|
-
cause: `${feature} is declared and not implemented in @ultimat3/scraping`,
|
|
438
|
-
fix,
|
|
439
|
-
meta: { feature },
|
|
440
|
-
});
|
|
441
|
-
|
|
442
432
|
/**
|
|
443
433
|
* The crop rectangle's three refusals, one code. Each is a caller's DECLARATION being impossible
|
|
444
434
|
* rather than a site behaving badly, so each names the edit that fixes it — and the alternative in
|
package/src/errors.ts
CHANGED
|
@@ -41,11 +41,12 @@ export const SCRAPE_OWNED_ERROR_CODES = [
|
|
|
41
41
|
'X_SCRAPE_KEY_INVALID',
|
|
42
42
|
'X_SCRAPE_EGRESS_UNSUPPORTED',
|
|
43
43
|
'X_SCRAPE_EGRESS_IN_PAYLOAD',
|
|
44
|
+
'X_SCRAPE_LAUNCH_ARGS_INVALID',
|
|
44
45
|
] as const;
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
|
-
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s
|
|
48
|
-
*
|
|
48
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s: `recover.ts`, `cdp-target.ts` and `html-target.ts`
|
|
49
|
+
* throw it as core's own `NotImplementedError`, never a constructor of this package's. `X_ENV_MISSING` is core's too: a
|
|
49
50
|
* declared secret with no value in the environment is a missing environment variable, not a
|
|
50
51
|
* scraping concept needing its own code. No title is kept here for either — one code, one owner,
|
|
51
52
|
* or the two copies drift.
|
|
@@ -95,6 +96,8 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
|
|
|
95
96
|
X_SCRAPE_KEY_INVALID: 'the key chord names no key a browser can press',
|
|
96
97
|
X_SCRAPE_EGRESS_UNSUPPORTED: 'this driver cannot give the session the exit the run asked for',
|
|
97
98
|
X_SCRAPE_EGRESS_IN_PAYLOAD: 'the run exit carries a credential that is also in the job payload',
|
|
99
|
+
X_SCRAPE_LAUNCH_ARGS_INVALID:
|
|
100
|
+
'the launch options carry arguments this driver cannot hand the browser',
|
|
98
101
|
};
|
|
99
102
|
|
|
100
103
|
// One unconditional call, so a second package claiming one of these codes throws
|
|
@@ -202,6 +205,9 @@ export const SCRAPE_ERROR_RETRY = {
|
|
|
202
205
|
X_SCRAPE_EGRESS_UNSUPPORTED: 'terminal',
|
|
203
206
|
// The payload is the row: every remaining attempt reads the same credential out of it.
|
|
204
207
|
X_SCRAPE_EGRESS_IN_PAYLOAD: 'terminal',
|
|
208
|
+
// The args are the deploy's own literal: attempt 2 launches with the same list and is refused
|
|
209
|
+
// the same way. Launching anyway is the retry that "works" from the worker's own address.
|
|
210
|
+
X_SCRAPE_LAUNCH_ARGS_INVALID: 'terminal',
|
|
205
211
|
} as const satisfies Readonly<Record<ScrapeOwnedErrorCode, 'retryable' | 'terminal'>>;
|
|
206
212
|
|
|
207
213
|
registerErrorRetry(SCRAPE_ERROR_RETRY);
|
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/expect.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// where the data went. `expect` turns that into a red run: a yield under `minRows`, or under
|
|
4
4
|
// `maxDrop` of what this scrape normally returns, throws.
|
|
5
5
|
|
|
6
|
-
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
6
|
+
import { assert, finiteCount, finiteOption } from '@ultimat3/core';
|
|
7
7
|
import { yieldCollapsed } from './error-throws';
|
|
8
8
|
import type { ScrapeError } from './errors';
|
|
9
9
|
|
|
@@ -57,6 +57,23 @@ export interface YieldCheck {
|
|
|
57
57
|
readonly history: readonly number[];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* `maxDrop`, screened: finite, then a FRACTION in `[0, 1)`. `1` and anything above it — `50`, the
|
|
62
|
+
* percent habit — make `baseline * (1 - maxDrop)` zero or negative, so no yield is ever under it
|
|
63
|
+
* and the alarm is silent forever; a negative one fires on a run that matched its baseline. Asked
|
|
64
|
+
* by the rule and by `scrape()`, where the number is written.
|
|
65
|
+
*/
|
|
66
|
+
export function maxDropFraction(scrape: string, value: number): number {
|
|
67
|
+
const fraction = finiteOption('the scrape expect', 'maxDrop', value);
|
|
68
|
+
assert(
|
|
69
|
+
fraction >= 0 && fraction < 1,
|
|
70
|
+
`scrape "${scrape}" declares expect.maxDrop: ${String(fraction)}, and maxDrop is a fraction of the trailing median from 0 up to but not including 1 — at 1 or above no yield is ever under the line and the alarm can never fire; write it as a fraction, expect: { maxDrop: 0.5 } allows half`,
|
|
71
|
+
// Lists every declaration outside [0, 1) — static, so no declared value reaches a shell.
|
|
72
|
+
"grep -rnE --include='*.ts' 'maxDrop: *(-|[1-9]|1\\.)' .",
|
|
73
|
+
);
|
|
74
|
+
return fraction;
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* The whole rule, pure: the error this yield earns, or `undefined`. Pure so a test can hand it
|
|
62
79
|
* fifty histories without a queue, a browser or a clock.
|
|
@@ -69,8 +86,8 @@ export function yieldProblem(check: YieldCheck): ScrapeError | undefined {
|
|
|
69
86
|
// instead, and an alarm that always fires is an alarm somebody turns off.
|
|
70
87
|
//
|
|
71
88
|
// `minRows: 0` is legal and stays legal: this file asks an author whose answer is legitimately
|
|
72
|
-
// sometimes zero to declare exactly that. `maxDrop` is a FRACTION of a median, so
|
|
73
|
-
//
|
|
89
|
+
// sometimes zero to declare exactly that. `maxDrop` is a FRACTION of a median, so not
|
|
90
|
+
// `finiteCount` — `0.5` is the documented value (`maxDropFraction`).
|
|
74
91
|
const minRows =
|
|
75
92
|
check.expect.minRows === undefined
|
|
76
93
|
? undefined
|
|
@@ -78,7 +95,7 @@ export function yieldProblem(check: YieldCheck): ScrapeError | undefined {
|
|
|
78
95
|
const maxDrop =
|
|
79
96
|
check.expect.maxDrop === undefined
|
|
80
97
|
? undefined
|
|
81
|
-
:
|
|
98
|
+
: maxDropFraction(check.scrape, check.expect.maxDrop);
|
|
82
99
|
if (minRows !== undefined && check.rows < minRows) {
|
|
83
100
|
return yieldCollapsed({ scrape: check.scrape, rows: check.rows, reason: 'min-rows', minRows });
|
|
84
101
|
}
|