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
|
@@ -32,6 +32,22 @@ export interface LocalEngineBackend {
|
|
|
32
32
|
targets(): Promise<Record<string, unknown>>;
|
|
33
33
|
usage(): Promise<Record<string, unknown>>;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Scrape result for a page the site refused (bot check, 403/429/503).
|
|
37
|
+
* `success: false` with no markdown, so callers never mistake the block page
|
|
38
|
+
* for content. Exported for unit tests.
|
|
39
|
+
*/
|
|
40
|
+
export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number, reason?: string): Record<string, unknown>;
|
|
41
|
+
/** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
|
|
42
|
+
export declare const MIN_CACHEABLE_MARKDOWN = 200;
|
|
43
|
+
/** Attach source HTML when the caller asked for the `html` format. */
|
|
44
|
+
export declare function withOptionalHtml(result: Record<string, unknown>, html: string | undefined, formats?: Array<"markdown" | "links" | "html">): Record<string, unknown>;
|
|
45
|
+
/** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
|
|
46
|
+
export declare function extractOutputData(extracted: Record<string, unknown>, opts: {
|
|
47
|
+
prompt?: string;
|
|
48
|
+
schema?: Record<string, unknown>;
|
|
49
|
+
target?: unknown;
|
|
50
|
+
}): Record<string, unknown>;
|
|
35
51
|
/**
|
|
36
52
|
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
37
53
|
* Matching is a case-insensitive substring of the URL. Exported for unit tests.
|
|
@@ -9,10 +9,10 @@
|
|
|
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, 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
|
-
import { fetchResource, lastFetchTier, needsBrowserEscalation } from "./engine/fetcher.js";
|
|
15
|
+
import { fetchResource, isAntiBotChallenge, lastFetchTier, needsBrowserEscalation, } from "./engine/fetcher.js";
|
|
16
16
|
import { parseFile, publicParseMeta } from "./parse/index.js";
|
|
17
17
|
import { isBinaryContentType, looksLikeBinaryUrl } from "./parse/fetch.js";
|
|
18
18
|
import { maxDownloadBytes } from "./parse/limits.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 {
|
|
@@ -29,6 +30,109 @@ function safeDomainOf(url) {
|
|
|
29
30
|
return "";
|
|
30
31
|
}
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Scrape result for a page the site refused (bot check, 403/429/503).
|
|
35
|
+
* `success: false` with no markdown, so callers never mistake the block page
|
|
36
|
+
* for content. Exported for unit tests.
|
|
37
|
+
*/
|
|
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.");
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
mode: "local",
|
|
45
|
+
url,
|
|
46
|
+
blocked: true,
|
|
47
|
+
statusCode,
|
|
48
|
+
error,
|
|
49
|
+
title: url,
|
|
50
|
+
markdown: "",
|
|
51
|
+
links: [],
|
|
52
|
+
data: { url, title: url, markdown: "", links: [] },
|
|
53
|
+
evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
|
|
54
|
+
};
|
|
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
|
+
}
|
|
32
136
|
/**
|
|
33
137
|
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
34
138
|
* Matching is a case-insensitive substring of the URL. Exported for unit tests.
|
|
@@ -68,7 +172,7 @@ export function createLocalEngineClient() {
|
|
|
68
172
|
const resolved = resolveTarget({ query, category });
|
|
69
173
|
return {
|
|
70
174
|
...res,
|
|
71
|
-
success:
|
|
175
|
+
success: res.success !== false,
|
|
72
176
|
mode: "local",
|
|
73
177
|
target: {
|
|
74
178
|
id: resolved.plugin.id,
|
|
@@ -138,7 +242,12 @@ export function createLocalEngineClient() {
|
|
|
138
242
|
// 3. Check local cache first
|
|
139
243
|
if (!p.noCache) {
|
|
140
244
|
const cached = await getCachedPageAsync(targetUrl, "none");
|
|
141
|
-
|
|
245
|
+
// Entries written before blocked pages stopped being cached may hold a
|
|
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) {
|
|
142
251
|
const targetInspection = await inspectTarget({
|
|
143
252
|
url: targetUrl,
|
|
144
253
|
markdown: cached.markdown,
|
|
@@ -211,68 +320,64 @@ export function createLocalEngineClient() {
|
|
|
211
320
|
// ladder — the memo may be stale or the provider flaky.
|
|
212
321
|
}
|
|
213
322
|
const fetched = await fetchWithTlsImpersonation(targetUrl);
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
markdown,
|
|
227
|
-
});
|
|
228
|
-
// Tier R: the native ladder passed the bot check but the page is a
|
|
229
|
-
// JS shell / challenge page the ladder cannot render. Memoize the
|
|
230
|
-
// domain and either render via the provider (when configured) or
|
|
231
|
-
// return the shell with an honest limitation note.
|
|
232
|
-
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) {
|
|
233
335
|
await markDomainRenderNeeded(renderDomain);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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) {
|
|
238
341
|
setCachedPage(targetUrl, "none", {
|
|
239
342
|
title: rendered.title,
|
|
240
343
|
markdown: rendered.markdown,
|
|
241
|
-
links:
|
|
344
|
+
links: rendered.links,
|
|
242
345
|
statusCode: 200,
|
|
243
346
|
contentHash: renderHash,
|
|
244
347
|
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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: {
|
|
248
357
|
url: targetUrl,
|
|
249
358
|
title: rendered.title || targetUrl,
|
|
250
359
|
markdown: rendered.markdown,
|
|
251
|
-
links:
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
latencyMs: Date.now() - start,
|
|
263
|
-
sourceUrl: rendered.sourceUrl,
|
|
264
|
-
matchedSignals: ["render:provider"],
|
|
265
|
-
limitations: rendered.warnings,
|
|
266
|
-
},
|
|
267
|
-
};
|
|
268
|
-
}
|
|
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);
|
|
269
371
|
}
|
|
270
|
-
|
|
271
|
-
?
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
372
|
+
if (blocked || !markdown.trim()) {
|
|
373
|
+
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
|
|
374
|
+
}
|
|
375
|
+
const targetInspection = await inspectTarget({
|
|
376
|
+
url: targetUrl,
|
|
377
|
+
html,
|
|
378
|
+
markdown,
|
|
379
|
+
});
|
|
380
|
+
return withOptionalHtml({
|
|
276
381
|
success: true,
|
|
277
382
|
mode: "local",
|
|
278
383
|
url: targetUrl,
|
|
@@ -289,20 +394,38 @@ export function createLocalEngineClient() {
|
|
|
289
394
|
evidence: {
|
|
290
395
|
...targetInspection.evidence,
|
|
291
396
|
tier: nativeTier,
|
|
292
|
-
contentHash,
|
|
397
|
+
contentHash: sha256(markdown || html),
|
|
293
398
|
latencyMs: Date.now() - start,
|
|
294
399
|
limitations: [
|
|
295
400
|
...(Array.isArray(targetInspection.evidence?.limitations)
|
|
296
401
|
? targetInspection.evidence.limitations
|
|
297
402
|
: []),
|
|
298
|
-
"Page needs JS rendering;
|
|
299
|
-
"
|
|
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`, " +
|
|
300
405
|
"or self-host a render provider via WEBCRAWL_RENDER_URL.",
|
|
301
406
|
],
|
|
302
407
|
},
|
|
303
|
-
};
|
|
408
|
+
}, html, p.formats);
|
|
304
409
|
}
|
|
305
|
-
|
|
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({
|
|
306
429
|
success: true,
|
|
307
430
|
mode: "local",
|
|
308
431
|
url: targetUrl,
|
|
@@ -318,15 +441,11 @@ export function createLocalEngineClient() {
|
|
|
318
441
|
},
|
|
319
442
|
evidence: {
|
|
320
443
|
...targetInspection.evidence,
|
|
321
|
-
tier:
|
|
322
|
-
? "impersonate"
|
|
323
|
-
: lastFetchTier() === "proxy-impersonate"
|
|
324
|
-
? "proxy-impersonate"
|
|
325
|
-
: "impit-tls",
|
|
444
|
+
tier: nativeTier,
|
|
326
445
|
contentHash,
|
|
327
446
|
latencyMs: Date.now() - start,
|
|
328
447
|
},
|
|
329
|
-
};
|
|
448
|
+
}, html, p.formats);
|
|
330
449
|
},
|
|
331
450
|
async parse(p) {
|
|
332
451
|
const url = typeof p.url === "string" ? p.url : undefined;
|
|
@@ -432,63 +551,51 @@ export function createLocalEngineClient() {
|
|
|
432
551
|
},
|
|
433
552
|
async extract(p) {
|
|
434
553
|
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const renderedMarkdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
|
|
445
|
-
if (renderedMarkdown.trim().length > 40) {
|
|
446
|
-
const targetInspection = await inspectTarget({
|
|
447
|
-
url: targetUrl,
|
|
448
|
-
markdown: renderedMarkdown,
|
|
449
|
-
});
|
|
450
|
-
const siteData = await extractWebsiteData(targetUrl);
|
|
451
|
-
return {
|
|
452
|
-
success: true,
|
|
453
|
-
mode: "local",
|
|
454
|
-
url: targetUrl,
|
|
455
|
-
data: {
|
|
456
|
-
...siteData,
|
|
457
|
-
...(targetInspection.structured ? { target: targetInspection.structured } : {}),
|
|
458
|
-
},
|
|
459
|
-
evidence: targetInspection.evidence,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
}
|
|
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) {
|
|
463
563
|
return {
|
|
464
|
-
success:
|
|
564
|
+
success: false,
|
|
565
|
+
blocked: true,
|
|
465
566
|
mode: "local",
|
|
466
567
|
url: targetUrl,
|
|
467
568
|
data: {},
|
|
569
|
+
error: scraped.error || emptyPageError(200),
|
|
468
570
|
evidence: {
|
|
469
571
|
tier: lastFetchTier(),
|
|
470
572
|
sourceUrl: targetUrl,
|
|
471
|
-
limitations: [
|
|
472
|
-
"Page needs JS rendering; the local native tiers cannot render it. " +
|
|
473
|
-
"Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
|
|
474
|
-
"or self-host a render provider via WEBCRAWL_RENDER_URL.",
|
|
475
|
-
],
|
|
573
|
+
limitations: [String(scraped.error || emptyPageError(200))],
|
|
476
574
|
},
|
|
477
575
|
};
|
|
478
576
|
}
|
|
479
577
|
const targetInspection = await inspectTarget({
|
|
480
578
|
url: targetUrl,
|
|
481
|
-
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,
|
|
482
588
|
});
|
|
483
|
-
const
|
|
589
|
+
const { cached: _c, cacheLayer: _l, ...extracted } = siteData;
|
|
484
590
|
return {
|
|
485
591
|
success: true,
|
|
486
592
|
mode: "local",
|
|
487
593
|
url: targetUrl,
|
|
488
|
-
data: {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
594
|
+
data: extractOutputData(extracted, {
|
|
595
|
+
prompt: p.prompt,
|
|
596
|
+
schema: p.schema,
|
|
597
|
+
target: targetInspection.structured,
|
|
598
|
+
}),
|
|
492
599
|
evidence: targetInspection.evidence,
|
|
493
600
|
};
|
|
494
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;
|