webcrawl 1.4.3 → 1.4.5
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.d.ts +3 -1
- package/dist/cli.js +25 -1
- package/dist/core/engine/chrome-cdp.d.ts +29 -0
- package/dist/core/engine/chrome-cdp.js +339 -0
- package/dist/core/engine/chrome-launch.d.ts +49 -0
- package/dist/core/engine/chrome-launch.js +244 -0
- package/dist/core/engine/chrome.d.ts +26 -7
- package/dist/core/engine/chrome.js +155 -78
- package/dist/core/extract/index.d.ts +1 -1
- package/dist/core/extract/index.js +1 -1
- package/dist/core/extract/structured.d.ts +19 -0
- package/dist/core/extract/structured.js +192 -20
- package/dist/core/local-engine.d.ts +6 -0
- package/dist/core/local-engine.js +138 -79
- package/dist/core/search/catalog.d.ts +1 -1
- package/dist/core/search/catalog.js +1 -1
- package/dist/core/search/pipeline.js +5 -3
- package/dist/core/search/providers.d.ts +15 -3
- package/dist/core/search/providers.js +177 -39
- package/dist/core/targets/registry.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/setup.js +2 -2
- package/package.json +3 -3
|
@@ -1,16 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Prompt/schema-aware structured extraction used by `extractWebsiteData`.
|
|
3
3
|
* Deterministic (no LLM): quote/author cards, embedded JSON, and schema shaping.
|
|
4
|
+
*
|
|
5
|
+
* Nested objects/arrays follow a JSON-schema walk: shorthand
|
|
6
|
+
* `{items:[{quote,author}]}` is coerced to a real object/array schema,
|
|
7
|
+
* root arrays are wrapped as `{items: ...}`, and empty results are detectable.
|
|
4
8
|
*/
|
|
5
9
|
import { decodeHtmlEntities } from "../engine/markdown.js";
|
|
6
|
-
|
|
10
|
+
const CACHE_META_KEYS = new Set(["cached", "cacheLayer"]);
|
|
11
|
+
function isPlainObject(v) {
|
|
12
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
13
|
+
}
|
|
14
|
+
/** A bare property map or shorthand array becomes a JSON Schema. */
|
|
15
|
+
export function coerceExtractSchema(node) {
|
|
16
|
+
if (Array.isArray(node)) {
|
|
17
|
+
const item = node.length > 0 ? coerceExtractSchema(node[0]) : { type: "object" };
|
|
18
|
+
return { type: "array", items: item };
|
|
19
|
+
}
|
|
20
|
+
if (typeof node === "string") {
|
|
21
|
+
const t = node.trim() || "string";
|
|
22
|
+
return { type: t };
|
|
23
|
+
}
|
|
24
|
+
if (!isPlainObject(node)) {
|
|
25
|
+
return { type: "string" };
|
|
26
|
+
}
|
|
27
|
+
if (typeof node.type === "string" || Array.isArray(node.type)) {
|
|
28
|
+
const out = { ...node };
|
|
29
|
+
if (isPlainObject(out.properties)) {
|
|
30
|
+
out.properties = Object.fromEntries(Object.entries(out.properties).map(([k, v]) => [k, coerceExtractSchema(v)]));
|
|
31
|
+
}
|
|
32
|
+
if (out.items !== undefined) {
|
|
33
|
+
out.items = coerceExtractSchema(out.items);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
const properties = Object.fromEntries(Object.entries(node).map(([k, v]) => [k, coerceExtractSchema(v)]));
|
|
38
|
+
return {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties,
|
|
41
|
+
required: Object.keys(properties),
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Root-array wrap: a top-level array becomes
|
|
47
|
+
* `{ type: object, properties: { items: <array> } }`.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeExtractSchema(schema) {
|
|
7
50
|
if (!schema || typeof schema !== "object")
|
|
51
|
+
return undefined;
|
|
52
|
+
let coerced = coerceExtractSchema(schema);
|
|
53
|
+
if (coerced.type === "array") {
|
|
54
|
+
coerced = {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: { items: coerced },
|
|
57
|
+
required: ["items"],
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return coerced;
|
|
62
|
+
}
|
|
63
|
+
export function schemaProperties(schema) {
|
|
64
|
+
const normalized = normalizeExtractSchema(schema);
|
|
65
|
+
if (!normalized)
|
|
8
66
|
return {};
|
|
9
|
-
const props =
|
|
10
|
-
if (
|
|
67
|
+
const props = normalized.properties;
|
|
68
|
+
if (isPlainObject(props))
|
|
11
69
|
return props;
|
|
12
|
-
|
|
13
|
-
return schema;
|
|
70
|
+
return normalized;
|
|
14
71
|
}
|
|
15
72
|
export function hasCustomExtractRequest(prompt, schema) {
|
|
16
73
|
if (prompt && prompt.trim())
|
|
@@ -18,7 +75,58 @@ export function hasCustomExtractRequest(prompt, schema) {
|
|
|
18
75
|
return Object.keys(schemaProperties(schema)).length > 0;
|
|
19
76
|
}
|
|
20
77
|
function blobForIntent(prompt, schema) {
|
|
21
|
-
|
|
78
|
+
const keys = [];
|
|
79
|
+
const walk = (node) => {
|
|
80
|
+
if (Array.isArray(node)) {
|
|
81
|
+
for (const n of node)
|
|
82
|
+
walk(n);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!isPlainObject(node))
|
|
86
|
+
return;
|
|
87
|
+
keys.push(...Object.keys(node));
|
|
88
|
+
if (isPlainObject(node.properties))
|
|
89
|
+
walk(node.properties);
|
|
90
|
+
if (node.items)
|
|
91
|
+
walk(node.items);
|
|
92
|
+
};
|
|
93
|
+
walk(schema);
|
|
94
|
+
walk(schemaProperties(schema));
|
|
95
|
+
return `${prompt ?? ""} ${keys.join(" ")}`.toLowerCase();
|
|
96
|
+
}
|
|
97
|
+
/** Nested object/array field names that look like quote+author records. */
|
|
98
|
+
export function nestedQuoteAuthorFields(schema) {
|
|
99
|
+
const normalized = normalizeExtractSchema(schema);
|
|
100
|
+
if (!normalized)
|
|
101
|
+
return null;
|
|
102
|
+
const visit = (node, path) => {
|
|
103
|
+
if (node.type === "array" && node.items) {
|
|
104
|
+
const items = coerceExtractSchema(node.items);
|
|
105
|
+
const itemProps = isPlainObject(items.properties) ? items.properties : {};
|
|
106
|
+
const itemKeys = Object.keys(itemProps);
|
|
107
|
+
const textKey = itemKeys.find((k) => /quote|text|content|body/i.test(k));
|
|
108
|
+
const authorKey = itemKeys.find((k) => /author|by|name/i.test(k));
|
|
109
|
+
if (textKey && authorKey) {
|
|
110
|
+
return { arrayPath: path, textKey, authorKey };
|
|
111
|
+
}
|
|
112
|
+
if (items.type === "object" && isPlainObject(items.properties)) {
|
|
113
|
+
const nested = visit(items, path);
|
|
114
|
+
if (nested)
|
|
115
|
+
return nested;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const props = isPlainObject(node.properties) ? node.properties : {};
|
|
119
|
+
for (const [key, value] of Object.entries(props)) {
|
|
120
|
+
if (!isPlainObject(value) && !Array.isArray(value))
|
|
121
|
+
continue;
|
|
122
|
+
const child = coerceExtractSchema(value);
|
|
123
|
+
const found = visit(child, [...path, key]);
|
|
124
|
+
if (found)
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
};
|
|
129
|
+
return visit(normalized, []);
|
|
22
130
|
}
|
|
23
131
|
export function wantsQuoteAuthorExtract(prompt, schema) {
|
|
24
132
|
const blob = blobForIntent(prompt, schema);
|
|
@@ -27,8 +135,44 @@ export function wantsQuoteAuthorExtract(prompt, schema) {
|
|
|
27
135
|
const props = schemaProperties(schema);
|
|
28
136
|
if (props.quotes || props.quote || props.authors)
|
|
29
137
|
return true;
|
|
138
|
+
if (nestedQuoteAuthorFields(schema))
|
|
139
|
+
return true;
|
|
30
140
|
return false;
|
|
31
141
|
}
|
|
142
|
+
/** Strip script/style/template (including unclosed tags) before reading visible DOM. */
|
|
143
|
+
export function stripNonContentHtml(html) {
|
|
144
|
+
return String(html ?? "")
|
|
145
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
146
|
+
.replace(/<(script|style|noscript|svg|canvas|template|iframe)(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1>/gi, "")
|
|
147
|
+
.replace(/<(script|style|noscript|svg|canvas|template|iframe)(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*/gi, "");
|
|
148
|
+
}
|
|
149
|
+
/** Inline `document.write("... " + d['text'] + ...)` leftovers, not real quotes. */
|
|
150
|
+
export function isJsTemplateJunk(text) {
|
|
151
|
+
const t = String(text ?? "");
|
|
152
|
+
return (/\+\s*d\s*\[/i.test(t) ||
|
|
153
|
+
/['"]\s*\+\s*d/i.test(t) ||
|
|
154
|
+
/\bd\s*\[\s*['"]text['"]\s*\]/.test(t) ||
|
|
155
|
+
/\bd\s*\[\s*['"]author['"]\s*\]/.test(t) ||
|
|
156
|
+
/document\.write/i.test(t));
|
|
157
|
+
}
|
|
158
|
+
export function isEmptyExtractResult(data) {
|
|
159
|
+
if (!data || typeof data !== "object")
|
|
160
|
+
return true;
|
|
161
|
+
const keys = Object.keys(data).filter((k) => !CACHE_META_KEYS.has(k));
|
|
162
|
+
if (keys.length === 0)
|
|
163
|
+
return true;
|
|
164
|
+
return keys.every((k) => {
|
|
165
|
+
const v = data[k];
|
|
166
|
+
if (v == null || v === "")
|
|
167
|
+
return true;
|
|
168
|
+
if (Array.isArray(v))
|
|
169
|
+
return v.length === 0;
|
|
170
|
+
if (typeof v === "object" && !Array.isArray(v)) {
|
|
171
|
+
return isEmptyExtractResult(v);
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
32
176
|
function cleanText(raw) {
|
|
33
177
|
return decodeHtmlEntities(raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
|
|
34
178
|
}
|
|
@@ -37,6 +181,8 @@ function pushPair(out, text, author) {
|
|
|
37
181
|
const a = cleanText(author);
|
|
38
182
|
if (t.length < 8 || a.length < 2)
|
|
39
183
|
return;
|
|
184
|
+
if (isJsTemplateJunk(t) || isJsTemplateJunk(a))
|
|
185
|
+
return;
|
|
40
186
|
if (out.some((p) => p.text === t && p.author === a))
|
|
41
187
|
return;
|
|
42
188
|
out.push({ text: t, author: a });
|
|
@@ -44,7 +190,8 @@ function pushPair(out, text, author) {
|
|
|
44
190
|
/** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
|
|
45
191
|
export function extractQuoteAuthorPairs(html, markdown = "") {
|
|
46
192
|
const out = [];
|
|
47
|
-
const
|
|
193
|
+
const source = String(html ?? "");
|
|
194
|
+
const dataAssign = source.match(/var\s+data\s*=\s*(\[[\s\S]*?\]);/);
|
|
48
195
|
if (dataAssign) {
|
|
49
196
|
try {
|
|
50
197
|
const parsed = JSON.parse(dataAssign[1]);
|
|
@@ -60,9 +207,10 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
|
|
|
60
207
|
/* not JSON */
|
|
61
208
|
}
|
|
62
209
|
}
|
|
210
|
+
const visible = stripNonContentHtml(source);
|
|
63
211
|
const cardRe = /<(?:div|figure|blockquote|li)[^>]*class=["'][^"']*\bquote\b[^"']*["'][^>]*>([\s\S]*?)<\/(?:div|figure|blockquote|li)>/gi;
|
|
64
212
|
let card;
|
|
65
|
-
while ((card = cardRe.exec(
|
|
213
|
+
while ((card = cardRe.exec(visible)) !== null) {
|
|
66
214
|
const block = card[1];
|
|
67
215
|
const text = block.match(/<(?:span|div|p|q)[^>]*class=["'][^"']*\btext\b[^"']*["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
|
|
68
216
|
block.match(/itemprop=["']text["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
|
|
@@ -75,7 +223,7 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
|
|
|
75
223
|
}
|
|
76
224
|
const microRe = /itemprop=["']text["'][^>]*>([\s\S]*?)<\/[^>]+>[\s\S]{0,400}?itemprop=["']author["'][^>]*>([\s\S]*?)<\//gi;
|
|
77
225
|
let micro;
|
|
78
|
-
while ((micro = microRe.exec(
|
|
226
|
+
while ((micro = microRe.exec(visible)) !== null) {
|
|
79
227
|
pushPair(out, micro[1], micro[2]);
|
|
80
228
|
}
|
|
81
229
|
if (out.length === 0 && markdown) {
|
|
@@ -88,6 +236,14 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
|
|
|
88
236
|
return out;
|
|
89
237
|
}
|
|
90
238
|
function itemFieldNames(schema) {
|
|
239
|
+
const nested = nestedQuoteAuthorFields(schema);
|
|
240
|
+
if (nested && nested.arrayPath.length > 0) {
|
|
241
|
+
return {
|
|
242
|
+
arrayKey: nested.arrayPath[nested.arrayPath.length - 1] || "items",
|
|
243
|
+
textKey: nested.textKey,
|
|
244
|
+
authorKey: nested.authorKey,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
91
247
|
const props = schemaProperties(schema);
|
|
92
248
|
const keys = Object.keys(props);
|
|
93
249
|
const arrayKey = keys.find((k) => {
|
|
@@ -96,8 +252,7 @@ function itemFieldNames(schema) {
|
|
|
96
252
|
return /quotes?|items|results|entries/i.test(k);
|
|
97
253
|
const t = p.type;
|
|
98
254
|
return t === "array" || /quotes?|items|results|entries/i.test(k);
|
|
99
|
-
}) ||
|
|
100
|
-
(keys.length === 0 ? "quotes" : undefined);
|
|
255
|
+
}) || (keys.length === 0 ? "quotes" : undefined);
|
|
101
256
|
const itemSchema = arrayKey && props[arrayKey] && typeof props[arrayKey] === "object"
|
|
102
257
|
? props[arrayKey]
|
|
103
258
|
: undefined;
|
|
@@ -107,6 +262,22 @@ function itemFieldNames(schema) {
|
|
|
107
262
|
const authorKey = itemKeys.find((k) => /author|by|name/i.test(k)) ?? "author";
|
|
108
263
|
return { arrayKey: arrayKey ?? "quotes", textKey, authorKey };
|
|
109
264
|
}
|
|
265
|
+
function assignAtPath(root, path, value) {
|
|
266
|
+
if (path.length === 0) {
|
|
267
|
+
return isPlainObject(value) ? { ...root, ...value } : root;
|
|
268
|
+
}
|
|
269
|
+
let cursor = root;
|
|
270
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
271
|
+
const key = path[i];
|
|
272
|
+
const next = cursor[key];
|
|
273
|
+
if (!isPlainObject(next)) {
|
|
274
|
+
cursor[key] = {};
|
|
275
|
+
}
|
|
276
|
+
cursor = cursor[key];
|
|
277
|
+
}
|
|
278
|
+
cursor[path[path.length - 1]] = value;
|
|
279
|
+
return root;
|
|
280
|
+
}
|
|
110
281
|
export function shapeQuoteAuthors(pairs, schema) {
|
|
111
282
|
const props = schemaProperties(schema);
|
|
112
283
|
const keys = Object.keys(props);
|
|
@@ -119,10 +290,13 @@ export function shapeQuoteAuthors(pairs, schema) {
|
|
|
119
290
|
[authorArr]: pairs.map((p) => p.author),
|
|
120
291
|
};
|
|
121
292
|
}
|
|
293
|
+
const nested = nestedQuoteAuthorFields(schema);
|
|
122
294
|
const { arrayKey, textKey, authorKey } = itemFieldNames(schema);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
295
|
+
const rows = pairs.map((p) => ({ [textKey]: p.text, [authorKey]: p.author }));
|
|
296
|
+
if (nested && nested.arrayPath.length > 0) {
|
|
297
|
+
return assignAtPath({}, nested.arrayPath, rows);
|
|
298
|
+
}
|
|
299
|
+
return { [arrayKey]: rows };
|
|
126
300
|
}
|
|
127
301
|
/**
|
|
128
302
|
* Deterministic extract when the caller passed a prompt and/or JSON schema.
|
|
@@ -131,13 +305,11 @@ export function shapeQuoteAuthors(pairs, schema) {
|
|
|
131
305
|
export function extractByPromptAndSchema(html, markdown, prompt, schema) {
|
|
132
306
|
if (!hasCustomExtractRequest(prompt, schema))
|
|
133
307
|
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
308
|
const pairs = extractQuoteAuthorPairs(html, markdown);
|
|
140
|
-
if (pairs.length > 0
|
|
309
|
+
if (wantsQuoteAuthorExtract(prompt, schema) && pairs.length > 0) {
|
|
310
|
+
return shapeQuoteAuthors(pairs, schema);
|
|
311
|
+
}
|
|
312
|
+
if (pairs.length > 0 && /\b(quotes?|items|results|entries)\b/i.test(blobForIntent(prompt, schema))) {
|
|
141
313
|
return shapeQuoteAuthors(pairs, schema);
|
|
142
314
|
}
|
|
143
315
|
return null;
|
|
@@ -38,6 +38,12 @@ export interface LocalEngineBackend {
|
|
|
38
38
|
* for content. Exported for unit tests.
|
|
39
39
|
*/
|
|
40
40
|
export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number, reason?: string): Record<string, unknown>;
|
|
41
|
+
/**
|
|
42
|
+
* JS page whose renderer failed. `success: false` so callers never treat the
|
|
43
|
+
* unrendered nav shell as content (v1.4.3 smoke test: 114 chars of nav with
|
|
44
|
+
* success:true after Chrome hung on --dump-dom).
|
|
45
|
+
*/
|
|
46
|
+
export declare function jsRenderFailedResult(url: string, statusCode: number, tier: string, latencyMs: number, reason: string): Record<string, unknown>;
|
|
41
47
|
/** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
|
|
42
48
|
export declare const MIN_CACHEABLE_MARKDOWN = 200;
|
|
43
49
|
/** Attach source HTML when the caller asked for the `html` format. */
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Every community improvement to extractors, parsers, or targets in `src/` immediately
|
|
10
10
|
* powers both this local engine (`npx webcrawl`) and the hosted `webcrawl.app` API.
|
|
11
11
|
*/
|
|
12
|
-
import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, isChallengeMarkdown, isLocalChromeAvailable, mapWebsiteUrls,
|
|
12
|
+
import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, isChallengeMarkdown, isLocalChromeAvailable, mapWebsiteUrls, renderViaLocalChromeDetailed, setCachedPage, sha256, } from "./engine.js";
|
|
13
13
|
import { isDomainRenderNeeded, markDomainRenderNeeded, } from "./engine/session.js";
|
|
14
14
|
import { isRenderProviderEnabled, renderViaProvider } from "./engine/render.js";
|
|
15
15
|
import { fetchResource, isAntiBotChallenge, lastFetchTier, needsBrowserEscalation, } from "./engine/fetcher.js";
|
|
@@ -20,7 +20,7 @@ import { parseYouTubeCached, youtubeVideoId } from "./parse/youtube.js";
|
|
|
20
20
|
import { rankDocuments } from "./ranker.js";
|
|
21
21
|
import { getSearchCategoriesCatalog, searchWeb } from "./search.js";
|
|
22
22
|
import { inspectTarget, listTargets, resolveTarget } from "./targets.js";
|
|
23
|
-
import { hasCustomExtractRequest } from "./extract/structured.js";
|
|
23
|
+
import { hasCustomExtractRequest, isEmptyExtractResult, } from "./extract/structured.js";
|
|
24
24
|
/** Lowercased hostname of a URL (memo key for the Tier R render memo). */
|
|
25
25
|
function safeDomainOf(url) {
|
|
26
26
|
try {
|
|
@@ -53,6 +53,34 @@ export function blockedScrapeResult(url, statusCode, tier, latencyMs, reason) {
|
|
|
53
53
|
evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* JS page whose renderer failed. `success: false` so callers never treat the
|
|
58
|
+
* unrendered nav shell as content (v1.4.3 smoke test: 114 chars of nav with
|
|
59
|
+
* success:true after Chrome hung on --dump-dom).
|
|
60
|
+
*/
|
|
61
|
+
export function jsRenderFailedResult(url, statusCode, tier, latencyMs, reason) {
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
mode: "local",
|
|
65
|
+
url,
|
|
66
|
+
blocked: false,
|
|
67
|
+
needsJs: true,
|
|
68
|
+
statusCode,
|
|
69
|
+
error: reason,
|
|
70
|
+
title: url,
|
|
71
|
+
markdown: "",
|
|
72
|
+
links: [],
|
|
73
|
+
data: { url, title: url, markdown: "", links: [] },
|
|
74
|
+
evidence: {
|
|
75
|
+
tier,
|
|
76
|
+
blocked: false,
|
|
77
|
+
needsJs: true,
|
|
78
|
+
statusCode,
|
|
79
|
+
latencyMs,
|
|
80
|
+
limitations: [reason],
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
56
84
|
/** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
|
|
57
85
|
export const MIN_CACHEABLE_MARKDOWN = 200;
|
|
58
86
|
/** Attach source HTML when the caller asked for the `html` format. */
|
|
@@ -82,33 +110,55 @@ function emptyPageError(statusCode) {
|
|
|
82
110
|
"The site may be blocking automated clients or serving a client-rendered shell. " +
|
|
83
111
|
"Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
|
|
84
112
|
}
|
|
113
|
+
function defaultJsRenderError() {
|
|
114
|
+
return ("Page needs JavaScript rendering; local Chrome was not available or produced no content. " +
|
|
115
|
+
"Install Chrome (see `webcrawl doctor`), point WEBCRAWL_CHROME_PATH at chrome-headless-shell " +
|
|
116
|
+
"(`npx playwright install chromium-headless-shell`), run `webcrawl login` and retry with `--mode cloud`, " +
|
|
117
|
+
"or self-host a render provider via WEBCRAWL_RENDER_URL.");
|
|
118
|
+
}
|
|
85
119
|
/**
|
|
86
|
-
* JS / challenge shell → render provider, then local Chrome dump-dom,
|
|
87
|
-
* Returns null when no renderer produced real content.
|
|
120
|
+
* JS / challenge shell → render provider, then local Chrome (CDP, then dump-dom),
|
|
121
|
+
* then stealth. Returns null when no renderer produced real content.
|
|
88
122
|
*/
|
|
89
123
|
async function escalateJsRender(url) {
|
|
124
|
+
const errors = [];
|
|
90
125
|
if (isRenderProviderEnabled()) {
|
|
91
126
|
const rendered = await renderViaProvider(url);
|
|
92
127
|
if (rendered && rendered.markdown.trim().length >= 40) {
|
|
93
128
|
return {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
129
|
+
page: {
|
|
130
|
+
title: rendered.title,
|
|
131
|
+
markdown: rendered.markdown,
|
|
132
|
+
html: "",
|
|
133
|
+
links: [],
|
|
134
|
+
tier: "render",
|
|
135
|
+
},
|
|
99
136
|
};
|
|
100
137
|
}
|
|
138
|
+
errors.push("render provider produced no content");
|
|
101
139
|
}
|
|
102
140
|
if (isLocalChromeAvailable()) {
|
|
103
|
-
const
|
|
104
|
-
if (html && html.length > 100) {
|
|
105
|
-
const parsed = fastHtmlToMarkdown(html, url);
|
|
141
|
+
const chrome = await renderViaLocalChromeDetailed(url);
|
|
142
|
+
if (chrome.html && chrome.html.length > 100) {
|
|
143
|
+
const parsed = fastHtmlToMarkdown(chrome.html, url);
|
|
106
144
|
if (parsed.markdown.trim().length > 40 &&
|
|
107
|
-
!isAntiBotChallenge(html, 200) &&
|
|
145
|
+
!isAntiBotChallenge(chrome.html, 200) &&
|
|
108
146
|
!isChallengeMarkdown(parsed.title, parsed.markdown)) {
|
|
109
|
-
return { ...parsed, html, tier: "browser" };
|
|
147
|
+
return { page: { ...parsed, html: chrome.html, tier: "browser" } };
|
|
110
148
|
}
|
|
149
|
+
errors.push(chrome.method
|
|
150
|
+
? `${chrome.method} rendered HTML but markdown was still a shell`
|
|
151
|
+
: "local Chrome rendered HTML but markdown was still a shell");
|
|
152
|
+
}
|
|
153
|
+
else if (chrome.error) {
|
|
154
|
+
errors.push(chrome.error);
|
|
111
155
|
}
|
|
156
|
+
else {
|
|
157
|
+
errors.push("local Chrome produced no content");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
errors.push("no local Chrome/Chromium binary");
|
|
112
162
|
}
|
|
113
163
|
try {
|
|
114
164
|
const { stealthAvailable } = await import("./engine/stealth.js");
|
|
@@ -119,19 +169,22 @@ async function escalateJsRender(url) {
|
|
|
119
169
|
esc.markdown.trim().length > 40 &&
|
|
120
170
|
!isChallengeResult(esc.html, esc.markdown, esc.statusCode)) {
|
|
121
171
|
return {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
172
|
+
page: {
|
|
173
|
+
title: esc.title,
|
|
174
|
+
markdown: esc.markdown,
|
|
175
|
+
html: esc.html,
|
|
176
|
+
links: esc.links,
|
|
177
|
+
tier: esc.tier,
|
|
178
|
+
},
|
|
127
179
|
};
|
|
128
180
|
}
|
|
181
|
+
errors.push("stealth browser produced no content");
|
|
129
182
|
}
|
|
130
183
|
}
|
|
131
184
|
catch {
|
|
132
185
|
/* stealth optional */
|
|
133
186
|
}
|
|
134
|
-
return null;
|
|
187
|
+
return { page: null, error: errors.filter(Boolean).join("; ") || defaultJsRenderError() };
|
|
135
188
|
}
|
|
136
189
|
/**
|
|
137
190
|
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
@@ -329,12 +382,15 @@ export function createLocalEngineClient() {
|
|
|
329
382
|
const needsRender = needsBrowserEscalation(fetched.status, html, markdown);
|
|
330
383
|
const nativeTier = nativeScrapeTier();
|
|
331
384
|
// JS shell / challenge: memoize the domain and escalate to a renderer
|
|
332
|
-
// (hosted provider, local Chrome
|
|
333
|
-
// the unrendered shell.
|
|
385
|
+
// (hosted provider, local Chrome CDP, then stealth). Never cache
|
|
386
|
+
// the unrendered shell, and never report the shell as success:true.
|
|
334
387
|
if (needsRender) {
|
|
335
388
|
await markDomainRenderNeeded(renderDomain);
|
|
336
389
|
// 403/429/503 is already a refusal — don't spend a Chrome launch on the block page.
|
|
337
|
-
const
|
|
390
|
+
const escalated = blocked && fetched.status >= 400
|
|
391
|
+
? { page: null, error: undefined }
|
|
392
|
+
: await escalateJsRender(targetUrl);
|
|
393
|
+
const rendered = escalated.page;
|
|
338
394
|
if (rendered) {
|
|
339
395
|
const renderHash = sha256(rendered.markdown);
|
|
340
396
|
if (rendered.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
@@ -369,43 +425,10 @@ export function createLocalEngineClient() {
|
|
|
369
425
|
},
|
|
370
426
|
}, rendered.html, p.formats);
|
|
371
427
|
}
|
|
372
|
-
if (blocked
|
|
373
|
-
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start
|
|
428
|
+
if (blocked) {
|
|
429
|
+
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
|
|
374
430
|
}
|
|
375
|
-
|
|
376
|
-
url: targetUrl,
|
|
377
|
-
html,
|
|
378
|
-
markdown,
|
|
379
|
-
});
|
|
380
|
-
return withOptionalHtml({
|
|
381
|
-
success: true,
|
|
382
|
-
mode: "local",
|
|
383
|
-
url: targetUrl,
|
|
384
|
-
title: title || targetInspection.title || targetUrl,
|
|
385
|
-
markdown: targetInspection.markdown || markdown,
|
|
386
|
-
links,
|
|
387
|
-
data: {
|
|
388
|
-
url: targetUrl,
|
|
389
|
-
title: title || targetInspection.title || targetUrl,
|
|
390
|
-
markdown: targetInspection.markdown || markdown,
|
|
391
|
-
links,
|
|
392
|
-
structured: targetInspection.structured,
|
|
393
|
-
},
|
|
394
|
-
evidence: {
|
|
395
|
-
...targetInspection.evidence,
|
|
396
|
-
tier: nativeTier,
|
|
397
|
-
contentHash: sha256(markdown || html),
|
|
398
|
-
latencyMs: Date.now() - start,
|
|
399
|
-
limitations: [
|
|
400
|
-
...(Array.isArray(targetInspection.evidence?.limitations)
|
|
401
|
-
? targetInspection.evidence.limitations
|
|
402
|
-
: []),
|
|
403
|
-
"Page needs JS rendering; local Chrome was not available or produced no content. " +
|
|
404
|
-
"Install Chrome (see `webcrawl doctor`), run `webcrawl login` and retry with `--mode cloud`, " +
|
|
405
|
-
"or self-host a render provider via WEBCRAWL_RENDER_URL.",
|
|
406
|
-
],
|
|
407
|
-
},
|
|
408
|
-
}, html, p.formats);
|
|
431
|
+
return jsRenderFailedResult(targetUrl, fetched.status, nativeTier, Date.now() - start, escalated.error || (!markdown.trim() ? emptyPageError(fetched.status) : defaultJsRenderError()));
|
|
409
432
|
}
|
|
410
433
|
if (blocked || !markdown.trim()) {
|
|
411
434
|
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
|
|
@@ -557,22 +580,38 @@ export function createLocalEngineClient() {
|
|
|
557
580
|
formats: ["markdown", "html"],
|
|
558
581
|
noCache: true,
|
|
559
582
|
}));
|
|
560
|
-
|
|
561
|
-
|
|
583
|
+
let markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
|
|
584
|
+
let html = (scraped.data && scraped.data.html) || scraped.html || "";
|
|
562
585
|
if (scraped.blocked || scraped.success === false) {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
586
|
+
// Bot-blocked pages stay failures. A JS shell whose Chrome render failed
|
|
587
|
+
// can still yield structured data from JSON islands once script tags are
|
|
588
|
+
// stripped for card matching — never from `document.write` templates.
|
|
589
|
+
if (scraped.needsJs && scraped.blocked !== true) {
|
|
590
|
+
try {
|
|
591
|
+
const fetched = await fetchWithTlsImpersonation(targetUrl);
|
|
592
|
+
html = fetched.html || html;
|
|
593
|
+
const parsedShell = fastHtmlToMarkdown(html, targetUrl);
|
|
594
|
+
markdown = parsedShell.markdown || markdown;
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
/* keep whatever scrape returned */
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
return {
|
|
602
|
+
success: false,
|
|
603
|
+
blocked: true,
|
|
604
|
+
mode: "local",
|
|
605
|
+
url: targetUrl,
|
|
606
|
+
data: {},
|
|
607
|
+
error: scraped.error || emptyPageError(200),
|
|
608
|
+
evidence: {
|
|
609
|
+
tier: lastFetchTier(),
|
|
610
|
+
sourceUrl: targetUrl,
|
|
611
|
+
limitations: [String(scraped.error || emptyPageError(200))],
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
576
615
|
}
|
|
577
616
|
const targetInspection = await inspectTarget({
|
|
578
617
|
url: targetUrl,
|
|
@@ -587,15 +626,35 @@ export function createLocalEngineClient() {
|
|
|
587
626
|
bypassCache: true,
|
|
588
627
|
});
|
|
589
628
|
const { cached: _c, cacheLayer: _l, ...extracted } = siteData;
|
|
629
|
+
const data = extractOutputData(extracted, {
|
|
630
|
+
prompt: p.prompt,
|
|
631
|
+
schema: p.schema,
|
|
632
|
+
target: targetInspection.structured,
|
|
633
|
+
});
|
|
634
|
+
if (hasCustomExtractRequest(p.prompt, p.schema) && isEmptyExtractResult(data)) {
|
|
635
|
+
const error = "Extraction produced no data matching the prompt/schema. " +
|
|
636
|
+
"If this is a JS-rendered page, install Chrome (see `webcrawl doctor`) and retry; " +
|
|
637
|
+
"empty objects are not reported as success.";
|
|
638
|
+
return {
|
|
639
|
+
success: false,
|
|
640
|
+
mode: "local",
|
|
641
|
+
url: targetUrl,
|
|
642
|
+
data: {},
|
|
643
|
+
error,
|
|
644
|
+
evidence: {
|
|
645
|
+
...targetInspection.evidence,
|
|
646
|
+
limitations: [
|
|
647
|
+
...(targetInspection.evidence?.limitations ?? []),
|
|
648
|
+
error,
|
|
649
|
+
],
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
}
|
|
590
653
|
return {
|
|
591
654
|
success: true,
|
|
592
655
|
mode: "local",
|
|
593
656
|
url: targetUrl,
|
|
594
|
-
data
|
|
595
|
-
prompt: p.prompt,
|
|
596
|
-
schema: p.schema,
|
|
597
|
-
target: targetInspection.structured,
|
|
598
|
-
}),
|
|
657
|
+
data,
|
|
599
658
|
evidence: targetInspection.evidence,
|
|
600
659
|
};
|
|
601
660
|
},
|
|
@@ -52,7 +52,7 @@ export interface SearchRequest {
|
|
|
52
52
|
bypassCache?: boolean;
|
|
53
53
|
/**
|
|
54
54
|
* Pick one search provider and skip the default multi-engine pipeline.
|
|
55
|
-
* `auto` (default) uses the free engines and falls back to
|
|
55
|
+
* `auto` (default) uses the free engines and falls back to a keyed provider when results are thin.
|
|
56
56
|
*/
|
|
57
57
|
provider?: SearchProviderChoice;
|
|
58
58
|
rankOptions?: {
|
|
@@ -11,7 +11,7 @@ export const SEARCH_PROVIDER_ENV_KEYS = {
|
|
|
11
11
|
export const SEARCH_CATEGORY_CATALOG = {
|
|
12
12
|
general: {
|
|
13
13
|
description: "Broad web search across general-purpose engines.",
|
|
14
|
-
engines: ["brave-native", "ddg-lite", "wikipedia"],
|
|
14
|
+
engines: ["brave-native", "ddg-lite", "wikipedia", "hn-algolia", "bing-rss"],
|
|
15
15
|
localizeBy: "none",
|
|
16
16
|
preferLocalDomains: false,
|
|
17
17
|
subcategories: {
|