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
|
@@ -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, mapWebsiteUrls, setCachedPage, sha256, } from "./engine.js";
|
|
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,6 +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, isEmptyExtractResult, } from "./extract/structured.js";
|
|
23
24
|
/** Lowercased hostname of a URL (memo key for the Tier R render memo). */
|
|
24
25
|
function safeDomainOf(url) {
|
|
25
26
|
try {
|
|
@@ -34,9 +35,10 @@ function safeDomainOf(url) {
|
|
|
34
35
|
* `success: false` with no markdown, so callers never mistake the block page
|
|
35
36
|
* for content. Exported for unit tests.
|
|
36
37
|
*/
|
|
37
|
-
export function blockedScrapeResult(url, statusCode, tier, latencyMs) {
|
|
38
|
-
const error =
|
|
39
|
-
|
|
38
|
+
export function blockedScrapeResult(url, statusCode, tier, latencyMs, reason) {
|
|
39
|
+
const error = reason ??
|
|
40
|
+
(`Blocked by the site's bot check (HTTP ${statusCode}). No content was returned. ` +
|
|
41
|
+
"Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
|
|
40
42
|
return {
|
|
41
43
|
success: false,
|
|
42
44
|
mode: "local",
|
|
@@ -51,6 +53,139 @@ export function blockedScrapeResult(url, statusCode, tier, latencyMs) {
|
|
|
51
53
|
evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
|
|
52
54
|
};
|
|
53
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
|
+
}
|
|
84
|
+
/** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
|
|
85
|
+
export const MIN_CACHEABLE_MARKDOWN = 200;
|
|
86
|
+
/** Attach source HTML when the caller asked for the `html` format. */
|
|
87
|
+
export function withOptionalHtml(result, html, formats) {
|
|
88
|
+
if (!formats?.includes("html") || !html)
|
|
89
|
+
return result;
|
|
90
|
+
const data = result.data && typeof result.data === "object" && !Array.isArray(result.data)
|
|
91
|
+
? result.data
|
|
92
|
+
: {};
|
|
93
|
+
return { ...result, html, data: { ...data, html } };
|
|
94
|
+
}
|
|
95
|
+
/** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
|
|
96
|
+
export function extractOutputData(extracted, opts) {
|
|
97
|
+
if (hasCustomExtractRequest(opts.prompt, opts.schema))
|
|
98
|
+
return extracted;
|
|
99
|
+
return {
|
|
100
|
+
...extracted,
|
|
101
|
+
...(opts.target ? { target: opts.target } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function nativeScrapeTier() {
|
|
105
|
+
const t = lastFetchTier();
|
|
106
|
+
return t === "impersonate" || t === "proxy-impersonate" ? t : "impit-tls";
|
|
107
|
+
}
|
|
108
|
+
function emptyPageError(statusCode) {
|
|
109
|
+
return (`Page returned no readable content (HTTP ${statusCode}). ` +
|
|
110
|
+
"The site may be blocking automated clients or serving a client-rendered shell. " +
|
|
111
|
+
"Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
|
|
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
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* JS / challenge shell → render provider, then local Chrome (CDP, then dump-dom),
|
|
121
|
+
* then stealth. Returns null when no renderer produced real content.
|
|
122
|
+
*/
|
|
123
|
+
async function escalateJsRender(url) {
|
|
124
|
+
const errors = [];
|
|
125
|
+
if (isRenderProviderEnabled()) {
|
|
126
|
+
const rendered = await renderViaProvider(url);
|
|
127
|
+
if (rendered && rendered.markdown.trim().length >= 40) {
|
|
128
|
+
return {
|
|
129
|
+
page: {
|
|
130
|
+
title: rendered.title,
|
|
131
|
+
markdown: rendered.markdown,
|
|
132
|
+
html: "",
|
|
133
|
+
links: [],
|
|
134
|
+
tier: "render",
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
errors.push("render provider produced no content");
|
|
139
|
+
}
|
|
140
|
+
if (isLocalChromeAvailable()) {
|
|
141
|
+
const chrome = await renderViaLocalChromeDetailed(url);
|
|
142
|
+
if (chrome.html && chrome.html.length > 100) {
|
|
143
|
+
const parsed = fastHtmlToMarkdown(chrome.html, url);
|
|
144
|
+
if (parsed.markdown.trim().length > 40 &&
|
|
145
|
+
!isAntiBotChallenge(chrome.html, 200) &&
|
|
146
|
+
!isChallengeMarkdown(parsed.title, parsed.markdown)) {
|
|
147
|
+
return { page: { ...parsed, html: chrome.html, tier: "browser" } };
|
|
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);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
errors.push("local Chrome produced no content");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
errors.push("no local Chrome/Chromium binary");
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const { stealthAvailable } = await import("./engine/stealth.js");
|
|
165
|
+
const { escalateToSandboxOrBrowser, isChallengeResult } = await import("./sandbox.js");
|
|
166
|
+
if (await stealthAvailable()) {
|
|
167
|
+
const esc = await escalateToSandboxOrBrowser(url, { browser: true, timeoutMs: 25_000 });
|
|
168
|
+
if (esc &&
|
|
169
|
+
esc.markdown.trim().length > 40 &&
|
|
170
|
+
!isChallengeResult(esc.html, esc.markdown, esc.statusCode)) {
|
|
171
|
+
return {
|
|
172
|
+
page: {
|
|
173
|
+
title: esc.title,
|
|
174
|
+
markdown: esc.markdown,
|
|
175
|
+
html: esc.html,
|
|
176
|
+
links: esc.links,
|
|
177
|
+
tier: esc.tier,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
errors.push("stealth browser produced no content");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* stealth optional */
|
|
186
|
+
}
|
|
187
|
+
return { page: null, error: errors.filter(Boolean).join("; ") || defaultJsRenderError() };
|
|
188
|
+
}
|
|
54
189
|
/**
|
|
55
190
|
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
56
191
|
* Matching is a case-insensitive substring of the URL. Exported for unit tests.
|
|
@@ -90,7 +225,7 @@ export function createLocalEngineClient() {
|
|
|
90
225
|
const resolved = resolveTarget({ query, category });
|
|
91
226
|
return {
|
|
92
227
|
...res,
|
|
93
|
-
success:
|
|
228
|
+
success: res.success !== false,
|
|
94
229
|
mode: "local",
|
|
95
230
|
target: {
|
|
96
231
|
id: resolved.plugin.id,
|
|
@@ -161,8 +296,11 @@ export function createLocalEngineClient() {
|
|
|
161
296
|
if (!p.noCache) {
|
|
162
297
|
const cached = await getCachedPageAsync(targetUrl, "none");
|
|
163
298
|
// Entries written before blocked pages stopped being cached may hold a
|
|
164
|
-
// bot-check page: treat those as a miss and fetch again.
|
|
165
|
-
|
|
299
|
+
// bot-check page: treat those as a miss and fetch again. Thin shells
|
|
300
|
+
// (header-only JS pages) must also miss so local Chrome can hydrate them.
|
|
301
|
+
if (cached &&
|
|
302
|
+
!isChallengeMarkdown(cached.title, cached.markdown) &&
|
|
303
|
+
cached.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
166
304
|
const targetInspection = await inspectTarget({
|
|
167
305
|
url: targetUrl,
|
|
168
306
|
markdown: cached.markdown,
|
|
@@ -235,106 +373,82 @@ export function createLocalEngineClient() {
|
|
|
235
373
|
// ladder — the memo may be stale or the provider flaky.
|
|
236
374
|
}
|
|
237
375
|
const fetched = await fetchWithTlsImpersonation(targetUrl);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
const targetInspection = await inspectTarget({
|
|
253
|
-
url: targetUrl,
|
|
254
|
-
html: fetched.html,
|
|
255
|
-
markdown,
|
|
256
|
-
});
|
|
257
|
-
// Tier R: the native ladder passed the bot check but the page is a
|
|
258
|
-
// JS shell / challenge page the ladder cannot render. Memoize the
|
|
259
|
-
// domain and either render via the provider (when configured) or
|
|
260
|
-
// return the shell with an honest limitation note.
|
|
261
|
-
if (needsBrowserEscalation(fetched.status, fetched.html, markdown)) {
|
|
376
|
+
const parsed = fastHtmlToMarkdown(fetched.html, targetUrl);
|
|
377
|
+
let title = parsed.title;
|
|
378
|
+
let markdown = parsed.markdown;
|
|
379
|
+
let links = parsed.links;
|
|
380
|
+
let html = fetched.html;
|
|
381
|
+
const blocked = isAntiBotChallenge(html, fetched.status);
|
|
382
|
+
const needsRender = needsBrowserEscalation(fetched.status, html, markdown);
|
|
383
|
+
const nativeTier = nativeScrapeTier();
|
|
384
|
+
// JS shell / challenge: memoize the domain and escalate to a renderer
|
|
385
|
+
// (hosted provider, local Chrome CDP, then stealth). Never cache
|
|
386
|
+
// the unrendered shell, and never report the shell as success:true.
|
|
387
|
+
if (needsRender) {
|
|
262
388
|
await markDomainRenderNeeded(renderDomain);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
389
|
+
// 403/429/503 is already a refusal — don't spend a Chrome launch on the block page.
|
|
390
|
+
const escalated = blocked && fetched.status >= 400
|
|
391
|
+
? { page: null, error: undefined }
|
|
392
|
+
: await escalateJsRender(targetUrl);
|
|
393
|
+
const rendered = escalated.page;
|
|
394
|
+
if (rendered) {
|
|
395
|
+
const renderHash = sha256(rendered.markdown);
|
|
396
|
+
if (rendered.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
267
397
|
setCachedPage(targetUrl, "none", {
|
|
268
398
|
title: rendered.title,
|
|
269
399
|
markdown: rendered.markdown,
|
|
270
|
-
links:
|
|
400
|
+
links: rendered.links,
|
|
271
401
|
statusCode: 200,
|
|
272
402
|
contentHash: renderHash,
|
|
273
403
|
});
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
404
|
+
}
|
|
405
|
+
return withOptionalHtml({
|
|
406
|
+
success: true,
|
|
407
|
+
mode: "local",
|
|
408
|
+
url: targetUrl,
|
|
409
|
+
title: rendered.title || targetUrl,
|
|
410
|
+
markdown: rendered.markdown,
|
|
411
|
+
links: rendered.links,
|
|
412
|
+
data: {
|
|
277
413
|
url: targetUrl,
|
|
278
414
|
title: rendered.title || targetUrl,
|
|
279
415
|
markdown: rendered.markdown,
|
|
280
|
-
links:
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
latencyMs: Date.now() - start,
|
|
292
|
-
sourceUrl: rendered.sourceUrl,
|
|
293
|
-
matchedSignals: ["render:provider"],
|
|
294
|
-
limitations: rendered.warnings,
|
|
295
|
-
},
|
|
296
|
-
};
|
|
297
|
-
}
|
|
416
|
+
links: rendered.links,
|
|
417
|
+
},
|
|
418
|
+
evidence: {
|
|
419
|
+
targetId: rendered.tier === "render" ? "render-provider" : "web-spa",
|
|
420
|
+
tier: rendered.tier,
|
|
421
|
+
contentHash: renderHash,
|
|
422
|
+
latencyMs: Date.now() - start,
|
|
423
|
+
sourceUrl: targetUrl,
|
|
424
|
+
matchedSignals: rendered.tier === "browser" ? ["render:local-chrome"] : [`render:${rendered.tier}`],
|
|
425
|
+
},
|
|
426
|
+
}, rendered.html, p.formats);
|
|
298
427
|
}
|
|
299
|
-
const nativeTier = lastFetchTier() === "impersonate"
|
|
300
|
-
? "impersonate"
|
|
301
|
-
: lastFetchTier() === "proxy-impersonate"
|
|
302
|
-
? "proxy-impersonate"
|
|
303
|
-
: "impit-tls";
|
|
304
428
|
if (blocked) {
|
|
305
429
|
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
|
|
306
430
|
}
|
|
307
|
-
return
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
431
|
+
return jsRenderFailedResult(targetUrl, fetched.status, nativeTier, Date.now() - start, escalated.error || (!markdown.trim() ? emptyPageError(fetched.status) : defaultJsRenderError()));
|
|
432
|
+
}
|
|
433
|
+
if (blocked || !markdown.trim()) {
|
|
434
|
+
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
|
|
435
|
+
}
|
|
436
|
+
const contentHash = sha256(markdown || html);
|
|
437
|
+
if (markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
438
|
+
setCachedPage(targetUrl, "none", {
|
|
439
|
+
title,
|
|
440
|
+
markdown,
|
|
313
441
|
links,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
markdown: targetInspection.markdown || markdown,
|
|
318
|
-
links,
|
|
319
|
-
structured: targetInspection.structured,
|
|
320
|
-
},
|
|
321
|
-
evidence: {
|
|
322
|
-
...targetInspection.evidence,
|
|
323
|
-
tier: nativeTier,
|
|
324
|
-
contentHash,
|
|
325
|
-
latencyMs: Date.now() - start,
|
|
326
|
-
limitations: [
|
|
327
|
-
...(Array.isArray(targetInspection.evidence?.limitations)
|
|
328
|
-
? targetInspection.evidence.limitations
|
|
329
|
-
: []),
|
|
330
|
-
"Page needs JS rendering; the local native tiers cannot render it. " +
|
|
331
|
-
"Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
|
|
332
|
-
"or self-host a render provider via WEBCRAWL_RENDER_URL.",
|
|
333
|
-
],
|
|
334
|
-
},
|
|
335
|
-
};
|
|
442
|
+
statusCode: fetched.status,
|
|
443
|
+
contentHash,
|
|
444
|
+
});
|
|
336
445
|
}
|
|
337
|
-
|
|
446
|
+
const targetInspection = await inspectTarget({
|
|
447
|
+
url: targetUrl,
|
|
448
|
+
html,
|
|
449
|
+
markdown,
|
|
450
|
+
});
|
|
451
|
+
return withOptionalHtml({
|
|
338
452
|
success: true,
|
|
339
453
|
mode: "local",
|
|
340
454
|
url: targetUrl,
|
|
@@ -350,15 +464,11 @@ export function createLocalEngineClient() {
|
|
|
350
464
|
},
|
|
351
465
|
evidence: {
|
|
352
466
|
...targetInspection.evidence,
|
|
353
|
-
tier:
|
|
354
|
-
? "impersonate"
|
|
355
|
-
: lastFetchTier() === "proxy-impersonate"
|
|
356
|
-
? "proxy-impersonate"
|
|
357
|
-
: "impit-tls",
|
|
467
|
+
tier: nativeTier,
|
|
358
468
|
contentHash,
|
|
359
469
|
latencyMs: Date.now() - start,
|
|
360
470
|
},
|
|
361
|
-
};
|
|
471
|
+
}, html, p.formats);
|
|
362
472
|
},
|
|
363
473
|
async parse(p) {
|
|
364
474
|
const url = typeof p.url === "string" ? p.url : undefined;
|
|
@@ -464,63 +574,87 @@ export function createLocalEngineClient() {
|
|
|
464
574
|
},
|
|
465
575
|
async extract(p) {
|
|
466
576
|
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
mode: "local",
|
|
486
|
-
url: targetUrl,
|
|
487
|
-
data: {
|
|
488
|
-
...siteData,
|
|
489
|
-
...(targetInspection.structured ? { target: targetInspection.structured } : {}),
|
|
490
|
-
},
|
|
491
|
-
evidence: targetInspection.evidence,
|
|
492
|
-
};
|
|
577
|
+
// Scrape first (local Chrome / render provider) so JS pages yield rendered HTML, not the shell.
|
|
578
|
+
const scraped = (await this.scrape({
|
|
579
|
+
url: targetUrl,
|
|
580
|
+
formats: ["markdown", "html"],
|
|
581
|
+
noCache: true,
|
|
582
|
+
}));
|
|
583
|
+
let markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
|
|
584
|
+
let html = (scraped.data && scraped.data.html) || scraped.html || "";
|
|
585
|
+
if (scraped.blocked || scraped.success === false) {
|
|
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;
|
|
493
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
|
+
};
|
|
494
614
|
}
|
|
615
|
+
}
|
|
616
|
+
const targetInspection = await inspectTarget({
|
|
617
|
+
url: targetUrl,
|
|
618
|
+
html,
|
|
619
|
+
markdown,
|
|
620
|
+
});
|
|
621
|
+
const siteData = await extractWebsiteData(targetUrl, {
|
|
622
|
+
prompt: p.prompt,
|
|
623
|
+
schema: p.schema,
|
|
624
|
+
markdown,
|
|
625
|
+
html,
|
|
626
|
+
bypassCache: true,
|
|
627
|
+
});
|
|
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.";
|
|
495
638
|
return {
|
|
496
|
-
success:
|
|
639
|
+
success: false,
|
|
497
640
|
mode: "local",
|
|
498
641
|
url: targetUrl,
|
|
499
642
|
data: {},
|
|
643
|
+
error,
|
|
500
644
|
evidence: {
|
|
501
|
-
|
|
502
|
-
sourceUrl: targetUrl,
|
|
645
|
+
...targetInspection.evidence,
|
|
503
646
|
limitations: [
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
"or self-host a render provider via WEBCRAWL_RENDER_URL.",
|
|
647
|
+
...(targetInspection.evidence?.limitations ?? []),
|
|
648
|
+
error,
|
|
507
649
|
],
|
|
508
650
|
},
|
|
509
651
|
};
|
|
510
652
|
}
|
|
511
|
-
const targetInspection = await inspectTarget({
|
|
512
|
-
url: targetUrl,
|
|
513
|
-
html: fetched.html,
|
|
514
|
-
});
|
|
515
|
-
const siteData = await extractWebsiteData(targetUrl);
|
|
516
653
|
return {
|
|
517
654
|
success: true,
|
|
518
655
|
mode: "local",
|
|
519
656
|
url: targetUrl,
|
|
520
|
-
data
|
|
521
|
-
...siteData,
|
|
522
|
-
...(targetInspection.structured ? { target: targetInspection.structured } : {}),
|
|
523
|
-
},
|
|
657
|
+
data,
|
|
524
658
|
evidence: targetInspection.evidence,
|
|
525
659
|
};
|
|
526
660
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type SearchEngineSource = "bing-shopping" | "google-shopping" | "brave-native" | "ddg-lite" | "google-news-rss" | "wikipedia" | "hn-algolia" | "arxiv" | "nvd" | "osv" | "serper" | "brave-api" | "jina";
|
|
1
|
+
export type SearchEngineSource = "bing-shopping" | "google-shopping" | "brave-native" | "ddg-lite" | "bing-rss" | "google-news-rss" | "wikipedia" | "hn-algolia" | "arxiv" | "nvd" | "osv" | "serper" | "brave-api" | "jina";
|
|
2
2
|
export type DecisionModelId = "baseten/mammoth-1" | "baseten/elephant-1" | "convaiinnovations/laya" | "auto" | "none";
|
|
3
3
|
export interface RawCandidateDocument {
|
|
4
4
|
title: string;
|
|
@@ -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?: {
|
|
@@ -107,6 +107,8 @@ export interface SearchResponse {
|
|
|
107
107
|
/** Structured CVE records from NVD and OSV: CVSS score and severity, CWE, affected products and versions, fixed versions, CISA KEV flag, dates and references (populated when category === "security") */
|
|
108
108
|
cves?: CveRecord[];
|
|
109
109
|
provider: "native-federated" | "brave" | "serper" | "jina" | "ddg-edge";
|
|
110
|
+
/** Set when a keyless search returns no hits (engines blocked or empty). */
|
|
111
|
+
error?: string;
|
|
110
112
|
/** Set when the primary engines came back thin and a paid fallback engine filled in. */
|
|
111
113
|
fallback?: "jina";
|
|
112
114
|
cached?: boolean;
|
|
@@ -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: {
|
|
@@ -18,7 +18,7 @@ function isHotelOta(url) {
|
|
|
18
18
|
return hostMatchesAny(url, HOTEL_OTA_HOSTS);
|
|
19
19
|
}
|
|
20
20
|
import { hostMatchesAny, normalizeHostList, resolveCategory, resolveUserLocation, SEARCH_CATEGORY_CATALOG, } from "./catalog.js";
|
|
21
|
-
import { fetchHedgedNativeFederation, fetchJinaSearch, fetchNvdByIds, } from "./providers.js";
|
|
21
|
+
import { EMPTY_SEARCH_ERROR, fetchHedgedNativeFederation, fetchJinaSearch, fetchNvdByIds, } from "./providers.js";
|
|
22
22
|
import { runInBackground, upsertCves } from "../extract/cve-cache.js";
|
|
23
23
|
const searchCache = new Map();
|
|
24
24
|
const SEARCH_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours warm edge cache
|
|
@@ -115,7 +115,9 @@ export async function searchWeb(req) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
const perEngineLimit = Math.min(Math.max(limit + 4, 8), 15);
|
|
118
|
-
|
|
118
|
+
// Must outlast DDG/Brave impersonation (2200ms) so a slow SERP is not cut off
|
|
119
|
+
// after Wikipedia's 2 hits — the v1.4.3 keyless-search failure mode.
|
|
120
|
+
const quorumTimeoutMs = req.rankOptions?.quorumTimeoutMs ?? 2800;
|
|
119
121
|
// Stage 1: Hedged Multi-Engine Native Federation
|
|
120
122
|
const collectedOffers = [];
|
|
121
123
|
const offerByUrl = new Map();
|
|
@@ -257,7 +259,7 @@ export async function searchWeb(req) {
|
|
|
257
259
|
filterContext,
|
|
258
260
|
};
|
|
259
261
|
let ranked = await rankDocuments(localizedSearchQuery, rankCandidates, rankOpts);
|
|
260
|
-
// Thin result set after ranking and filtering: ask
|
|
262
|
+
// Thin result set after ranking and filtering: ask the keyed search provider once, then re-rank.
|
|
261
263
|
const minResults = Math.min(3, limit);
|
|
262
264
|
if ((!req.provider || req.provider === "auto") &&
|
|
263
265
|
ranked.results.length < minResults &&
|
|
@@ -649,27 +651,30 @@ export async function searchWeb(req) {
|
|
|
649
651
|
...(matchedCves?.length ? { cves: matchedCves } : {}),
|
|
650
652
|
};
|
|
651
653
|
});
|
|
652
|
-
// Charge the fallback surcharge only when a
|
|
654
|
+
// Charge the fallback surcharge only when a keyed-provider result actually reached the caller.
|
|
653
655
|
// An explicitly chosen provider is not a fallback.
|
|
654
656
|
const usedJinaFallback = (!req.provider || req.provider === "auto") &&
|
|
655
657
|
finalItems.some((item) => item.sources?.includes("jina"));
|
|
656
|
-
const
|
|
658
|
+
const engines = ranked.rankingMetrics.enginesContributing;
|
|
659
|
+
const provider = engines.length > 1
|
|
657
660
|
? "native-federated"
|
|
658
|
-
:
|
|
661
|
+
: engines.includes("serper")
|
|
659
662
|
? "serper"
|
|
660
|
-
:
|
|
663
|
+
: engines.includes("jina")
|
|
661
664
|
? "jina"
|
|
662
|
-
:
|
|
663
|
-
ranked.rankingMetrics.enginesContributing.includes("brave-native")
|
|
665
|
+
: engines.includes("brave-api") || engines.includes("brave-native")
|
|
664
666
|
? "brave"
|
|
665
|
-
: "ddg-
|
|
666
|
-
|
|
667
|
+
: engines.includes("ddg-lite")
|
|
668
|
+
? "ddg-edge"
|
|
669
|
+
: "native-federated";
|
|
670
|
+
const hasHits = finalItems.length > 0 ||
|
|
667
671
|
(products && products.length > 0) ||
|
|
668
672
|
(dedupedListings && dedupedListings.length > 0) ||
|
|
669
673
|
(dedupedFlights && dedupedFlights.length > 0) ||
|
|
670
674
|
(dedupedHotels && dedupedHotels.length > 0) ||
|
|
671
675
|
(dedupedBusinesses && dedupedBusinesses.length > 0) ||
|
|
672
|
-
(dedupedCves && dedupedCves.length > 0)
|
|
676
|
+
(dedupedCves && dedupedCves.length > 0);
|
|
677
|
+
if (hasHits) {
|
|
673
678
|
searchCache.set(cacheKey, {
|
|
674
679
|
data: finalItems,
|
|
675
680
|
...(products ? { products } : {}),
|
|
@@ -684,6 +689,18 @@ export async function searchWeb(req) {
|
|
|
684
689
|
expiresAt: Date.now() + SEARCH_CACHE_TTL_MS,
|
|
685
690
|
});
|
|
686
691
|
}
|
|
692
|
+
if (!hasHits) {
|
|
693
|
+
return {
|
|
694
|
+
success: false,
|
|
695
|
+
data: [],
|
|
696
|
+
provider,
|
|
697
|
+
error: EMPTY_SEARCH_ERROR,
|
|
698
|
+
cached: false,
|
|
699
|
+
latencyMs: Math.round(performance.now() - start),
|
|
700
|
+
...(hasGeo ? { userLocation } : {}),
|
|
701
|
+
rankingMetrics: ranked.rankingMetrics,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
687
704
|
return {
|
|
688
705
|
success: true,
|
|
689
706
|
data: finalItems,
|