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
package/dist/index.js
CHANGED
|
@@ -1089,6 +1089,8 @@ var init_driver = __esm(() => {
|
|
|
1089
1089
|
list_mocks: ["network.intercept"],
|
|
1090
1090
|
clear_mocks: ["network.intercept"],
|
|
1091
1091
|
dispatch_mouse: ["input.raw"],
|
|
1092
|
+
focus_emulation: ["emulate.focus"],
|
|
1093
|
+
click_focus_gated: ["emulate.focus"],
|
|
1092
1094
|
wait_for_download: ["browser.downloads"],
|
|
1093
1095
|
grant_permissions: ["browser.permissions"]
|
|
1094
1096
|
};
|
|
@@ -1559,6 +1561,7 @@ var CDP_CAPABILITIES = new Set([
|
|
|
1559
1561
|
"capture.screencast",
|
|
1560
1562
|
"input.raw",
|
|
1561
1563
|
"input.html5Drag",
|
|
1564
|
+
"emulate.focus",
|
|
1562
1565
|
"browser.downloads",
|
|
1563
1566
|
"browser.permissions",
|
|
1564
1567
|
"worker.targets"
|
|
@@ -4979,6 +4982,99 @@ async function dispatchMouse(args) {
|
|
|
4979
4982
|
});
|
|
4980
4983
|
}
|
|
4981
4984
|
|
|
4985
|
+
// src/tools/focus-emulation.ts
|
|
4986
|
+
init_activity();
|
|
4987
|
+
init_client();
|
|
4988
|
+
function validateFocusEmulationArgs(args) {
|
|
4989
|
+
if (typeof args.enabled !== "boolean") {
|
|
4990
|
+
throw new Error("focus_emulation requires { enabled } of true or false");
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
function validateClickFocusGatedArgs(args) {
|
|
4994
|
+
const hasSel = typeof args.selector === "string" && args.selector.length > 0;
|
|
4995
|
+
const hasText = typeof args.text === "string" && args.text.length > 0;
|
|
4996
|
+
if (hasSel === hasText) {
|
|
4997
|
+
throw new Error("click_focus_gated requires exactly one of { selector } or { text }");
|
|
4998
|
+
}
|
|
4999
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
5000
|
+
throw new Error("click_focus_gated: timeoutMs must be a positive number");
|
|
5001
|
+
}
|
|
5002
|
+
if (args.pollMs !== undefined && (!Number.isFinite(args.pollMs) || args.pollMs <= 0)) {
|
|
5003
|
+
throw new Error("click_focus_gated: pollMs must be a positive number");
|
|
5004
|
+
}
|
|
5005
|
+
}
|
|
5006
|
+
var FIND_BUTTON = `(() => {
|
|
5007
|
+
const SEL = %SEL%;
|
|
5008
|
+
const TEXT = %TEXT%;
|
|
5009
|
+
let btn = null;
|
|
5010
|
+
let matchedBy = null;
|
|
5011
|
+
if (SEL) {
|
|
5012
|
+
btn = document.querySelector(SEL);
|
|
5013
|
+
if (btn) matchedBy = "selector";
|
|
5014
|
+
}
|
|
5015
|
+
if (!btn && TEXT) {
|
|
5016
|
+
btn = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]"))
|
|
5017
|
+
.find((b) => (b.textContent || b.value || "").trim() === TEXT);
|
|
5018
|
+
if (btn) matchedBy = "text";
|
|
5019
|
+
}
|
|
5020
|
+
if (!btn) return null;
|
|
5021
|
+
const r = btn.getBoundingClientRect();
|
|
5022
|
+
if (r.width === 0 || r.height === 0) return null;
|
|
5023
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2,
|
|
5024
|
+
disabled: !!btn.disabled || btn.getAttribute("aria-disabled") === "true",
|
|
5025
|
+
matchedBy, hasFocus: document.hasFocus(), visibility: document.visibilityState };
|
|
5026
|
+
})()`;
|
|
5027
|
+
function findButtonExpression(selector, text) {
|
|
5028
|
+
return FIND_BUTTON.replace("%SEL%", JSON.stringify(selector ?? null)).replace("%TEXT%", JSON.stringify(text ?? null));
|
|
5029
|
+
}
|
|
5030
|
+
async function focusEmulation(args) {
|
|
5031
|
+
validateFocusEmulationArgs(args);
|
|
5032
|
+
return withPage(args.target, async (conn) => {
|
|
5033
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: args.enabled });
|
|
5034
|
+
return { enabled: args.enabled };
|
|
5035
|
+
}, { lease: args.lease });
|
|
5036
|
+
}
|
|
5037
|
+
async function clickFocusGated(args) {
|
|
5038
|
+
validateClickFocusGatedArgs(args);
|
|
5039
|
+
const timeoutMs = args.timeoutMs ?? 30000;
|
|
5040
|
+
const pollMs = args.pollMs ?? 500;
|
|
5041
|
+
const start = Date.now();
|
|
5042
|
+
return withPage(args.target, async (conn, target) => {
|
|
5043
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: true });
|
|
5044
|
+
try {
|
|
5045
|
+
const expression = findButtonExpression(args.selector, args.text);
|
|
5046
|
+
for (;; ) {
|
|
5047
|
+
const { result } = await conn.send("Runtime.evaluate", {
|
|
5048
|
+
expression,
|
|
5049
|
+
returnByValue: true
|
|
5050
|
+
});
|
|
5051
|
+
const btn = result?.value;
|
|
5052
|
+
if (btn && !btn.disabled) {
|
|
5053
|
+
const base = { x: btn.x, y: btn.y, button: "left", buttons: 1, clickCount: 1 };
|
|
5054
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: btn.x, y: btn.y });
|
|
5055
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mousePressed" });
|
|
5056
|
+
await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased" });
|
|
5057
|
+
return {
|
|
5058
|
+
clicked: true,
|
|
5059
|
+
x: btn.x,
|
|
5060
|
+
y: btn.y,
|
|
5061
|
+
matchedBy: btn.matchedBy,
|
|
5062
|
+
elapsedMs: Date.now() - start
|
|
5063
|
+
};
|
|
5064
|
+
}
|
|
5065
|
+
if (Date.now() - start >= timeoutMs) {
|
|
5066
|
+
throw new Error(`click_focus_gated: button never became clickable within ${timeoutMs}ms` + (btn ? " (found but stayed disabled — focus emulation may not satisfy this gate)" : " (never located)"));
|
|
5067
|
+
}
|
|
5068
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
5069
|
+
}
|
|
5070
|
+
} finally {
|
|
5071
|
+
if (!args.keepFocus) {
|
|
5072
|
+
await conn.send("Emulation.setFocusEmulationEnabled", { enabled: false }).catch(() => {});
|
|
5073
|
+
}
|
|
5074
|
+
}
|
|
5075
|
+
}, { lease: args.lease, timeoutMs: timeoutMs + 5000 });
|
|
5076
|
+
}
|
|
5077
|
+
|
|
4982
5078
|
// src/tools/downloads.ts
|
|
4983
5079
|
init_client();
|
|
4984
5080
|
import { readdir as readdir3, rename as rename2, stat as stat4 } from "node:fs/promises";
|
|
@@ -5207,6 +5303,421 @@ async function grantPermissions(args = {}) {
|
|
|
5207
5303
|
return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
|
|
5208
5304
|
}
|
|
5209
5305
|
|
|
5306
|
+
// src/tools/extract.ts
|
|
5307
|
+
init_client();
|
|
5308
|
+
import { mkdir as mkdir12, writeFile as writeFile9 } from "node:fs/promises";
|
|
5309
|
+
import { dirname as dirname3, join as join10 } from "node:path";
|
|
5310
|
+
var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
5311
|
+
var DEFAULT_BASE_URL = "http://127.0.0.1:8090/v1";
|
|
5312
|
+
var DEFAULT_MODEL = "schematron";
|
|
5313
|
+
var DEFAULT_TIMEOUT_MS2 = 90000;
|
|
5314
|
+
var DEFAULT_MAX_CHARS = 300000;
|
|
5315
|
+
var MAX_TIMEOUT_MS = 300000;
|
|
5316
|
+
var DEFAULT_PROMPT_MODE = "html";
|
|
5317
|
+
var CHARS_PER_TOKEN = 3.5;
|
|
5318
|
+
var COMPLESION_TOKENS = 8192;
|
|
5319
|
+
var SAFETY_TOKENS = 8000;
|
|
5320
|
+
var CONTEXT_TOKEN_BUDGET = 128000;
|
|
5321
|
+
function extractBaseUrl() {
|
|
5322
|
+
const raw = process.env.CDP_EXTRACT_BASE_URL?.trim();
|
|
5323
|
+
return raw ? raw : DEFAULT_BASE_URL;
|
|
5324
|
+
}
|
|
5325
|
+
function extractModel() {
|
|
5326
|
+
const raw = process.env.CDP_EXTRACT_MODEL?.trim();
|
|
5327
|
+
return raw ? raw : DEFAULT_MODEL;
|
|
5328
|
+
}
|
|
5329
|
+
function extractPromptMode() {
|
|
5330
|
+
const raw = process.env.CDP_EXTRACT_PROMPT?.trim();
|
|
5331
|
+
return raw ? raw : DEFAULT_PROMPT_MODE;
|
|
5332
|
+
}
|
|
5333
|
+
function extractApiKey() {
|
|
5334
|
+
const raw = process.env.CDP_EXTRACT_API_KEY;
|
|
5335
|
+
return raw && raw.length > 0 ? raw : undefined;
|
|
5336
|
+
}
|
|
5337
|
+
function extractTimeoutMs() {
|
|
5338
|
+
const raw = Number(process.env.CDP_EXTRACT_TIMEOUT_MS);
|
|
5339
|
+
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS2;
|
|
5340
|
+
}
|
|
5341
|
+
function extractMaxChars() {
|
|
5342
|
+
const raw = Number(process.env.CDP_EXTRACT_MAX_CHARS);
|
|
5343
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_CHARS;
|
|
5344
|
+
}
|
|
5345
|
+
function hasAnyDescription(node, seen = new Set) {
|
|
5346
|
+
if (typeof node !== "object" || node === null || seen.has(node))
|
|
5347
|
+
return false;
|
|
5348
|
+
seen.add(node);
|
|
5349
|
+
const n = node;
|
|
5350
|
+
if (typeof n.description === "string" && n.description.trim() !== "")
|
|
5351
|
+
return true;
|
|
5352
|
+
const props = n.properties;
|
|
5353
|
+
if (typeof props === "object" && props !== null) {
|
|
5354
|
+
for (const value of Object.values(props))
|
|
5355
|
+
if (hasAnyDescription(value, seen))
|
|
5356
|
+
return true;
|
|
5357
|
+
}
|
|
5358
|
+
if (hasAnyDescription(n.items, seen))
|
|
5359
|
+
return true;
|
|
5360
|
+
for (const key of ["anyOf", "oneOf", "allOf", "prefixItems"]) {
|
|
5361
|
+
const branches = n[key];
|
|
5362
|
+
if (Array.isArray(branches)) {
|
|
5363
|
+
for (const branch of branches)
|
|
5364
|
+
if (hasAnyDescription(branch, seen))
|
|
5365
|
+
return true;
|
|
5366
|
+
}
|
|
5367
|
+
}
|
|
5368
|
+
return false;
|
|
5369
|
+
}
|
|
5370
|
+
function validateExtractArgs(args) {
|
|
5371
|
+
if (args.schema === null || typeof args.schema !== "object" || Array.isArray(args.schema)) {
|
|
5372
|
+
throw new CdpError("extract_page: 'schema' is required and must be a JSON Schema object");
|
|
5373
|
+
}
|
|
5374
|
+
if (args.schema.type !== "object") {
|
|
5375
|
+
throw new CdpError(`extract_page: 'schema.type' must be "object" (got ${JSON.stringify(args.schema.type)})`);
|
|
5376
|
+
}
|
|
5377
|
+
if (args.schema.additionalProperties === true) {
|
|
5378
|
+
throw new CdpError("extract_page: schema must not set additionalProperties:true — strict json_schema mode requires a closed object so the extraction cannot invent fields");
|
|
5379
|
+
}
|
|
5380
|
+
if (!hasAnyDescription(args.schema)) {
|
|
5381
|
+
throw new CdpError("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.");
|
|
5382
|
+
}
|
|
5383
|
+
if (args.selector !== undefined && (typeof args.selector !== "string" || args.selector.length === 0)) {
|
|
5384
|
+
throw new CdpError("extract_page: 'selector' must be a non-empty CSS selector string");
|
|
5385
|
+
}
|
|
5386
|
+
if (args.source !== undefined && args.source !== "dom" && args.source !== "served") {
|
|
5387
|
+
throw new CdpError(`extract_page: 'source' must be "dom" or "served" (got ${JSON.stringify(args.source)})`);
|
|
5388
|
+
}
|
|
5389
|
+
if (args.source === "served") {
|
|
5390
|
+
throw new CdpError("extract_page: source 'served' (the raw server response before rendering) is not implemented; use source 'dom' (the live rendered DOM).");
|
|
5391
|
+
}
|
|
5392
|
+
if (args.clean !== undefined && args.clean !== "standard" && args.clean !== "aggressive" && args.clean !== "none") {
|
|
5393
|
+
throw new CdpError(`extract_page: 'clean' must be "standard", "aggressive" or "none" (got ${JSON.stringify(args.clean)})`);
|
|
5394
|
+
}
|
|
5395
|
+
if (args.prompt !== undefined && args.prompt !== "html" && args.prompt !== "schematron") {
|
|
5396
|
+
throw new CdpError(`extract_page: 'prompt' must be "html" or "schematron" (got ${JSON.stringify(args.prompt)})`);
|
|
5397
|
+
}
|
|
5398
|
+
const envPrompt = process.env.CDP_EXTRACT_PROMPT?.trim();
|
|
5399
|
+
if (args.prompt === undefined && envPrompt && envPrompt !== "html" && envPrompt !== "schematron") {
|
|
5400
|
+
throw new CdpError(`extract_page: CDP_EXTRACT_PROMPT must be "html" or "schematron" (got ${JSON.stringify(envPrompt)})`);
|
|
5401
|
+
}
|
|
5402
|
+
if (args.maxChars !== undefined && (!Number.isFinite(args.maxChars) || args.maxChars <= 0)) {
|
|
5403
|
+
throw new CdpError("extract_page: 'maxChars' must be a positive number");
|
|
5404
|
+
}
|
|
5405
|
+
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
|
5406
|
+
throw new CdpError("extract_page: 'timeoutMs' must be a positive number");
|
|
5407
|
+
}
|
|
5408
|
+
if (args.model !== undefined && (typeof args.model !== "string" || args.model.length === 0)) {
|
|
5409
|
+
throw new CdpError("extract_page: 'model' must be a non-empty string");
|
|
5410
|
+
}
|
|
5411
|
+
if (args.baseUrl !== undefined && (typeof args.baseUrl !== "string" || args.baseUrl.length === 0)) {
|
|
5412
|
+
throw new CdpError("extract_page: 'baseUrl' must be a non-empty string (the API key is env-only: CDP_EXTRACT_API_KEY)");
|
|
5413
|
+
}
|
|
5414
|
+
}
|
|
5415
|
+
function buildCleanFunctionSource(selector, clean) {
|
|
5416
|
+
return `function() {
|
|
5417
|
+
var SEL = ${JSON.stringify(selector ?? null)};
|
|
5418
|
+
var CLEAN = ${JSON.stringify(clean)};
|
|
5419
|
+
var STRIP = ["script","style","noscript","svg","canvas","iframe","video","audio","source","track","template","object","embed","link","meta"];
|
|
5420
|
+
var AGGRO = ["nav","header","footer","aside","form","button"];
|
|
5421
|
+
var WHITELIST = ["href","src","alt","title","value","placeholder","for","name","type","role","aria-label","selected","checked","disabled"];
|
|
5422
|
+
|
|
5423
|
+
function bytes(s) {
|
|
5424
|
+
try { return new TextEncoder().encode(s).length; } catch (e) { return s.length; }
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5427
|
+
function collectShadowScopes(root, out) {
|
|
5428
|
+
var els = root.querySelectorAll("*");
|
|
5429
|
+
for (var i = 0; i < els.length; i++) {
|
|
5430
|
+
var sr = els[i].shadowRoot;
|
|
5431
|
+
if (sr) { out.push(sr); collectShadowScopes(sr, out); }
|
|
5432
|
+
}
|
|
5433
|
+
}
|
|
5434
|
+
|
|
5435
|
+
// Locate SEL piercing open shadow roots (querySelector on document first,
|
|
5436
|
+
// then every open shadow scope). LIMIT: outerHTML serializes the matched
|
|
5437
|
+
// element's light DOM only; a closed shadow root is invisible by design.
|
|
5438
|
+
function deepQuery(sel) {
|
|
5439
|
+
var direct = document.querySelector(sel);
|
|
5440
|
+
if (direct) return direct;
|
|
5441
|
+
var scopes = [];
|
|
5442
|
+
collectShadowScopes(document, scopes);
|
|
5443
|
+
for (var i = 0; i < scopes.length; i++) {
|
|
5444
|
+
var hit = scopes[i].querySelector(sel);
|
|
5445
|
+
if (hit) return hit;
|
|
5446
|
+
}
|
|
5447
|
+
return null;
|
|
5448
|
+
}
|
|
5449
|
+
|
|
5450
|
+
// The visibility heuristic, same shape as bidi/snapshot.ts's isVisible:
|
|
5451
|
+
// hidden attr, aria-hidden, computed display/visibility, then the 0x0
|
|
5452
|
+
// paint check. OPTION/OPTGROUP skip the size check (a closed select's
|
|
5453
|
+
// popup is unpainted though its options are real, extractable content).
|
|
5454
|
+
function isHidden(el) {
|
|
5455
|
+
if (el.hidden) return true;
|
|
5456
|
+
if (el.getAttribute("aria-hidden") === "true") return true;
|
|
5457
|
+
var style = null;
|
|
5458
|
+
try { style = el.ownerDocument.defaultView.getComputedStyle(el); } catch (e) {}
|
|
5459
|
+
if (style && (style.display === "none" || style.visibility === "hidden")) return true;
|
|
5460
|
+
var tag = el.tagName;
|
|
5461
|
+
if (tag !== "OPTION" && tag !== "OPTGROUP") {
|
|
5462
|
+
var rect = el.getBoundingClientRect();
|
|
5463
|
+
if (rect.width === 0 && rect.height === 0) return true;
|
|
5464
|
+
}
|
|
5465
|
+
return false;
|
|
5466
|
+
}
|
|
5467
|
+
|
|
5468
|
+
function countDrop(dropped, tag) {
|
|
5469
|
+
dropped.elements[tag] = (dropped.elements[tag] || 0) + 1;
|
|
5470
|
+
}
|
|
5471
|
+
|
|
5472
|
+
function cleanRoot(root) {
|
|
5473
|
+
var rawHtml = root.outerHTML;
|
|
5474
|
+
var dropped = { comments: 0, hidden: 0, attributes: 0, elements: {} };
|
|
5475
|
+
if (CLEAN === "none") {
|
|
5476
|
+
return { matched: true, html: rawHtml, rawBytes: bytes(rawHtml), cleanBytes: bytes(rawHtml), dropped: dropped };
|
|
5477
|
+
}
|
|
5478
|
+
var live = root.querySelectorAll("*");
|
|
5479
|
+
var clone = root.cloneNode(true);
|
|
5480
|
+
var mirrored = clone.querySelectorAll("*");
|
|
5481
|
+
for (var i = 0; i < mirrored.length; i++) {
|
|
5482
|
+
var cel = mirrored[i];
|
|
5483
|
+
if (!cel.parentNode) continue; // dropped already, with an ancestor
|
|
5484
|
+
var low = cel.tagName.toLowerCase();
|
|
5485
|
+
if (STRIP.indexOf(low) !== -1) { countDrop(dropped, low); cel.parentNode.removeChild(cel); continue; }
|
|
5486
|
+
if (CLEAN === "aggressive" && AGGRO.indexOf(low) !== -1) { countDrop(dropped, low); cel.parentNode.removeChild(cel); continue; }
|
|
5487
|
+
var lel = live[i];
|
|
5488
|
+
if (lel && isHidden(lel)) { dropped.hidden++; cel.parentNode.removeChild(cel); continue; }
|
|
5489
|
+
for (var j = cel.attributes.length - 1; j >= 0; j--) {
|
|
5490
|
+
var attr = cel.attributes[j];
|
|
5491
|
+
if (WHITELIST.indexOf(attr.name.toLowerCase()) === -1) {
|
|
5492
|
+
cel.removeAttribute(attr.name);
|
|
5493
|
+
dropped.attributes++;
|
|
5494
|
+
}
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
5497
|
+
var walker = clone.ownerDocument.createTreeWalker(clone, NodeFilter.SHOW_COMMENT);
|
|
5498
|
+
var comments = [];
|
|
5499
|
+
while (walker.nextNode()) comments.push(walker.currentNode);
|
|
5500
|
+
for (var k = 0; k < comments.length; k++) {
|
|
5501
|
+
var c = comments[k];
|
|
5502
|
+
if (c.parentNode) c.parentNode.removeChild(c);
|
|
5503
|
+
}
|
|
5504
|
+
dropped.comments = comments.length;
|
|
5505
|
+
// Whitespace collapse at the TEXT-NODE level, never on the serialized
|
|
5506
|
+
// string: a regex over outerHTML would also flatten <pre> content.
|
|
5507
|
+
var tw = clone.ownerDocument.createTreeWalker(clone, NodeFilter.SHOW_TEXT);
|
|
5508
|
+
while (tw.nextNode()) {
|
|
5509
|
+
var node = tw.currentNode;
|
|
5510
|
+
node.nodeValue = node.nodeValue.replace(/\\s+/g, " ");
|
|
5511
|
+
}
|
|
5512
|
+
var html = clone.outerHTML;
|
|
5513
|
+
return { matched: true, html: html, rawBytes: bytes(rawHtml), cleanBytes: bytes(html), dropped: dropped };
|
|
5514
|
+
}
|
|
5515
|
+
|
|
5516
|
+
if (SEL) {
|
|
5517
|
+
var found = deepQuery(SEL);
|
|
5518
|
+
if (!found) return { matched: false };
|
|
5519
|
+
return cleanRoot(found);
|
|
5520
|
+
}
|
|
5521
|
+
return cleanRoot(document.documentElement);
|
|
5522
|
+
}`;
|
|
5523
|
+
}
|
|
5524
|
+
function coerceCleanOutcome(raw, selector) {
|
|
5525
|
+
if (typeof raw !== "object" || raw === null) {
|
|
5526
|
+
throw new CdpError("extract_page: in-page cleaning returned an unexpected shape");
|
|
5527
|
+
}
|
|
5528
|
+
const r = raw;
|
|
5529
|
+
if (r.matched === false) {
|
|
5530
|
+
throw new CdpError(`extract_page: no element matches selector '${selector}' (checked document and every open shadow root)`);
|
|
5531
|
+
}
|
|
5532
|
+
if (typeof r.html !== "string" || typeof r.rawBytes !== "number" || !Number.isFinite(r.rawBytes) || typeof r.cleanBytes !== "number" || !Number.isFinite(r.cleanBytes)) {
|
|
5533
|
+
throw new CdpError("extract_page: in-page cleaning returned an unexpected shape");
|
|
5534
|
+
}
|
|
5535
|
+
return {
|
|
5536
|
+
html: r.html,
|
|
5537
|
+
rawBytes: r.rawBytes,
|
|
5538
|
+
cleanBytes: r.cleanBytes,
|
|
5539
|
+
dropped: typeof r.dropped === "object" && r.dropped !== null ? r.dropped : {}
|
|
5540
|
+
};
|
|
5541
|
+
}
|
|
5542
|
+
function estimateTokens(chars) {
|
|
5543
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
5544
|
+
}
|
|
5545
|
+
function guardSize(outcome, maxChars) {
|
|
5546
|
+
const estTokens = estimateTokens(outcome.html.length);
|
|
5547
|
+
const overChars = outcome.html.length > maxChars;
|
|
5548
|
+
const overContext = estTokens + COMPLESION_TOKENS + SAFETY_TOKENS > CONTEXT_TOKEN_BUDGET;
|
|
5549
|
+
if (overChars || overContext) {
|
|
5550
|
+
const why = overChars ? `cleaned HTML is ${outcome.html.length} chars (${outcome.cleanBytes} bytes), over the ${maxChars}-char cap` : `estimated ${estTokens} prompt tokens + ${COMPLESION_TOKENS} completion + ${SAFETY_TOKENS} safety exceeds the ${CONTEXT_TOKEN_BUDGET}-token context budget`;
|
|
5551
|
+
throw new CdpError(`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.`);
|
|
5552
|
+
}
|
|
5553
|
+
return estTokens;
|
|
5554
|
+
}
|
|
5555
|
+
function redactSecrets(text, apiKey) {
|
|
5556
|
+
let out = text;
|
|
5557
|
+
if (apiKey && apiKey.length > 0) {
|
|
5558
|
+
out = out.split(apiKey).join("[redacted]");
|
|
5559
|
+
}
|
|
5560
|
+
return out.replace(/sk-[A-Za-z0-9_-]{8,}/g, "[redacted]");
|
|
5561
|
+
}
|
|
5562
|
+
function pickCostUsd(usage) {
|
|
5563
|
+
for (const v of [usage.cost_usd, usage.costUsd, usage.cost]) {
|
|
5564
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
5565
|
+
return v;
|
|
5566
|
+
}
|
|
5567
|
+
return;
|
|
5568
|
+
}
|
|
5569
|
+
function excerpt(text, max) {
|
|
5570
|
+
const t = text.trim();
|
|
5571
|
+
return t.length > max ? `${t.slice(0, max)}…` : t;
|
|
5572
|
+
}
|
|
5573
|
+
function buildExtractionMessages(mode, html, schema) {
|
|
5574
|
+
if (mode !== "schematron")
|
|
5575
|
+
return [{ role: "user", content: html }];
|
|
5576
|
+
const user = "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:" + `
|
|
5577
|
+
|
|
5578
|
+
` + JSON.stringify(schema) + `
|
|
5579
|
+
|
|
5580
|
+
` + "Here is the HTML page:" + `
|
|
5581
|
+
|
|
5582
|
+
` + html + `
|
|
5583
|
+
|
|
5584
|
+
` + "MAKE SURE ITS VALID JSON.";
|
|
5585
|
+
return [
|
|
5586
|
+
{ role: "system", content: "You are a helpful assistant" },
|
|
5587
|
+
{ role: "user", content: user }
|
|
5588
|
+
];
|
|
5589
|
+
}
|
|
5590
|
+
async function callExtractionEndpoint(opts) {
|
|
5591
|
+
const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
|
5592
|
+
const body = JSON.stringify({
|
|
5593
|
+
model: opts.model,
|
|
5594
|
+
messages: buildExtractionMessages(opts.prompt ?? "html", opts.html, opts.schema),
|
|
5595
|
+
temperature: 0,
|
|
5596
|
+
max_tokens: COMPLESION_TOKENS,
|
|
5597
|
+
response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } }
|
|
5598
|
+
});
|
|
5599
|
+
const headers = { "content-type": "application/json" };
|
|
5600
|
+
if (opts.apiKey)
|
|
5601
|
+
headers.authorization = `Bearer ${opts.apiKey}`;
|
|
5602
|
+
let lastStatus = 0;
|
|
5603
|
+
let lastBody = "";
|
|
5604
|
+
for (let attempt = 1;attempt <= 2; attempt++) {
|
|
5605
|
+
let res;
|
|
5606
|
+
try {
|
|
5607
|
+
res = await fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(opts.timeoutMs) });
|
|
5608
|
+
} catch (e) {
|
|
5609
|
+
const err = e;
|
|
5610
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
5611
|
+
throw new CdpError(`extract_page: upstream call to ${opts.baseUrl} timed out after ${opts.timeoutMs}ms`);
|
|
5612
|
+
}
|
|
5613
|
+
throw new CdpError(`extract_page: could not reach ${opts.baseUrl} (${redactSecrets(err.message, opts.apiKey)}) — is ferry running?`);
|
|
5614
|
+
}
|
|
5615
|
+
const text = await res.text();
|
|
5616
|
+
if (res.ok) {
|
|
5617
|
+
let parsed;
|
|
5618
|
+
try {
|
|
5619
|
+
parsed = JSON.parse(text);
|
|
5620
|
+
} catch {
|
|
5621
|
+
throw new CdpError(`extract_page: endpoint returned a non-JSON 200 body: ${excerpt(redactSecrets(text, opts.apiKey), 200)}`);
|
|
5622
|
+
}
|
|
5623
|
+
if (parsed.error) {
|
|
5624
|
+
throw new CdpError(`extract_page: upstream_error from ${opts.baseUrl}: ${excerpt(redactSecrets(String(parsed.error.message ?? JSON.stringify(parsed.error)), opts.apiKey), 600)}`);
|
|
5625
|
+
}
|
|
5626
|
+
const content = parsed.choices?.[0]?.message?.content;
|
|
5627
|
+
if (typeof content !== "string" || content.length === 0) {
|
|
5628
|
+
throw new CdpError(`extract_page: endpoint response carried no message content: ${excerpt(redactSecrets(text, opts.apiKey), 200)}`);
|
|
5629
|
+
}
|
|
5630
|
+
const u = parsed.usage ?? {};
|
|
5631
|
+
const usage = {
|
|
5632
|
+
promptTokens: u.prompt_tokens ?? 0,
|
|
5633
|
+
completionTokens: u.completion_tokens ?? 0,
|
|
5634
|
+
totalTokens: u.total_tokens ?? 0,
|
|
5635
|
+
...pickCostUsd(u) !== undefined ? { costUsd: pickCostUsd(u) } : {}
|
|
5636
|
+
};
|
|
5637
|
+
const fr = parsed.choices?.[0]?.finish_reason;
|
|
5638
|
+
const finishReason = typeof fr === "string" ? fr : undefined;
|
|
5639
|
+
return { content, usage, model: parsed.model ?? opts.model, finishReason };
|
|
5640
|
+
}
|
|
5641
|
+
lastStatus = res.status;
|
|
5642
|
+
lastBody = text;
|
|
5643
|
+
if (res.status === 429) {
|
|
5644
|
+
const retryAfter = res.headers.get("retry-after");
|
|
5645
|
+
throw new CdpError(`extract_page: upstream rate-limited (HTTP 429) from ${opts.baseUrl}${retryAfter !== null ? `, retry-after: ${retryAfter}` : " (no retry-after header)"}`);
|
|
5646
|
+
}
|
|
5647
|
+
if (res.status < 500 || res.status >= 600)
|
|
5648
|
+
break;
|
|
5649
|
+
}
|
|
5650
|
+
throw new CdpError(`extract_page: upstream_error HTTP ${lastStatus} from POST ${url}: ${excerpt(redactSecrets(lastBody, opts.apiKey), 600)}`);
|
|
5651
|
+
}
|
|
5652
|
+
async function withPage3(driver, target, fn) {
|
|
5653
|
+
const page = await driver.page(target);
|
|
5654
|
+
try {
|
|
5655
|
+
return await fn(page);
|
|
5656
|
+
} finally {
|
|
5657
|
+
await page.release();
|
|
5658
|
+
}
|
|
5659
|
+
}
|
|
5660
|
+
async function extractPage(driver, args) {
|
|
5661
|
+
validateExtractArgs(args);
|
|
5662
|
+
const clean = args.clean ?? "standard";
|
|
5663
|
+
const maxChars = args.maxChars ?? extractMaxChars();
|
|
5664
|
+
const timeoutMs = Math.min(args.timeoutMs ?? extractTimeoutMs(), MAX_TIMEOUT_MS);
|
|
5665
|
+
const baseUrl = args.baseUrl ?? extractBaseUrl();
|
|
5666
|
+
const model = args.model ?? extractModel();
|
|
5667
|
+
const prompt = args.prompt ?? extractPromptMode();
|
|
5668
|
+
const apiKey = extractApiKey();
|
|
5669
|
+
return withPage3(driver, args.target, async (page) => {
|
|
5670
|
+
const source = `(${buildCleanFunctionSource(args.selector, clean)})()`;
|
|
5671
|
+
const outcome = coerceCleanOutcome(await page.evaluate(source, { awaitPromise: true }), args.selector);
|
|
5672
|
+
const estTokens = guardSize(outcome, maxChars);
|
|
5673
|
+
const { content, usage, model: respondedModel, finishReason } = await callExtractionEndpoint({
|
|
5674
|
+
baseUrl,
|
|
5675
|
+
apiKey,
|
|
5676
|
+
model,
|
|
5677
|
+
html: outcome.html,
|
|
5678
|
+
schema: args.schema,
|
|
5679
|
+
timeoutMs,
|
|
5680
|
+
prompt
|
|
5681
|
+
});
|
|
5682
|
+
if (finishReason === "length") {
|
|
5683
|
+
throw new CdpError(`extract_page: completion truncated at max_tokens=${COMPLESION_TOKENS} (finish_reason=length, ${usage.completionTokens} completion tokens); ` + `narrow the payload with 'selector' or ask the schema for less: ${excerpt(redactSecrets(content, apiKey), 200)}`);
|
|
5684
|
+
}
|
|
5685
|
+
let data;
|
|
5686
|
+
try {
|
|
5687
|
+
data = JSON.parse(content);
|
|
5688
|
+
} catch (e) {
|
|
5689
|
+
throw new CdpError(`extract_page: endpoint returned invalid JSON (${e.message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
|
|
5690
|
+
}
|
|
5691
|
+
const result = {
|
|
5692
|
+
data,
|
|
5693
|
+
model: respondedModel,
|
|
5694
|
+
usage,
|
|
5695
|
+
html: {
|
|
5696
|
+
source: "dom",
|
|
5697
|
+
rawBytes: outcome.rawBytes,
|
|
5698
|
+
cleanBytes: outcome.cleanBytes,
|
|
5699
|
+
estTokens,
|
|
5700
|
+
dropped: outcome.dropped
|
|
5701
|
+
},
|
|
5702
|
+
target: { id: page.info.id, url: page.info.url, title: page.info.title }
|
|
5703
|
+
};
|
|
5704
|
+
if (args.savePath === undefined || args.savePath === "")
|
|
5705
|
+
return result;
|
|
5706
|
+
const path = args.savePath.startsWith("/") ? args.savePath : join10(ARTIFACT_DIR7, args.savePath);
|
|
5707
|
+
await mkdir12(dirname3(path), { recursive: true });
|
|
5708
|
+
const json = JSON.stringify(result, null, 2);
|
|
5709
|
+
await writeFile9(path, json, "utf8");
|
|
5710
|
+
const saved = {
|
|
5711
|
+
path,
|
|
5712
|
+
bytes: Buffer.byteLength(json, "utf8"),
|
|
5713
|
+
model: respondedModel,
|
|
5714
|
+
usage,
|
|
5715
|
+
target: result.target
|
|
5716
|
+
};
|
|
5717
|
+
return saved;
|
|
5718
|
+
});
|
|
5719
|
+
}
|
|
5720
|
+
|
|
5210
5721
|
// src/index.ts
|
|
5211
5722
|
init_client();
|
|
5212
5723
|
var cdpDriver = createCdpDriver();
|
|
@@ -5254,8 +5765,11 @@ var TOOLS = {
|
|
|
5254
5765
|
list_mocks: listMocks,
|
|
5255
5766
|
clear_mocks: clearMocks,
|
|
5256
5767
|
dispatch_mouse: dispatchMouse,
|
|
5768
|
+
focus_emulation: focusEmulation,
|
|
5769
|
+
click_focus_gated: clickFocusGated,
|
|
5257
5770
|
wait_for_download: waitForDownload,
|
|
5258
5771
|
grant_permissions: grantPermissions,
|
|
5772
|
+
extract_page: onCdp(extractPage),
|
|
5259
5773
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
5260
5774
|
release_page: onCdp(LEASE_TOOLS.release_page),
|
|
5261
5775
|
list_leases: onCdp(LEASE_TOOLS.list_leases)
|