webcrawl 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +5 -1
- 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/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 +11 -1
- package/dist/core/local-engine.js +191 -116
- 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 +5 -1
- package/dist/setup.js +5 -1
- 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, renderViaLocalChrome, 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 } 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,86 @@ export function blockedScrapeResult(url, statusCode, tier, latencyMs) {
|
|
|
51
53
|
evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
|
|
52
54
|
};
|
|
53
55
|
}
|
|
56
|
+
/** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
|
|
57
|
+
export const MIN_CACHEABLE_MARKDOWN = 200;
|
|
58
|
+
/** Attach source HTML when the caller asked for the `html` format. */
|
|
59
|
+
export function withOptionalHtml(result, html, formats) {
|
|
60
|
+
if (!formats?.includes("html") || !html)
|
|
61
|
+
return result;
|
|
62
|
+
const data = result.data && typeof result.data === "object" && !Array.isArray(result.data)
|
|
63
|
+
? result.data
|
|
64
|
+
: {};
|
|
65
|
+
return { ...result, html, data: { ...data, html } };
|
|
66
|
+
}
|
|
67
|
+
/** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
|
|
68
|
+
export function extractOutputData(extracted, opts) {
|
|
69
|
+
if (hasCustomExtractRequest(opts.prompt, opts.schema))
|
|
70
|
+
return extracted;
|
|
71
|
+
return {
|
|
72
|
+
...extracted,
|
|
73
|
+
...(opts.target ? { target: opts.target } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function nativeScrapeTier() {
|
|
77
|
+
const t = lastFetchTier();
|
|
78
|
+
return t === "impersonate" || t === "proxy-impersonate" ? t : "impit-tls";
|
|
79
|
+
}
|
|
80
|
+
function emptyPageError(statusCode) {
|
|
81
|
+
return (`Page returned no readable content (HTTP ${statusCode}). ` +
|
|
82
|
+
"The site may be blocking automated clients or serving a client-rendered shell. " +
|
|
83
|
+
"Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* JS / challenge shell → render provider, then local Chrome dump-dom, then stealth.
|
|
87
|
+
* Returns null when no renderer produced real content.
|
|
88
|
+
*/
|
|
89
|
+
async function escalateJsRender(url) {
|
|
90
|
+
if (isRenderProviderEnabled()) {
|
|
91
|
+
const rendered = await renderViaProvider(url);
|
|
92
|
+
if (rendered && rendered.markdown.trim().length >= 40) {
|
|
93
|
+
return {
|
|
94
|
+
title: rendered.title,
|
|
95
|
+
markdown: rendered.markdown,
|
|
96
|
+
html: "",
|
|
97
|
+
links: [],
|
|
98
|
+
tier: "render",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (isLocalChromeAvailable()) {
|
|
103
|
+
const html = await renderViaLocalChrome(url);
|
|
104
|
+
if (html && html.length > 100) {
|
|
105
|
+
const parsed = fastHtmlToMarkdown(html, url);
|
|
106
|
+
if (parsed.markdown.trim().length > 40 &&
|
|
107
|
+
!isAntiBotChallenge(html, 200) &&
|
|
108
|
+
!isChallengeMarkdown(parsed.title, parsed.markdown)) {
|
|
109
|
+
return { ...parsed, html, tier: "browser" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const { stealthAvailable } = await import("./engine/stealth.js");
|
|
115
|
+
const { escalateToSandboxOrBrowser, isChallengeResult } = await import("./sandbox.js");
|
|
116
|
+
if (await stealthAvailable()) {
|
|
117
|
+
const esc = await escalateToSandboxOrBrowser(url, { browser: true, timeoutMs: 25_000 });
|
|
118
|
+
if (esc &&
|
|
119
|
+
esc.markdown.trim().length > 40 &&
|
|
120
|
+
!isChallengeResult(esc.html, esc.markdown, esc.statusCode)) {
|
|
121
|
+
return {
|
|
122
|
+
title: esc.title,
|
|
123
|
+
markdown: esc.markdown,
|
|
124
|
+
html: esc.html,
|
|
125
|
+
links: esc.links,
|
|
126
|
+
tier: esc.tier,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* stealth optional */
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
54
136
|
/**
|
|
55
137
|
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
56
138
|
* Matching is a case-insensitive substring of the URL. Exported for unit tests.
|
|
@@ -90,7 +172,7 @@ export function createLocalEngineClient() {
|
|
|
90
172
|
const resolved = resolveTarget({ query, category });
|
|
91
173
|
return {
|
|
92
174
|
...res,
|
|
93
|
-
success:
|
|
175
|
+
success: res.success !== false,
|
|
94
176
|
mode: "local",
|
|
95
177
|
target: {
|
|
96
178
|
id: resolved.plugin.id,
|
|
@@ -161,8 +243,11 @@ export function createLocalEngineClient() {
|
|
|
161
243
|
if (!p.noCache) {
|
|
162
244
|
const cached = await getCachedPageAsync(targetUrl, "none");
|
|
163
245
|
// Entries written before blocked pages stopped being cached may hold a
|
|
164
|
-
// bot-check page: treat those as a miss and fetch again.
|
|
165
|
-
|
|
246
|
+
// bot-check page: treat those as a miss and fetch again. Thin shells
|
|
247
|
+
// (header-only JS pages) must also miss so local Chrome can hydrate them.
|
|
248
|
+
if (cached &&
|
|
249
|
+
!isChallengeMarkdown(cached.title, cached.markdown) &&
|
|
250
|
+
cached.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
166
251
|
const targetInspection = await inspectTarget({
|
|
167
252
|
url: targetUrl,
|
|
168
253
|
markdown: cached.markdown,
|
|
@@ -235,76 +320,64 @@ export function createLocalEngineClient() {
|
|
|
235
320
|
// ladder — the memo may be stale or the provider flaky.
|
|
236
321
|
}
|
|
237
322
|
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)) {
|
|
323
|
+
const parsed = fastHtmlToMarkdown(fetched.html, targetUrl);
|
|
324
|
+
let title = parsed.title;
|
|
325
|
+
let markdown = parsed.markdown;
|
|
326
|
+
let links = parsed.links;
|
|
327
|
+
let html = fetched.html;
|
|
328
|
+
const blocked = isAntiBotChallenge(html, fetched.status);
|
|
329
|
+
const needsRender = needsBrowserEscalation(fetched.status, html, markdown);
|
|
330
|
+
const nativeTier = nativeScrapeTier();
|
|
331
|
+
// JS shell / challenge: memoize the domain and escalate to a renderer
|
|
332
|
+
// (hosted provider, local Chrome dump-dom, then stealth). Never cache
|
|
333
|
+
// the unrendered shell.
|
|
334
|
+
if (needsRender) {
|
|
262
335
|
await markDomainRenderNeeded(renderDomain);
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
336
|
+
// 403/429/503 is already a refusal — don't spend a Chrome launch on the block page.
|
|
337
|
+
const rendered = blocked && fetched.status >= 400 ? null : await escalateJsRender(targetUrl);
|
|
338
|
+
if (rendered) {
|
|
339
|
+
const renderHash = sha256(rendered.markdown);
|
|
340
|
+
if (rendered.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
267
341
|
setCachedPage(targetUrl, "none", {
|
|
268
342
|
title: rendered.title,
|
|
269
343
|
markdown: rendered.markdown,
|
|
270
|
-
links:
|
|
344
|
+
links: rendered.links,
|
|
271
345
|
statusCode: 200,
|
|
272
346
|
contentHash: renderHash,
|
|
273
347
|
});
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
348
|
+
}
|
|
349
|
+
return withOptionalHtml({
|
|
350
|
+
success: true,
|
|
351
|
+
mode: "local",
|
|
352
|
+
url: targetUrl,
|
|
353
|
+
title: rendered.title || targetUrl,
|
|
354
|
+
markdown: rendered.markdown,
|
|
355
|
+
links: rendered.links,
|
|
356
|
+
data: {
|
|
277
357
|
url: targetUrl,
|
|
278
358
|
title: rendered.title || targetUrl,
|
|
279
359
|
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
|
-
}
|
|
360
|
+
links: rendered.links,
|
|
361
|
+
},
|
|
362
|
+
evidence: {
|
|
363
|
+
targetId: rendered.tier === "render" ? "render-provider" : "web-spa",
|
|
364
|
+
tier: rendered.tier,
|
|
365
|
+
contentHash: renderHash,
|
|
366
|
+
latencyMs: Date.now() - start,
|
|
367
|
+
sourceUrl: targetUrl,
|
|
368
|
+
matchedSignals: rendered.tier === "browser" ? ["render:local-chrome"] : [`render:${rendered.tier}`],
|
|
369
|
+
},
|
|
370
|
+
}, rendered.html, p.formats);
|
|
298
371
|
}
|
|
299
|
-
|
|
300
|
-
?
|
|
301
|
-
: lastFetchTier() === "proxy-impersonate"
|
|
302
|
-
? "proxy-impersonate"
|
|
303
|
-
: "impit-tls";
|
|
304
|
-
if (blocked) {
|
|
305
|
-
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
|
|
372
|
+
if (blocked || !markdown.trim()) {
|
|
373
|
+
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
|
|
306
374
|
}
|
|
307
|
-
|
|
375
|
+
const targetInspection = await inspectTarget({
|
|
376
|
+
url: targetUrl,
|
|
377
|
+
html,
|
|
378
|
+
markdown,
|
|
379
|
+
});
|
|
380
|
+
return withOptionalHtml({
|
|
308
381
|
success: true,
|
|
309
382
|
mode: "local",
|
|
310
383
|
url: targetUrl,
|
|
@@ -321,20 +394,38 @@ export function createLocalEngineClient() {
|
|
|
321
394
|
evidence: {
|
|
322
395
|
...targetInspection.evidence,
|
|
323
396
|
tier: nativeTier,
|
|
324
|
-
contentHash,
|
|
397
|
+
contentHash: sha256(markdown || html),
|
|
325
398
|
latencyMs: Date.now() - start,
|
|
326
399
|
limitations: [
|
|
327
400
|
...(Array.isArray(targetInspection.evidence?.limitations)
|
|
328
401
|
? targetInspection.evidence.limitations
|
|
329
402
|
: []),
|
|
330
|
-
"Page needs JS rendering;
|
|
331
|
-
"
|
|
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`, " +
|
|
332
405
|
"or self-host a render provider via WEBCRAWL_RENDER_URL.",
|
|
333
406
|
],
|
|
334
407
|
},
|
|
335
|
-
};
|
|
408
|
+
}, html, p.formats);
|
|
336
409
|
}
|
|
337
|
-
|
|
410
|
+
if (blocked || !markdown.trim()) {
|
|
411
|
+
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
|
|
412
|
+
}
|
|
413
|
+
const contentHash = sha256(markdown || html);
|
|
414
|
+
if (markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
415
|
+
setCachedPage(targetUrl, "none", {
|
|
416
|
+
title,
|
|
417
|
+
markdown,
|
|
418
|
+
links,
|
|
419
|
+
statusCode: fetched.status,
|
|
420
|
+
contentHash,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const targetInspection = await inspectTarget({
|
|
424
|
+
url: targetUrl,
|
|
425
|
+
html,
|
|
426
|
+
markdown,
|
|
427
|
+
});
|
|
428
|
+
return withOptionalHtml({
|
|
338
429
|
success: true,
|
|
339
430
|
mode: "local",
|
|
340
431
|
url: targetUrl,
|
|
@@ -350,15 +441,11 @@ export function createLocalEngineClient() {
|
|
|
350
441
|
},
|
|
351
442
|
evidence: {
|
|
352
443
|
...targetInspection.evidence,
|
|
353
|
-
tier:
|
|
354
|
-
? "impersonate"
|
|
355
|
-
: lastFetchTier() === "proxy-impersonate"
|
|
356
|
-
? "proxy-impersonate"
|
|
357
|
-
: "impit-tls",
|
|
444
|
+
tier: nativeTier,
|
|
358
445
|
contentHash,
|
|
359
446
|
latencyMs: Date.now() - start,
|
|
360
447
|
},
|
|
361
|
-
};
|
|
448
|
+
}, html, p.formats);
|
|
362
449
|
},
|
|
363
450
|
async parse(p) {
|
|
364
451
|
const url = typeof p.url === "string" ? p.url : undefined;
|
|
@@ -464,63 +551,51 @@ export function createLocalEngineClient() {
|
|
|
464
551
|
},
|
|
465
552
|
async extract(p) {
|
|
466
553
|
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const renderedMarkdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
|
|
477
|
-
if (renderedMarkdown.trim().length > 40) {
|
|
478
|
-
const targetInspection = await inspectTarget({
|
|
479
|
-
url: targetUrl,
|
|
480
|
-
markdown: renderedMarkdown,
|
|
481
|
-
});
|
|
482
|
-
const siteData = await extractWebsiteData(targetUrl);
|
|
483
|
-
return {
|
|
484
|
-
success: true,
|
|
485
|
-
mode: "local",
|
|
486
|
-
url: targetUrl,
|
|
487
|
-
data: {
|
|
488
|
-
...siteData,
|
|
489
|
-
...(targetInspection.structured ? { target: targetInspection.structured } : {}),
|
|
490
|
-
},
|
|
491
|
-
evidence: targetInspection.evidence,
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
}
|
|
554
|
+
// Scrape first (local Chrome / render provider) so JS pages yield rendered HTML, not the shell.
|
|
555
|
+
const scraped = (await this.scrape({
|
|
556
|
+
url: targetUrl,
|
|
557
|
+
formats: ["markdown", "html"],
|
|
558
|
+
noCache: true,
|
|
559
|
+
}));
|
|
560
|
+
const markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
|
|
561
|
+
const html = (scraped.data && scraped.data.html) || scraped.html || "";
|
|
562
|
+
if (scraped.blocked || scraped.success === false) {
|
|
495
563
|
return {
|
|
496
|
-
success:
|
|
564
|
+
success: false,
|
|
565
|
+
blocked: true,
|
|
497
566
|
mode: "local",
|
|
498
567
|
url: targetUrl,
|
|
499
568
|
data: {},
|
|
569
|
+
error: scraped.error || emptyPageError(200),
|
|
500
570
|
evidence: {
|
|
501
571
|
tier: lastFetchTier(),
|
|
502
572
|
sourceUrl: targetUrl,
|
|
503
|
-
limitations: [
|
|
504
|
-
"Page needs JS rendering; the local native tiers cannot render it. " +
|
|
505
|
-
"Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
|
|
506
|
-
"or self-host a render provider via WEBCRAWL_RENDER_URL.",
|
|
507
|
-
],
|
|
573
|
+
limitations: [String(scraped.error || emptyPageError(200))],
|
|
508
574
|
},
|
|
509
575
|
};
|
|
510
576
|
}
|
|
511
577
|
const targetInspection = await inspectTarget({
|
|
512
578
|
url: targetUrl,
|
|
513
|
-
html
|
|
579
|
+
html,
|
|
580
|
+
markdown,
|
|
581
|
+
});
|
|
582
|
+
const siteData = await extractWebsiteData(targetUrl, {
|
|
583
|
+
prompt: p.prompt,
|
|
584
|
+
schema: p.schema,
|
|
585
|
+
markdown,
|
|
586
|
+
html,
|
|
587
|
+
bypassCache: true,
|
|
514
588
|
});
|
|
515
|
-
const
|
|
589
|
+
const { cached: _c, cacheLayer: _l, ...extracted } = siteData;
|
|
516
590
|
return {
|
|
517
591
|
success: true,
|
|
518
592
|
mode: "local",
|
|
519
593
|
url: targetUrl,
|
|
520
|
-
data: {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
594
|
+
data: extractOutputData(extracted, {
|
|
595
|
+
prompt: p.prompt,
|
|
596
|
+
schema: p.schema,
|
|
597
|
+
target: targetInspection.structured,
|
|
598
|
+
}),
|
|
524
599
|
evidence: targetInspection.evidence,
|
|
525
600
|
};
|
|
526
601
|
},
|
|
@@ -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;
|
|
@@ -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;
|
|
@@ -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
|
|
@@ -653,23 +653,26 @@ export async function searchWeb(req) {
|
|
|
653
653
|
// An explicitly chosen provider is not a fallback.
|
|
654
654
|
const usedJinaFallback = (!req.provider || req.provider === "auto") &&
|
|
655
655
|
finalItems.some((item) => item.sources?.includes("jina"));
|
|
656
|
-
const
|
|
656
|
+
const engines = ranked.rankingMetrics.enginesContributing;
|
|
657
|
+
const provider = engines.length > 1
|
|
657
658
|
? "native-federated"
|
|
658
|
-
:
|
|
659
|
+
: engines.includes("serper")
|
|
659
660
|
? "serper"
|
|
660
|
-
:
|
|
661
|
+
: engines.includes("jina")
|
|
661
662
|
? "jina"
|
|
662
|
-
:
|
|
663
|
-
ranked.rankingMetrics.enginesContributing.includes("brave-native")
|
|
663
|
+
: engines.includes("brave-api") || engines.includes("brave-native")
|
|
664
664
|
? "brave"
|
|
665
|
-
: "ddg-
|
|
666
|
-
|
|
665
|
+
: engines.includes("ddg-lite")
|
|
666
|
+
? "ddg-edge"
|
|
667
|
+
: "native-federated";
|
|
668
|
+
const hasHits = finalItems.length > 0 ||
|
|
667
669
|
(products && products.length > 0) ||
|
|
668
670
|
(dedupedListings && dedupedListings.length > 0) ||
|
|
669
671
|
(dedupedFlights && dedupedFlights.length > 0) ||
|
|
670
672
|
(dedupedHotels && dedupedHotels.length > 0) ||
|
|
671
673
|
(dedupedBusinesses && dedupedBusinesses.length > 0) ||
|
|
672
|
-
(dedupedCves && dedupedCves.length > 0)
|
|
674
|
+
(dedupedCves && dedupedCves.length > 0);
|
|
675
|
+
if (hasHits) {
|
|
673
676
|
searchCache.set(cacheKey, {
|
|
674
677
|
data: finalItems,
|
|
675
678
|
...(products ? { products } : {}),
|
|
@@ -684,6 +687,18 @@ export async function searchWeb(req) {
|
|
|
684
687
|
expiresAt: Date.now() + SEARCH_CACHE_TTL_MS,
|
|
685
688
|
});
|
|
686
689
|
}
|
|
690
|
+
if (!hasHits) {
|
|
691
|
+
return {
|
|
692
|
+
success: false,
|
|
693
|
+
data: [],
|
|
694
|
+
provider,
|
|
695
|
+
error: EMPTY_SEARCH_ERROR,
|
|
696
|
+
cached: false,
|
|
697
|
+
latencyMs: Math.round(performance.now() - start),
|
|
698
|
+
...(hasGeo ? { userLocation } : {}),
|
|
699
|
+
rankingMetrics: ranked.rankingMetrics,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
687
702
|
return {
|
|
688
703
|
success: true,
|
|
689
704
|
data: finalItems,
|
|
@@ -16,6 +16,16 @@ export declare function isEntityQuery(q: string): boolean;
|
|
|
16
16
|
export declare function fetchHedgedNativeFederation(rawQuery: string, localizedQuery: string, perEngineLimit: number, req: SearchRequest, userLocation: UserLocationContext, quorumTimeoutMs: number, targetSites: string[], onOffer: (offer: ProductOffer) => void, onCve?: (cve: CveRecord) => void): Promise<RawCandidateDocument[]>;
|
|
17
17
|
/** Below this many unique URLs from the free engines, the Jina fallback runs. */
|
|
18
18
|
export declare const JINA_FALLBACK_MIN_URLS = 3;
|
|
19
|
+
/** True when a scraped SERP is a bot-check / captcha rather than results. */
|
|
20
|
+
export declare function isSerpChallengeHtml(html: string, status?: number): boolean;
|
|
21
|
+
export declare const EMPTY_SEARCH_ERROR: string;
|
|
22
|
+
/** Parse Bing's `format=rss` web-search feed into candidate docs. */
|
|
23
|
+
export declare function parseBingWebRss(xml: string, limit: number): RawCandidateDocument[];
|
|
24
|
+
/**
|
|
25
|
+
* Last-resort keyless engines used when DDG/Brave HTML SERPs come back empty.
|
|
26
|
+
* Wikipedia OpenSearch, HN Algolia, and Bing RSS do not require a JS challenge.
|
|
27
|
+
*/
|
|
28
|
+
export declare function fetchKeylessSearchFallbacks(query: string, limit: number): Promise<RawCandidateDocument[]>;
|
|
19
29
|
export declare function fetchJinaSearch(query: string, limit: number, req?: {
|
|
20
30
|
country?: string;
|
|
21
31
|
lang?: string;
|
|
@@ -219,10 +219,105 @@ export async function fetchHedgedNativeFederation(rawQuery, localizedQuery, perE
|
|
|
219
219
|
}
|
|
220
220
|
collected.push(...jina);
|
|
221
221
|
}
|
|
222
|
+
// HTML SERPs (DDG, Brave) are frequently challenge/429 pages. When they
|
|
223
|
+
// yield nothing, fall back to keyless APIs that still work without a key.
|
|
224
|
+
if (collected.length === 0 && (!req.provider || req.provider === "auto")) {
|
|
225
|
+
const extras = await fetchKeylessSearchFallbacks(localizedQuery, perEngineLimit);
|
|
226
|
+
collected.push(...extras);
|
|
227
|
+
}
|
|
222
228
|
return collected;
|
|
223
229
|
}
|
|
224
230
|
/** Below this many unique URLs from the free engines, the Jina fallback runs. */
|
|
225
231
|
export const JINA_FALLBACK_MIN_URLS = 3;
|
|
232
|
+
/** True when a scraped SERP is a bot-check / captcha rather than results. */
|
|
233
|
+
export function isSerpChallengeHtml(html, status = 200) {
|
|
234
|
+
if (status === 403 || status === 429 || status === 503)
|
|
235
|
+
return true;
|
|
236
|
+
const lower = String(html ?? "").slice(0, 8000).toLowerCase();
|
|
237
|
+
return (lower.includes("bots use duckduckgo") ||
|
|
238
|
+
lower.includes("anomaly-modal") ||
|
|
239
|
+
lower.includes("please complete the following challenge") ||
|
|
240
|
+
lower.includes("schedule a captcha") ||
|
|
241
|
+
lower.includes("flagged as being suspicious") ||
|
|
242
|
+
lower.includes("enable javascript to function"));
|
|
243
|
+
}
|
|
244
|
+
export const EMPTY_SEARCH_ERROR = "Keyless search engines returned no results (DuckDuckGo and Brave often serve a bot challenge). " +
|
|
245
|
+
"Tried Wikipedia, Hacker News, and Bing RSS as well. " +
|
|
246
|
+
"Set BRAVE_SEARCH_API_KEY, SERPER_API_KEY, or JINA_API_KEY, or retry later.";
|
|
247
|
+
/** Parse Bing's `format=rss` web-search feed into candidate docs. */
|
|
248
|
+
export function parseBingWebRss(xml, limit) {
|
|
249
|
+
const items = [];
|
|
250
|
+
const itemRe = /<item>([\s\S]*?)<\/item>/gi;
|
|
251
|
+
let match;
|
|
252
|
+
while ((match = itemRe.exec(xml)) !== null && items.length < limit) {
|
|
253
|
+
const block = match[1];
|
|
254
|
+
const title = decodeHtmlEntities((block.match(/<title>([\s\S]*?)<\/title>/i)?.[1] || "")
|
|
255
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
|
|
256
|
+
.trim());
|
|
257
|
+
const link = (block.match(/<link>([\s\S]*?)<\/link>/i)?.[1] || "")
|
|
258
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
|
|
259
|
+
.trim();
|
|
260
|
+
const description = decodeHtmlEntities((block.match(/<description>([\s\S]*?)<\/description>/i)?.[1] || "")
|
|
261
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
|
|
262
|
+
.replace(/<[^>]+>/g, " ")
|
|
263
|
+
.replace(/\s+/g, " ")
|
|
264
|
+
.trim());
|
|
265
|
+
if (!title || !link.startsWith("http"))
|
|
266
|
+
continue;
|
|
267
|
+
items.push({
|
|
268
|
+
title,
|
|
269
|
+
url: link,
|
|
270
|
+
description: description || title,
|
|
271
|
+
engine: "bing-rss",
|
|
272
|
+
engineRank: items.length + 1,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return items;
|
|
276
|
+
}
|
|
277
|
+
async function fetchBingWebRss(query, limit) {
|
|
278
|
+
try {
|
|
279
|
+
const rssUrl = `https://www.bing.com/search?format=rss&q=${encodeURIComponent(query)}`;
|
|
280
|
+
const res = await fetch(rssUrl, {
|
|
281
|
+
headers: {
|
|
282
|
+
Accept: "application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8",
|
|
283
|
+
"User-Agent": "Mozilla/5.0 (compatible; WebCrawl/1.4; +https://webcrawl.app)",
|
|
284
|
+
},
|
|
285
|
+
signal: AbortSignal.timeout(4000),
|
|
286
|
+
});
|
|
287
|
+
if (!res.ok)
|
|
288
|
+
return [];
|
|
289
|
+
const xml = await res.text();
|
|
290
|
+
if (!xml.includes("<item>"))
|
|
291
|
+
return [];
|
|
292
|
+
return parseBingWebRss(xml, limit);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Last-resort keyless engines used when DDG/Brave HTML SERPs come back empty.
|
|
300
|
+
* Wikipedia OpenSearch, HN Algolia, and Bing RSS do not require a JS challenge.
|
|
301
|
+
*/
|
|
302
|
+
export async function fetchKeylessSearchFallbacks(query, limit) {
|
|
303
|
+
const per = Math.max(limit, 5);
|
|
304
|
+
const [hn, wiki, bing] = await Promise.all([
|
|
305
|
+
fetchHackerNewsAlgolia(query, per),
|
|
306
|
+
fetchWikipediaOpenSearch(query, per),
|
|
307
|
+
fetchBingWebRss(query, per),
|
|
308
|
+
]);
|
|
309
|
+
const seen = new Set();
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const item of [...hn, ...wiki, ...bing]) {
|
|
312
|
+
if (!item.url || seen.has(item.url))
|
|
313
|
+
continue;
|
|
314
|
+
seen.add(item.url);
|
|
315
|
+
out.push(item);
|
|
316
|
+
if (out.length >= Math.max(limit + 4, 8))
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
226
321
|
export async function fetchJinaSearch(query, limit, req) {
|
|
227
322
|
const key = process.env.JINA_API_KEY;
|
|
228
323
|
if (!key) {
|
|
@@ -435,7 +530,7 @@ export async function fetchBraveNativeSerp(query, limit, userLocation) {
|
|
|
435
530
|
: "";
|
|
436
531
|
const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}&source=web${countryParam}`;
|
|
437
532
|
const res = await fetchWithTlsImpersonation(url, 2200);
|
|
438
|
-
if (res.status !== 200 || !res.html) {
|
|
533
|
+
if (res.status !== 200 || !res.html || isSerpChallengeHtml(res.html, res.status)) {
|
|
439
534
|
return [];
|
|
440
535
|
}
|
|
441
536
|
const html = res.html;
|
|
@@ -545,7 +640,7 @@ async function fetchDuckDuckGoLiteOnly(query, limit, userLocation) {
|
|
|
545
640
|
const kl = ddgRegion(countryCode, undefined);
|
|
546
641
|
const klParam = kl ? `&kl=${encodeURIComponent(kl)}` : "";
|
|
547
642
|
const liteRes = await fetchWithTlsImpersonation(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}${klParam}`, 2200);
|
|
548
|
-
if (liteRes && liteRes.html) {
|
|
643
|
+
if (liteRes && liteRes.html && !isSerpChallengeHtml(liteRes.html, liteRes.status)) {
|
|
549
644
|
const html = liteRes.html;
|
|
550
645
|
const linkMatches = Array.from(html.matchAll(/<a\s+[^>]*href=["']([^"']+)["'][^>]*class=["']result-link["'][^>]*>([\s\S]*?)<\/a>|<a\s+[^>]*class=["']result-link["'][^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi));
|
|
551
646
|
const snippetMatches = Array.from(html.matchAll(/<td\s+[^>]*class=["']result-snippet["'][^>]*>([\s\S]*?)<\/td>/gi));
|