webcrawl 1.4.2 → 1.4.4

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,208 @@
1
+ /**
2
+ * Local Chrome renderer (JS pages the native fetch ladder cannot hydrate).
3
+ *
4
+ * Primary driver is Chrome DevTools Protocol:
5
+ * launch headless Chrome, navigate, wait until the DOM is content-ready
6
+ * (load + 200ms mutation-idle), then read `documentElement.outerHTML`.
7
+ *
8
+ * dump-dom is a last-resort fallback: Chrome 156 hangs on dump-dom with a
9
+ * fresh profile, which used to look like a successful scrape of the
10
+ * unrendered nav shell. Every attempt has a hard timeout and a process-group
11
+ * kill. Playwright's chrome-headless-shell is discovered automatically when
12
+ * present.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { renderUrlViaCdp } from "./chrome-cdp.js";
19
+ import { DEFAULT_TIMEOUT_MS, DUMP_MAX_BYTES, chromeDumpDomArgs, discoverChromeBinaries, killProcessGroup, } from "./chrome-launch.js";
20
+ export { DEFAULT_TIMEOUT_MS, VIRTUAL_TIME_MS, discoverChromeBinaries, findPlaywrightHeadlessShell, installedChromePath, isLocalChromeAvailable, parseDevToolsActivePort, chromeCdpArgs, chromeDumpDomArgs, killProcessGroup, } from "./chrome-launch.js";
21
+ function attemptPlan(binary) {
22
+ const isShell = /headless[-_]?shell/i.test(binary);
23
+ if (isShell) {
24
+ return [
25
+ { driver: "cdp", headless: "shell" },
26
+ { driver: "dump-dom", headless: "shell" },
27
+ ];
28
+ }
29
+ return [
30
+ { driver: "cdp", headless: "new" },
31
+ { driver: "cdp", headless: "old" },
32
+ { driver: "dump-dom", headless: "old" },
33
+ { driver: "dump-dom", headless: "new" },
34
+ ];
35
+ }
36
+ function methodName(binary, kind) {
37
+ const base = binary.split(/[/\\]/).pop() || binary;
38
+ return `${kind.driver}/${kind.headless} (${base})`;
39
+ }
40
+ /**
41
+ * Render `url` in local Chrome and return the hydrated HTML, or null when
42
+ * Chrome is missing, times out, or produces nothing.
43
+ */
44
+ export async function renderViaLocalChrome(url, opts = {}) {
45
+ const result = await renderViaLocalChromeDetailed(url, opts);
46
+ return result.html;
47
+ }
48
+ /**
49
+ * Same as `renderViaLocalChrome` but with the attempt log and an actionable
50
+ * error when every driver fails. Callers that need to report honest JS-render
51
+ * failures should use this.
52
+ */
53
+ export async function renderViaLocalChromeDetailed(url, opts = {}) {
54
+ const binaries = discoverChromeBinaries();
55
+ if (binaries.length === 0) {
56
+ return {
57
+ html: null,
58
+ error: "No local Chrome/Chromium binary found. Install Chrome, set WEBCRAWL_CHROME_PATH, " +
59
+ "or run `npx playwright install chromium-headless-shell`. See `webcrawl doctor`.",
60
+ attempts: [],
61
+ };
62
+ }
63
+ const budgetMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
64
+ const started = Date.now();
65
+ const attempts = [];
66
+ let lastError = "local Chrome produced no content";
67
+ for (const binary of binaries) {
68
+ for (const kind of attemptPlan(binary)) {
69
+ const remaining = budgetMs - (Date.now() - started);
70
+ if (remaining < 1_200)
71
+ break;
72
+ const method = methodName(binary, kind);
73
+ const attemptBudget = kind.driver === "cdp"
74
+ ? Math.min(remaining, kind.headless === "new" ? 12_000 : 8_000)
75
+ : Math.min(remaining, 6_000);
76
+ const t0 = Date.now();
77
+ try {
78
+ const html = kind.driver === "cdp"
79
+ ? (await renderUrlViaCdp(binary, url, {
80
+ timeoutMs: attemptBudget,
81
+ headless: kind.headless,
82
+ })).html
83
+ : await dumpDom(binary, url, kind.headless, attemptBudget);
84
+ const ms = Date.now() - t0;
85
+ if (html && html.length > 100) {
86
+ attempts.push({ ok: true, method, ms });
87
+ return { html, method, attempts };
88
+ }
89
+ lastError = `${method} returned empty HTML`;
90
+ attempts.push({ ok: false, method, ms, error: lastError });
91
+ }
92
+ catch (err) {
93
+ const ms = Date.now() - t0;
94
+ lastError = `${method}: ${err instanceof Error ? err.message : String(err)}`;
95
+ attempts.push({ ok: false, method, ms, error: lastError });
96
+ }
97
+ }
98
+ }
99
+ return {
100
+ html: null,
101
+ error: `JavaScript rendering failed (${lastError}). ` +
102
+ "Chrome hung, timed out, or produced no content. Install Chrome, point WEBCRAWL_CHROME_PATH " +
103
+ "at chrome-headless-shell (`npx playwright install chromium-headless-shell`), " +
104
+ "run `webcrawl login` and retry with `--mode cloud`, or set WEBCRAWL_RENDER_URL.",
105
+ attempts,
106
+ };
107
+ }
108
+ async function dumpDom(chrome, url, headless, timeoutMs) {
109
+ let profile = "";
110
+ try {
111
+ profile = mkdtempSync(join(tmpdir(), "wc-chrome-"));
112
+ }
113
+ catch {
114
+ throw new Error("could not create Chrome profile directory");
115
+ }
116
+ const args = chromeDumpDomArgs({
117
+ profile,
118
+ headless,
119
+ url,
120
+ timeoutMs,
121
+ binaryPath: chrome,
122
+ });
123
+ try {
124
+ return await new Promise((resolve, reject) => {
125
+ let settled = false;
126
+ const done = (err, value) => {
127
+ if (settled)
128
+ return;
129
+ settled = true;
130
+ if (err)
131
+ reject(err);
132
+ else
133
+ resolve(value ?? "");
134
+ };
135
+ let child;
136
+ try {
137
+ child = spawn(chrome, args, {
138
+ stdio: ["ignore", "pipe", "pipe"],
139
+ detached: true,
140
+ });
141
+ }
142
+ catch (err) {
143
+ done(err instanceof Error ? err : new Error(String(err)));
144
+ return;
145
+ }
146
+ const chunks = [];
147
+ let bytes = 0;
148
+ let idle = null;
149
+ const finish = (err) => {
150
+ clearTimeout(timer);
151
+ if (idle)
152
+ clearTimeout(idle);
153
+ killProcessGroup(child.pid, child);
154
+ if (err) {
155
+ done(err);
156
+ return;
157
+ }
158
+ const text = Buffer.concat(chunks).toString("utf8");
159
+ done(null, text.trim());
160
+ };
161
+ const timer = setTimeout(() => {
162
+ if (bytes > 100)
163
+ finish(null);
164
+ else
165
+ finish(new Error(`dump-dom timed out after ${timeoutMs}ms`));
166
+ }, timeoutMs);
167
+ child.stdout?.on("data", (c) => {
168
+ if (bytes >= DUMP_MAX_BYTES)
169
+ return;
170
+ const take = c.subarray(0, DUMP_MAX_BYTES - bytes);
171
+ chunks.push(take);
172
+ bytes += take.length;
173
+ // Chrome 156 often prints the DOM then never exits (`--dump-dom` hang).
174
+ if (idle)
175
+ clearTimeout(idle);
176
+ idle = setTimeout(() => {
177
+ if (bytes > 40)
178
+ finish(null);
179
+ }, 400);
180
+ });
181
+ child.on("error", (err) => {
182
+ clearTimeout(timer);
183
+ if (idle)
184
+ clearTimeout(idle);
185
+ done(err);
186
+ });
187
+ child.on("close", () => {
188
+ clearTimeout(timer);
189
+ if (idle)
190
+ clearTimeout(idle);
191
+ if (bytes === 0) {
192
+ done(new Error("dump-dom produced no stdout"));
193
+ return;
194
+ }
195
+ const text = Buffer.concat(chunks).toString("utf8");
196
+ done(null, text.trim().length > 0 ? text : "");
197
+ });
198
+ });
199
+ }
200
+ finally {
201
+ try {
202
+ rmSync(profile, { recursive: true, force: true });
203
+ }
204
+ catch {
205
+ /* leftover profile is in /tmp */
206
+ }
207
+ }
208
+ }
@@ -66,8 +66,9 @@ export declare function checkUrlsStatusBatch(urls: string[], timeoutMs?: number)
66
66
  ok: boolean;
