cdp-toolkit 2.2.0 → 2.5.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/CHANGELOG.md +33 -0
- package/README.md +43 -17
- package/dist/bidi/mux-daemon.js +2 -0
- package/dist/cli.js +695 -42
- package/dist/index.js +514 -0
- package/dist/mcp.js +724 -30
- package/package.json +5 -3
- package/src/cdp/driver.ts +1 -1
- package/src/driver.ts +3 -0
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +16 -3
- package/src/manifest.ts +138 -0
- package/src/toolDocs.ts +37 -0
- package/src/toolGroups.ts +3 -2
- package/src/tools/extract.ts +777 -0
- package/src/tools/focus-emulation.ts +185 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract.ts: `extract_page` — structured extraction from a page's HTML via an
|
|
3
|
+
* OpenAI-compatible /chat/completions endpoint.
|
|
4
|
+
*
|
|
5
|
+
* THE PIPELINE (why each stage exists)
|
|
6
|
+
* ====================================
|
|
7
|
+
* A page's DOM is a terrible prompt: scripts, styles, hidden boilerplate and
|
|
8
|
+
* 40KB of attribute noise drown the content an extraction actually needs. So
|
|
9
|
+
* the tool cleans the HTML IN-PAGE (a self-contained function evaluated on the
|
|
10
|
+
* resolved page, the bidi/snapshot.ts pattern: no closures over TS scope, no
|
|
11
|
+
* imports, only DOM calls every shipped browser has), measures the result, and
|
|
12
|
+
* only then hands it to the endpoint — page content never leaves the machine
|
|
13
|
+
* unless an operator explicitly points baseUrl (or CDP_EXTRACT_BASE_URL) at a
|
|
14
|
+
* remote host.
|
|
15
|
+
*
|
|
16
|
+
* The caller's JSON Schema is BOTH the response contract (sent as
|
|
17
|
+
* response_format json_schema strict) AND the extraction prompt: Schematron
|
|
18
|
+
* reads the schema's property DESCRIPTIONS as its only instructions. A schema
|
|
19
|
+
* with no descriptions anywhere would extract garbage with no hint why, so it
|
|
20
|
+
* is rejected up front, before any page work happens.
|
|
21
|
+
*
|
|
22
|
+
* COST IS ALWAYS VISIBLE: every successful call reports the endpoint's token
|
|
23
|
+
* counts, so a bloated extraction is visible in the answer itself, not
|
|
24
|
+
* discovered on a bill later.
|
|
25
|
+
*
|
|
26
|
+
* ENDPOINT CONFIG (env, read per call like leases.ts's getters — never at
|
|
27
|
+
* module load, so a test or operator can redirect without a restart):
|
|
28
|
+
* CDP_EXTRACT_BASE_URL default http://127.0.0.1:8090/v1 (loopback llm-ferry)
|
|
29
|
+
* CDP_EXTRACT_MODEL default "schematron"
|
|
30
|
+
* CDP_EXTRACT_API_KEY optional; sent as Bearer if set. THE KEY IS NEVER AN
|
|
31
|
+
* ARGUMENT (args can land in transcripts/logs; env
|
|
32
|
+
* cannot), never logged, and redacted out of every
|
|
33
|
+
* upstream-derived error text (see redactSecrets).
|
|
34
|
+
* CDP_EXTRACT_TIMEOUT_MS default 90000 (hard-capped at 300000 with the arg)
|
|
35
|
+
* CDP_EXTRACT_MAX_CHARS default 300000
|
|
36
|
+
* CDP_EXTRACT_PROMPT default "html"; "schematron" switches to the
|
|
37
|
+
* Schematron model-card prompt (see buildMessages)
|
|
38
|
+
*
|
|
39
|
+
* BOTH BACKENDS, NO capability gating: everything page-side goes through the
|
|
40
|
+
* neutral Driver's page.evaluate (Chrome Runtime.evaluate / Firefox
|
|
41
|
+
* script.evaluate), so the tool works identically under --browser firefox.
|
|
42
|
+
* Lease gate: driver.page() -> resolveTarget/resolveContext -> assertLeaseOk,
|
|
43
|
+
* the same choke points as every other driver-based tool (the `lease` arg is
|
|
44
|
+
* consumed by mcp.ts's ambient withLeaseScope, never read here).
|
|
45
|
+
*/
|
|
46
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
47
|
+
import { dirname, join } from "node:path";
|
|
48
|
+
import { CdpError } from "../client.ts";
|
|
49
|
+
import type { BrowserDriver, PageDriver } from "../driver.ts";
|
|
50
|
+
import type { TargetSelector } from "../types.ts";
|
|
51
|
+
|
|
52
|
+
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
53
|
+
|
|
54
|
+
const DEFAULT_BASE_URL = "http://127.0.0.1:8090/v1";
|
|
55
|
+
const DEFAULT_MODEL = "schematron";
|
|
56
|
+
const DEFAULT_TIMEOUT_MS = 90_000;
|
|
57
|
+
const DEFAULT_MAX_CHARS = 300_000;
|
|
58
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
59
|
+
const DEFAULT_PROMPT_MODE = "html";
|
|
60
|
+
|
|
61
|
+
/** Token arithmetic for the size guard: ~3.5 chars/token on HTML, the fixed
|
|
62
|
+
* completion budget we request, a safety margin, against a 128k context. */
|
|
63
|
+
const CHARS_PER_TOKEN = 3.5;
|
|
64
|
+
const COMPLESION_TOKENS = 8_192;
|
|
65
|
+
const SAFETY_TOKENS = 8_000;
|
|
66
|
+
const CONTEXT_TOKEN_BUDGET = 128_000;
|
|
67
|
+
|
|
68
|
+
export interface ExtractPageArgs {
|
|
69
|
+
target?: TargetSelector;
|
|
70
|
+
/** Consumed by mcp.ts's ambient lease scope; declared for manifest parity. */
|
|
71
|
+
lease?: string;
|
|
72
|
+
/**
|
|
73
|
+
* REQUIRED JSON Schema (type "object") describing the JSON you want back.
|
|
74
|
+
* Its property descriptions ARE the extraction instructions — a schema with
|
|
75
|
+
* no descriptions anywhere is rejected before any page work happens.
|
|
76
|
+
* additionalProperties:true is rejected too: strict json_schema mode would
|
|
77
|
+
* otherwise silently widen the contract.
|
|
78
|
+
*/
|
|
79
|
+
schema: Record<string, unknown>;
|
|
80
|
+
/** CSS selector scoping the payload to that subtree (pierces open shadow
|
|
81
|
+
* roots when locating; the serialized subtree is its light DOM). */
|
|
82
|
+
selector?: string;
|
|
83
|
+
/** Page source to read. "dom" (default) only; "served" is not implemented. */
|
|
84
|
+
source?: "dom" | "served";
|
|
85
|
+
/** Cleaning level: "standard" (default), "aggressive", or "none". */
|
|
86
|
+
clean?: "standard" | "aggressive" | "none";
|
|
87
|
+
/** Character cap on the cleaned HTML. Default 300000. NO truncation: over
|
|
88
|
+
* the cap is an html_too_large error naming the levers, never a half page
|
|
89
|
+
* extracted as though it were whole. */
|
|
90
|
+
maxChars?: number;
|
|
91
|
+
/** Endpoint budget in ms. Default 90000, hard max 300000. */
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
/**
|
|
94
|
+
* Prompt shape sent upstream. "html" (default) sends the cleaned HTML as the
|
|
95
|
+
* only user message and lets the server carry the schema — what the hosted
|
|
96
|
+
* Schematron API expects, since it injects the schema server-side.
|
|
97
|
+
* "schematron" sends the open-weight Schematron model card's own messages
|
|
98
|
+
* (system + a user message with the schema INLINE, then the HTML), which a
|
|
99
|
+
* locally served open-weight Schematron was fine-tuned on: without it, an
|
|
100
|
+
* HTML-only prompt yields garbage even under constrained decoding. It adds
|
|
101
|
+
* roughly the JSON-stringified schema's length to the prompt. Default from
|
|
102
|
+
* CDP_EXTRACT_PROMPT, then "html". response_format is sent in BOTH modes.
|
|
103
|
+
*/
|
|
104
|
+
prompt?: "html" | "schematron";
|
|
105
|
+
/** Model name override (default CDP_EXTRACT_MODEL, then "schematron"). */
|
|
106
|
+
model?: string;
|
|
107
|
+
/** Base URL override; the tool appends /chat/completions. Default is the
|
|
108
|
+
* loopback llm-ferry. THE API KEY IS NOT AN ARGUMENT — env only. */
|
|
109
|
+
baseUrl?: string;
|
|
110
|
+
/** Write the full result object to this path instead of returning it
|
|
111
|
+
* inline; the response then carries the path, bytes, model, usage and
|
|
112
|
+
* target only. Absolute used as-is, relative under the artifact dir. */
|
|
113
|
+
savePath?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ExtractUsage {
|
|
117
|
+
promptTokens: number;
|
|
118
|
+
completionTokens: number;
|
|
119
|
+
totalTokens: number;
|
|
120
|
+
costUsd?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ExtractPageResult {
|
|
124
|
+
data: unknown;
|
|
125
|
+
model: string;
|
|
126
|
+
usage: ExtractUsage;
|
|
127
|
+
html: {
|
|
128
|
+
source: "dom";
|
|
129
|
+
rawBytes: number;
|
|
130
|
+
cleanBytes: number;
|
|
131
|
+
estTokens: number;
|
|
132
|
+
dropped: Record<string, unknown>;
|
|
133
|
+
};
|
|
134
|
+
target: { id: string; url: string; title: string };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ExtractPageSaveResult {
|
|
138
|
+
path: string;
|
|
139
|
+
bytes: number;
|
|
140
|
+
model: string;
|
|
141
|
+
usage: ExtractUsage;
|
|
142
|
+
target: { id: string; url: string; title: string };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ------------------------------ env (per call) ------------------------------ */
|
|
146
|
+
|
|
147
|
+
/** Read per call, not at module load, so a test or operator can redirect the
|
|
148
|
+
* endpoint without a restart. An empty/blank value counts as unset (the same
|
|
149
|
+
* precedence rule backend.ts gives CDP_FIREFOX_ENDPOINT). */
|
|
150
|
+
function extractBaseUrl(): string {
|
|
151
|
+
const raw = process.env.CDP_EXTRACT_BASE_URL?.trim();
|
|
152
|
+
return raw ? raw : DEFAULT_BASE_URL;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function extractModel(): string {
|
|
156
|
+
const raw = process.env.CDP_EXTRACT_MODEL?.trim();
|
|
157
|
+
return raw ? raw : DEFAULT_MODEL;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Prompt shape default. Same precedence rule as the others: an empty/blank
|
|
161
|
+
* value counts as unset. An unrecognized value is NOT silently ignored — it
|
|
162
|
+
* is reported by validateExtractArgs, so a typo'd env var fails loudly
|
|
163
|
+
* instead of quietly extracting garbage from the wrong prompt. */
|
|
164
|
+
function extractPromptMode(): string {
|
|
165
|
+
const raw = process.env.CDP_EXTRACT_PROMPT?.trim();
|
|
166
|
+
return raw ? raw : DEFAULT_PROMPT_MODE;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The key lives in env only, never in args, never in a log line. */
|
|
170
|
+
function extractApiKey(): string | undefined {
|
|
171
|
+
const raw = process.env.CDP_EXTRACT_API_KEY;
|
|
172
|
+
return raw && raw.length > 0 ? raw : undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function extractTimeoutMs(): number {
|
|
176
|
+
const raw = Number(process.env.CDP_EXTRACT_TIMEOUT_MS);
|
|
177
|
+
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function extractMaxChars(): number {
|
|
181
|
+
const raw = Number(process.env.CDP_EXTRACT_MAX_CHARS);
|
|
182
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_CHARS;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ------------------------------ arg validation ------------------------------ */
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Recursively look for one non-empty `description` anywhere in the schema
|
|
189
|
+
* (property values under `properties`, items, anyOf/oneOf/allOf branches).
|
|
190
|
+
* Inner descriptions count, because Schematron reads whichever ones exist,
|
|
191
|
+
* wherever they sit.
|
|
192
|
+
*/
|
|
193
|
+
function hasAnyDescription(node: unknown, seen: Set<unknown> = new Set()): boolean {
|
|
194
|
+
if (typeof node !== "object" || node === null || seen.has(node)) return false;
|
|
195
|
+
seen.add(node);
|
|
196
|
+
const n = node as Record<string, unknown>;
|
|
197
|
+
if (typeof n.description === "string" && n.description.trim() !== "") return true;
|
|
198
|
+
const props = n.properties;
|
|
199
|
+
if (typeof props === "object" && props !== null) {
|
|
200
|
+
for (const value of Object.values(props)) if (hasAnyDescription(value, seen)) return true;
|
|
201
|
+
}
|
|
202
|
+
if (hasAnyDescription(n.items, seen)) return true;
|
|
203
|
+
for (const key of ["anyOf", "oneOf", "allOf", "prefixItems"] as const) {
|
|
204
|
+
const branches = n[key];
|
|
205
|
+
if (Array.isArray(branches)) {
|
|
206
|
+
for (const branch of branches) if (hasAnyDescription(branch, seen)) return true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function validateExtractArgs(args: ExtractPageArgs): void {
|
|
213
|
+
if (args.schema === null || typeof args.schema !== "object" || Array.isArray(args.schema)) {
|
|
214
|
+
throw new CdpError("extract_page: 'schema' is required and must be a JSON Schema object");
|
|
215
|
+
}
|
|
216
|
+
if (args.schema.type !== "object") {
|
|
217
|
+
throw new CdpError(`extract_page: 'schema.type' must be "object" (got ${JSON.stringify(args.schema.type)})`);
|
|
218
|
+
}
|
|
219
|
+
if (args.schema.additionalProperties === true) {
|
|
220
|
+
throw new CdpError(
|
|
221
|
+
'extract_page: schema must not set additionalProperties:true — strict json_schema mode requires a closed object so the extraction cannot invent fields',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (!hasAnyDescription(args.schema)) {
|
|
225
|
+
throw new CdpError(
|
|
226
|
+
"extract_page: no property in 'schema' carries a description. This is prompt-driven extraction: the property descriptions ARE the instructions the extractor follows ('the job title, exactly as printed'). Write them like prompts and retry.",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (args.selector !== undefined && (typeof args.selector !== "string" || args.selector.length === 0)) {
|
|
230
|
+
throw new CdpError("extract_page: 'selector' must be a non-empty CSS selector string");
|
|
231
|
+
}
|
|
232
|
+
if (args.source !== undefined && args.source !== "dom" && args.source !== "served") {
|
|
233
|
+
throw new CdpError(`extract_page: 'source' must be "dom" or "served" (got ${JSON.stringify(args.source)})`);
|
|
234
|
+
}
|
|
235
|
+
if (args.source === "served") {
|
|
236
|
+
throw new CdpError(
|
|
237
|
+
"extract_page: source 'served' (the raw server response before rendering) is not implemented; use source 'dom' (the live rendered DOM).",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (args.clean !== undefined && args.clean !== "standard" && args.clean !== "aggressive" && args.clean !== "none") {
|
|
241
|
+
throw new CdpError(`extract_page: 'clean' must be "standard", "aggressive" or "none" (got ${JSON.stringify(args.clean)})`);
|
|
242
|
+
}
|
|
243
|
+
if (args.prompt !== undefined && args.prompt !== "html" && args.prompt !== "schematron") {
|
|
244
|
+
throw new CdpError(`extract_page: 'prompt' must be "html" or "schematron" (got ${JSON.stringify(args.prompt)})`);
|
|
245
|
+
}
|
|
246
|
+
const envPrompt = process.env.CDP_EXTRACT_PROMPT?.trim();
|
|
247
|
+
if (args.prompt === undefined && envPrompt && envPrompt !== "html" && envPrompt !== "schematron") {
|
|
248
|
+
throw new CdpError(`extract_page: CDP_EXTRACT_PROMPT must be "html" or "schematron" (got ${JSON.stringify(envPrompt)})`);
|
|
249
|
+
}
|
|
250
|
+
if (args.maxChars !== undefined && (!Number.isFinite(args.maxChars) || args.maxChars <= 0)) {
|
|
251
|
+
throw new CdpError("extract_page: 'maxChars' must be a positive number");
|
|
252
|
+
}
|
|
253
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
254
|
+
throw new CdpError("extract_page: 'timeoutMs' must be a positive number");
|
|
255
|
+
}
|
|
256
|
+
if (args.model !== undefined && (typeof args.model !== "string" || args.model.length === 0)) {
|
|
257
|
+
throw new CdpError("extract_page: 'model' must be a non-empty string");
|
|
258
|
+
}
|
|
259
|
+
if (args.baseUrl !== undefined && (typeof args.baseUrl !== "string" || args.baseUrl.length === 0)) {
|
|
260
|
+
throw new CdpError("extract_page: 'baseUrl' must be a non-empty string (the API key is env-only: CDP_EXTRACT_API_KEY)");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* --------------------------- in-page HTML cleaning --------------------------- */
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The in-page cleaner, as a zero-arg self-contained function SOURCE (the
|
|
268
|
+
* bidi/snapshot.ts pattern): stringified, injected with its two parameters,
|
|
269
|
+
* referencing nothing outside itself, and run via the neutral Driver's
|
|
270
|
+
* page.evaluate — so Chrome and Firefox run byte-identical cleaning logic.
|
|
271
|
+
*
|
|
272
|
+
* Everything operates on a CLONE of the scoped root; the live DOM is never
|
|
273
|
+
* mutated. Visibility is computed on the LIVE twins zipped against the clone's
|
|
274
|
+
* element list (safe: the whole function is one synchronous run, so the page
|
|
275
|
+
* cannot mutate between the two querySelectorAll calls). Returns
|
|
276
|
+
* { matched, html, rawBytes, cleanBytes, dropped } — matched:false is the
|
|
277
|
+
* selector miss the caller turns into a CdpError, because a page-side throw
|
|
278
|
+
* would surface as a generic page-error and lose the selector.
|
|
279
|
+
*/
|
|
280
|
+
export function buildCleanFunctionSource(selector: string | undefined, clean: "standard" | "aggressive" | "none"): string {
|
|
281
|
+
return `function() {
|
|
282
|
+
var SEL = ${JSON.stringify(selector ?? null)};
|
|
283
|
+
var CLEAN = ${JSON.stringify(clean)};
|
|
284
|
+
var STRIP = ["script","style","noscript","svg","canvas","iframe","video","audio","source","track","template","object","embed","link","meta"];
|
|
285
|
+
var AGGRO = ["nav","header","footer","aside","form","button"];
|
|
286
|
+
var WHITELIST = ["href","src","alt","title","value","placeholder","for","name","type","role","aria-label","selected","checked","disabled"];
|
|
287
|
+
|
|
288
|
+
function bytes(s) {
|
|
289
|
+
try { return new TextEncoder().encode(s).length; } catch (e) { return s.length; }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function collectShadowScopes(root, out) {
|
|
293
|
+
var els = root.querySelectorAll("*");
|
|
294
|
+
for (var i = 0; i < els.length; i++) {
|
|
295
|
+
var sr = els[i].shadowRoot;
|
|
296
|
+
if (sr) { out.push(sr); collectShadowScopes(sr, out); }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Locate SEL piercing open shadow roots (querySelector on document first,
|
|
301
|
+
// then every open shadow scope). LIMIT: outerHTML serializes the matched
|
|
302
|
+
// element's light DOM only; a closed shadow root is invisible by design.
|
|
303
|
+
function deepQuery(sel) {
|
|
304
|
+
var direct = document.querySelector(sel);
|
|
305
|
+
if (direct) return direct;
|
|
306
|
+
var scopes = [];
|
|
307
|
+
collectShadowScopes(document, scopes);
|
|
308
|
+
for (var i = 0; i < scopes.length; i++) {
|
|
309
|
+
var hit = scopes[i].querySelector(sel);
|
|
310
|
+
if (hit) return hit;
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The visibility heuristic, same shape as bidi/snapshot.ts's isVisible:
|
|
316
|
+
// hidden attr, aria-hidden, computed display/visibility, then the 0x0
|
|
317
|
+
// paint check. OPTION/OPTGROUP skip the size check (a closed select's
|
|
318
|
+
// popup is unpainted though its options are real, extractable content).
|
|
319
|
+
function isHidden(el) {
|
|
320
|
+
if (el.hidden) return true;
|
|
321
|
+
if (el.getAttribute("aria-hidden") === "true") return true;
|
|
322
|
+
var style = null;
|
|
323
|
+
try { style = el.ownerDocument.defaultView.getComputedStyle(el); } catch (e) {}
|
|
324
|
+
if (style && (style.display === "none" || style.visibility === "hidden")) return true;
|
|
325
|
+
var tag = el.tagName;
|
|
326
|
+
if (tag !== "OPTION" && tag !== "OPTGROUP") {
|
|
327
|
+
var rect = el.getBoundingClientRect();
|
|
328
|
+
if (rect.width === 0 && rect.height === 0) return true;
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function countDrop(dropped, tag) {
|
|
334
|
+
dropped.elements[tag] = (dropped.elements[tag] || 0) + 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function cleanRoot(root) {
|
|
338
|
+
var rawHtml = root.outerHTML;
|
|
339
|
+
var dropped = { comments: 0, hidden: 0, attributes: 0, elements: {} };
|
|
340
|
+
if (CLEAN === "none") {
|
|
341
|
+
return { matched: true, html: rawHtml, rawBytes: bytes(rawHtml), cleanBytes: bytes(rawHtml), dropped: dropped };
|
|
342
|
+
}
|
|
343
|
+
var live = root.querySelectorAll("*");
|
|
344
|
+
var clone = root.cloneNode(true);
|
|
345
|
+
var mirrored = clone.querySelectorAll("*");
|
|
346
|
+
for (var i = 0; i < mirrored.length; i++) {
|
|
347
|
+
var cel = mirrored[i];
|
|
348
|
+
if (!cel.parentNode) continue; // dropped already, with an ancestor
|
|
349
|
+
var low = cel.tagName.toLowerCase();
|
|
350
|
+
if (STRIP.indexOf(low) !== -1) { countDrop(dropped, low); cel.parentNode.removeChild(cel); continue; }
|
|
351
|
+
if (CLEAN === "aggressive" && AGGRO.indexOf(low) !== -1) { countDrop(dropped, low); cel.parentNode.removeChild(cel); continue; }
|
|
352
|
+
var lel = live[i];
|
|
353
|
+
if (lel && isHidden(lel)) { dropped.hidden++; cel.parentNode.removeChild(cel); continue; }
|
|
354
|
+
for (var j = cel.attributes.length - 1; j >= 0; j--) {
|
|
355
|
+
var attr = cel.attributes[j];
|
|
356
|
+
if (WHITELIST.indexOf(attr.name.toLowerCase()) === -1) {
|
|
357
|
+
cel.removeAttribute(attr.name);
|
|
358
|
+
dropped.attributes++;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
var walker = clone.ownerDocument.createTreeWalker(clone, NodeFilter.SHOW_COMMENT);
|
|
363
|
+
var comments = [];
|
|
364
|
+
while (walker.nextNode()) comments.push(walker.currentNode);
|
|
365
|
+
for (var k = 0; k < comments.length; k++) {
|
|
366
|
+
var c = comments[k];
|
|
367
|
+
if (c.parentNode) c.parentNode.removeChild(c);
|
|
368
|
+
}
|
|
369
|
+
dropped.comments = comments.length;
|
|
370
|
+
// Whitespace collapse at the TEXT-NODE level, never on the serialized
|
|
371
|
+
// string: a regex over outerHTML would also flatten <pre> content.
|
|
372
|
+
var tw = clone.ownerDocument.createTreeWalker(clone, NodeFilter.SHOW_TEXT);
|
|
373
|
+
while (tw.nextNode()) {
|
|
374
|
+
var node = tw.currentNode;
|
|
375
|
+
node.nodeValue = node.nodeValue.replace(/\\s+/g, " ");
|
|
376
|
+
}
|
|
377
|
+
var html = clone.outerHTML;
|
|
378
|
+
return { matched: true, html: html, rawBytes: bytes(rawHtml), cleanBytes: bytes(html), dropped: dropped };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (SEL) {
|
|
382
|
+
var found = deepQuery(SEL);
|
|
383
|
+
if (!found) return { matched: false };
|
|
384
|
+
return cleanRoot(found);
|
|
385
|
+
}
|
|
386
|
+
return cleanRoot(document.documentElement);
|
|
387
|
+
}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
interface CleanOutcome {
|
|
391
|
+
html: string;
|
|
392
|
+
rawBytes: number;
|
|
393
|
+
cleanBytes: number;
|
|
394
|
+
dropped: Record<string, unknown>;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Validate + narrow the untyped page.evaluate() return; a `matched:false`
|
|
398
|
+
* marks the selector miss so its error can name the selector. `matched` is
|
|
399
|
+
* ADDITIVE: our own cleaner always sets it, but the contract shape is the
|
|
400
|
+
* bare {html, rawBytes, cleanBytes, dropped}, which is accepted as-is. */
|
|
401
|
+
export function coerceCleanOutcome(raw: unknown, selector: string | undefined): CleanOutcome {
|
|
402
|
+
if (typeof raw !== "object" || raw === null) {
|
|
403
|
+
throw new CdpError("extract_page: in-page cleaning returned an unexpected shape");
|
|
404
|
+
}
|
|
405
|
+
const r = raw as { matched?: unknown; html?: unknown; rawBytes?: unknown; cleanBytes?: unknown; dropped?: unknown };
|
|
406
|
+
if (r.matched === false) {
|
|
407
|
+
throw new CdpError(`extract_page: no element matches selector '${selector}' (checked document and every open shadow root)`);
|
|
408
|
+
}
|
|
409
|
+
if (
|
|
410
|
+
typeof r.html !== "string" ||
|
|
411
|
+
typeof r.rawBytes !== "number" || !Number.isFinite(r.rawBytes) ||
|
|
412
|
+
typeof r.cleanBytes !== "number" || !Number.isFinite(r.cleanBytes)
|
|
413
|
+
) {
|
|
414
|
+
throw new CdpError("extract_page: in-page cleaning returned an unexpected shape");
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
html: r.html,
|
|
418
|
+
rawBytes: r.rawBytes,
|
|
419
|
+
cleanBytes: r.cleanBytes,
|
|
420
|
+
dropped: (typeof r.dropped === "object" && r.dropped !== null ? r.dropped : {}) as Record<string, unknown>,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* -------------------------------- size guard -------------------------------- */
|
|
425
|
+
|
|
426
|
+
/** ~3.5 chars per token on cleaned HTML — an estimate for the guard, not a
|
|
427
|
+
* bill; the true count comes back in usage.promptTokens. */
|
|
428
|
+
export function estimateTokens(chars: number): number {
|
|
429
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* NO truncation, NO chunking: a half page extracted as though it were whole
|
|
434
|
+
* is a wrong answer that looks right, so over-budget is a loud error naming
|
|
435
|
+
* the numbers and the three levers that actually shrink the payload.
|
|
436
|
+
*
|
|
437
|
+
* maxChars is a CHARACTER cap (as its name, the manifest and the docs all
|
|
438
|
+
* promise): the comparison is html.length, not cleanBytes — comparing UTF-8
|
|
439
|
+
* bytes against a char cap would false-fail a CJK page at a third of its
|
|
440
|
+
* documented budget. cleanBytes is still reported alongside.
|
|
441
|
+
*/
|
|
442
|
+
function guardSize(outcome: CleanOutcome, maxChars: number): number {
|
|
443
|
+
const estTokens = estimateTokens(outcome.html.length);
|
|
444
|
+
const overChars = outcome.html.length > maxChars;
|
|
445
|
+
const overContext = estTokens + COMPLESION_TOKENS + SAFETY_TOKENS > CONTEXT_TOKEN_BUDGET;
|
|
446
|
+
if (overChars || overContext) {
|
|
447
|
+
const why = overChars
|
|
448
|
+
? `cleaned HTML is ${outcome.html.length} chars (${outcome.cleanBytes} bytes), over the ${maxChars}-char cap`
|
|
449
|
+
: `estimated ${estTokens} prompt tokens + ${COMPLESION_TOKENS} completion + ${SAFETY_TOKENS} safety exceeds the ${CONTEXT_TOKEN_BUDGET}-token context budget`;
|
|
450
|
+
throw new CdpError(
|
|
451
|
+
`extract_page: html_too_large — ${why}. Shrink the payload with 'selector' to scope to a subtree, clean:"aggressive" to strip nav/footer/form/button too, or a larger maxChars if you truly need it all. No truncation: a partial page extracted as whole is a wrong answer that looks right.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return estTokens;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* --------------------- upstream response shape & helpers --------------------- */
|
|
458
|
+
|
|
459
|
+
interface ChatCompletionResponse {
|
|
460
|
+
choices?: { message?: { content?: unknown }; finish_reason?: unknown }[];
|
|
461
|
+
model?: string;
|
|
462
|
+
usage?: {
|
|
463
|
+
prompt_tokens?: number;
|
|
464
|
+
completion_tokens?: number;
|
|
465
|
+
total_tokens?: number;
|
|
466
|
+
cost_usd?: number;
|
|
467
|
+
costUsd?: number;
|
|
468
|
+
cost?: number;
|
|
469
|
+
};
|
|
470
|
+
error?: { message?: unknown; code?: unknown };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Strip the API key value (if one is set) and any sk-… credential out of
|
|
475
|
+
* upstream-derived text BEFORE it can ride an error message into a transcript
|
|
476
|
+
* or log. Runs on every excerpt we embed — response bodies, network error
|
|
477
|
+
* text, model output fragments — because an upstream 401 body echoing the
|
|
478
|
+
* request's Authorization header is exactly the leak this exists to stop.
|
|
479
|
+
*/
|
|
480
|
+
export function redactSecrets(text: string, apiKey?: string): string {
|
|
481
|
+
let out = text;
|
|
482
|
+
if (apiKey && apiKey.length > 0) {
|
|
483
|
+
out = out.split(apiKey).join("[redacted]");
|
|
484
|
+
}
|
|
485
|
+
return out.replace(/sk-[A-Za-z0-9_-]{8,}/g, "[redacted]");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The cost field name is not standardized across OpenAI-compatible servers;
|
|
489
|
+
* take the first of the usual spellings, and only when it is a finite number. */
|
|
490
|
+
function pickCostUsd(usage: NonNullable<ChatCompletionResponse["usage"]>): number | undefined {
|
|
491
|
+
for (const v of [usage.cost_usd, usage.costUsd, usage.cost]) {
|
|
492
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
493
|
+
}
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function excerpt(text: string, max: number): string {
|
|
498
|
+
const t = text.trim();
|
|
499
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* ------------------------------ prompt shapes ------------------------------ */
|
|
503
|
+
|
|
504
|
+
export interface ChatMessage {
|
|
505
|
+
role: "system" | "user";
|
|
506
|
+
content: string;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The two prompt shapes, kept in one place because the wire body is the only
|
|
511
|
+
* thing a refactor can silently break here.
|
|
512
|
+
*
|
|
513
|
+
* "html" (default, unchanged): the cleaned HTML is the ONLY user message and
|
|
514
|
+
* the schema rides in response_format alone. That is what the HOSTED
|
|
515
|
+
* Schematron API expects — it injects the schema into the prompt server-side.
|
|
516
|
+
*
|
|
517
|
+
* "schematron": the open-weight Schematron model card's own `construct_messages`
|
|
518
|
+
* (inference-net/Schematron-8B), verbatim — a "You are a helpful assistant"
|
|
519
|
+
* system message plus a user message carrying the COMPACT JSON.stringify'd
|
|
520
|
+
* schema inline, then the HTML, then the "MAKE SURE ITS VALID JSON." tail. The
|
|
521
|
+
* model was fine-tuned on exactly this string; a locally served open-weight
|
|
522
|
+
* Schematron handed a bare HTML prompt returns garbage even with constrained
|
|
523
|
+
* decoding, which is why this mode exists. Note the schema text is charged to
|
|
524
|
+
* the prompt on top of the HTML (maxChars caps the HTML only).
|
|
525
|
+
*/
|
|
526
|
+
export function buildExtractionMessages(
|
|
527
|
+
mode: "html" | "schematron",
|
|
528
|
+
html: string,
|
|
529
|
+
schema: Record<string, unknown>,
|
|
530
|
+
): ChatMessage[] {
|
|
531
|
+
if (mode !== "schematron") return [{ role: "user", content: html }];
|
|
532
|
+
const user =
|
|
533
|
+
"You are going to be given a JSON schema following the standardized JSON Schema format. You are going to be given a HTML page and you are going to apply the schema to the HTML page however you see it as applicable and return the results in a JSON object. The schema is as follows:" +
|
|
534
|
+
"\n\n" +
|
|
535
|
+
JSON.stringify(schema) +
|
|
536
|
+
"\n\n" +
|
|
537
|
+
"Here is the HTML page:" +
|
|
538
|
+
"\n\n" +
|
|
539
|
+
html +
|
|
540
|
+
"\n\n" +
|
|
541
|
+
"MAKE SURE ITS VALID JSON.";
|
|
542
|
+
return [
|
|
543
|
+
{ role: "system", content: "You are a helpful assistant" },
|
|
544
|
+
{ role: "user", content: user },
|
|
545
|
+
];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/* ------------------------------ upstream call ------------------------------ */
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* One POST to ${baseUrl}/chat/completions, with a single bounded retry on 5xx
|
|
552
|
+
* (transient gateway flaps are the one failure worth an automatic second try;
|
|
553
|
+
* everything else fails fast and loud). Each attempt gets its own
|
|
554
|
+
* AbortSignal.timeout(timeoutMs), so an expiry is a timeout error, never a hang.
|
|
555
|
+
*/
|
|
556
|
+
export async function callExtractionEndpoint(opts: {
|
|
557
|
+
baseUrl: string;
|
|
558
|
+
apiKey: string | undefined;
|
|
559
|
+
model: string;
|
|
560
|
+
html: string;
|
|
561
|
+
schema: Record<string, unknown>;
|
|
562
|
+
timeoutMs: number;
|
|
563
|
+
/** Prompt shape; default "html" (the pre-existing wire shape). */
|
|
564
|
+
prompt?: "html" | "schematron";
|
|
565
|
+
}): Promise<{ content: string; usage: ExtractUsage; model: string; finishReason: string | undefined }> {
|
|
566
|
+
const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
567
|
+
const body = JSON.stringify({
|
|
568
|
+
model: opts.model,
|
|
569
|
+
messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
|
|
570
|
+
temperature: 0,
|
|
571
|
+
max_tokens: COMPLESION_TOKENS,
|
|
572
|
+
response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } },
|
|
573
|
+
});
|
|
574
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
575
|
+
if (opts.apiKey) headers.authorization = `Bearer ${opts.apiKey}`;
|
|
576
|
+
|
|
577
|
+
let lastStatus = 0;
|
|
578
|
+
let lastBody = "";
|
|
579
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
580
|
+
let res: Response;
|
|
581
|
+
try {
|
|
582
|
+
res = await fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(opts.timeoutMs) });
|
|
583
|
+
} catch (e) {
|
|
584
|
+
const err = e as Error;
|
|
585
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
586
|
+
throw new CdpError(
|
|
587
|
+
`extract_page: upstream call to ${opts.baseUrl} timed out after ${opts.timeoutMs}ms`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
// fetch's network-level failure mode is a TypeError; anything carrying
|
|
591
|
+
// upstream text through it still passes redaction on the way out.
|
|
592
|
+
throw new CdpError(
|
|
593
|
+
`extract_page: could not reach ${opts.baseUrl} (${redactSecrets(err.message, opts.apiKey)}) — is ferry running?`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const text = await res.text();
|
|
597
|
+
if (res.ok) {
|
|
598
|
+
let parsed: ChatCompletionResponse;
|
|
599
|
+
try {
|
|
600
|
+
parsed = JSON.parse(text) as ChatCompletionResponse;
|
|
601
|
+
} catch {
|
|
602
|
+
throw new CdpError(
|
|
603
|
+
`extract_page: endpoint returned a non-JSON 200 body: ${excerpt(redactSecrets(text, opts.apiKey), 200)}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (parsed.error) {
|
|
607
|
+
throw new CdpError(
|
|
608
|
+
`extract_page: upstream_error from ${opts.baseUrl}: ${excerpt(redactSecrets(String(parsed.error.message ?? JSON.stringify(parsed.error)), opts.apiKey), 600)}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const content = parsed.choices?.[0]?.message?.content;
|
|
612
|
+
if (typeof content !== "string" || content.length === 0) {
|
|
613
|
+
throw new CdpError(
|
|
614
|
+
`extract_page: endpoint response carried no message content: ${excerpt(redactSecrets(text, opts.apiKey), 200)}`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
const u = parsed.usage ?? {};
|
|
618
|
+
const usage: ExtractUsage = {
|
|
619
|
+
promptTokens: u.prompt_tokens ?? 0,
|
|
620
|
+
completionTokens: u.completion_tokens ?? 0,
|
|
621
|
+
totalTokens: u.total_tokens ?? 0,
|
|
622
|
+
...(pickCostUsd(u) !== undefined ? { costUsd: pickCostUsd(u) } : {}),
|
|
623
|
+
};
|
|
624
|
+
const fr = parsed.choices?.[0]?.finish_reason;
|
|
625
|
+
const finishReason = typeof fr === "string" ? fr : undefined;
|
|
626
|
+
return { content, usage, model: parsed.model ?? opts.model, finishReason };
|
|
627
|
+
}
|
|
628
|
+
lastStatus = res.status;
|
|
629
|
+
lastBody = text;
|
|
630
|
+
if (res.status === 429) {
|
|
631
|
+
const retryAfter = res.headers.get("retry-after");
|
|
632
|
+
throw new CdpError(
|
|
633
|
+
`extract_page: upstream rate-limited (HTTP 429) from ${opts.baseUrl}${retryAfter !== null ? `, retry-after: ${retryAfter}` : " (no retry-after header)"}`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (res.status < 500 || res.status >= 600) break; // not retryable, not 5xx
|
|
637
|
+
// 5xx on the FIRST attempt falls through to the single retry; on the
|
|
638
|
+
// second it exits the loop into the error below.
|
|
639
|
+
}
|
|
640
|
+
throw new CdpError(
|
|
641
|
+
`extract_page: upstream_error HTTP ${lastStatus} from POST ${url}: ${excerpt(redactSecrets(lastBody, opts.apiKey), 600)}`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/* --------------------------------- the tool --------------------------------- */
|
|
646
|
+
|
|
647
|
+
/** Acquire, run, always release — the local twin of shared-tools.ts's
|
|
648
|
+
* file-private withPage (that file is owned elsewhere; the 8 lines are
|
|
649
|
+
* replicated rather than widening its exports mid-flight). */
|
|
650
|
+
async function withPage<T>(driver: BrowserDriver, target: TargetSelector, fn: (page: PageDriver) => Promise<T>): Promise<T> {
|
|
651
|
+
const page = await driver.page(target);
|
|
652
|
+
try {
|
|
653
|
+
return await fn(page);
|
|
654
|
+
} finally {
|
|
655
|
+
await page.release();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Extract schema-conformant JSON from the target page: clean its HTML in-page,
|
|
661
|
+
* enforce the size guard, POST to the OpenAI-compatible endpoint with the
|
|
662
|
+
* caller's schema as a strict json_schema response_format, and return the
|
|
663
|
+
* parsed JSON plus the token usage that produced it.
|
|
664
|
+
*/
|
|
665
|
+
export async function extractPage(
|
|
666
|
+
driver: BrowserDriver,
|
|
667
|
+
args: ExtractPageArgs,
|
|
668
|
+
): Promise<ExtractPageResult | ExtractPageSaveResult> {
|
|
669
|
+
validateExtractArgs(args);
|
|
670
|
+
const clean = args.clean ?? "standard";
|
|
671
|
+
const maxChars = args.maxChars ?? extractMaxChars();
|
|
672
|
+
const timeoutMs = Math.min(args.timeoutMs ?? extractTimeoutMs(), MAX_TIMEOUT_MS);
|
|
673
|
+
const baseUrl = args.baseUrl ?? extractBaseUrl();
|
|
674
|
+
const model = args.model ?? extractModel();
|
|
675
|
+
// The arg wins over the env; validateExtractArgs already refused any other
|
|
676
|
+
// value from either source, so the cast cannot widen the contract.
|
|
677
|
+
const prompt = (args.prompt ?? extractPromptMode()) as "html" | "schematron";
|
|
678
|
+
const apiKey = extractApiKey();
|
|
679
|
+
|
|
680
|
+
return withPage(driver, args.target, async (page) => {
|
|
681
|
+
// The builder emits a zero-arg function declaration; evaluate() takes an
|
|
682
|
+
// EXPRESSION, so wrap-and-call in parens (both drivers evaluate the
|
|
683
|
+
// string as-is when no args are given).
|
|
684
|
+
const source = `(${buildCleanFunctionSource(args.selector, clean)})()`;
|
|
685
|
+
const outcome = coerceCleanOutcome(await page.evaluate(source, { awaitPromise: true }), args.selector);
|
|
686
|
+
const estTokens = guardSize(outcome, maxChars);
|
|
687
|
+
|
|
688
|
+
const { content, usage, model: respondedModel, finishReason } = await callExtractionEndpoint({
|
|
689
|
+
baseUrl,
|
|
690
|
+
apiKey,
|
|
691
|
+
model,
|
|
692
|
+
html: outcome.html,
|
|
693
|
+
schema: args.schema,
|
|
694
|
+
timeoutMs,
|
|
695
|
+
prompt,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// A completion cut off at max_tokens is NOT a JSON bug, and the endpoint
|
|
699
|
+
// says so in finish_reason. Without this check the truncated tail surfaces
|
|
700
|
+
// as "Unterminated string", which reads as a model or quoting defect and
|
|
701
|
+
// sends the operator debugging the wrong layer. The remedy is to shrink
|
|
702
|
+
// the payload (selector) or the schema, so name that here.
|
|
703
|
+
if (finishReason === "length") {
|
|
704
|
+
throw new CdpError(
|
|
705
|
+
`extract_page: completion truncated at max_tokens=${COMPLESION_TOKENS} (finish_reason=length, ${usage.completionTokens} completion tokens); ` +
|
|
706
|
+
`narrow the payload with 'selector' or ask the schema for less: ${excerpt(redactSecrets(content, apiKey), 200)}`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
let data: unknown;
|
|
711
|
+
try {
|
|
712
|
+
data = JSON.parse(content);
|
|
713
|
+
} catch (e) {
|
|
714
|
+
throw new CdpError(
|
|
715
|
+
`extract_page: endpoint returned invalid JSON (${(e as Error).message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const result: ExtractPageResult = {
|
|
720
|
+
data,
|
|
721
|
+
model: respondedModel,
|
|
722
|
+
usage,
|
|
723
|
+
html: {
|
|
724
|
+
source: "dom",
|
|
725
|
+
rawBytes: outcome.rawBytes,
|
|
726
|
+
cleanBytes: outcome.cleanBytes,
|
|
727
|
+
estTokens,
|
|
728
|
+
dropped: outcome.dropped,
|
|
729
|
+
},
|
|
730
|
+
target: { id: page.info.id, url: page.info.url, title: page.info.title },
|
|
731
|
+
};
|
|
732
|
+
if (args.savePath === undefined || args.savePath === "") return result;
|
|
733
|
+
|
|
734
|
+
// Local copy of shared-tools.ts's file-private writeJsonSink (~20 lines,
|
|
735
|
+
// replicated for the same ownership reason as withPage above): absolute
|
|
736
|
+
// path as-is, relative under the artifact dir, parents created, full
|
|
737
|
+
// result object written, and NOTHING value-shaped leaked back inline.
|
|
738
|
+
const path = args.savePath.startsWith("/") ? args.savePath : join(ARTIFACT_DIR, args.savePath);
|
|
739
|
+
await mkdir(dirname(path), { recursive: true });
|
|
740
|
+
const json = JSON.stringify(result, null, 2);
|
|
741
|
+
await writeFile(path, json, "utf8");
|
|
742
|
+
const saved: ExtractPageSaveResult = {
|
|
743
|
+
path,
|
|
744
|
+
bytes: Buffer.byteLength(json, "utf8"),
|
|
745
|
+
model: respondedModel,
|
|
746
|
+
usage,
|
|
747
|
+
target: result.target,
|
|
748
|
+
};
|
|
749
|
+
return saved;
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/*
|
|
754
|
+
* ---------------------------------------------------------------------------
|
|
755
|
+
* Driver-surface module (both backends, no raw CDP): the ONLY page access is
|
|
756
|
+
* BrowserDriver.page() -> PageDriver.evaluate() (Chrome: Runtime.evaluate via
|
|
757
|
+
* openPage/resolveTarget → assertLeaseOk, the standard choke point; Firefox:
|
|
758
|
+
* script.evaluate via resolveContext → assertLeaseOk). No other CDP/BiDi
|
|
759
|
+
* method is used, and the live DOM is never mutated (cleaning runs on a clone).
|
|
760
|
+
*
|
|
761
|
+
* Upstream dependency (global fetch, zero npm deps): POST ${baseUrl}/chat/completions
|
|
762
|
+
* with response_format json_schema strict; default baseUrl is the loopback
|
|
763
|
+
* llm-ferry (http://127.0.0.1:8090/v1, model "schematron"). If it is not
|
|
764
|
+
* running, the network error names the URL and asks "is ferry running?".
|
|
765
|
+
*
|
|
766
|
+
* Parity gaps / known limits:
|
|
767
|
+
* - source:"served" (raw server response before rendering) is REJECTED as
|
|
768
|
+
* not-implemented; only the live rendered DOM ("dom") is read.
|
|
769
|
+
* - A scoped selector piercing an open shadow root serializes the matched
|
|
770
|
+
* element's LIGHT DOM; shadow content and closed roots do not serialize.
|
|
771
|
+
* - The response is JSON.parse'd only; it is NOT re-validated against the
|
|
772
|
+
* caller's schema (no schema-validation dep by contract) — strict
|
|
773
|
+
* json_schema mode is the conformance mechanism.
|
|
774
|
+
* - The token estimate (chars/3.5) guards the context budget; the true
|
|
775
|
+
* count is whatever usage.prompt_tokens reports after the call.
|
|
776
|
+
* ---------------------------------------------------------------------------
|
|
777
|
+
*/
|