@ultimat3/scraping 13.0.0 → 14.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 +36 -1
- package/package.json +5 -5
- package/src/browser-record.ts +35 -0
- package/src/cdp-fake.ts +117 -10
- package/src/cdp-port.ts +13 -0
- package/src/cdp-snapshot.ts +37 -6
- package/src/cdp-target.ts +58 -22
- package/src/driver-cdp.ts +4 -0
- package/src/html-target.ts +133 -25
- package/src/http-recorded.ts +9 -2
- package/src/http.ts +26 -3
- package/src/index.ts +6 -1
- package/src/offline-session.ts +3 -0
- package/src/page-over-target.ts +23 -4
- package/src/page.ts +22 -3
- package/src/recording.ts +25 -4
- package/src/rings.ts +20 -0
- package/src/secrets.ts +69 -4
- package/src/session-state.ts +53 -2
- package/src/target.ts +13 -2
package/README.md
CHANGED
|
@@ -85,6 +85,32 @@ Both legs replay from **one** fixture directory (`fixtureBrowser(dir)`), so a hy
|
|
|
85
85
|
login, session handoff, HTTP bulk fetch — is tested end to end. Both legs apply the same robots
|
|
86
86
|
gate, offline included.
|
|
87
87
|
|
|
88
|
+
## Reading elements, frames, and the network condition
|
|
89
|
+
|
|
90
|
+
| Read | Answers |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `page.values(selector)` | `ElementValue[]` — tag, text, value, attrs. The projection row assembly wants |
|
|
93
|
+
| `page.query(selector)` | `ElementSnapshot[]` — the same matches PLUS `visible`, `enabled`, and the layout `box`/`hitTarget` a driver with a layout engine can answer. The read for a decision ABOUT an element |
|
|
94
|
+
| `page.frame(nameOrSelector)` | a `ScrapeFrame`, re-resolved on every call through it |
|
|
95
|
+
| `page.offline(enabled)` | cuts the BROWSER's network, or restores it |
|
|
96
|
+
|
|
97
|
+
`query()` exists because there is exactly one definition of "visible" in this framework and it is
|
|
98
|
+
the port's. A caller that had to compute its own wrote
|
|
99
|
+
`display !== 'none' && visibility !== 'hidden' && opacity !== '0'` a second time, which is the
|
|
100
|
+
copy axiom 1 forbids.
|
|
101
|
+
|
|
102
|
+
**A frame verb reaches the FRAME.** `fill`, `type`, `select`, `clear`, `click` and `query` through
|
|
103
|
+
a `ScrapeFrame` handle all address that frame's own document — never the parent's, even when the
|
|
104
|
+
two carry the same ids, which is what an iframe'd SSO login looks like. `driver-parity-frames.test.ts`
|
|
105
|
+
drives all six through a frame on all three drivers. The one thing an offline driver cannot do is
|
|
106
|
+
NAVIGATE a frame: a recorded frame is one static document, so a click inside one moves nothing
|
|
107
|
+
(and, in particular, never moves the parent).
|
|
108
|
+
|
|
109
|
+
**`page.offline()` is set on a browser or refused by name.** The offline drivers answer
|
|
110
|
+
`X_NOT_IMPLEMENTED` rather than resolving: patching `fetch` in a test process cannot reach a
|
|
111
|
+
browser's own requests, so a driver that quietly answered "done" would let "a like taken offline is
|
|
112
|
+
queued" pass against an app that was online for the whole test.
|
|
113
|
+
|
|
88
114
|
## What the page reports back, and the one thing a picture cannot say
|
|
89
115
|
|
|
90
116
|
Three bounded rings, read off `ScrapePage`. Bounded because a ten-thousand-page run that kept
|
|
@@ -97,6 +123,14 @@ bound threw away, so a count taken from one is never quietly a floor.
|
|
|
97
123
|
| `page.pageErrors()` / `page.pageErrorsDropped()` | `PageError[]` — what the page THREW and nobody caught, with the `stack` when the exception carried one |
|
|
98
124
|
| `page.network()` / `page.networkDropped()` | `NetworkEntry[]` — every request, refusals included |
|
|
99
125
|
|
|
126
|
+
All three are **redacted by value** on the way out, the same pass `page.html()` makes: a login
|
|
127
|
+
endpoint fetched with the password in its query string, or a site that logs the credential it
|
|
128
|
+
rejected, would otherwise put the value in `page.network()`/`page.console()` verbatim and from
|
|
129
|
+
there into the stored failure artifact. The HTTP leg's `X_SCRAPE_HTTP_FAILED` cause is redacted
|
|
130
|
+
too, at its throw site. Values shorter than `MIN_REDACTABLE_LENGTH` are not — a 3-character PIN is
|
|
131
|
+
a substring of ordinary prose — and pixels never can be, which is why a typed secret TAINTS the
|
|
132
|
+
page and `screenshot()`/`pdf()` are refused outright.
|
|
133
|
+
|
|
100
134
|
`console()` and `pageErrors()` are separate streams because they are separate events: an island
|
|
101
135
|
that throws during hydration calls no console method, so a scrape reading the console alone sees a
|
|
102
136
|
page that looks silent and is broken — and a screenshot of it is a picture of the server-rendered
|
|
@@ -141,7 +175,8 @@ on a machine with no browser — the case CI is.
|
|
|
141
175
|
| `driver-cdp.ts` / `cdp-*.ts` | the real browser, over a structural CDP port |
|
|
142
176
|
| `driver-fake.ts` / `driver-fixture.ts` / `html-*.ts` | the offline drivers, on Bun's `HTMLRewriter` |
|
|
143
177
|
| `http.ts` / `http-recorded.ts` | the second transport, live and replayed |
|
|
144
|
-
| `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn |
|
|
178
|
+
| `auth.ts` / `session-state.ts` | acquire → persist → reuse → validate → burn. The session key encodes each part (`<sanitised>.<digest>`), so two account names that differ only outside `[a-zA-Z0-9._-]` are two sessions |
|
|
179
|
+
| `secrets.ts` / `browser-record.ts` | what may leave this package, and how a browser's own string map is read |
|
|
145
180
|
| `expect.ts` | the silent-green alarm |
|
|
146
181
|
| `watchdog.ts` | the wedge and zombie discipline |
|
|
147
182
|
| `capture-clip.ts` | the one framing rule — crop, whole page, or a refusal — checked before any driver sees it |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/scraping",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.0.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": "
|
|
34
|
-
"@ultimat3/jobs": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
36
|
-
"@ultimat3/storage": "
|
|
33
|
+
"@ultimat3/core": "14.0.0",
|
|
34
|
+
"@ultimat3/jobs": "14.0.0",
|
|
35
|
+
"@ultimat3/schema": "14.0.0",
|
|
36
|
+
"@ultimat3/storage": "14.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A string-to-string map that came out of the BROWSER — an element's attributes, `localStorage` —
|
|
2
|
+
// read permissively and onto a null prototype. One reader, because the two call sites had the same
|
|
3
|
+
// bug and would have been fixed twice.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The browser's own keys, READ rather than refused by name.
|
|
7
|
+
*
|
|
8
|
+
* `t.record()` refuses `__proto__`, `constructor` and `prototype` (`packages/schema/src/
|
|
9
|
+
* validators.ts`), which is exactly right where a record's keys are a caller's — a request body —
|
|
10
|
+
* and exactly wrong here. `<div constructor="Foo">` is legal HTML that real build tooling emits,
|
|
11
|
+
* and `localStorage.setItem('constructor', …)` is legal storage; both threw `X_VALIDATION_FAILED`
|
|
12
|
+
* out of a page read with nothing wrong with it, and `cdp-target.ts`'s `guard()` then re-labelled
|
|
13
|
+
* that `X_SCRAPE_BROWSER_UNREACHABLE`, which is registered RETRYABLE — five browser launches and
|
|
14
|
+
* five arrivals at a login over one benign attribute.
|
|
15
|
+
*
|
|
16
|
+
* The null prototype is the other half, and it is why this is not merely a looser schema: on a
|
|
17
|
+
* `{}` literal `attrs['toString']` answers an `Object.prototype` function the page never sent, and
|
|
18
|
+
* `attrs['__proto__'] = value` files no own key at all. `Readonly<Record<string, string>>` would
|
|
19
|
+
* be a lie the caller cannot see through either way. Same construction and the same argument as
|
|
20
|
+
* `http.ts`'s `headerRecord` makes for response headers, one leg over.
|
|
21
|
+
*
|
|
22
|
+
* TOTAL, deliberately: a non-object answers an empty map and a non-string value is skipped. The
|
|
23
|
+
* caller is a driver reading a live browser, and a refusal from here is read by everything above
|
|
24
|
+
* it as a browser that went away.
|
|
25
|
+
*/
|
|
26
|
+
export function browserRecord(value: unknown): Record<string, string> {
|
|
27
|
+
const out = Object.create(null) as Record<string, string>;
|
|
28
|
+
if (typeof value !== 'object' || value === null) return out;
|
|
29
|
+
// Assignment, not `defineProperty`: with no prototype there is no inherited `__proto__` setter
|
|
30
|
+
// to swallow the write, so a key spelled `__proto__` files as an ordinary own key.
|
|
31
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
32
|
+
if (typeof entry === 'string') out[key] = entry;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
package/src/cdp-fake.ts
CHANGED
|
@@ -19,11 +19,35 @@ const selectorOf = (expression: string): string | undefined => {
|
|
|
19
19
|
return JSON.parse(match[1]) as string;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The selector inside `clearExpression()`'s `document.querySelector("…")`, READ rather than
|
|
24
|
+
* imported. A clear is the one verb this port has no method for — it travels as an expression — so
|
|
25
|
+
* a fake that recognised it by identity could not tell the page's document from a frame's, which
|
|
26
|
+
* is precisely the divergence `driver-parity-frames.test.ts` needs it to expose.
|
|
27
|
+
*/
|
|
28
|
+
const clearedSelectorOf = (expression: string): string | undefined => {
|
|
29
|
+
if (!expression.includes(".value = ''")) return undefined;
|
|
30
|
+
const match = /querySelector\((".*?")\)/s.exec(expression);
|
|
31
|
+
if (match?.[1] === undefined) return undefined;
|
|
32
|
+
return JSON.parse(match[1]) as string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface FakeCdpFrameInit {
|
|
36
|
+
readonly url: string;
|
|
37
|
+
readonly html: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
export interface FakeCdpPageInit {
|
|
23
41
|
readonly url: string;
|
|
24
42
|
readonly html: string;
|
|
25
43
|
/** Selectors a click navigates from, and where to. */
|
|
26
44
|
readonly routes?: Readonly<Record<string, { readonly url: string; readonly html: string }>>;
|
|
45
|
+
/**
|
|
46
|
+
* Child frames by NAME, each its own document. A frame with its own markup is what makes a
|
|
47
|
+
* frame verb aimed at the parent document visible — with `frames(): []` the whole frame half of
|
|
48
|
+
* `cdp-target.ts` was unreachable from every test in the package.
|
|
49
|
+
*/
|
|
50
|
+
readonly frames?: Readonly<Record<string, FakeCdpFrameInit>>;
|
|
27
51
|
readonly cookies?: readonly ScrapeCookie[];
|
|
28
52
|
readonly storage?: Readonly<Record<string, string>>;
|
|
29
53
|
readonly userAgent?: string;
|
|
@@ -44,6 +68,8 @@ export interface FakeCdpBrowser extends CdpBrowserLike {
|
|
|
44
68
|
emitPageError(payload: unknown): void;
|
|
45
69
|
readonly aborted: readonly string[];
|
|
46
70
|
readonly closed: boolean;
|
|
71
|
+
/** What `page.setOfflineMode()` last set, so a test asserts on the CONDITION, not on a call. */
|
|
72
|
+
readonly offline: boolean;
|
|
47
73
|
}
|
|
48
74
|
|
|
49
75
|
/** A layout box every element gets, so the CDP path exercises the fields the fake target lacks. */
|
|
@@ -53,58 +79,136 @@ const boxed = (snapshot: ElementSnapshot, covered: boolean): ElementSnapshot =>
|
|
|
53
79
|
hitTarget: !covered,
|
|
54
80
|
});
|
|
55
81
|
|
|
82
|
+
/**
|
|
83
|
+
* ONE document — the page's, or a frame's. A browser gives every browsing context its own DOM, so
|
|
84
|
+
* the fake gives every one its own typed values: a verb that reaches the wrong document then shows
|
|
85
|
+
* up as text in the wrong place rather than as nothing at all.
|
|
86
|
+
*/
|
|
87
|
+
interface FakeDocument {
|
|
88
|
+
html(): string;
|
|
89
|
+
/** Typed text, keyed the way `keyOf` keys it offline: `#id` when there is one, else the selector. */
|
|
90
|
+
readonly typed: Map<string, string>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const keyFor = (selector: string, element: ElementSnapshot | undefined): string => {
|
|
94
|
+
const id = element?.attrs['id'];
|
|
95
|
+
return id === undefined ? selector : `#${id}`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** What the DOM would answer after a `type()`: the element, carrying whatever was typed into it. */
|
|
99
|
+
const withTyped = (
|
|
100
|
+
document: FakeDocument,
|
|
101
|
+
selector: string,
|
|
102
|
+
elements: readonly ElementSnapshot[],
|
|
103
|
+
): readonly ElementSnapshot[] =>
|
|
104
|
+
elements.map((element) => {
|
|
105
|
+
const typed = document.typed.get(keyFor(selector, element));
|
|
106
|
+
return typed === undefined ? element : { ...element, value: typed };
|
|
107
|
+
});
|
|
108
|
+
|
|
56
109
|
export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
57
110
|
const handlers: Handlers = new Map();
|
|
58
111
|
const aborted: string[] = [];
|
|
59
112
|
let url = init.url;
|
|
60
113
|
let html = init.html;
|
|
61
114
|
let closed = false;
|
|
115
|
+
let offline = false;
|
|
62
116
|
const covered = new Set(init.covered ?? []);
|
|
63
117
|
const storage: Record<string, string> = { ...init.storage };
|
|
64
118
|
let cookies: readonly ScrapeCookie[] = init.cookies ?? [];
|
|
65
119
|
|
|
66
|
-
const
|
|
120
|
+
const documentOf = (read: () => string): FakeDocument => ({ html: read, typed: new Map() });
|
|
121
|
+
const pageDocument = documentOf(() => html);
|
|
122
|
+
|
|
123
|
+
/** Every expression this package sends, answered against the document it was sent to. */
|
|
124
|
+
const evaluateIn = async (target: FakeDocument, expression: string): Promise<unknown> => {
|
|
67
125
|
const selector = selectorOf(expression);
|
|
68
126
|
if (selector !== undefined) {
|
|
69
|
-
const found = await queryHtml(html, selector);
|
|
127
|
+
const found = withTyped(target, selector, await queryHtml(target.html(), selector));
|
|
70
128
|
return JSON.stringify(found.map((element) => boxed(element, covered.has(selector))));
|
|
71
129
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
130
|
+
const cleared = clearedSelectorOf(expression);
|
|
131
|
+
if (cleared !== undefined) {
|
|
132
|
+
const [first] = await queryHtml(target.html(), cleared);
|
|
133
|
+
target.typed.set(keyFor(cleared, first), '');
|
|
134
|
+
return undefined;
|
|
75
135
|
}
|
|
76
|
-
if (expression.includes('navigator.userAgent')) return init.userAgent ?? 'fake-agent';
|
|
77
136
|
return undefined;
|
|
78
137
|
};
|
|
79
138
|
|
|
139
|
+
const typeIn = async (target: FakeDocument, selector: string, text: string): Promise<void> => {
|
|
140
|
+
const [first] = await queryHtml(target.html(), selector);
|
|
141
|
+
const key = keyFor(selector, first);
|
|
142
|
+
target.typed.set(key, `${target.typed.get(key) ?? first?.value ?? ''}${text}`);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const selectIn = async (target: FakeDocument, selector: string, value: string): Promise<void> => {
|
|
146
|
+
const [first] = await queryHtml(target.html(), selector);
|
|
147
|
+
target.typed.set(keyFor(selector, first), value);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const frames: readonly CdpFrameLike[] = Object.entries(init.frames ?? {}).map(([name, frame]) => {
|
|
151
|
+
const frameDocument = documentOf(() => frame.html);
|
|
152
|
+
return {
|
|
153
|
+
name: () => name,
|
|
154
|
+
url: () => frame.url,
|
|
155
|
+
content: () => Promise.resolve(frame.html),
|
|
156
|
+
evaluate: (expression: string) => evaluateIn(frameDocument, expression),
|
|
157
|
+
click: () => Promise.resolve(),
|
|
158
|
+
type: (selector: string, text: string) => typeIn(frameDocument, selector, text),
|
|
159
|
+
select: async (selector: string, ...values: string[]): Promise<string[]> => {
|
|
160
|
+
await selectIn(frameDocument, selector, values[0] ?? '');
|
|
161
|
+
return [...values];
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
|
|
80
166
|
const page: CdpPageLike = {
|
|
81
167
|
url: () => url,
|
|
82
168
|
goto: (next: string) => {
|
|
83
169
|
url = next;
|
|
170
|
+
// A navigation is a new document, so what was typed into the old one is gone. Keeping it
|
|
171
|
+
// would let a stale value answer a query on a page that never had it.
|
|
172
|
+
pageDocument.typed.clear();
|
|
84
173
|
return Promise.resolve(undefined);
|
|
85
174
|
},
|
|
86
175
|
content: () => Promise.resolve(html),
|
|
87
|
-
evaluate
|
|
176
|
+
async evaluate(expression: string): Promise<unknown> {
|
|
177
|
+
if (expression.includes('localStorage')) {
|
|
178
|
+
if (expression.includes('setItem')) return undefined;
|
|
179
|
+
return JSON.stringify(storage);
|
|
180
|
+
}
|
|
181
|
+
if (expression.includes('navigator.userAgent')) return init.userAgent ?? 'fake-agent';
|
|
182
|
+
return await evaluateIn(pageDocument, expression);
|
|
183
|
+
},
|
|
88
184
|
click: (selector: string) => {
|
|
89
185
|
const route = init.routes?.[selector];
|
|
90
186
|
if (route !== undefined) {
|
|
91
187
|
url = route.url;
|
|
92
188
|
html = route.html;
|
|
189
|
+
pageDocument.typed.clear();
|
|
93
190
|
}
|
|
94
191
|
return Promise.resolve();
|
|
95
192
|
},
|
|
96
|
-
type: () =>
|
|
97
|
-
select: ()
|
|
193
|
+
type: (selector: string, text: string) => typeIn(pageDocument, selector, text),
|
|
194
|
+
select: async (selector: string, ...values: string[]): Promise<string[]> => {
|
|
195
|
+
await selectIn(pageDocument, selector, values[0] ?? '');
|
|
196
|
+
return [...values];
|
|
197
|
+
},
|
|
98
198
|
screenshot: () => Promise.resolve(new Uint8Array([1, 2, 3])),
|
|
99
199
|
pdf: () => Promise.resolve(new Uint8Array([4, 5])),
|
|
100
200
|
setRequestInterception: () => Promise.resolve(),
|
|
201
|
+
setOfflineMode: (enabled: boolean) => {
|
|
202
|
+
offline = enabled;
|
|
203
|
+
return Promise.resolve();
|
|
204
|
+
},
|
|
101
205
|
on: (event: string, handler: (payload: unknown) => void) => {
|
|
102
206
|
const listeners = handlers.get(event) ?? [];
|
|
103
207
|
listeners.push(handler);
|
|
104
208
|
handlers.set(event, listeners);
|
|
105
209
|
return undefined;
|
|
106
210
|
},
|
|
107
|
-
frames: (): readonly CdpFrameLike[] =>
|
|
211
|
+
frames: (): readonly CdpFrameLike[] => frames,
|
|
108
212
|
close: () => {
|
|
109
213
|
closed = true;
|
|
110
214
|
return Promise.resolve();
|
|
@@ -143,6 +247,9 @@ export function fakeCdpBrowser(init: FakeCdpPageInit): FakeCdpBrowser {
|
|
|
143
247
|
get closed(): boolean {
|
|
144
248
|
return closed;
|
|
145
249
|
},
|
|
250
|
+
get offline(): boolean {
|
|
251
|
+
return offline;
|
|
252
|
+
},
|
|
146
253
|
};
|
|
147
254
|
}
|
|
148
255
|
|
package/src/cdp-port.ts
CHANGED
|
@@ -56,6 +56,19 @@ export interface CdpPageLike {
|
|
|
56
56
|
screenshot(options: CdpScreenshotOptions): Promise<Uint8Array | string>;
|
|
57
57
|
pdf(options?: Record<string, unknown>): Promise<Uint8Array>;
|
|
58
58
|
setRequestInterception(enabled: boolean): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* The browser's own network condition — `Network.emulateNetworkConditions` under CDP.
|
|
61
|
+
*
|
|
62
|
+
* OPTIONAL, read defensively, for `CdpRequestLike.method` and `CdpBrowserLike.cookies`' reason:
|
|
63
|
+
* this file is the shape of somebody ELSE's object, and a provider SDK or a launcher that
|
|
64
|
+
* predates the method must still satisfy the port. It does not go unwired by being optional —
|
|
65
|
+
* `cdp-target.ts` refuses BY NAME with `X_NOT_IMPLEMENTED` and a fix when a launcher lacks it,
|
|
66
|
+
* exactly as `cookies()` does, so the gap is a coded refusal rather than a silent no-op.
|
|
67
|
+
*
|
|
68
|
+
* Required instead would cost every existing launcher, every provider SDK and every test double
|
|
69
|
+
* a type error for a capability the port cannot make them have.
|
|
70
|
+
*/
|
|
71
|
+
setOfflineMode?(enabled: boolean): Promise<void>;
|
|
59
72
|
/**
|
|
60
73
|
* `event` stays a bare `string` — a union of the four names this package subscribes to would be
|
|
61
74
|
* this file naming somebody else's event vocabulary, which is the thing it exists not to do, and
|
package/src/cdp-snapshot.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The in-page expressions this package runs, and the schema that reads their answer back.
|
|
2
2
|
//
|
|
3
3
|
// It computes exactly `ElementSnapshot` — including the two fields the offline drivers cannot
|
|
4
4
|
// have: the layout box, and whether the element is what a click at its own centre would hit. A
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
9
9
|
import { parse, t } from '@ultimat3/schema';
|
|
10
|
+
import { browserRecord } from './browser-record';
|
|
10
11
|
import type { ElementSnapshot } from './target';
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -36,6 +37,18 @@ export const snapshotExpression = (selector: string): string => `(() => {
|
|
|
36
37
|
return JSON.stringify(out);
|
|
37
38
|
})()`;
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Emptying a control, as an expression — the one verb `CdpPageLike`/`CdpFrameLike` has no method
|
|
42
|
+
* for, so it travels as text and is `evaluate`d against whichever document it is handed to.
|
|
43
|
+
*
|
|
44
|
+
* ONE declaration, and that is the whole point of it living here: the page target and the frame
|
|
45
|
+
* target each `evaluate` it, and a second copy is how the frame half came to send the page's
|
|
46
|
+
* expression to the page's own document — emptying the PARENT's same-named field while the
|
|
47
|
+
* frame's kept its value and a `fill` appended to it.
|
|
48
|
+
*/
|
|
49
|
+
export const clearExpression = (selector: string): string =>
|
|
50
|
+
`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el) { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); } })()`;
|
|
51
|
+
|
|
39
52
|
/**
|
|
40
53
|
* `t.string` refuses an empty string, and an element's `text` and `value` are legitimately empty
|
|
41
54
|
* far more often than not — a `<div>` wrapper, an unfilled input. `.min(0)` says "a string, and
|
|
@@ -43,10 +56,16 @@ export const snapshotExpression = (selector: string): string => `(() => {
|
|
|
43
56
|
*/
|
|
44
57
|
const anyString = t.string.min(0);
|
|
45
58
|
|
|
59
|
+
/**
|
|
60
|
+
* `attrs` is absent from the shape and read by `browserRecord` instead — the one field whose KEYS
|
|
61
|
+
* are the page's rather than a schema's. `t.record()` refuses `__proto__`, `constructor` and
|
|
62
|
+
* `prototype` by name, so `<div constructor="Foo">` on any queried element refused the whole read;
|
|
63
|
+
* see `browser-record.ts` for why the refusal is right for a request body and wrong for a DOM.
|
|
64
|
+
* Every other field is still parsed, and a `tag` that is not a string still refuses.
|
|
65
|
+
*/
|
|
46
66
|
const snapshotSchema = t.array(
|
|
47
67
|
t.object({
|
|
48
68
|
tag: t.string,
|
|
49
|
-
attrs: t.record(anyString),
|
|
50
69
|
text: anyString,
|
|
51
70
|
value: anyString,
|
|
52
71
|
visible: t.boolean,
|
|
@@ -54,10 +73,22 @@ const snapshotSchema = t.array(
|
|
|
54
73
|
box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
|
|
55
74
|
hitTarget: t.boolean,
|
|
56
75
|
}),
|
|
57
|
-
) as unknown as StandardSchemaV1<unknown, ElementSnapshot[]>;
|
|
76
|
+
) as unknown as StandardSchemaV1<unknown, Omit<ElementSnapshot, 'attrs'>[]>;
|
|
77
|
+
|
|
78
|
+
const attrsOf = (row: unknown): unknown =>
|
|
79
|
+
typeof row === 'object' && row !== null ? (row as { readonly attrs?: unknown }).attrs : undefined;
|
|
58
80
|
|
|
59
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* The browser's answer is `unknown` and stays `unknown` until this reads it. Never a cast.
|
|
83
|
+
*
|
|
84
|
+
* The parsed rows and the raw rows are zipped by INDEX, which is sound because the array schema
|
|
85
|
+
* preserves order and length: a row that failed makes the whole parse throw before this runs.
|
|
86
|
+
*/
|
|
60
87
|
export function parseSnapshots(raw: unknown): readonly ElementSnapshot[] {
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
const value = typeof raw === 'string' ? (JSON.parse(raw) as unknown) : raw;
|
|
89
|
+
const rows: readonly unknown[] = Array.isArray(value) ? (value as readonly unknown[]) : [];
|
|
90
|
+
return parse(snapshotSchema, value).map((element, index) => ({
|
|
91
|
+
...element,
|
|
92
|
+
attrs: browserRecord(attrsOf(rows[index])),
|
|
93
|
+
}));
|
|
63
94
|
}
|
package/src/cdp-target.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
import { isUltimateError } from '@ultimat3/core';
|
|
5
5
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
6
6
|
import { parse, t } from '@ultimat3/schema';
|
|
7
|
+
import { browserRecord } from './browser-record';
|
|
7
8
|
import type { CdpBrowserLike, CdpFrameLike, CdpPageLike, CdpRequestLike } from './cdp-port';
|
|
8
|
-
import { parseSnapshots, snapshotExpression } from './cdp-snapshot';
|
|
9
|
+
import { clearExpression, parseSnapshots, snapshotExpression } from './cdp-snapshot';
|
|
9
10
|
import type { ScrapeClock } from './clock';
|
|
10
11
|
import { browserUnreachable, pageCrashed, scrapeNotImplemented } from './error-throws';
|
|
11
12
|
import type { InterceptRules } from './intercept';
|
|
@@ -47,11 +48,6 @@ const cookieSchema = t.array(
|
|
|
47
48
|
}),
|
|
48
49
|
) as unknown as StandardSchemaV1<unknown, ScrapeCookie[]>;
|
|
49
50
|
|
|
50
|
-
const storageSchema = t.record(anyString) as unknown as StandardSchemaV1<
|
|
51
|
-
unknown,
|
|
52
|
-
Record<string, string>
|
|
53
|
-
>;
|
|
54
|
-
|
|
55
51
|
const asResourceType = (raw: string): ResourceType =>
|
|
56
52
|
(RESOURCE_TYPES as readonly string[]).includes(raw) ? (raw as ResourceType) : 'other';
|
|
57
53
|
|
|
@@ -130,31 +126,51 @@ const readPageError = (payload: unknown, at: number): PageError => {
|
|
|
130
126
|
};
|
|
131
127
|
|
|
132
128
|
/**
|
|
133
|
-
* The
|
|
129
|
+
* The failures `guard()` must NOT re-label. Two codes, each because a SECOND attempt reaches the
|
|
130
|
+
* identical answer — never because the error looked coded.
|
|
131
|
+
*
|
|
132
|
+
* | Code | Why a retry cannot change it |
|
|
133
|
+
* |---|---|
|
|
134
|
+
* | `X_NOT_IMPLEMENTED` | a fact about the launcher's own shape. A browser cannot produce it; only this file's `scrapeNotImplemented()` can, and the method is still missing on attempt five |
|
|
135
|
+
* | `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 |
|
|
134
136
|
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* `X_SCRAPE_BROWSER_UNREACHABLE` (registered `retryable` in `errors.ts`) it spends every attempt
|
|
139
|
-
* in the scrape's retry policy on a method that is still missing on attempt five, and tells the
|
|
140
|
-
* operator the browser went away while the browser is answering fine.
|
|
137
|
+
* Both were re-labelled `X_SCRAPE_BROWSER_UNREACHABLE`, which `errors.ts` registers `retryable`,
|
|
138
|
+
* so each spent the whole retry policy — five browser launches, five arrivals at a login — while
|
|
139
|
+
* telling the operator the browser went away about a browser that was answering perfectly.
|
|
141
140
|
*
|
|
142
141
|
* Every OTHER coded error stays wrapped, deliberately. `thrown instanceof UltimateError` is the
|
|
143
142
|
* naive version of this check and it is wrong: an `X_SCRAPE_TIMEOUT` raised while the socket was
|
|
144
143
|
* already dead would then arrive unwrapped, and "the browser went away mid-run" is the frame that
|
|
145
144
|
* makes a disconnect legible — which is the whole reason this wrapper exists.
|
|
146
145
|
*
|
|
146
|
+
* The half this package cannot close: both codes belong to `@ultimat3/core`/`@ultimat3/schema` and
|
|
147
|
+
* NEITHER is classified, so `classifyThrown` reads them as unclassified and the job's attempt
|
|
148
|
+
* count still governs. Passing them through stops the wrong TITLE and the false `retryable`
|
|
149
|
+
* claim; making them terminal is a `registerErrorRetry` beside the code that declares it.
|
|
150
|
+
*
|
|
147
151
|
* `isUltimateError`, not `instanceof`: the brand survives a duplicated module instance.
|
|
148
152
|
*/
|
|
153
|
+
const PASSED_THROUGH_CODES: ReadonlySet<string> = new Set([
|
|
154
|
+
'X_NOT_IMPLEMENTED',
|
|
155
|
+
'X_VALIDATION_FAILED',
|
|
156
|
+
]);
|
|
157
|
+
|
|
149
158
|
const isStructuralRefusal = (thrown: unknown): boolean =>
|
|
150
|
-
isUltimateError(thrown) && thrown.code
|
|
159
|
+
isUltimateError(thrown) && PASSED_THROUGH_CODES.has(thrown.code);
|
|
151
160
|
|
|
161
|
+
/**
|
|
162
|
+
* `ringCapacity` was declared here, exported, read three lines into `cdpTarget` — and passed by
|
|
163
|
+
* NOBODY: `driver-cdp.ts` constructs without it and no `BrowserOptions` or `ScrapeDefinition`
|
|
164
|
+
* field reaches it, so an app could not set it at any distance. Deleted rather than threaded, the
|
|
165
|
+
* same call `CaptureOptions.timeoutMs` got: wiring it through only the CDP driver would give one
|
|
166
|
+
* of three drivers a bound the other two ignore, which is precisely the divergence
|
|
167
|
+
* `driver-parity.test.ts` exists to refuse. `DEFAULT_RING_CAPACITY` is the one bound.
|
|
168
|
+
*/
|
|
152
169
|
export interface CdpTargetInit {
|
|
153
170
|
readonly page: CdpPageLike;
|
|
154
171
|
readonly browser: CdpBrowserLike;
|
|
155
172
|
readonly rules: InterceptRules;
|
|
156
173
|
readonly clock: ScrapeClock;
|
|
157
|
-
readonly ringCapacity?: number | undefined;
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
/**
|
|
@@ -228,9 +244,9 @@ async function arm(init: CdpTargetInit, sinks: CdpSinks): Promise<void> {
|
|
|
228
244
|
}
|
|
229
245
|
|
|
230
246
|
export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
231
|
-
const console_ = createRing<ConsoleLine>(
|
|
232
|
-
const network = createRing<NetworkEntry>(
|
|
233
|
-
const pageErrors = createRing<PageError>(
|
|
247
|
+
const console_ = createRing<ConsoleLine>();
|
|
248
|
+
const network = createRing<NetworkEntry>();
|
|
249
|
+
const pageErrors = createRing<PageError>();
|
|
234
250
|
const crashed: { value: string | undefined } = { value: undefined };
|
|
235
251
|
await arm(init, { network, console: console_, pageErrors, crashed });
|
|
236
252
|
let pendingStorage: SessionSnapshot | undefined;
|
|
@@ -288,6 +304,15 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
288
304
|
),
|
|
289
305
|
click: (selector) => guard('click', () => frame.click(selector)),
|
|
290
306
|
type: (selector, text) => guard('type', () => frame.type(selector, text)),
|
|
307
|
+
// Listed even though `type` above already covers the pair a `fill` performs: `clear` is the
|
|
308
|
+
// one verb with no port method, so it is the one an author of a new frame verb forgets. It was
|
|
309
|
+
// forgotten — the spread handed the frame the PAGE's closure, so `frame.fill()` emptied the
|
|
310
|
+
// parent document's same-named field and merely APPENDED to the frame's, which is how an
|
|
311
|
+
// iframe'd login submits `oldUserNEWUSER` and passes its own offline test doing it.
|
|
312
|
+
clear: (selector) =>
|
|
313
|
+
guard('clear', async () => {
|
|
314
|
+
await frame.evaluate(clearExpression(selector));
|
|
315
|
+
}),
|
|
291
316
|
select: (selector, values) =>
|
|
292
317
|
guard('select', async () => {
|
|
293
318
|
await frame.select(selector, ...values);
|
|
@@ -318,15 +343,24 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
318
343
|
type: (selector, text) => guard('type', () => init.page.type(selector, text)),
|
|
319
344
|
clear: (selector) =>
|
|
320
345
|
guard('clear', async () => {
|
|
321
|
-
await init.page.evaluate(
|
|
322
|
-
`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (el) { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); } })()`,
|
|
323
|
-
);
|
|
346
|
+
await init.page.evaluate(clearExpression(selector));
|
|
324
347
|
}),
|
|
325
348
|
select: (selector, values) =>
|
|
326
349
|
guard('select', async () => {
|
|
327
350
|
await init.page.select(selector, ...values);
|
|
328
351
|
}),
|
|
329
352
|
evaluate: (expression) => guard('evaluate', () => init.page.evaluate(expression)),
|
|
353
|
+
setOfflineMode: (enabled: boolean): Promise<void> =>
|
|
354
|
+
guard('setOfflineMode', async () => {
|
|
355
|
+
const source = init.page as { setOfflineMode?: (value: boolean) => Promise<void> };
|
|
356
|
+
if (typeof source.setOfflineMode !== 'function') {
|
|
357
|
+
throw scrapeNotImplemented(
|
|
358
|
+
'setOfflineMode() on a CDP page with no setOfflineMode() method',
|
|
359
|
+
'upgrade the launcher to a puppeteer-core that exposes page.setOfflineMode(), or drive the condition from the app under test instead of the browser',
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
await source.setOfflineMode(enabled);
|
|
363
|
+
}),
|
|
330
364
|
screenshot: (options: CaptureOptions) =>
|
|
331
365
|
guard('screenshot', async () => {
|
|
332
366
|
// `fullPage` is OMITTED when a clip is given rather than sent as `false`: the two are
|
|
@@ -398,7 +432,9 @@ export async function cdpTarget(init: CdpTargetInit): Promise<ScrapeTarget> {
|
|
|
398
432
|
cookies:
|
|
399
433
|
typeof source.cookies === 'function' ? parse(cookieSchema, await source.cookies()) : [],
|
|
400
434
|
headers: {},
|
|
401
|
-
|
|
435
|
+
// `browserRecord` and not `t.record()`: a page may legitimately hold a key called
|
|
436
|
+
// `constructor`, and the schema refuses that name outright — see `browser-record.ts`.
|
|
437
|
+
storage: browserRecord(JSON.parse(typeof storage === 'string' ? storage : '{}')),
|
|
402
438
|
userAgent: typeof agent === 'string' ? agent : '',
|
|
403
439
|
origin,
|
|
404
440
|
};
|
package/src/driver-cdp.ts
CHANGED
|
@@ -143,6 +143,10 @@ async function sessionOver(
|
|
|
143
143
|
signal,
|
|
144
144
|
onActivity,
|
|
145
145
|
proxy: options.proxy,
|
|
146
|
+
// The run's secrets, so the ONE thing this leg quotes from the site — the first 200 bytes
|
|
147
|
+
// of a non-2xx body, in `X_SCRAPE_HTTP_FAILED`'s cause — cannot carry the password the
|
|
148
|
+
// login endpoint echoed back. The bag was in scope here and unread.
|
|
149
|
+
secrets: init.secrets,
|
|
146
150
|
}),
|
|
147
151
|
close: () => guard.shutdown(),
|
|
148
152
|
};
|