67
67
  }>>;
68
68
  /**
69
- * Extracts structured `{ app_name, product_features }` (or custom JSON schema) from a URL.
70
- * Replaces `firecrawl.asyncExtract` in `website-extract.ts`.
69
+ * Extracts structured JSON from a URL.
70
+ * Honours `prompt` / `schema` when provided; otherwise falls back to
71
+ * `{ app_name, product_features }` from title and headings.
71
72
  */
72
73
  export declare function extractWebsiteData(url: string, options?: {
73
74
  prompt?: string;
@@ -76,9 +77,9 @@ export declare function extractWebsiteData(url: string, options?: {
76
77
  bypassCache?: boolean;
77
78
  /** Already-extracted content (e.g. a parsed PDF); skips the HTML fetch and cache. */
78
79
  markdown?: string;
79
- }): Promise<{
80
- app_name: string;
81
- product_features: string[];
80
+ /** Source HTML when the caller already fetched the page (quote cards, JSON-LD). */
81
+ html?: string;
82
+ }): Promise<Record<string, unknown> & {
82
83
  cached?: boolean;
83
84
  cacheLayer?: "memory" | "disk" | "postgres" | "miss";
84
85
  }>;
@@ -5,6 +5,7 @@ import { escalateToSandboxOrBrowser } from "../sandbox.js";
5
5
  import { getCachedMapAsync, getCachedPageAsync, setCachedMap, setCachedPage, sha256, } from "./cache.js";
6
6
  import { fetchWithTlsImpersonation, getBrowserHeaders, isAntiBotChallenge, } from "./fetcher.js";
7
7
  import { decodeHtmlEntities, extractLinksFromHtml, fastHtmlToMarkdown, } from "./markdown.js";
8
+ import { extractByPromptAndSchema, hasCustomExtractRequest, } from "../extract/structured.js";
8
9
  export async function mapWebsiteUrls(targetUrl, options = {}) {
9
10
  const start = performance.now();
10
11
  const limit = Math.max(1, options.limit ?? 5000);
@@ -490,11 +491,13 @@ export async function checkUrlsStatusBatch(urls, timeoutMs = 8000) {
490
491
  }));
