cdp-toolkit 2.3.0 → 2.5.1

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