crawlforge-mcp-server 5.6.6 → 5.6.8

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.
Files changed (34) hide show
  1. package/CLAUDE.md +3 -3
  2. package/package.json +5 -3
  3. package/server.js +86 -62
  4. package/src/core/AgentOrchestrator.js +107 -5
  5. package/src/core/ChangeTracker.js +2 -1
  6. package/src/core/LLMsTxtAnalyzer.js +2 -1
  7. package/src/core/LocalizationManager.js +13 -6
  8. package/src/core/StealthBrowserManager.js +132 -5
  9. package/src/core/analysis/ContentAnalyzer.js +34 -3
  10. package/src/core/analysis/sentenceUtils.js +10 -7
  11. package/src/core/crawlers/BFSCrawler.js +2 -1
  12. package/src/core/integrations/PerformanceIntegration.js +2 -1
  13. package/src/core/processing/ContentProcessor.js +3 -2
  14. package/src/core/workers/worker.js +2 -1
  15. package/src/server/fallbackHints.js +66 -0
  16. package/src/server/toolFilter.js +1 -1
  17. package/src/server/withAuth.js +8 -0
  18. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  19. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  20. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +2 -2
  22. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +7 -5
  25. package/src/skills/installer.js +19 -0
  26. package/src/tools/advanced/ScrapeWithActionsTool.js +2 -1
  27. package/src/tools/advanced/batchScrape/worker.js +4 -3
  28. package/src/tools/crawl/mapSite.js +2 -1
  29. package/src/tools/extract/_fetchAndParse.js +11 -26
  30. package/src/tools/extract/extractContent.js +5 -1
  31. package/src/tools/extract/extractStructured.js +13 -3
  32. package/src/tools/scrape/unifiedScrape.js +2 -1
  33. package/src/utils/challengeDetection.js +11 -0
  34. package/src/utils/pageTitle.js +18 -0
@@ -34,7 +34,11 @@ const DEFAULT_MAX_URLS = 10;
34
34
  function isRelevant(text, query) {
35
35
  if (!text || !query) return true; // fail-open
36
36
  const lc = text.toLowerCase();
37
- return query.toLowerCase().split(/\s+/).some(term => term.length > 3 && lc.includes(term));
37
+ // Terms are compared bare: the quoted entity in `npm package "commander"?`
38
+ // arrived as `"commander"?` and matched nothing (R17, 2026-09-04).
39
+ return query.toLowerCase().split(/\s+/)
40
+ .map(term => term.replace(/[^\p{L}\p{N}]/gu, ''))
41
+ .some(term => term.length > 3 && lc.includes(term));
38
42
  }
39
43
 
