webcrawl 1.4.2 → 1.4.3

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/cli.js CHANGED
@@ -429,8 +429,12 @@ export async function main(argv, io, deps = {}) {
429
429
  city: v.city,
430
430
  scrapeContent: v.content,
431
431
  });
432
+ if (res.success === false && !v.json) {
433
+ io.err(`${formatSearch(res)}\n`);
434
+ return 1;
435
+ }
432
436
  emit(res, formatSearch(res));
433
- return 0;
437
+ return res.success === false ? 1 : 0;
434
438
  }
435
439
  case "scrape": {
436
440
  const res = await client.scrape({ url: need("url"), formats: ["markdown"], noCache: v["no-cache"] });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Path to a real installed Chrome/Chromium, or null.
3
+ * Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
4
+ * defaults (same locations `webcrawl doctor` checks).
5
+ */
6
+ export declare function installedChromePath(): string | null;
7
+ export declare function isLocalChromeAvailable(): boolean;
8
+ /**
9
+ * Render `url` in local Chrome and return the hydrated HTML, or null when
10
+ * Chrome is missing, times out, or produces nothing.
11
+ */
12
+ export declare function renderViaLocalChrome(url: string, opts?: {
13
+ timeoutMs?: number;
14
+ }): Promise<string | null>;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Local Chrome dump-dom renderer (JS pages the native fetch ladder cannot hydrate).
3
+ *
4
+ * Uses a user-installed Chrome/Chromium binary — the same one `webcrawl doctor`
5
+ * reports — with `--dump-dom` + virtual time so `document.write` / client-rendered
6
+ * content appears in the HTML. Playwright is not required; this is the zero-key
7
+ * local JS tier.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ const DUMP_MAX_BYTES = 2 * 1024 * 1024;
14
+ const DEFAULT_TIMEOUT_MS = 20_000;
15
+ const VIRTUAL_TIME_MS = 10_000;
16
+ /**
17
+ * Path to a real installed Chrome/Chromium, or null.
18
+ * Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
19
+ * defaults (same locations `webcrawl doctor` checks).
20
+ */
21
+ export function installedChromePath() {
22
+ const fromEnv = process.env.WEBCRAWL_CHROME_PATH?.trim() ||
23
+ process.env.INTERACT_CHROME_PATH?.trim() ||
24
+ process.env.CHROME_PATH?.trim();
25
+ if (fromEnv)
26
+ return fromEnv;
27
+ const candidates = [];
28
+ if (process.platform === "darwin") {
29
+ candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", `${process.env.HOME || ""}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`);
30
+ }
31
+ else if (process.platform === "win32") {
32
+ candidates.push(`${process.env["ProgramFiles"] || "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`);
33
+ }
34
+ else {
35
+ candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium-browser", "/usr/bin/chromium", "/snap/bin/chromium");
36
+ }
37
+ for (const p of candidates) {
38
+ try {
39
+ if (p && existsSync(p))
40
+ return p;
41
+ }
42
+ catch {
43
+ /* not installed here */
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ export function isLocalChromeAvailable() {
49
+ return installedChromePath() !== null;
50
+ }
51
+ /**
52
+ * Render `url` in local Chrome and return the hydrated HTML, or null when
53
+ * Chrome is missing, times out, or produces nothing.
54
+ */
55
+ export async function renderViaLocalChrome(url, opts = {}) {
56
+ const chrome = installedChromePath();
57
+ if (!chrome)
58
+ return null;
59
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
60
+ let profile = "";
61
+ try {
62
+ profile = mkdtempSync(join(tmpdir(), "wc-chrome-"));
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ const args = [
68
+ "--headless=new",
69
+ "--no-sandbox",
70
+ "--disable-gpu",
71
+ "--disable-dev-shm-usage",
72
+ "--no-first-run",
73
+ "--no-default-browser-check",
74
+ "--disable-crash-reporter",
75
+ "--disable-breakpad",
76
+ `--user-data-dir=${profile}`,
77
+ `--virtual-time-budget=${VIRTUAL_TIME_MS}`,
78
+ `--timeout=${timeoutMs}`,
79
+ "--dump-dom",
80
+ url,
81
+ ];
82
+ try {
83
+ return await new Promise((resolve) => {
84
+ let settled = false;
85
+ const done = (value) => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ resolve(value);
90
+ };
91
+ let child;
92
+ try {
93
+ child = spawn(chrome, args, { stdio: ["ignore", "pipe", "pipe"] });
94
+ }
95
+ catch {
96
+ done(null);
97
+ return;
98
+ }
99
+ const chunks = [];
100
+ let bytes = 0;
101
+ const timer = setTimeout(() => {
102
+ try {
103
+ child.kill("SIGKILL");
104
+ }
105
+ catch {
106
+ /* already gone */
107
+ }
108
+ done(null);
109
+ }, timeoutMs + 2_000);
110
+ child.stdout?.on("data", (c) => {
111
+ if (bytes >= DUMP_MAX_BYTES)
112
+ return;
113
+ const take = c.subarray(0, DUMP_MAX_BYTES - bytes);
114
+ chunks.push(take);
115
+ bytes += take.length;
116
+ });
117
+ child.on("error", () => {
118
+ clearTimeout(timer);
119
+ done(null);
120
+ });
121
+ child.on("close", () => {
122
+ clearTimeout(timer);
123
+ if (bytes === 0) {
124
+ done(null);
125
+ return;
126
+ }
127
+ const text = Buffer.concat(chunks).toString("utf8");
128
+ done(text.trim().length > 0 ? text : null);
129
+ });
130
+ });
131
+ }
132
+ finally {
133
+ try {
134
+ rmSync(profile, { recursive: true, force: true });
135
+ }
136
+ catch {
137
+ /* leftover profile is in /tmp */
138
+ }
139
+ }
140
+ }
@@ -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 { extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, shapeQuoteAuthors, 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 { extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, shapeQuoteAuthors, wantsQuoteAuthorExtract, } from "./structured.js";
@@ -0,0 +1,14 @@
1
+ export interface QuoteAuthor {
2
+ text: string;
3
+ author: string;
4
+ }
5
+ export declare function hasCustomExtractRequest(prompt?: string, schema?: Record<string, unknown>): boolean;
6
+ export declare function wantsQuoteAuthorExtract(prompt?: string, schema?: Record<string, unknown>): boolean;
7
+ /** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
8
+ export declare function extractQuoteAuthorPairs(html: string, markdown?: string): QuoteAuthor[];
9
+ export declare function shapeQuoteAuthors(pairs: QuoteAuthor[], schema?: Record<string, unknown>): Record<string, unknown>;
10
+ /**
11
+ * Deterministic extract when the caller passed a prompt and/or JSON schema.
12
+ * Returns null when this page/request has nothing this extractor can fill.
13
+ */
14
+ export declare function extractByPromptAndSchema(html: string, markdown: string, prompt?: string, schema?: Record<string, unknown>): Record<string, unknown> | null;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Prompt/schema-aware structured extraction used by `extractWebsiteData`.
3
+ * Deterministic (no LLM): quote/author cards, embedded JSON, and schema shaping.
4
+ */
5
+ import { decodeHtmlEntities } from "../engine/markdown.js";
6
+ function schemaProperties(schema) {
7
+ if (!schema || typeof schema !== "object")
8
+ return {};
9
+ const props = schema.properties;
10
+ if (props && typeof props === "object" && !Array.isArray(props)) {
11
+ return props;
12
+ }
13
+ return schema;
14
+ }
15
+ export function hasCustomExtractRequest(prompt, schema) {
16
+ if (prompt && prompt.trim())
17
+ return true;
18
+ return Object.keys(schemaProperties(schema)).length > 0;
19
+ }
20
+ function blobForIntent(prompt, schema) {
21
+ return `${prompt ?? ""} ${Object.keys(schemaProperties(schema)).join(" ")}`.toLowerCase();
22
+ }
23
+ export function wantsQuoteAuthorExtract(prompt, schema) {
24
+ const blob = blobForIntent(prompt, schema);
25
+ if (/\bquotes?\b/.test(blob) && /\bauthors?\b/.test(blob))
26
+ return true;
27
+ const props = schemaProperties(schema);
28
+ if (props.quotes || props.quote || props.authors)
29
+ return true;
30
+ return false;
31
+ }
32
+ function cleanText(raw) {
33
+ return decodeHtmlEntities(raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
34
+ }
35
+ function pushPair(out, text, author) {
36
+ const t = cleanText(text).replace(/^["“]|["”]$/g, "").trim();
37
+ const a = cleanText(author);
38
+ if (t.length < 8 || a.length < 2)
39
+ return;
40
+ if (out.some((p) => p.text === t && p.author === a))
41
+ return;
42
+ out.push({ text: t, author: a });
43
+ }
44
+ /** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
45
+ export function extractQuoteAuthorPairs(html, markdown = "") {
46
+ const out = [];
47
+ const dataAssign = html.match(/var\s+data\s*=\s*(\[[\s\S]*?\]);/);
48
+ if (dataAssign) {
49
+ try {
50
+ const parsed = JSON.parse(dataAssign[1]);
51
+ if (Array.isArray(parsed)) {
52
+ for (const row of parsed) {
53
+ const author = typeof row.author === "string" ? row.author : row.author?.name ?? "";
54
+ if (row.text && author)
55
+ pushPair(out, row.text, author);
56
+ }
57
+ }
58
+ }
59
+ catch {
60
+ /* not JSON */
61
+ }
62
+ }
63
+ const cardRe = /<(?:div|figure|blockquote|li)[^>]*class=["'][^"']*\bquote\b[^"']*["'][^>]*>([\s\S]*?)<\/(?:div|figure|blockquote|li)>/gi;
64
+ let card;
65
+ while ((card = cardRe.exec(html)) !== null) {
66
+ const block = card[1];
67
+ const text = block.match(/<(?:span|div|p|q)[^>]*class=["'][^"']*\btext\b[^"']*["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
68
+ block.match(/itemprop=["']text["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
69
+ "";
70
+ const author = block.match(/<(?:small|span|div|p)[^>]*class=["'][^"']*\bauthor\b[^"']*["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
71
+ block.match(/itemprop=["']author["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
72
+ "";
73
+ if (text && author)
74
+ pushPair(out, text, author);
75
+ }
76
+ const microRe = /itemprop=["']text["'][^>]*>([\s\S]*?)<\/[^>]+>[\s\S]{0,400}?itemprop=["']author["'][^>]*>([\s\S]*?)<\//gi;
77
+ let micro;
78
+ while ((micro = microRe.exec(html)) !== null) {
79
+ pushPair(out, micro[1], micro[2]);
80
+ }
81
+ if (out.length === 0 && markdown) {
82
+ const mdRe = /[“"]([^”"]{12,400})[”"]\s*(?:\n+)?\s*(?:by\s+|\u2014\s*|\s+[-–—]\s+)([A-Z][^\n]{1,80})/g;
83
+ let md;
84
+ while ((md = mdRe.exec(markdown)) !== null) {
85
+ pushPair(out, md[1], md[2]);
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+ function itemFieldNames(schema) {
91
+ const props = schemaProperties(schema);
92
+ const keys = Object.keys(props);
93
+ const arrayKey = keys.find((k) => {
94
+ const p = props[k];
95
+ if (!p || typeof p !== "object")
96
+ return /quotes?|items|results|entries/i.test(k);
97
+ const t = p.type;
98
+ return t === "array" || /quotes?|items|results|entries/i.test(k);
99
+ }) ||
100
+ (keys.length === 0 ? "quotes" : undefined);
101
+ const itemSchema = arrayKey && props[arrayKey] && typeof props[arrayKey] === "object"
102
+ ? props[arrayKey]
103
+ : undefined;
104
+ const itemProps = itemSchema?.items?.properties ?? {};
105
+ const itemKeys = Object.keys(itemProps);
106
+ const textKey = itemKeys.find((k) => /quote|text|content|body/i.test(k)) ?? "quote";
107
+ const authorKey = itemKeys.find((k) => /author|by|name/i.test(k)) ?? "author";
108
+ return { arrayKey: arrayKey ?? "quotes", textKey, authorKey };
109
+ }
110
+ export function shapeQuoteAuthors(pairs, schema) {
111
+ const props = schemaProperties(schema);
112
+ const keys = Object.keys(props);
113
+ const quoteArr = keys.find((k) => /quotes?/i.test(k) && props[k]?.type === "array");
114
+ const authorArr = keys.find((k) => /authors?/i.test(k) && props[k]?.type === "array");
115
+ const quoteArrItems = quoteArr && props[quoteArr]?.items?.type;
116
+ if (quoteArr && authorArr && quoteArrItems === "string") {
117
+ return {
118
+ [quoteArr]: pairs.map((p) => p.text),
119
+ [authorArr]: pairs.map((p) => p.author),
120
+ };
121
+ }
122
+ const { arrayKey, textKey, authorKey } = itemFieldNames(schema);
123
+ return {
124
+ [arrayKey]: pairs.map((p) => ({ [textKey]: p.text, [authorKey]: p.author })),
125
+ };
126
+ }
127
+ /**
128
+ * Deterministic extract when the caller passed a prompt and/or JSON schema.
129
+ * Returns null when this page/request has nothing this extractor can fill.
130
+ */
131
+ export function extractByPromptAndSchema(html, markdown, prompt, schema) {
132
+ if (!hasCustomExtractRequest(prompt, schema))
133
+ return null;
134
+ if (wantsQuoteAuthorExtract(prompt, schema)) {
135
+ const pairs = extractQuoteAuthorPairs(html, markdown);
136
+ if (pairs.length > 0)
137
+ return shapeQuoteAuthors(pairs, schema);
138
+ }
139
+ const pairs = extractQuoteAuthorPairs(html, markdown);
140
+ if (pairs.length > 0 && /\bquotes?\b/i.test(blobForIntent(prompt, schema))) {
141
+ return shapeQuoteAuthors(pairs, schema);
142
+ }
143
+ return null;
144
+ }
@@ -37,7 +37,17 @@ export interface LocalEngineBackend {
37
37
  * `success: false` with no markdown, so callers never mistake the block page
38
38
  * for content. Exported for unit tests.
39
39
  */
40
- export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number): Record<string, unknown>;
40
+ export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number, reason?: string): Record<string, unknown>;
41
+ /** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
42
+ export declare const MIN_CACHEABLE_MARKDOWN = 200;
43
+ /** Attach source HTML when the caller asked for the `html` format. */
44
+ export declare function withOptionalHtml(result: Record<string, unknown>, html: string | undefined, formats?: Array<"markdown" | "links" | "html">): Record<string, unknown>;
45
+ /** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
46
+ export declare function extractOutputData(extracted: Record<string, unknown>, opts: {
47
+ prompt?: string;
48
+ schema?: Record<string, unknown>;
49
+ target?: unknown;
50
+ }): Record<string, unknown>;
41
51
  /**
42
52
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
43
53
  * Matching is a case-insensitive substring of the URL. Exported for unit tests.