491
492
  }
492
493
  /**
493
- * Extracts structured `{ app_name, product_features }` (or custom JSON schema) from a URL.
494
- * Replaces `firecrawl.asyncExtract` in `website-extract.ts`.
494
+ * Extracts structured JSON from a URL.
495
+ * Honours `prompt` / `schema` when provided; otherwise falls back to
496
+ * `{ app_name, product_features }` from title and headings.
495
497
  */
496
498
  export async function extractWebsiteData(url, options = {}) {
497
- if (!options.bypassCache && !options.markdown) {
499
+ const custom = hasCustomExtractRequest(options.prompt, options.schema);
500
+ if (!custom && !options.bypassCache && !options.markdown) {
498
501
  const cachedPage = await getCachedPageAsync(url, "all");
499
502
  if (cachedPage?.structuredData &&
500
503
  typeof cachedPage.structuredData.app_name === "string" &&
@@ -509,6 +512,7 @@ export async function extractWebsiteData(url, options = {}) {
509
512
  }
510
513
  let title = "";
511
514
  let markdown = "";
515
+ let html = options.html ?? "";
512
516
  let links = [];
513
517
  const existingPage = !options.bypassCache && !options.markdown
514
518
  ? await getCachedPageAsync(url, "all")
@@ -532,16 +536,25 @@ export async function extractWebsiteData(url, options = {}) {
532
536
  headers: { location: r.headers.get("location") ?? "" },
533
537
  text: () => r.text(),
534
538
  })));
535
- const html = await res.text();
539
+ html = await res.text();
536
540
  const parsed = fastHtmlToMarkdown(html, url);
537
541
  title = parsed.title;
538
542
  markdown = parsed.markdown;
539
543
  links = parsed.links;
540
544
  }