40
44
  /**
@@ -45,6 +49,46 @@ function truncate(text, maxChars = 8000) {
45
49
  return text.slice(0, maxChars) + '\n[...truncated]';
46
50
  }
47
51
 
52
+ /**
53
+ * Literal provenance check: every version, date and large number in the
54
+ * answer must appear somewhere in the fetched source text. Returns the raw
55
+ * strings that do not (deduplicated, in answer order). Comparison is
56
+ * case-insensitive with whitespace and thousands separators removed, so
57
+ * "6.3.3" matches "6.3.3" and "1,234" matches "1 234". URLs in the answer are
58
+ * ignored: they are citations, not claims.
59
+ */
60
+ const MONTHS = '(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*';
61
+ const VALUE_PATTERNS = [
62
+ /\b\d+\.\d+(?:\.\d+){0,2}\b/g, // versions 1.2 / 1.2.3 / 1.2.3.4
63
+ /\b\d{4}-\d{2}-\d{2}\b/g, // ISO dates
64
+ new RegExp(`\\b${MONTHS}\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?,?\\s+\\d{4}\\b`, 'gi'), // January 18, 2024
65
+ new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTHS}\\.?\\s+\\d{4}\\b`, 'gi'), // 18 January 2024
66
+ /\b\d{1,3}(?:,\d{3})+\b|\b\d{4,}\b/g, // 1,234 / 12345
67
+ ];
68
+
69
+ function normalizeValue(value) {
70
+ return value.toLowerCase().replace(/[\s,]/g, '');
71
+ }
72
+
73
+ export function unverifiedValues(answer, sourceText) {
74
+ if (!answer) return [];
75
+ const stripped = answer.replace(/https?:\/\/\S+/g, ' ');
76
+ const haystack = normalizeValue(sourceText || '');
77
+ const missing = [];
78
+ const seen = new Set();
79
+ for (const pattern of VALUE_PATTERNS) {
80
+ for (const match of stripped.matchAll(pattern)) {
81
+ const raw = match[0];
82
+ const key = normalizeValue(raw);
83
+ if (!key || seen.has(key)) continue;
84
+ seen.add(key);
85
+ if (!haystack.includes(key)) missing.push(raw);
86
+ }
87
+ }
88
+ // "2024" inside an already-flagged "January 18, 2024" is the same claim.
89
+ return missing.filter((v, i) => !missing.some((o, j) => j !== i && o.length > v.length && o.includes(v)));
90
+ }
91
+
48
92
  /**
49
93
  * Current-state task gate: prompts about the live "now" ("right now",
50
94
  * "currently", "today", "latest", "#1 … now") go stale through search alone —
@@ -222,6 +266,17 @@ export class AgentOrchestrator {
222
266
  } catch {
223
267
  // Sampling unavailable — use raw prompt
224
268
  }
269
+ // A quoted term in the prompt ("commander") is the thing being asked
270
+ // about. A planner told to search the bare entity reduced
271
+ // 'npm package "commander"' to "npm", fetched only npm's own docs and
272
+ // answered from memory (R17, 2026-09-04). Every query carries the quoted
273
+ // terms.
274
+ const quotedTerms = [...prompt.matchAll(/"([^"\n]{2,60})"/g)].map(m => m[1].trim()).filter(Boolean);
275
+ if (quotedTerms.length > 0) {
276
+ searchQueries = searchQueries.map(q =>
277
+ quotedTerms.every(t => q.toLowerCase().includes(t.toLowerCase())) ? q : `${q} ${quotedTerms.join(' ')}`.trim()
278
+ );
279
+ }
225
280
 
226
281
  // ── GATHER (search) ───────────────────────────────────────────────────────
227
282
  const urlQueue = [...seedUrls]; // start with any user-provided seeds
@@ -238,6 +293,8 @@ export class AgentOrchestrator {
238
293
  if (!priorityUrls.includes(url)) priorityUrls.push(url);
239
294
  }
240
295
  const searchResults = [];
296
+ /** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
297
+ const excerpts = new Map();
241
298
 
242
299
  if (urlQueue.length < capUrls) {
243
300
  try {
@@ -253,6 +310,11 @@ export class AgentOrchestrator {
253
310
  for (const r of parsed.results) {
254
311
  if (r.link && !urlQueue.includes(r.link)) urlQueue.push(r.link);
255
312
  searchResults.push({ query: q, title: r.title || '', url: r.link || '', snippet: r.snippet || '' });
313
+ // What the engine saw of the page: the snippet plus the
314
+ // page's own meta description, which for npmjs.com carries
315
+ // "Latest version: 15.0.0" while the snippet does not.
316
+ const excerpt = [r.snippet, r.pagemap?.metatags?.description].filter(Boolean).join(' ');
317
+ if (r.link && excerpt && !excerpts.has(r.link)) excerpts.set(r.link, { title: r.title || '', text: excerpt });
256
318
  }
257
319
  }
258
320
  } catch { /* skip failed search */ }
@@ -318,7 +380,17 @@ export class AgentOrchestrator {
318
380
  text: truncate(textContent),
319
381
  step
320
382
  });
321
- } catch { /* skip unreachable URL */ }
383
+ } catch {
384
+ // The page is out of reach (challenge page, 403, timeout) but the
385
+ // search engine's excerpt of it is not: npmjs.com's "Latest version:
386
+ // 15.0.0" sat in the snippet while every fetch of the package page was
387
+ // challenged, and the run ended with no evidence at all (R17,
388
+ // 2026-09-04). Keep a relevant snippet as evidence, labelled as one.
389
+ const excerpt = excerpts.get(url);
390
+ if (excerpt && isRelevant(excerpt.text, prompt)) {
391
+ evidence.push({ url, title: excerpt.title, text: excerpt.text, snippet: true, step });
392
+ }
393
+ }
322
394
  }
323
395
 
324
396
  // ── SHAPE ─────────────────────────────────────────────────────────────────
@@ -340,7 +412,7 @@ export class AgentOrchestrator {
340
412
  .sort((a, b) => b._score - a._score);
341
413
  const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
342
414
  const combinedText = orderedEvidence
343
- .map(e => `--- Source: ${e.url} ---\n${truncate(e.text, perSourceCap)}`)
415
+ .map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${truncate(e.text, perSourceCap)}`)
344
416
  .join('\n\n');
345
417
 
