webcrawl 1.4.3 → 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.
@@ -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, renderViaLocalChrome, 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,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, then stealth.
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
- title: rendered.title,
95
- markdown: rendered.markdown,
96
- html: "",
97
- links: [],
98
- tier: "render",
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 html = await renderViaLocalChrome(url);
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
- title: esc.title,
123
- markdown: esc.markdown,
124
- html: esc.html,
125
- links: esc.links,
126
- tier: esc.tier,
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 dump-dom, then stealth). Never cache
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 rendered = blocked && fetched.status >= 400 ? null : await escalateJsRender(targetUrl);
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 || !markdown.trim()) {
373
- return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
428
+ if (blocked) {
429
+ return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
374
430
  }
375
- const targetInspection = await inspectTarget({
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
- const markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
561
- const html = (scraped.data && scraped.data.html) || scraped.html || "";
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
- return {
564
- success: false,
565
- blocked: true,
566
- mode: "local",
567
- url: targetUrl,
568
- data: {},
569
- error: scraped.error || emptyPageError(200),
570
- evidence: {
571
- tier: lastFetchTier(),
572
- sourceUrl: targetUrl,
573
- limitations: [String(scraped.error || emptyPageError(200))],
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: extractOutputData(extracted, {
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 Jina when results are thin.
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: {
@@ -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
- const quorumTimeoutMs = req.rankOptions?.quorumTimeoutMs ?? 1500;
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 Jina once, then re-rank the combined pool.
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,7 +651,7 @@ export async function searchWeb(req) {
649
651
  ...(matchedCves?.length ? { cves: matchedCves } : {}),
650
652
  };
651
653
  });
652
- // Charge the fallback surcharge only when a Jina result actually reached the caller.
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"));
@@ -14,18 +14,30 @@ export declare function isEntityQuery(q: string): boolean;
14
14
  * preventing any single slow engine from inflating tail latency.
15
15
  */
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
- /** Below this many unique URLs from the free engines, the Jina fallback runs. */
17
+ /** Below this many unique URLs from the free engines, the keyed search fallback runs. */
18
18
  export declare const JINA_FALLBACK_MIN_URLS = 3;
19
19
  /** True when a scraped SERP is a bot-check / captcha rather than results. */
20
20
  export declare function isSerpChallengeHtml(html: string, status?: number): boolean;
21
21
  export declare const EMPTY_SEARCH_ERROR: string;
22
+ /** Canonical URL for search dedupe (host lowercased, trailing slash / tracking params stripped). */
23
+ export declare function canonicalSearchUrl(raw: string | undefined): string;
24
+ /** Round-robin merge so one source (e.g. Wikipedia) cannot fill `-n` by itself. */
25
+ export declare function mergeSearchCandidates(groups: RawCandidateDocument[][], limit: number): RawCandidateDocument[];
26
+ export declare function withRetry<T>(fn: () => Promise<T>, opts?: {
27
+ retries?: number;
28
+ delayMs?: number;
29
+ isEmpty?: (v: T) => boolean;
30
+ }): Promise<T>;
22
31
  /** Parse Bing's `format=rss` web-search feed into candidate docs. */
23
32
  export declare function parseBingWebRss(xml: string, limit: number): RawCandidateDocument[];
24
33
  /**
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.
34
+ * Last-resort keyless engines used when DDG/Brave HTML SERPs come back empty
35
+ * or thinner than `limit`. Wikipedia OpenSearch, HN Algolia, Bing RSS, and
36
+ * DuckDuckGo Instant Answer do not require a JS challenge or API key.
27
37
  */
28
38
  export declare function fetchKeylessSearchFallbacks(query: string, limit: number): Promise<RawCandidateDocument[]>;
39
+ /** DuckDuckGo Instant Answer API — keyless JSON, unrelated to the HTML SERP challenge. */
40
+ export declare function fetchDuckDuckGoInstantAnswer(query: string, limit: number): Promise<RawCandidateDocument[]>;
29
41
  export declare function fetchJinaSearch(query: string, limit: number, req?: {
30
42
  country?: string;
31
43
  lang?: string;