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.
- package/README.md +2 -2
- package/dist/cli.js +13 -2
- package/dist/core/engine/chrome-cdp.d.ts +22 -0
- package/dist/core/engine/chrome-cdp.js +318 -0
- package/dist/core/engine/chrome-launch.d.ts +38 -0
- package/dist/core/engine/chrome-launch.js +214 -0
- package/dist/core/engine/chrome.d.ts +28 -0
- package/dist/core/engine/chrome.js +208 -0
- package/dist/core/engine/crawler.d.ts +6 -5
- package/dist/core/engine/crawler.js +51 -18
- package/dist/core/engine/fetcher.js +24 -5
- package/dist/core/engine/index.d.ts +1 -0
- package/dist/core/engine/index.js +1 -0
- package/dist/core/extract/index.d.ts +1 -0
- package/dist/core/extract/index.js +1 -0
- package/dist/core/extract/structured.d.ts +33 -0
- package/dist/core/extract/structured.js +316 -0
- package/dist/core/local-engine.d.ts +17 -1
- package/dist/core/local-engine.js +272 -138
- package/dist/core/ranker/lexical.js +1 -0
- package/dist/core/ranker/types.d.ts +1 -1
- package/dist/core/search/catalog.d.ts +3 -1
- package/dist/core/search/catalog.js +1 -1
- package/dist/core/search/pipeline.js +29 -12
- package/dist/core/search/providers.d.ts +23 -1
- package/dist/core/search/providers.js +253 -20
- package/dist/core/targets/registry.js +1 -1
- package/dist/format.js +5 -1
- package/dist/setup.js +7 -3
- package/package.json +3 -3
|
@@ -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
|
|
70
|
-
*
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
494
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 &&
|
|
490
|
+
if (html.length > 250_000 && text.length < 500) {
|
|
472
491
|
return true;
|
|
473
492
|
}
|
|
474
493
|
return false;
|
|
@@ -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;
|