@ultimat3/scraping 11.0.0 → 11.2.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 +24 -0
- package/package.json +5 -5
- package/src/capture-clip.ts +57 -0
- package/src/cdp-port.ts +20 -1
- package/src/cdp-target.ts +8 -1
- package/src/error-throws.ts +51 -3
- package/src/errors.ts +13 -3
- package/src/html-target.ts +19 -1
- package/src/index.ts +3 -0
- package/src/page-over-target.ts +16 -2
- package/src/page.ts +8 -4
- package/src/target.ts +8 -4
package/README.md
CHANGED
|
@@ -108,6 +108,29 @@ same reason — a third-party driver that could omit it would be silent about er
|
|
|
108
108
|
Entries are built with `pageErrorEntry()`, which truncates at `MAX_PAGE_ERROR_CHARS`: the ring
|
|
109
109
|
bounds the count, and one `Maximum call stack size exceeded` is thousands of frames.
|
|
110
110
|
|
|
111
|
+
### A picture of ONE component
|
|
112
|
+
|
|
113
|
+
`page.screenshot({ clip: { x, y, width, height } })` crops to a rectangle in the page's own
|
|
114
|
+
coordinate space — the space `getBoundingClientRect()` answers in. It exists because the reader of
|
|
115
|
+
a scrape's screenshot is increasingly a vision model, whose pixels are the scarce resource: a
|
|
116
|
+
whole-viewport picture spends them on everything that is not the component under review.
|
|
117
|
+
|
|
118
|
+
| Framing | Answer |
|
|
119
|
+
|---|---|
|
|
120
|
+
| neither | the viewport, exactly as before the clip existed — byte for byte |
|
|
121
|
+
| `fullPage: true` | the whole document |
|
|
122
|
+
| `clip` | that rectangle |
|
|
123
|
+
| **both** | `X_SCRAPE_CAPTURE_INVALID` — a browser honours one of the two without saying which |
|
|
124
|
+
| `clip` with no area, or entirely in negative coordinates | `X_SCRAPE_CAPTURE_INVALID` — a blank picture that reads as a successful capture is the failure this exists to remove |
|
|
125
|
+
| `clip` on `page.pdf()` | `X_SCRAPE_CAPTURE_INVALID` — a print engine paginates the document and has no crop |
|
|
126
|
+
|
|
127
|
+
A rectangle **below the fold is accepted**. It is not checked against a viewport: this package
|
|
128
|
+
neither sets nor reads one, and a component under the fold is the case a component crop is for.
|
|
129
|
+
|
|
130
|
+
The offline drivers honour it, so the crop is provable with no Chrome — `fakeBrowser()` answers
|
|
131
|
+
different deterministic bytes per rectangle, which is what lets a component screenshot be tested
|
|
132
|
+
on a machine with no browser — the case CI is.
|
|
133
|
+
|
|
111
134
|
## What it owns
|
|
112
135
|
|
|
113
136
|
| Module | Owns |
|
|
@@ -121,6 +144,7 @@ bounds the count, and one `Maximum call stack size exceeded` is thousands of fra
|
|
|
121
144
|
| `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn |
|
|
122
145
|
| `expect.ts` | the silent-green alarm |
|
|
123
146
|
| `watchdog.ts` | the wedge and zombie discipline |
|
|
147
|
+
| `capture-clip.ts` | the one framing rule — crop, whole page, or a refusal — checked before any driver sees it |
|
|
124
148
|
| `errors.ts` / `error-throws.ts` | this package's `X_*` codes and their retry classification |
|
|
125
149
|
|
|
126
150
|
## Extending it — there is no plugin API, and none is needed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.0",
|
|
4
4
|
"description": "Browser automation as a job: scrape() returns a JobHandle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"test": "bun test"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "11.
|
|
34
|
-
"@ultimat3/jobs": "11.
|
|
35
|
-
"@ultimat3/schema": "11.
|
|
36
|
-
"@ultimat3/storage": "11.
|
|
33
|
+
"@ultimat3/core": "11.2.0",
|
|
34
|
+
"@ultimat3/jobs": "11.2.0",
|
|
35
|
+
"@ultimat3/schema": "11.2.0",
|
|
36
|
+
"@ultimat3/storage": "11.2.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The crop rectangle a capture may name, and the four shapes of one that would answer a blank
|
|
2
|
+
// picture instead of a refusal. Its own module because the rule belongs to EVERY driver
|
|
3
|
+
// identically — a check per driver is three chances to disagree — and because `target.ts` is a
|
|
4
|
+
// port declaration, which is not a place decisions live.
|
|
5
|
+
|
|
6
|
+
import { captureClipEmpty, captureClipOnPdf, captureFramingConflict } from './error-throws';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CSS pixels in the page's own coordinate space, top-left origin — the space
|
|
10
|
+
* `getBoundingClientRect()` answers in, which is where a caller's rectangle comes from.
|
|
11
|
+
*
|
|
12
|
+
* No `scale`. CDP's clip has one and nothing here would read it, which is this repo's most
|
|
13
|
+
* repeated defect (`scripts/config-readers.ts`); a device pixel ratio belongs to the launch, not
|
|
14
|
+
* to one capture.
|
|
15
|
+
*/
|
|
16
|
+
export interface CaptureClip {
|
|
17
|
+
readonly x: number;
|
|
18
|
+
readonly y: number;
|
|
19
|
+
readonly width: number;
|
|
20
|
+
readonly height: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What both capture verbs are framed by. `CaptureOptions` and `CaptureRequest` are this, named. */
|
|
24
|
+
export interface CaptureFraming {
|
|
25
|
+
readonly fullPage?: boolean | undefined;
|
|
26
|
+
readonly clip?: CaptureClip | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const finite = (value: number): boolean => Number.isFinite(value);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Refuse a framing no driver can honour, BEFORE any driver sees it.
|
|
33
|
+
*
|
|
34
|
+
* What is deliberately NOT checked: whether the rectangle is inside the viewport. This package
|
|
35
|
+
* neither sets nor reads a viewport — nothing in `src/` mentions one — so the answer would cost a
|
|
36
|
+
* round trip into the page, and it would be the WRONG answer: a component below the fold is
|
|
37
|
+
* exactly what a component crop is for, and every current build captures beyond the viewport. A
|
|
38
|
+
* rectangle entirely in negative space needs no browser to refuse and is the half that is real.
|
|
39
|
+
*/
|
|
40
|
+
export function assertCaptureFraming(kind: 'screenshot' | 'pdf', framing: CaptureFraming): void {
|
|
41
|
+
const clip = framing.clip;
|
|
42
|
+
if (clip === undefined) return;
|
|
43
|
+
// A PDF is paginated by the print engine; CDP's `Page.printToPDF` has no clip to forward one to.
|
|
44
|
+
// Refused rather than dropped: a whole-page PDF returned to a caller who asked for one component
|
|
45
|
+
// is the same silent wrong answer the fullPage/clip pair produces.
|
|
46
|
+
if (kind === 'pdf') throw captureClipOnPdf(clip);
|
|
47
|
+
// `=== true` and not truthiness: `fullPage: false` beside a clip is a caller spelling out the
|
|
48
|
+
// default, which is not a conflict. Only the pair that CDP resolves silently is refused.
|
|
49
|
+
if (framing.fullPage === true) throw captureFramingConflict(clip);
|
|
50
|
+
if (!finite(clip.x) || !finite(clip.y) || !finite(clip.width) || !finite(clip.height)) {
|
|
51
|
+
throw captureClipEmpty(clip, 'is not four finite numbers');
|
|
52
|
+
}
|
|
53
|
+
if (clip.width <= 0 || clip.height <= 0) throw captureClipEmpty(clip, 'has no area');
|
|
54
|
+
if (clip.x + clip.width <= 0 || clip.y + clip.height <= 0) {
|
|
55
|
+
throw captureClipEmpty(clip, 'lies entirely in negative coordinates, where no page content is');
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/cdp-port.ts
CHANGED
|
@@ -12,6 +12,25 @@
|
|
|
12
12
|
// `connect({ browserWSEndpoint })`, headless Chrome 150): the WebSocket upgrade Playwright's
|
|
13
13
|
// `connectOverCDP` cannot do under Bun works here, which is why this is the intended library.
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The library's screenshot options, restated here rather than shared with `CaptureClip`.
|
|
17
|
+
*
|
|
18
|
+
* This file is the shape of somebody ELSE's object, and importing this package's own vocabulary
|
|
19
|
+
* into it would make a puppeteer call signature depend on a scraping type — the direction this
|
|
20
|
+
* seam exists to forbid. The two shapes are structurally identical, so a `CaptureClip` passes
|
|
21
|
+
* where this is expected and no cast is needed anywhere.
|
|
22
|
+
*/
|
|
23
|
+
export interface CdpScreenshotOptions {
|
|
24
|
+
readonly fullPage?: boolean;
|
|
25
|
+
/** CSS pixels, page coordinates. Set INSTEAD of `fullPage`, never beside it. */
|
|
26
|
+
readonly clip?: {
|
|
27
|
+
readonly x: number;
|
|
28
|
+
readonly y: number;
|
|
29
|
+
readonly width: number;
|
|
30
|
+
readonly height: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
export interface CdpRequestLike {
|
|
16
35
|
url(): string;
|
|
17
36
|
resourceType(): string;
|
|
@@ -34,7 +53,7 @@ export interface CdpPageLike {
|
|
|
34
53
|
click(selector: string): Promise<void>;
|
|
35
54
|
type(selector: string, text: string): Promise<void>;
|
|
36
55
|
select(selector: string, ...values: string[]): Promise<string[]>;
|
|
37
|
-
screenshot(options:
|
|
56
|
+
screenshot(options: CdpScreenshotOptions): Promise<Uint8Array | string>;
|
|
38
57
|
pdf(options?: Record<string, unknown>): Promise<Uint8Array>;
|
|
39
58
|
setRequestInterception(enabled: boolean): Promise<void>;
|
|
40
59
|
/**
|
package/src/cdp-target.ts
CHANGED
|
@@ -329,7 +329,14 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
329
329
|
evaluate: (expression) => guard('evaluate', () => init.page.evaluate(expression)),
|
|
330
330
|
screenshot: (options: CaptureOptions) =>
|
|
331
331
|
guard('screenshot', async () => {
|
|
332
|
-
|
|
332
|
+
// `fullPage` is OMITTED when a clip is given rather than sent as `false`: the two are
|
|
333
|
+
// exclusive at the library too, and some builds refuse the pair on truthiness while others
|
|
334
|
+
// resolve it silently. Sending only what was asked for is the request neither can misread.
|
|
335
|
+
const shot = await init.page.screenshot(
|
|
336
|
+
options.clip === undefined
|
|
337
|
+
? { fullPage: options.fullPage === true }
|
|
338
|
+
: { clip: options.clip },
|
|
339
|
+
);
|
|
333
340
|
// Some builds answer base64 text, some answer bytes. `atob` is the one decoder both a
|
|
334
341
|
// browser and Bun agree on, and it keeps this file free of a Buffer import.
|
|
335
342
|
return typeof shot === 'string'
|
package/src/error-throws.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// a browser is rendered through core's `renderThrowable`, which is the rule `bun run error-render`
|
|
4
4
|
// enforces.
|
|
5
5
|
|
|
6
|
-
import { renderThrowable, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { isRetryableStatus, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { CaptureClip } from './capture-clip';
|
|
7
8
|
import { ScrapeError } from './errors';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -230,14 +231,29 @@ export const secretExposed = (artifact: string, url: string): ScrapeError =>
|
|
|
230
231
|
meta: { artifact, url },
|
|
231
232
|
});
|
|
232
233
|
|
|
233
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* A non-2xx from the HTTP leg. The per-instance override that `errors.ts` reserves for this code:
|
|
236
|
+
* the code is registered `retryable`, and only a PERMANENT status downgrades an instance.
|
|
237
|
+
*
|
|
238
|
+
* Which 4xx are permanent is `@ultimat3/core`'s `isRetryableStatus` and not this package's opinion.
|
|
239
|
+
* Scraping had the framework's fifth copy of that table and it had drifted: `status !== 429` alone
|
|
240
|
+
* made a 408, a 409 and a 425 terminal here while `cache`, `mail` and `ai` all called them
|
|
241
|
+
* retryable — and terminal is not documentation, it dead-letters the run on the attempt that threw
|
|
242
|
+
* it (`packages/jobs/src/retry-classification.ts`), so a scrape burned a five-attempt policy on one
|
|
243
|
+
* request timeout. Nothing about a SCRAPE argues for a different answer: the statuses that could,
|
|
244
|
+
* 401 and 403, are permanent in core's table too and stay terminal here.
|
|
245
|
+
*
|
|
246
|
+
* The 4xx window stays because it is the range this override speaks for. A sub-400 non-ok — a 304
|
|
247
|
+
* is the reachable one — keeps the code's registered `retryable`: an early dead-letter on a status
|
|
248
|
+
* no table in the framework classifies is the expensive way to guess.
|
|
249
|
+
*/
|
|
234
250
|
export const httpFailed = (url: string, status: number, body: string): ScrapeError =>
|
|
235
251
|
new ScrapeError({
|
|
236
252
|
code: 'X_SCRAPE_HTTP_FAILED',
|
|
237
253
|
cause: `${url} answered ${String(status)}: ${body}`,
|
|
238
254
|
fix: 'confirm the endpoint the browser leg calls is the one this request names — page.network() lists every URL the page actually fetched',
|
|
239
255
|
meta: { url, status },
|
|
240
|
-
retry: status >= 400 && status < 500 && status
|
|
256
|
+
retry: status >= 400 && status < 500 && !isRetryableStatus(status) ? 'terminal' : 'retryable',
|
|
241
257
|
});
|
|
242
258
|
|
|
243
259
|
/**
|
|
@@ -306,3 +322,35 @@ export const scrapeNotImplemented = (feature: string, fix: string): UltimateErro
|
|
|
306
322
|
fix,
|
|
307
323
|
meta: { feature },
|
|
308
324
|
});
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The crop rectangle's three refusals, one code. Each is a caller's DECLARATION being impossible
|
|
328
|
+
* rather than a site behaving badly, so each names the edit that fixes it — and the alternative in
|
|
329
|
+
* every case is a picture that looks like a successful capture and is not of what was asked for.
|
|
330
|
+
*/
|
|
331
|
+
const clipText = (clip: CaptureClip): string =>
|
|
332
|
+
`${String(clip.width)}x${String(clip.height)} at ${String(clip.x)},${String(clip.y)}`;
|
|
333
|
+
|
|
334
|
+
export const captureFramingConflict = (clip: CaptureClip): ScrapeError =>
|
|
335
|
+
new ScrapeError({
|
|
336
|
+
code: 'X_SCRAPE_CAPTURE_INVALID',
|
|
337
|
+
cause: `this capture names both fullPage: true and clip ${clipText(clip)}, and a browser silently honours one of them without saying which`,
|
|
338
|
+
fix: 'drop fullPage — a clip is already the whole request — or drop clip to photograph the whole page',
|
|
339
|
+
meta: { clip: { ...clip }, fullPage: true },
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
export const captureClipEmpty = (clip: CaptureClip, reason: string): ScrapeError =>
|
|
343
|
+
new ScrapeError({
|
|
344
|
+
code: 'X_SCRAPE_CAPTURE_INVALID',
|
|
345
|
+
cause: `the clip ${clipText(clip)} ${reason}, so the capture would answer a blank picture that reads as a success`,
|
|
346
|
+
fix: "measure the element first — page.query('<selector>') answers a box with a positive width and height — and pass that rectangle as clip",
|
|
347
|
+
meta: { clip: { ...clip } },
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
export const captureClipOnPdf = (clip: CaptureClip): ScrapeError =>
|
|
351
|
+
new ScrapeError({
|
|
352
|
+
code: 'X_SCRAPE_CAPTURE_INVALID',
|
|
353
|
+
cause: `page.pdf() was given the clip ${clipText(clip)} and a PDF has no crop rectangle — the print engine paginates the whole document`,
|
|
354
|
+
fix: 'call page.screenshot({ clip }) for one component, or page.pdf() with no clip for the document',
|
|
355
|
+
meta: { clip: { ...clip } },
|
|
356
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const SCRAPE_OWNED_ERROR_CODES = [
|
|
|
29
29
|
'X_SCRAPE_REMOTE_REQUIRED',
|
|
30
30
|
'X_SCRAPE_RECOVER_REFUSED',
|
|
31
31
|
'X_SCRAPE_SECRET_EXPOSED',
|
|
32
|
+
'X_SCRAPE_CAPTURE_INVALID',
|
|
32
33
|
'X_SCRAPE_HTTP_FAILED',
|
|
33
34
|
'X_SCRAPE_BODY_TOO_LARGE',
|
|
34
35
|
'X_SCRAPE_AUTH_FAILED',
|
|
@@ -77,6 +78,7 @@ export const SCRAPE_ERROR_TITLES: Readonly<Record<ScrapeOwnedErrorCode, string>>
|
|
|
77
78
|
X_SCRAPE_REMOTE_REQUIRED: 'this driver needs a cdpUrl and was given none',
|
|
78
79
|
X_SCRAPE_RECOVER_REFUSED: 'the recovery hook declined to recover this failure',
|
|
79
80
|
X_SCRAPE_SECRET_EXPOSED: 'an artifact would have carried a secret this run typed',
|
|
81
|
+
X_SCRAPE_CAPTURE_INVALID: 'the capture names a framing no picture can be taken with',
|
|
80
82
|
X_SCRAPE_HTTP_FAILED: 'the site answered the HTTP leg with a non-2xx status',
|
|
81
83
|
X_SCRAPE_BODY_TOO_LARGE: 'the HTTP response body passed its byte cap',
|
|
82
84
|
X_SCRAPE_AUTH_FAILED: 'the credentials were rejected',
|
|
@@ -112,9 +114,12 @@ export const SCRAPE_ERROR_RETRY = {
|
|
|
112
114
|
// persisted identity re-trips it every time: the flagged cookies are the thing being refused,
|
|
113
115
|
// so the retry has to arrive as somebody else or it is arithmetic, not a retry.
|
|
114
116
|
X_SCRAPE_BLOCKED: 'retryable',
|
|
115
|
-
// Non-2xx is transient far more often than not (429, 502, a deploy). A 4xx that is
|
|
116
|
-
// permanent is thrown with a per-instance `terminal` override, which `UltimateError`
|
|
117
|
-
// one code, and the throw site decides, because the same status is both at different
|
|
117
|
+
// Non-2xx is transient far more often than not (408, 429, 502, a deploy). A 4xx that is
|
|
118
|
+
// genuinely permanent is thrown with a per-instance `terminal` override, which `UltimateError`
|
|
119
|
+
// supports — one code, and the throw site decides, because the same status is both at different
|
|
120
|
+
// sites. WHICH 4xx are permanent is not decided here: `httpFailed` reads core's
|
|
121
|
+
// `isRetryableStatus`, the framework's one table, so this leg cannot drift from `cache`, `mail`
|
|
122
|
+
// and `ai` again — it had, on 408, 409 and 425.
|
|
118
123
|
X_SCRAPE_HTTP_FAILED: 'retryable',
|
|
119
124
|
// Everything below is terminal, and each one is listed rather than left to the default so that
|
|
120
125
|
// deleting a line is a visible decision.
|
|
@@ -135,6 +140,11 @@ export const SCRAPE_ERROR_RETRY = {
|
|
|
135
140
|
// A declaration error, raised by `scrape()` before any attempt exists — there is no run to
|
|
136
141
|
// retry, and the same definition would refuse identically forever.
|
|
137
142
|
X_SCRAPE_YIELD_HISTORY_MISSING: 'terminal',
|
|
143
|
+
// A declaration error too, and the early dead letter is the CORRECT answer here: the rectangle
|
|
144
|
+
// is the caller's own literal, attempt 2 passes the identical one, and a retry is a browser
|
|
145
|
+
// launch and a login for no chance of a different verdict. Refused before any driver runs, so
|
|
146
|
+
// there is no half-done capture to resume.
|
|
147
|
+
X_SCRAPE_CAPTURE_INVALID: 'terminal',
|
|
138
148
|
// TERMINAL where its sibling `X_SCRAPE_WEDGED` is retryable, and the difference IS the reason
|
|
139
149
|
// the two codes are separate. A wedge is the site or the browser being slow — the definition of
|
|
140
150
|
// "run it again and it may go differently". This is the guard's own loop dying on code the
|
package/src/html-target.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// through to the network would make a green offline suite that is secretly hitting production —
|
|
6
6
|
// the exact failure an offline driver exists to prevent.
|
|
7
7
|
|
|
8
|
+
import type { CaptureClip } from './capture-clip';
|
|
8
9
|
import type { ScrapeClock } from './clock';
|
|
9
10
|
import { browserUnreachable, downloadTimeout, fixtureMissing, fixtureStale } from './error-throws';
|
|
10
11
|
import { queryHtml } from './html-query';
|
|
@@ -29,6 +30,22 @@ import type {
|
|
|
29
30
|
|
|
30
31
|
/** Deterministic bytes, so an artifact test asserts on a stable digest. Not a real PNG render. */
|
|
31
32
|
const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A clipped capture answers DIFFERENT deterministic bytes, one picture per rectangle. CI has no
|
|
36
|
+
* Chrome, so the offline drivers are the only place a crop can be proved at all: a fake that
|
|
37
|
+
* answered the same eight bytes whatever the rectangle would let a driver that silently drops the
|
|
38
|
+
* clip pass every test there is. Unclipped bytes are unchanged, byte for byte.
|
|
39
|
+
*/
|
|
40
|
+
const clippedPng = (clip: CaptureClip): Uint8Array => {
|
|
41
|
+
const suffix = new TextEncoder().encode(
|
|
42
|
+
` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`,
|
|
43
|
+
);
|
|
44
|
+
const out = new Uint8Array(FAKE_PNG.length + suffix.length);
|
|
45
|
+
out.set(FAKE_PNG);
|
|
46
|
+
out.set(suffix, FAKE_PNG.length);
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
32
49
|
const FAKE_PDF = new TextEncoder().encode('%PDF-1.4 offline');
|
|
33
50
|
|
|
34
51
|
export type RecordingLookup = (url: string) => Promise<PageRecording | undefined>;
|
|
@@ -208,7 +225,8 @@ export function htmlTarget(init: HtmlTargetInit): ScrapeTarget {
|
|
|
208
225
|
throw fixtureMissing(`${page.url} evaluate(${expression})`, init.source);
|
|
209
226
|
return Promise.resolve(JSON.parse(answer) as unknown);
|
|
210
227
|
},
|
|
211
|
-
screenshot: (
|
|
228
|
+
screenshot: (options: CaptureOptions): Promise<Uint8Array> =>
|
|
229
|
+
Promise.resolve(options.clip === undefined ? FAKE_PNG : clippedPng(options.clip)),
|
|
212
230
|
pdf: (_options: CaptureOptions): Promise<Uint8Array> => Promise.resolve(FAKE_PDF),
|
|
213
231
|
cookies: (): Promise<readonly ScrapeCookie[]> => Promise.resolve(session.cookies),
|
|
214
232
|
session: (): Promise<SessionSnapshot> => Promise.resolve(session),
|
package/src/index.ts
CHANGED
|
@@ -18,12 +18,15 @@ export type {
|
|
|
18
18
|
ScrapeAuth,
|
|
19
19
|
} from './auth';
|
|
20
20
|
export { burnSession, createPrompt, ensureAuthenticated, restorableSession } from './auth';
|
|
21
|
+
export type { CaptureClip, CaptureFraming } from './capture-clip';
|
|
22
|
+
export { assertCaptureFraming } from './capture-clip';
|
|
21
23
|
export type {
|
|
22
24
|
CdpBrowserLike,
|
|
23
25
|
CdpFrameLike,
|
|
24
26
|
CdpLauncherLike,
|
|
25
27
|
CdpPageLike,
|
|
26
28
|
CdpRequestLike,
|
|
29
|
+
CdpScreenshotOptions,
|
|
27
30
|
} from './cdp-port';
|
|
28
31
|
export { parseSnapshots, snapshotExpression } from './cdp-snapshot';
|
|
29
32
|
export type { CdpTargetInit } from './cdp-target';
|
package/src/page-over-target.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Secret } from '@ultimat3/core';
|
|
|
7
7
|
import { isSecret, revealSecret } from '@ultimat3/core';
|
|
8
8
|
import type { ActionabilityState } from './actionability';
|
|
9
9
|
import { awaitActionable } from './actionability';
|
|
10
|
+
import { assertCaptureFraming } from './capture-clip';
|
|
10
11
|
import type { ScrapeClock } from './clock';
|
|
11
12
|
import { deadline } from './clock';
|
|
12
13
|
import { hostBlocked, secretExposed, selectorMissing } from './error-throws';
|
|
@@ -22,7 +23,13 @@ import type {
|
|
|
22
23
|
import type { RobotsGate } from './robots';
|
|
23
24
|
import type { ScrapeSecrets } from './secrets';
|
|
24
25
|
import { safeHtml } from './secrets';
|
|
25
|
-
import type {
|
|
26
|
+
import type {
|
|
27
|
+
CaptureOptions,
|
|
28
|
+
ElementSnapshot,
|
|
29
|
+
ScrapeCookie,
|
|
30
|
+
ScrapeDownloadFile,
|
|
31
|
+
ScrapeTarget,
|
|
32
|
+
} from './target';
|
|
26
33
|
import { ROOT_SELECTOR } from './target';
|
|
27
34
|
|
|
28
35
|
export interface PageContext {
|
|
@@ -187,7 +194,14 @@ export function pageOverTarget(target: ScrapeTarget, ctx: PageContext): ScrapePa
|
|
|
187
194
|
// in object storage, forever. Refused rather than masked — a mask over pixels is a guess
|
|
188
195
|
// about layout, and `page.html()` already gives a redacted artifact that is exact.
|
|
189
196
|
if (state.tainted) throw secretExposed(kind, target.url());
|
|
190
|
-
|
|
197
|
+
// The ONE place a capture request is built, so the framing rule is checked once for every
|
|
198
|
+
// driver rather than three times with three chances to disagree. After the secret guard, which
|
|
199
|
+
// is the stronger refusal: a tainted page must not be told its rectangle is fine.
|
|
200
|
+
assertCaptureFraming(kind, options ?? {});
|
|
201
|
+
const request: CaptureOptions = {
|
|
202
|
+
fullPage: options?.fullPage,
|
|
203
|
+
...(options?.clip === undefined ? {} : { clip: options.clip }),
|
|
204
|
+
};
|
|
191
205
|
return kind === 'screenshot' ? target.screenshot(request) : target.pdf(request);
|
|
192
206
|
};
|
|
193
207
|
return {
|
package/src/page.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Secret } from '@ultimat3/core';
|
|
10
10
|
import type { ActionabilityState } from './actionability';
|
|
11
|
+
import type { CaptureFraming } from './capture-clip';
|
|
11
12
|
import type { ConsoleLine, NetworkEntry, PageError } from './rings';
|
|
12
13
|
import type { SessionSnapshot } from './session-state';
|
|
13
14
|
import type { ElementSnapshot, ScrapeCookie, ScrapeDownloadFile } from './target';
|
|
@@ -64,10 +65,13 @@ export interface ScrapeFrame {
|
|
|
64
65
|
frame(nameOrSelector: string): ScrapeFrame;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
/**
|
|
69
|
+
* `fullPage`, or a `clip` — never both. `timeout` is gone with the port's; see `CaptureOptions`.
|
|
70
|
+
*
|
|
71
|
+
* A clip is what makes a capture reviewable by a vision model: the whole viewport spends the
|
|
72
|
+
* reader's scarce pixels on everything that is not the component under review.
|
|
73
|
+
*/
|
|
74
|
+
export type CaptureRequest = CaptureFraming;
|
|
71
75
|
|
|
72
76
|
export interface DownloadRequest {
|
|
73
77
|
readonly timeout?: number | undefined;
|
package/src/target.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// lets `page-over-target.ts` be the ONE implementation of `ScrapePage` for the real browser, the
|
|
4
4
|
// fixture replayer and the fake alike.
|
|
5
5
|
|
|
6
|
+
import type { CaptureFraming } from './capture-clip';
|
|
6
7
|
import type { ConsoleRing, NetworkRing, PageErrorRing } from './rings';
|
|
7
8
|
import type { SessionSnapshot } from './session-state';
|
|
8
9
|
|
|
@@ -75,7 +76,12 @@ export interface GotoOptions {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
|
-
* `fullPage`
|
|
79
|
+
* `CaptureFraming`, under the port's own name: `fullPage` for the whole document, or a `clip` to
|
|
80
|
+
* crop to one rectangle, never both. ONE declaration for the port and the vocabulary — the two
|
|
81
|
+
* were separate copies of `{ fullPage }` and a field added to one would have reached neither the
|
|
82
|
+
* drivers nor the callers.
|
|
83
|
+
*
|
|
84
|
+
* It carried a required `timeoutMs` until 2026-08 that NO driver
|
|
79
85
|
* honoured — `cdp-target.ts` read only `fullPage`, `html-target.ts` ignored the whole object —
|
|
80
86
|
* so `page.screenshot({ timeout })` was a documented deadline that bounded nothing. Deleted
|
|
81
87
|
* rather than implemented: the CDP port's own `screenshot({ fullPage })` has no timeout slot to
|
|
@@ -83,9 +89,7 @@ export interface GotoOptions {
|
|
|
83
89
|
* `ScrapeClock.sleep`, which under `testClock` resolves on the first microtask and would time
|
|
84
90
|
* out every capture in every test. A driver's own default is the honest bound.
|
|
85
91
|
*/
|
|
86
|
-
export
|
|
87
|
-
readonly fullPage?: boolean | undefined;
|
|
88
|
-
}
|
|
92
|
+
export type CaptureOptions = CaptureFraming;
|
|
89
93
|
|
|
90
94
|
/**
|
|
91
95
|
* The port. Twelve methods, every one of them something a browser genuinely does and nothing a
|