webcrawl 1.4.1 → 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 +11 -2
- package/dist/core/engine/chrome.d.ts +14 -0
- package/dist/core/engine/chrome.js +140 -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/engine/safe-url.d.ts +6 -0
- package/dist/core/engine/safe-url.js +19 -2
- package/dist/core/extract/index.d.ts +1 -0
- package/dist/core/extract/index.js +1 -0
- package/dist/core/extract/structured.d.ts +14 -0
- package/dist/core/extract/structured.js +144 -0
- package/dist/core/local-engine.d.ts +16 -0
- package/dist/core/local-engine.js +213 -106
- package/dist/core/ranker/lexical.js +1 -0
- package/dist/core/ranker/types.d.ts +1 -1
- package/dist/core/search/catalog.d.ts +2 -0
- package/dist/core/search/pipeline.js +24 -9
- package/dist/core/search/providers.d.ts +10 -0
- package/dist/core/search/providers.js +97 -2
- package/dist/format.js +8 -1
- package/dist/setup.js +5 -1
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -429,13 +429,22 @@ 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"] });
|
|
441
|
+
// Blocked pages come back as success:false: report on stderr and exit 1, not empty content.
|
|
442
|
+
if (res.success === false && !v.json) {
|
|
443
|
+
io.err(`${formatScrape(res)}\n`);
|
|
444
|
+
return 1;
|
|
445
|
+
}
|
|
437
446
|
emit(res, formatScrape(res));
|
|
438
|
-
return 0;
|
|
447
|
+
return res.success === false ? 1 : 0;
|
|
439
448
|
}
|
|
440
449
|
case "map": {
|
|
441
450
|
const res = await client.map({ url: need("url"), search: v.filter, limit: int(v.limit, "--limit") });
|
|
@@ -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
|
|
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;
|
|
@@ -2,6 +2,12 @@ export declare class UnsafeUrlError extends Error {
|
|
|
2
2
|
constructor(message?: string);
|
|
3
3
|
}
|
|
4
4
|
export declare function isPrivateAddress(ip: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* True for 198.18.0.0/15, the benchmarking range that fake-IP proxies
|
|
7
|
+
* (Clash, Surge, sing-box, some VPN clients) hand out for every hostname.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isFakeIpAddress(ip: string): boolean;
|
|
10
|
+
export declare const FAKE_IP_HINT: string;
|
|
5
11
|
export type HostResolver = (host: string) => Promise<string[]>;
|
|
6
12
|
/**
|
|
7
13
|
* Throws `UnsafeUrlError` unless `raw` is an http(s) URL whose host resolves
|
|
@@ -35,6 +35,20 @@ export function isPrivateAddress(ip) {
|
|
|
35
35
|
}
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* True for 198.18.0.0/15, the benchmarking range that fake-IP proxies
|
|
40
|
+
* (Clash, Surge, sing-box, some VPN clients) hand out for every hostname.
|
|
41
|
+
*/
|
|
42
|
+
export function isFakeIpAddress(ip) {
|
|
43
|
+
const s = ip.toLowerCase().replace(/^::ffff:/, "");
|
|
44
|
+
if (isIP(s) !== 4)
|
|
45
|
+
return false;
|
|
46
|
+
const [a, b] = s.split(".").map(Number);
|
|
47
|
+
return a === 198 && (b === 18 || b === 19);
|
|
48
|
+
}
|
|
49
|
+
export const FAKE_IP_HINT = "That address isn't publicly reachable: it resolved to 198.18.x.x, which fake-IP proxies " +
|
|
50
|
+
"(Clash, Surge, sing-box, some VPNs) return for every site. Switch the proxy to redir-host/real-IP " +
|
|
51
|
+
"DNS mode, or set WEBCRAWL_ALLOW_PRIVATE_URLS=true for local runs you trust.";
|
|
38
52
|
const defaultResolve = async (h) => (await lookup(h, { all: true })).map((r) => r.address);
|
|
39
53
|
/** Local development escape hatch (never set in production). */
|
|
40
54
|
function allowPrivate() {
|
|
@@ -67,8 +81,11 @@ export async function assertSafeUrl(raw, resolve = defaultResolve) {
|
|
|
67
81
|
const addrs = isIP(host) ? [host] : await resolve(host).catch(() => []);
|
|
68
82
|
if (addrs.length === 0)
|
|
69
83
|
throw new UnsafeUrlError("We couldn't find that website");
|
|
70
|
-
if (addrs.some(isPrivateAddress))
|
|
71
|
-
|
|
84
|
+
if (addrs.some(isPrivateAddress)) {
|
|
85
|
+
// Every address in the fake-IP range means a local proxy rewrote DNS,
|
|
86
|
+
// not that the site is internal: say so and name the workaround.
|
|
87
|
+
throw new UnsafeUrlError(addrs.every(isFakeIpAddress) ? FAKE_IP_HINT : undefined);
|
|
88
|
+
}
|
|
72
89
|
return u;
|
|
73
90
|
}
|
|
74
91
|
export const MAX_REDIRECTS = 8;
|
|
@@ -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
|
+
}
|