545
+ const cacheMeta = () => ({
546
+ cached: Boolean(existingPage),
547
+ cacheLayer: existingPage?.cacheLayer || "miss",
548
+ });
541
549
  // If OPENAI_API_KEY is available, use gpt-4o-mini for structured JSON extraction
542
550
  const apiKey = options.openaiApiKey || process.env.OPENAI_API_KEY;
543
551
  if (apiKey) {
544
552
  try {
553
+ const schemaHint = options.schema
554
+ ? `Return a JSON object matching this JSON Schema:\n${JSON.stringify(options.schema)}`
555
+ : options.prompt
556
+ ? `Return JSON that satisfies this request: ${options.prompt}`
557
+ : 'Extract the application/website name and a list of key product features as JSON: {"app_name": string, "product_features": string[]}.';
545
558
  const llmRes = await fetch("https://api.openai.com/v1/chat/completions", {
546
559
  method: "POST",
547
560
  headers: {
@@ -554,11 +567,11 @@ export async function extractWebsiteData(url, options = {}) {
554
567
  messages: [
555
568
  {
556
569
  role: "system",
557
- content: 'Extract the application/website name and a list of key product features as JSON: {"app_name": string, "product_features": string[]}.',
570
+ content: schemaHint,
558
571
  },
559
572
  {
560
573
  role: "user",
561
- content: `URL: ${url}\nTitle: ${title}\n\nContent:\n${markdown.slice(0, 12000)}`,
574
+ content: `${options.prompt ? `${options.prompt}\n\n` : ""}URL: ${url}\nTitle: ${title}\n\nContent:\n${markdown.slice(0, 12000)}`,
562
575
  },
563
576
  ],
564
577
  temperature: 0,
@@ -570,11 +583,16 @@ export async function extractWebsiteData(url, options = {}) {
570
583
  const content = json.choices?.[0]?.message?.content;
571
584
  if (content) {
572
585
  const parsed = JSON.parse(content);
573
- if (parsed.app_name && Array.isArray(parsed.product_features)) {
574
- const structuredResult = {
575
- app_name: String(parsed.app_name),
576
- product_features: parsed.product_features.map(String),
577
- };
586
+ const usable = custom
587
+ ? parsed && typeof parsed === "object" && !Array.isArray(parsed)
588
+ : Boolean(parsed.app_name && Array.isArray(parsed.product_features));
589
+ if (usable) {
590
+ const structuredResult = custom
591
+ ? parsed
592
+ : {
593
+ app_name: String(parsed.app_name),
594
+ product_features: parsed.product_features.map(String),
595
+ };
578
596
  setCachedPage(url, "all", {
579
597
  title,
580
598
  markdown,
@@ -583,11 +601,7 @@ export async function extractWebsiteData(url, options = {}) {
583
601
  statusCode: 200,
584
602
  contentHash: sha256(markdown),
585
603
  });
586
- return {
587
- ...structuredResult,
588
- cached: Boolean(existingPage),
589
- cacheLayer: existingPage?.cacheLayer || "miss",
590
- };
604
+ return { ...structuredResult, ...cacheMeta() };
591
605
  }
592
606
  }
593
607
  }
@@ -596,6 +610,23 @@ export async function extractWebsiteData(url, options = {}) {
596
610
  // Fall through to deterministic heuristic extraction
597
611
  }
598
612
  }
613
+ if (custom) {
614
+ const shaped = extractByPromptAndSchema(html, markdown, options.prompt, options.schema);
615
+ if (shaped) {
616
+ setCachedPage(url, "all", {
617
+ title,
618
+ markdown,
619
+ links,
620
+ structuredData: shaped,
621
+ statusCode: 200,
622
+ contentHash: sha256(markdown),
623
+ });
624
+ return { ...shaped, ...cacheMeta() };
625
+ }
626
+ // Honour the caller's schema/prompt: never substitute the generic
627
+ // `{ app_name, product_features }` object when they asked for something else.
628
+ return { ...cacheMeta() };
629
+ }
599
630
  // Deterministic fast extraction from Title, Meta Description, and H2/H3/List headings (<5ms)
600
631
  const cleanAppName = title.split(/\s+[-|\u2014–:]\s+/)[0]?.trim() ||
601
632
  new URL(url).hostname.replace(/^www\./, "").split(".")[0];
@@ -640,8 +671,7 @@ export async function extractWebsiteData(url, options = {}) {
640
671
  });
641
672
  return {
642
673
  ...structuredResult,
643
- cached: Boolean(existingPage),
644
- cacheLayer: existingPage?.cacheLayer || "miss",
674
+ ...cacheMeta(),
645
675
  };
646
676
  }
647
677
  /**
@@ -694,6 +724,9 @@ export async function crawlWebsitePages(targetUrl, options = {}) {
694
724
  }
695
725
  /** True for markdown converted from a bot-check page (old cache entries). */
696
726
  export function isChallengeMarkdown(title, markdown) {
727
+ if (/blocked by network security/i.test(`${title}\n${markdown}`)) {
728
+ return true;
729
+ }
697
730
  const t = `${title}\n${markdown.slice(0, 600)}`.toLowerCase();
698
731
  return (t.includes("vercel security checkpoint") ||
699
732
  t.includes("we're verifying your browser") ||
@@ -95,7 +95,13 @@ export function isAntiBotChallenge(html, status) {
95
95
  if (status === 403 || status === 429 || status === 503) {
96
96
  return true;
97
97
  }
98
- const lower = html.slice(0, 6000).toLowerCase();
98
+ const raw = String(html ?? "");
99
+ // Reddit (and similar) hide this copy after tens of KB of CSS + a data-URI PNG.
100
+ if (/blocked by network security/i.test(raw)) {
101
+ return true;
102
+ }
103
+ // Head slice catches title/meta markers on ordinary challenge interstitials.
104
+ const lower = raw.slice(0, 8000).toLowerCase();
99
105
  if (lower.includes("cf-browser-verification") ||
100
106
  lower.includes("challenge-platform") ||
101
107
  lower.includes("just a moment...") ||
@@ -457,18 +463,31 @@ export function needsBrowserEscalation(status, html, markdown) {
457
463
  if (isAntiBotChallenge(html, status)) {
458
464
  return true;
459
465
  }
460
- const lower = html.slice(0, 6000).toLowerCase();
466
+ const text = markdown.trim();
467
+ const lower = html.slice(0, 8000).toLowerCase();
468
+ // Empty or near-empty body: nothing to return until a renderer hydrates it.
469
+ if (text.length === 0) {
470
+ return true;
471
+ }
461
472
  // Empty SPA shell check (<div id="root"></div> or <div id="app"></div> with almost no text)
462
- if (markdown.length < 120 &&
473
+ if (text.length < 200 &&
463
474
  (lower.includes('id="root"') ||
464
475
  lower.includes('id="app"') ||
465
- lower.includes('id="__next"'))) {
476
+ lower.includes('id="__next"') ||
477
+ lower.includes('id="__nuxt"'))) {
478
+ return true;
479
+ }
480
+ // Header-only JS pages (quotes.toscrape.com/js): real content lives in a
481
+ // <script> as JSON + document.write / innerHTML, so markdown is just the chrome.
482
+ if (text.length < 200 &&
483
+ /<script[\s>]/i.test(html) &&
484
+ /document\.write|innerHTML\s*=/i.test(html)) {
466
485
  return true;
467
486
  }
468
487
  // Big-HTML/no-content SPA shell: a very large page (JS bundles / inline
469
488
  // state, e.g. StubHub category pages ~3MB) that yields almost no
470
489
  // extractable text is serving its real content client-side.
471
- if (html.length > 250_000 && markdown.length < 500) {
490
+ if (html.length > 250_000 && text.length < 500) {
472
491
  return true;
473
492
  }
474
493
  return false;
@@ -3,4 +3,5 @@ export * from "./markdown.js";
3
3
  export * from "./session.js";
4
4
  export * from "./fetcher.js";
5
5
  export * from "./render.js";
6
+ export * from "./chrome.js";
6
7
  export * from "./crawler.js";
@@ -3,4 +3,5 @@ export * from "./markdown.js";
3
3
  export * from "./session.js";
4
4
  export * from "./fetcher.js";
5
5
  export * from "./render.js";
6
+ export * from "./chrome.js";
6
7
  export * from "./crawler.js";
@@ -6,3 +6,4 @@ export { dedupeHotelListings, extractHotelListingFromSerpResult, extractHotelLis
6
6
  export { dedupeBusinessListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, type BusinessListing, } from "./business.js";
7
7
  export { cvss3BaseScore, cveSeverityLabel, extractCveIdsFromText, extractGhsaIdsFromText, looksLikeVulnId, mergeCveRecords, normalizeCveId, nvdUrl, parseNvdCve, parseNvdResponse, parseOsvVuln, severityFromScore, sortCveRecords, type CveAffected, type CveRecord, type CveSeverity, } from "./cve.js";
8
8
  export { extractBusinessListingsWithScaleDown, extractFlightOffersWithScaleDown, extractHotelListingsWithScaleDown, extractPropertyListingsWithScaleDown, } from "./scaledown.js";
9
+ export { coerceExtractSchema, extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, isEmptyExtractResult, isJsTemplateJunk, nestedQuoteAuthorFields, normalizeExtractSchema, shapeQuoteAuthors, stripNonContentHtml, wantsQuoteAuthorExtract, type QuoteAuthor, } from "./structured.js";
@@ -6,3 +6,4 @@ export { dedupeHotelListings, extractHotelListingFromSerpResult, extractHotelLis
6
6
  export { dedupeBusinessListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, } from "./business.js";
7
7
  export { cvss3BaseScore, cveSeverityLabel, extractCveIdsFromText, extractGhsaIdsFromText, looksLikeVulnId, mergeCveRecords, normalizeCveId, nvdUrl, parseNvdCve, parseNvdResponse, parseOsvVuln, severityFromScore, sortCveRecords, } from "./cve.js";
8
8
  export { extractBusinessListingsWithScaleDown, extractFlightOffersWithScaleDown, extractHotelListingsWithScaleDown, extractPropertyListingsWithScaleDown, } from "./scaledown.js";
9
+ export { coerceExtractSchema, extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, isEmptyExtractResult, isJsTemplateJunk, nestedQuoteAuthorFields, normalizeExtractSchema, shapeQuoteAuthors, stripNonContentHtml, wantsQuoteAuthorExtract, } from "./structured.js";
@@ -0,0 +1,33 @@
1
+ export interface QuoteAuthor {
2
+ text: string;
3
+ author: string;
4
+ }
5
+ /** A bare property map or shorthand array becomes a JSON Schema. */
6
+ export declare function coerceExtractSchema(node: unknown): Record<string, unknown>;
7
+ /**
8
+ * Root-array wrap: a top-level array becomes
9
+ * `{ type: object, properties: { items: <array> } }`.
10
+ */
11
+ export declare function normalizeExtractSchema(schema?: Record<string, unknown>): Record<string, unknown> | undefined;
12
+ export declare function schemaProperties(schema?: Record<string, unknown>): Record<string, unknown>;
13
+ export declare function hasCustomExtractRequest(prompt?: string, schema?: Record<string, unknown>): boolean;
14
+ /** Nested object/array field names that look like quote+author records. */
15
+ export declare function nestedQuoteAuthorFields(schema?: Record<string, unknown>): {
16
+ arrayPath: string[];
17
+ textKey: string;
18
+ authorKey: string;
19
+ } | null;
20
+ export declare function wantsQuoteAuthorExtract(prompt?: string, schema?: Record<string, unknown>): boolean;
21
+ /** Strip script/style/template (including unclosed tags) before reading visible DOM. */
22
+ export declare function stripNonContentHtml(html: string): string;
23
+ /** Inline `document.write("... " + d['text'] + ...)` leftovers, not real quotes. */
24
+ export declare function isJsTemplateJunk(text: string): boolean;
25
+ export declare function isEmptyExtractResult(data: Record<string, unknown> | null | undefined): boolean;
26
+ /** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
27
+ export declare function extractQuoteAuthorPairs(html: string, markdown?: string): QuoteAuthor[];
28
+ export declare function shapeQuoteAuthors(pairs: QuoteAuthor[], schema?: Record<string, unknown>): Record<string, unknown>;
29
+ /**
30
+ * Deterministic extract when the caller passed a prompt and/or JSON schema.
31
+ * Returns null when this page/request has nothing this extractor can fill.
32
+ */
33
+ export declare function extractByPromptAndSchema(html: string, markdown: string, prompt?: string, schema?: Record<string, unknown>): Record<string, unknown> | null;