346
418
  if (!combinedText.trim()) {
@@ -386,6 +458,7 @@ export class AgentOrchestrator {
386
458
  let answer = null;
387
459
  let degraded = false;
388
460
  let degradedReason;
461
+ let unverified = [];
389
462
 
390
463
  try {
391
464
  // Wording matters for small local models (llama3.2-class): without the
@@ -414,6 +487,34 @@ export class AgentOrchestrator {
414
487
 
415
488
  const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
416
489
  answer = text;
490
+
491
+ // A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
492
+ // published January 18, 2024" — none of it on any fetched page (R17,
493
+ // 2026-09-04). Versions, dates and counts in the answer must appear in
494
+ // the source text; what does not gets one corrective rewrite, and
495
+ // whatever still remains is named in the answer and in `provenance`.
496
+ unverified = unverifiedValues(answer, combinedText);
497
+ if (unverified.length > 0) {
498
+ const retryPrompt =
499
+ `${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
500
+ `It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` +
501
+ `Rewrite the answer. Keep only values that the sources state, spelled as the sources spell them. ` +
502
+ `Where the sources do not state a value, say that it is not stated instead of guessing.`;
503
+ try {
504
+ const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024 });
505
+ if (rewritten && rewritten.trim()) {
506
+ answer = rewritten;
507
+ unverified = unverifiedValues(answer, combinedText);
508
+ }
509
+ } catch {
510
+ // Keep the first answer; it is flagged below.
511
+ }
512
+ }
513
+ if (unverified.length > 0) {
514
+ answer +=
515
+ `\n\nProvenance warning: ${unverified.map(v => `"${v}"`).join(', ')} ` +
516
+ `${unverified.length === 1 ? 'does' : 'do'} not appear in the fetched sources and may be invented.`;
517
+ }
417
518
  } catch (err) {
418
519
  degraded = true;
419
520
  degradedReason = `LLM synthesis unavailable: ${err.message}`;
@@ -425,11 +526,12 @@ export class AgentOrchestrator {
425
526
  success: true,
426
527
  answer,
427
528
  search_results: searchResults,
428
- evidence: degraded ? evidence : evidence.map(e => ({ url: e.url })),
529
+ evidence: degraded ? evidence : evidence.map(e => (e.snippet ? { url: e.url, snippet: true } : { url: e.url })),
429
530
  degraded,
430
531
  reason: degradedReason,
431
532
  steps: step,
432
- urls_fetched: urlsFetched
533
+ urls_fetched: urlsFetched,
534
+ provenance: { checked: !degraded, unverified }
433
535
  };
434
536
  }
435
537
 
@@ -13,6 +13,7 @@ import { load } from 'cheerio';
13
13
  import { diffWords, diffLines, diffChars } from 'diff';
14
14
  import { structureSignature, structuralSimilarity } from 'crawlforge-extractors';
15
15
  import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
16
+ import { pageTitle } from '../utils/pageTitle.js';
16
17
 
17
18
  const ChangeTrackingSchema = z.object({
18
19
  url: z.string().url(),
@@ -892,7 +893,7 @@ export class ChangeTracker extends EventEmitter {
892
893
 
893
894
  extractMetadata($, options) {
894
895
  const metadata = {
895
- title: $('title').text() || '',
896
+ title: pageTitle($) || '',
896
897
  headings: [],
897
898
  links: [],
898
899
  images: [],
@@ -9,6 +9,7 @@ import { safeFetch } from '../utils/ssrfGuard.js';
9
9
  import { resolveUserAgent } from '../utils/fetchIdentity.js';
10
10
  import { preflightFetch } from '../utils/robotsGate.js';
11
11
  import { noteRetryAfter } from '../utils/hostRateLimiter.js';
12
+ import { pageTitle } from '../utils/pageTitle.js';
12
13
 
13
14
  const logger = new Logger('LLMsTxtAnalyzer');
14
15
 
@@ -722,7 +723,7 @@ export class LLMsTxtAnalyzer {
722
723
  type: 'static',
723
724
  confidence: 0.7,
724
725
  metadata: {
725
- title: $('title').text().trim(),
726
+ title: pageTitle($),
726
727
  contentLength: html.length
727
728
  }
728
729
  };
@@ -679,11 +679,15 @@ export class LocalizationManager extends EventEmitter {
679
679
  return this.timezoneCache.get(timezone);
680
680
  }
681
681
 
682
- // Calculate dynamically if not cached
682
+ // Calculate dynamically if not cached. Compare the target zone against
683
+ // UTC, never against the host zone: the old form added the host offset
684
+ // once more, so America/Sao_Paulo came out as -420 from a UTC-4 host
685
+ // (R17, 2026-09-04). Both wall-clock strings are parsed the same way, so
686
+ // the difference is the zone's current offset in minutes.
683
687
  const now = new Date();
684
- const utc = new Date(now.getTime() + (now.getTimezoneOffset() * 60000));
685
- const targetTime = new Date(utc.toLocaleString('en-US', { timeZone: timezone }));
686
- const offset = (targetTime.getTime() - utc.getTime()) / (1000 * 60);
688
+ const asTarget = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
689
+ const asUtc = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
690
+ const offset = Math.round((asTarget.getTime() - asUtc.getTime()) / 60000);
687
691
 
688
692
  this.timezoneCache.set(timezone, offset);
689
693
  return offset;
@@ -1344,10 +1348,13 @@ export class LocalizationManager extends EventEmitter {
1344
1348
  }
1345
1349
 
1346
1350
  // CJK script detection
1351
+ // Kana and Hangul are tested before the unified ideographs: Japanese
1352
+ // text is full of kanji, so a chinese-first order labelled every
1353
+ // Japanese page "chinese" (R17, 2026-09-04).
1347
1354
  const cjkPatterns = {
1348
- chinese: /[\u4E00-\u9FFF]/,
1349
1355
  japanese: /[\u3040-\u309F\u30A0-\u30FF]/,
1350
- korean: /[\uAC00-\uD7AF]/
1356
+ korean: /[\uAC00-\uD7AF]/,
1357
+ chinese: /[\u4E00-\u9FFF]/
1351
1358
  };
1352
1359
 
1353
1360
  for (const [script, pattern] of Object.entries(cjkPatterns)) {
@@ -14,6 +14,7 @@ import crypto from 'crypto';
14
14
  import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
15
15
  import { BrowserContextPool } from './BrowserContextPool.js';
16
16
  import { safeGoto } from '../utils/ssrfGuard.js';
17
+ import { detectChallengePage } from '../utils/challengeDetection.js';
17
18
 
18
19
  const StealthConfigSchema = z.object({
19
20
  level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
@@ -290,10 +291,19 @@ export class StealthBrowserManager {
290
291
  this.browser = null;
291
292
  }
292
293
 
293
- // C2: if the requested engine differs from the running browser, tear it down first.
294
+ // C2: the requested engine differs from the running browser. Park the
295
+ // running one instead of closing it: closing took every live context in
296
+ // it along, so a create_context on Chromium followed by any camoufox
297
+ // scrape left the caller's contextId pointing at a closed browser
298
+ // ("Target page, context or browser has been closed", R17 2026-09-04).
299
+ // Each engine keeps its own browser; cleanup() closes the parked ones.
294
300
  if (this.browser && this._launchedEngine && this._launchedEngine !== validatedConfig.engine) {
295
- await this.browser.close().catch(() => {});
296
- this.browser = null;
301
+ this._parkedBrowsers ??= new Map();
302
+ this._parkedBrowsers.set(this._launchedEngine, this.browser);
303
+ const parked = this._parkedBrowsers.get(validatedConfig.engine);
304
+ this._parkedBrowsers.delete(validatedConfig.engine);
305
+ this.browser = parked && parked.isConnected() ? parked : null;
306
+ this._launchedEngine = this.browser ? validatedConfig.engine : null;
297
307
  }
298
308
 
299
309
  if (this.browser) {
@@ -484,7 +494,14 @@ export class StealthBrowserManager {
484
494
  // Policy is "invalid behavior for a normal browser" and rebrowser's
485
495
  // bot detector flags it red (R15, 2026-09-04). Every init script here
486
496
  // goes through addInitScript/evaluate, which need no CSP bypass.
487
- javaScriptEnabled: true
497
+ javaScriptEnabled: true,
498
+
499
+ // Init scripts never reach a service worker, whose navigator therefore
500
+ // reported the real "HeadlessChrome/151 (Macintosh)" beside a spoofed
501
+ // Windows Chrome main thread (bot.incolumitas.com, R17 2026-09-04).
502
+ // Blocking registration removes that vantage point; a page's content
503
+ // does not depend on its service worker on a first, uncached visit.
504
+ serviceWorkers: 'block'
488
505
  };
489
506
 
490
507
  // camoufox's Firefox build predates the Browser.setDefaultViewport fields
@@ -499,6 +516,7 @@ export class StealthBrowserManager {
499
516
  delete contextOptions.isMobile;
500
517
  delete contextOptions.hasTouch;
501
518
  delete contextOptions.screen;
519
+ delete contextOptions.serviceWorkers;
502
520
  }
503
521
 
504
522
  const context = await this.browser.newContext(contextOptions);
@@ -1605,6 +1623,79 @@ export class StealthBrowserManager {
1605
1623
  });
1606
1624
  }
1607
1625
  }, fingerprint.hardware);
1626
+
1627
+ // Init scripts never run inside a dedicated Worker, so a detector that
1628
+ // compares navigator in a worker with the main thread saw the real
1629
+ // platform, deviceMemory and hardwareConcurrency beside the spoofed
1630
+ // ones (bot.incolumitas.com "inconsistentWebWorkerNavigatorPropery",
1631
+ // R17 2026-09-04). At the advanced level, classic workers start from a
1632
+ // blob that patches WorkerNavigator and then importScripts() the real
1633
+ // script; relative importScripts/fetch inside such a worker resolve
1634
+ // against the original script URL. Module workers, data:/blob: worker
1635
+ // URLs and a CSP that refuses blob workers fall through to the native
1636
+ // constructor untouched. camoufox spoofs workers itself.
1637
+ if (config.level === 'advanced' && this._launchedEngine !== 'camoufox') {
1638
+ await context.addInitScript(({ hardware, locale }) => {
1639
+ const NativeWorker = window.Worker;
1640
+ if (typeof NativeWorker !== 'function') return;
1641
+ const primary = locale.split('-')[0];
1642
+ const languages = primary === locale ? [locale] : [locale, primary];
1643
+ const spoof = JSON.stringify({
1644
+ hardwareConcurrency: hardware.hardwareConcurrency,
1645
+ platform: hardware.platform,
1646
+ deviceMemory: hardware.deviceMemory,
1647
+ languages,
1648
+ language: locale
1649
+ });
1650
+ const prelude = (scriptUrl) =>
1651
+ `(() => {
1652
+ const spoof = ${spoof};
1653
+ const proto = self.WorkerNavigator && self.WorkerNavigator.prototype;
1654
+ const define = (key, value) => {
1655
+ try { Object.defineProperty(proto, key, { get: () => value, configurable: true }); } catch (e) {}
1656
+ };
1657
+ if (proto) {
1658
+ define('hardwareConcurrency', spoof.hardwareConcurrency);
1659
+ define('platform', spoof.platform);
1660
+ if ('deviceMemory' in self.navigator) define('deviceMemory', spoof.deviceMemory);
1661
+ define('languages', spoof.languages);
1662
+ define('language', spoof.language);
1663
+ }
1664
+ const base = ${JSON.stringify(scriptUrl)};
1665
+ const resolve = (u) => { try { return new URL(String(u), base).href; } catch (e) { return u; } };
1666
+ const nativeImport = self.importScripts;
1667
+ self.importScripts = (...urls) => nativeImport.apply(self, urls.map(resolve));
1668
+ const nativeFetch = self.fetch;
1669
+ if (typeof nativeFetch === 'function') {
1670
+ self.fetch = (input, init) => nativeFetch.call(self, typeof input === 'string' ? resolve(input) : input, init);
1671
+ }
1672
+ if (self.XMLHttpRequest) {
1673
+ const nativeOpen = self.XMLHttpRequest.prototype.open;
1674
+ self.XMLHttpRequest.prototype.open = function(method, url, ...rest) {
1675
+ return nativeOpen.call(this, method, resolve(url), ...rest);
1676
+ };
1677
+ }
1678
+ })();
1679
+ importScripts(${JSON.stringify(scriptUrl)});`;
1680
+ const Wrapped = function Worker(scriptURL, options) {
1681
+ try {
1682
+ const isModule = options && options.type === 'module';
1683
+ const absolute = new URL(String(scriptURL), location.href).href;
1684
+ if (!isModule && /^https?:/.test(absolute)) {
1685
+ const blob = new Blob([prelude(absolute)], { type: 'text/javascript' });
1686
+ return new NativeWorker(URL.createObjectURL(blob), options);
1687
+ }
1688
+ } catch (e) {
1689
+ // CSP refused the blob URL or the URL failed to parse: native worker.
1690
+ }
1691
+ return new NativeWorker(scriptURL, options);
1692
+ };
1693
+ Wrapped.prototype = NativeWorker.prototype;
1694
+ Object.defineProperty(Wrapped, 'name', { value: 'Worker' });
1695
+ Wrapped.toString = () => 'function Worker() { [native code] }';
1696
+ window.Worker = Wrapped;
1697
+ }, { hardware: fingerprint.hardware, locale: config.locale });
1698
+ }
1608
1699
  }
1609
1700
 
1610
1701
  // Font spoofing
@@ -1926,6 +2017,27 @@ export class StealthBrowserManager {
1926
2017
  * @param {Object} [params.stealthConfig] — stealth configuration overrides
1927
2018
  * @returns {Promise<{success:boolean, url:string, title:string, text:string, html:string, screenshot:?string}>}
1928
2019
  */
2020
+ /**
2021
+ * If the document is a self-solving bot-wall interstitial (its title is a
2022
+ * known challenge title), wait up to timeoutMs for the title to change —
2023
+ * the challenge script navigates to the real page when it passes — and for
2024
+ * that navigation to reach domcontentloaded. A challenge that never passes
2025
+ * is left to challengeDetection to report as blocked.
2026
+ */
2027
+ async _waitOutChallenge(page, { timeoutMs = 8000 } = {}) {
2028
+ let title;
2029
+ try {
2030
+ title = await page.title();
2031
+ } catch {
2032
+ return;
2033
+ }
2034
+ if (!detectChallengePage({ title })) return;
2035
+ await page
2036
+ .waitForFunction((t) => document.title !== t, title, { timeout: timeoutMs })
2037
+ .catch(() => {});
2038
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
2039
+ }
2040
+
1929
2041
  async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
1930
2042
  if (!url) throw new Error('scrapeWithStealth requires a url');
1931
2043
 
@@ -1938,6 +2050,12 @@ export class StealthBrowserManager {
1938
2050
  // itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
1939
2051
  await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
1940
2052
  if (wait_for > 0) await page.waitForTimeout(wait_for);
2053
+ // Cloudflare's and Vercel's JavaScript challenges solve themselves in a
2054
+ // real browser and then reload the page. camoufox on lesswrong.com was
2055
+ // read while the "Vercel Security Checkpoint" interstitial was still
2056
+ // up, so the interstitial came back as success:true (R17, 2026-09-04).
2057
+ // An auto-solving challenge gets one bounded wait to finish first.
2058
+ await this._waitOutChallenge(page);
1941
2059
 
1942
2060
  // A failure to read the document is a failure. With every read wrapped
1943
2061
  // in .catch(() => ''), a renderer that crashed or a page closed during
@@ -2195,9 +2313,18 @@ export class StealthBrowserManager {
2195
2313
  }
2196
2314
 
2197
2315
  // Close browser; if close hangs, kill the process so the OS reclaims it.
2316
+ // Browsers parked by an engine switch are closed the same way.
2317
+ const browsers = [];
2198
2318
  if (this.browser) {
2199
- const browser = this.browser;
2319
+ browsers.push(this.browser);
2200
2320
  this.browser = null;
2321
+ }
2322
+ if (this._parkedBrowsers) {
2323
+ browsers.push(...this._parkedBrowsers.values());
2324
+ this._parkedBrowsers.clear();
2325
+ }
2326
+ this._launchedEngine = null;
2327
+ for (const browser of browsers) {
2201
2328
  const closed = await withDeadline(browser.close(), 5000);
2202
2329
  if (!closed) {
2203
2330
  try {
@@ -204,6 +204,9 @@ export class ContentAnalyzer {
204
204
  if (analysisOptions.detectLanguage) {
205
205
  result.language = await this.detectLanguage(text, analysisOptions);
206
206
  }
207
+ // Topic and keyword extraction pick their tokenizer by language: the
208
+ // English noun-phrase matcher returns whole clauses for anything else.
209
+ analysisOptions.detectedLanguage = result.language?.code || null;
207
210
 
208
211
  // Text summarization
209
212
  if (analysisOptions.summarize) {
@@ -440,12 +443,34 @@ export class ContentAnalyzer {
440
443
  * @param {Object} options - Extraction options
441
444
  * @returns {Promise<Array>} - Array of topics
442
445
  */
446
+ /**
447
+ * Whether topic/keyword extraction must rank segmented content words
448
+ * instead of running the English noun-phrase matcher. compromise knows no
449
+ * grammar but English: on CJK text it emits whole multi-sentence runs as
450
+ * one "phrase", and on Hindi or Finnish it returned clause-length
451
+ * "keywords" such as "kaupungissa asuu noin 680 000 ihmistä" (R17,
452
+ * 2026-09-04). Any detected non-English language, or a text whose letters
453
+ * are mostly non-Latin, takes the word-frequency path.
454
+ * @param {string} text
455
+ * @param {{ detectedLanguage?: string|null }} options
456
+ * @returns {boolean}
457
+ */
458
+ needsSegmentedTerms(text, options = {}) {
459
+ if (this.isCjkText(text)) return true;
460
+ const lang = options.detectedLanguage;
461
+ if (lang && lang !== 'und' && lang !== 'eng' && lang !== 'sco') return true;
462
+ const letters = text.match(/\p{L}/gu);
463
+ if (!letters || letters.length === 0) return false;
464
+ const latin = text.match(/\p{Script=Latin}/gu) || [];
465
+ return latin.length / letters.length < 0.5;
466
+ }
467
+
443
468
  async extractTopics(text, options = {}) {
444
469
  try {
445
470
  // compromise noun-phrase matching is English-only: on CJK text it emits
446
471
  // whole multi-sentence runs as one "phrase". Use dictionary-segmented
447
472
  // content words, ranked by RAKE-style relative salience like below.
448
- if (this.isCjkText(text)) {
473
+ if (this.needsSegmentedTerms(text, options)) {
449
474
  const termFreq = this.cjkContentWordFrequencies(text);
450
475
  const maxFreq = Math.max(1, ...Object.values(termFreq));
451
476
  return Object.entries(termFreq)
@@ -632,7 +657,7 @@ export class ContentAnalyzer {
632
657
  try {
633
658
  // compromise is English-only: on CJK text it returns whole multi-sentence
634
659
  // runs as single "terms". Rank dictionary-segmented content words instead.
635
- if (this.isCjkText(text)) {
660
+ if (this.needsSegmentedTerms(text, options)) {
636
661
  const termFreq = this.cjkContentWordFrequencies(text);
637
662
  const totalTerms = Object.values(termFreq).reduce((sum, freq) => sum + freq, 0);
638
663
  return Object.entries(termFreq)
@@ -1027,7 +1052,13 @@ export class ContentAnalyzer {
1027
1052
  '也是', '就是', '我们', '你们', '他们', '她们', '它们', '这个', '那个',
1028
1053
  '这些', '那些', '一个', '一些', '以及', '或者', '但是', '因为', '所以',
1029
1054
  '如果', '没有', '可以', '已经', '通过', '由于', '对于', '其中', '并且',
1030
- '而且', '虽然', '什么', '自己', '这里', '那里'
1055
+ '而且', '虽然', '什么', '自己', '这里', '那里',
1056
+ // Japanese particles, auxiliaries and formal nouns the segmenter emits
1057
+ // as tokens ("ます", "です" led kakaku.com keywords in R17, 2026-09-04)
1058
+ 'ます', 'です', 'てい', 'って', 'ので', 'から', 'まで', 'など', 'また',
1059
+ 'する', 'いる', 'ある', 'こと', 'これ', 'それ', 'ため', 'よう', 'として',
1060
+ 'について', 'という', 'られ', 'れる', 'でき', 'ました', 'ません', 'した',
1061
+ 'その', 'この', 'あの', 'もの', 'ところ', 'ここ', 'そこ'
1031
1062
  ];
1032
1063
 
1033
1064
  return cjkStopWords.includes(word);
@@ -3,10 +3,13 @@
3
3
  * domain names, and other common patterns that contain periods.
4
4
  */
5
5
 
6
- // CJK / fullwidth sentence terminators. Unlike the ASCII '.' these are
7
- // unambiguous — no abbreviation, decimal or initial uses them — and CJK text
8
- // puts no whitespace after them, so they split on a zero-width boundary.
9
- const CJK_TERMINATORS = '。.!?;';
6
+ // CJK / fullwidth sentence terminators, plus the Devanagari danda (।) and
7
+ // double danda (॥) that end Hindi, Marathi, Nepali and Sanskrit sentences —
8
+ // a Hindi paragraph of five sentences counted as one (R17, 2026-09-04).
9
+ // Unlike the ASCII '.' these are unambiguous — no abbreviation, decimal or
10
+ // initial uses them — and CJK text puts no whitespace after them, so they
11
+ // split on a zero-width boundary.
12
+ const CJK_TERMINATORS = '。.!?;।॥';
10
13
 
11
14
  // Common abbreviations that should not trigger sentence splits
12
15
  const ABBREVIATIONS = new Set([
@@ -33,16 +36,16 @@ export function splitSentences(text) {
33
36
 
34
37
  // Split by potential sentence boundaries: . ! ? and the CJK terminators
35
38
  // But be smart about abbreviations, numbers, and domain-like patterns
36
- const tokens = text.split(/(?<=[.!?])\s+|(?<=[。.!?;])\s*/);
39
+ const tokens = text.split(/(?<=[.!?])\s+|(?<=[。.!?;।॥])\s*/);
37
40
 
38
41
  for (const token of tokens) {
39
42
  const combined = current ? current + ' ' + token : token;
40
43
 
41
44
  // Check if the current chunk ends with something that looks like a sentence end
42
- const endMatch = combined.match(/([.!?。.!?;])\s*$/);
45
+ const endMatch = combined.match(/([.!?。.!?;।॥])\s*$/);
43
46
  if (endMatch) {
44
47
  // Check if the period is likely NOT a sentence boundary
45
- const beforePeriod = combined.replace(/[.!?。.!?;]\s*$/, '');
48
+ const beforePeriod = combined.replace(/[.!?。.!?;।॥]\s*$/, '');
46
49
  const lastWord = beforePeriod.split(/\s+/).pop() || '';
47
50
  const lastWordLower = lastWord.toLowerCase().replace(/[^a-z]/g, '');
48
51
 
@@ -11,6 +11,7 @@ import { safeFetch } from '../../utils/ssrfGuard.js';
11
11
  import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.js';
12
12
  import { throttleHost } from '../../utils/hostRateLimiter.js';
13
13
  import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
14
+ import { pageTitle } from '../../utils/pageTitle.js';
14
15
 
15
16
  const logger = new Logger('BFSCrawler');
16
17
 
@@ -381,7 +382,7 @@ export class BFSCrawler {
381
382
  const $ = load(html);
382
383
 
383
384
  // Extract title
384
- const title = $('title').text().trim() || $('h1').first().text().trim() || '';
385
+ const title = pageTitle($) || $('h1').first().text().trim() || '';
385
386
 
386
387
  // Extract main content
387
388
  $('script, style, noscript').remove();
@@ -8,6 +8,7 @@ import WorkerPool from '../workers/WorkerPool.js';
8
8
  import ConnectionPool from '../connections/ConnectionPool.js';
9
9
  import StreamProcessor from '../processing/StreamProcessor.js';
10
10
  import { config } from '../../constants/config.js';
11
+ import { pageTitle } from '../../utils/pageTitle.js';
11
12
 
12
13
  let performanceManagerInstance = null;
13
14
  let workerPoolInstance = null;
@@ -279,7 +280,7 @@ async function parseHtmlSync(html, options = {}) {
279
280
 
280
281
  if (options.extractText !== false) {
281
282
  result.text = $('body').text().trim();
282
- result.title = $('title').text().trim();
283
+ result.title = pageTitle($);
283
284
  }
284
285
 
285
286
  if (options.extractLinks) {
@@ -9,6 +9,7 @@ import * as cheerio from 'cheerio';
9
9
  import { z } from 'zod';
10
10
  import { ContentQualityAssessor } from '../../utils/contentUtils.js';
11
11
  import { recoverDroppedTables } from '../../tools/scrape/_mainContent.js';
12
+ import { pageTitle } from '../../utils/pageTitle.js';
12
13
 
13
14
  const ContentProcessorSchema = z.object({
14
15
  html: z.string(),
@@ -303,7 +304,7 @@ export class ContentProcessor {
303
304
  extractMetadata(html) {
304
305
  const $ = cheerio.load(html);
305
306
  const metadata = {
306
- title: $('title').text().trim() || null,
307
+ title: pageTitle($) || null,
307
308
  description: null,
308
309
  keywords: null,
309
310
  author: null,
@@ -474,7 +475,7 @@ export class ContentProcessor {
474
475
 
475
476
  return {
476
477
  content: mainContent,
477
- title: $('title').text().trim() || null,
478
+ title: pageTitle($) || null,
478
479
  headings: this.extractHeadings($),
479
480
  length: mainContent ? mainContent.length : 0
480
481
  };
@@ -9,6 +9,7 @@ import { JSDOM } from 'jsdom';
9
9
  import { Readability } from '@mozilla/readability';
10
10
  import compromise from 'compromise';
11
11
  import { franc } from 'franc';
12
+ import { pageTitle } from '../../utils/pageTitle.js';
12
13
 
13
14
  // Task handlers
14
15
  const taskHandlers = {
@@ -93,7 +94,7 @@ async function handleParseHtml(data) {
93
94
  // Extract text content
94
95
  if (extractText) {
95
96
  result.text = $('body').text().trim();
96
- result.title = $('title').text().trim();
97
+ result.title = pageTitle($);
97
98
  }
98
99
 
99
100
  // Extract links