cdp-toolkit 2.3.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.
@@ -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
+ */