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.
package/dist/index.js CHANGED
@@ -5303,6 +5303,425 @@ async function grantPermissions(args = {}) {
5303
5303
  return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
5304
5304
  }
5305
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 promptMode = opts.prompt ?? "html";
5593
+ const body = JSON.stringify({
5594
+ model: opts.model,
5595
+ messages: buildExtractionMessages(promptMode, opts.html, opts.schema),
5596
+ temperature: 0,
5597
+ max_tokens: COMPLESION_TOKENS,
5598
+ ...promptMode === "schematron" ? {} : { response_format: { type: "json_schema", json_schema: { name: "extract", strict: true, schema: opts.schema } } }
5599
+ });
5600
+ const headers = { "content-type": "application/json" };
5601
+ if (opts.apiKey)
5602
+ headers.authorization = `Bearer ${opts.apiKey}`;
5603
+ let lastStatus = 0;
5604
+ let lastBody = "";
5605
+ for (let attempt = 1;attempt <= 2; attempt++) {
5606
+ let res;
5607
+ try {
5608
+ res = await fetch(url, { method: "POST", headers, body, signal: AbortSignal.timeout(opts.timeoutMs) });
5609
+ } catch (e) {
5610
+ const err = e;
5611
+ if (err.name === "TimeoutError" || err.name === "AbortError") {
5612
+ throw new CdpError(`extract_page: upstream call to ${opts.baseUrl} timed out after ${opts.timeoutMs}ms`);
5613
+ }
5614
+ throw new CdpError(`extract_page: could not reach ${opts.baseUrl} (${redactSecrets(err.message, opts.apiKey)}) — is ferry running?`);
5615
+ }
5616
+ const text = await res.text();
5617
+ if (res.ok) {
5618
+ let parsed;
5619
+ try {
5620
+ parsed = JSON.parse(text);
5621
+ } catch {
5622
+ throw new CdpError(`extract_page: endpoint returned a non-JSON 200 body: ${excerpt(redactSecrets(text, opts.apiKey), 200)}`);
5623
+ }
5624
+ if (parsed.error) {
5625
+ throw new CdpError(`extract_page: upstream_error from ${opts.baseUrl}: ${excerpt(redactSecrets(String(parsed.error.message ?? JSON.stringify(parsed.error)), opts.apiKey), 600)}`);
5626
+ }
5627
+ const content = parsed.choices?.[0]?.message?.content;
5628
+ if (typeof content !== "string" || content.length === 0) {
5629
+ throw new CdpError(`extract_page: endpoint response carried no message content: ${excerpt(redactSecrets(text, opts.apiKey), 200)}`);
5630
+ }
5631
+ const u = parsed.usage ?? {};
5632
+ const usage = {
5633
+ promptTokens: u.prompt_tokens ?? 0,
5634
+ completionTokens: u.completion_tokens ?? 0,
5635
+ totalTokens: u.total_tokens ?? 0,
5636
+ ...pickCostUsd(u) !== undefined ? { costUsd: pickCostUsd(u) } : {}
5637
+ };
5638
+ const fr = parsed.choices?.[0]?.finish_reason;
5639
+ const finishReason = typeof fr === "string" ? fr : undefined;
5640
+ return { content, usage, model: parsed.model ?? opts.model, finishReason };
5641
+ }
5642
+ lastStatus = res.status;
5643
+ lastBody = text;
5644
+ if (res.status === 429) {
5645
+ const retryAfter = res.headers.get("retry-after");
5646
+ throw new CdpError(`extract_page: upstream rate-limited (HTTP 429) from ${opts.baseUrl}${retryAfter !== null ? `, retry-after: ${retryAfter}` : " (no retry-after header)"}`);
5647
+ }
5648
+ if (res.status < 500 || res.status >= 600)
5649
+ break;
5650
+ }
5651
+ throw new CdpError(`extract_page: upstream_error HTTP ${lastStatus} from POST ${url}: ${excerpt(redactSecrets(lastBody, opts.apiKey), 600)}`);
5652
+ }
5653
+ async function withPage3(driver, target, fn) {
5654
+ const page = await driver.page(target);
5655
+ try {
5656
+ return await fn(page);
5657
+ } finally {
5658
+ await page.release();
5659
+ }
5660
+ }
5661
+ async function extractPage(driver, args) {
5662
+ validateExtractArgs(args);
5663
+ const clean = args.clean ?? "standard";
5664
+ const maxChars = args.maxChars ?? extractMaxChars();
5665
+ const timeoutMs = Math.min(args.timeoutMs ?? extractTimeoutMs(), MAX_TIMEOUT_MS);
5666
+ const baseUrl = args.baseUrl ?? extractBaseUrl();
5667
+ const model = args.model ?? extractModel();
5668
+ const prompt = args.prompt ?? extractPromptMode();
5669
+ const apiKey = extractApiKey();
5670
+ return withPage3(driver, args.target, async (page) => {
5671
+ const source = `(${buildCleanFunctionSource(args.selector, clean)})()`;
5672
+ const outcome = coerceCleanOutcome(await page.evaluate(source, { awaitPromise: true }), args.selector);
5673
+ const estTokens = guardSize(outcome, maxChars);
5674
+ const { content, usage, model: respondedModel, finishReason } = await callExtractionEndpoint({
5675
+ baseUrl,
5676
+ apiKey,
5677
+ model,
5678
+ html: outcome.html,
5679
+ schema: args.schema,
5680
+ timeoutMs,
5681
+ prompt
5682
+ });
5683
+ if (finishReason === "length") {
5684
+ 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)}`);
5685
+ }
5686
+ let data;
5687
+ try {
5688
+ data = JSON.parse(content);
5689
+ } catch (e) {
5690
+ throw new CdpError(`extract_page: endpoint returned invalid JSON (${e.message}${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
5691
+ }
5692
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
5693
+ throw new CdpError(`extract_page: endpoint returned invalid JSON (expected a JSON object${finishReason !== undefined ? `, finish_reason=${finishReason}` : ""}): ${excerpt(redactSecrets(content, apiKey), 200)}`);
5694
+ }
5695
+ const result = {
5696
+ data,
5697
+ model: respondedModel,
5698
+ usage,
5699
+ html: {
5700
+ source: "dom",
5701
+ rawBytes: outcome.rawBytes,
5702
+ cleanBytes: outcome.cleanBytes,
5703
+ estTokens,
5704
+ dropped: outcome.dropped
5705
+ },
5706
+ target: { id: page.info.id, url: page.info.url, title: page.info.title }
5707
+ };
5708
+ if (args.savePath === undefined || args.savePath === "")
5709
+ return result;
5710
+ const path = args.savePath.startsWith("/") ? args.savePath : join10(ARTIFACT_DIR7, args.savePath);
5711
+ await mkdir12(dirname3(path), { recursive: true });
5712
+ const json = JSON.stringify(result, null, 2);
5713
+ await writeFile9(path, json, "utf8");
5714
+ const saved = {
5715
+ path,
5716
+ bytes: Buffer.byteLength(json, "utf8"),
5717
+ model: respondedModel,
5718
+ usage,
5719
+ target: result.target
5720
+ };
5721
+ return saved;
5722
+ });
5723
+ }
5724
+
5306
5725
  // src/index.ts
5307
5726
  init_client();
5308
5727
  var cdpDriver = createCdpDriver();
@@ -5354,6 +5773,7 @@ var TOOLS = {
5354
5773
  click_focus_gated: clickFocusGated,
5355
5774
  wait_for_download: waitForDownload,
5356
5775
  grant_permissions: grantPermissions,
5776
+ extract_page: onCdp(extractPage),
5357
5777
  claim_page: onCdp(LEASE_TOOLS.claim_page),
5358
5778
  release_page: onCdp(LEASE_TOOLS.release_page),
5359
5779
  list_leases: onCdp(LEASE_TOOLS.list_leases)