crawlforge-mcp-server 5.6.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.7",
3
+ "version": "5.6.8",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -116,7 +116,7 @@
116
116
  "cheerio": "^1.1.2",
117
117
  "commander": "^14.0.3",
118
118
  "compromise": "^14.14.4",
119
- "crawlforge-extractors": "^1.6.3",
119
+ "crawlforge-extractors": "^1.6.4",
120
120
  "diff": "^9.0.0",
121
121
  "dotenv": "^17.2.1",
122
122
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -106,7 +106,7 @@ const taskStore = createTaskStore({ logger });
106
106
  // Create the server
107
107
  const server = new McpServer({
108
108
  name: "crawlforge",
109
- version: "5.6.7",
109
+ version: "5.6.8",
110
110
  description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
111
111
  homepage: "https://www.crawlforge.dev",
112
112
  icon: "https://www.crawlforge.dev/icon.png",
@@ -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
@@ -11,6 +11,7 @@ import { load } from 'cheerio';
11
11
  // Import existing tool for content extraction
12
12
  import ExtractContentTool from '../extract/extractContent.js';
13
13
  import { elementText } from '../../utils/elementText.js';
14
+ import { pageTitle } from '../../utils/pageTitle.js';
14
15
 
15
16
  // Recording / replay helpers
16
17
  import {
@@ -625,7 +626,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
625
626
  const state = {
626
627
  capturePoint: stateData.afterActionIndex + 1,
627
628
  url: stateData.url,
628
- title: $('title').text().trim(),
629
+ title: pageTitle($),
629
630
  timestamp: stateData.timestamp,
630
631
  content: {}
631
632
  };
@@ -12,6 +12,7 @@ import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
12
12
  import { preflightFetch } from '../../../utils/robotsGate.js';
13
13
  import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
14
14
  import { elementText } from '../../../utils/elementText.js';
15
+ import { pageTitle } from '../../../utils/pageTitle.js';
15
16
 
16
17
  /**
17
18
  * Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
@@ -133,7 +134,7 @@ export function generateFormats($, html, formats) {
133
134
  if (formats.includes('markdown')) content.markdown = buildMarkdown($);
134
135
  if (formats.includes('json')) {
135
136
  content.json = {
136
- title: $('title').text().trim(),
137
+ title: pageTitle($),
137
138
  headings: extractHeadings($),
138
139
  links: extractLinks($),
139
140
  images: extractImages($),
@@ -144,7 +145,7 @@ export function generateFormats($, html, formats) {
144
145
  }
145
146
 
146
147
  function buildMarkdown($) {
147
- const title = $('title').text().trim();
148
+ const title = pageTitle($);
148
149
 
149
150
  const selectors = ['article', 'main', '.content', '#content', '.post-content', '.entry-content'];
150
151
  let $body = null;
@@ -203,7 +204,7 @@ function extractImages($) {
203
204
 
204
205
  function extractMetadata($) {
205
206
  const m = {};
206
- m.title = $('title').text().trim();
207
+ m.title = pageTitle($);
207
208
  m.description = $('meta[name="description"]').attr('content') || '';
208
209
  m.og = {};
209
210
  $('meta[property^="og:"]').each((_, el) => {
@@ -8,6 +8,7 @@ import { ResultRanker } from '../search/ranking/ResultRanker.js';
8
8
  import { safeFetch } from '../../utils/ssrfGuard.js';
9
9
  import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
10
10
  import { preflightFetch } from '../../utils/robotsGate.js';
11
+ import { pageTitle } from '../../utils/pageTitle.js';
11
12
 
12
13
  // Lazy singleton — avoids creating a CacheManager timer per request
13
14
  let _ranker = null;
@@ -280,7 +281,7 @@ export class MapSiteTool {
280
281
  const $ = load(html);
281
282
 
282
283
  metadataMap.set(url, {
283
- title: $('title').text().trim(),
284
+ title: pageTitle($),
284
285
  description: $('meta[name="description"]').attr('content') || '',
285
286
  keywords: $('meta[name="keywords"]').attr('content') || '',
286
287
  h1: $('h1').first().text().trim(),
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { load } from 'cheerio';
14
+ import { readBody } from 'crawlforge-extractors';
14
15
  import { safeFetch } from '../../utils/ssrfGuard.js';
15
16
  import { config } from '../../constants/config.js';
16
17
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
@@ -23,8 +24,15 @@ const DEFAULT_TIMEOUT_MS = 15000;
23
24
  * the same cap _fetch.js applies to basic tools, so a large/hostile response
24
25
  * (multi-hundred-MB file, endpoint streaming zeros) can't be buffered whole
25
26
  * into a JS string and handed to cheerio/JSDOM/Turndown downstream.
26
- * Content-Length is checked up front; actual bytes read are counted as a
27
- * backstop for servers that omit or lie about it.
27
+ *
28
+ * Decoding goes through crawlforge-extractors' readBody, which honours the
29
+ * Content-Type charset and the <meta charset> sniff. The local reader it
30
+ * replaces decoded every body as UTF-8, so scrape, extract_structured,
31
+ * extract_embedded_state, extract_with_llm and agent all returned mojibake
32
+ * for Shift_JIS pages (kakaku.com, vector.co.jp, 2ch.sc — R17, 2026-09-04)
33
+ * while batch_scrape and extract_metadata, already on readBody, read them
34
+ * correctly. Content-Length is checked up front; actual bytes read are
35
+ * counted as a backstop for servers that omit or lie about it.
28
36
  * @param {Response} response
29
37
  * @returns {Promise<string>}
30
38
  */
@@ -47,30 +55,7 @@ async function readTextWithSizeCap(response) {
47
55
  return response.text();
48
56
  }
49
57
 
50
- const reader = response.body.getReader();
51
- const chunks = [];
52
- let totalBytes = 0;
53
-
54
- while (true) {
55
- const { done, value } = await reader.read();
56
- if (done) break;
57
- totalBytes += value.byteLength;
58
- if (totalBytes > maxBodySize) {
59
- reader.cancel();
60
- throw new Error(
61
- `Response body too large: exceeded limit of ${maxBodySize} bytes`
62
- );
63
- }
64
- chunks.push(value);
65
- }
66
-
67
- const mergedBytes = new Uint8Array(totalBytes);
68
- let offset = 0;
69
- for (const chunk of chunks) {
70
- mergedBytes.set(chunk, offset);
71
- offset += chunk.byteLength;
72
- }
73
- return new TextDecoder().decode(mergedBytes);
58
+ return readBody(response, { maxBytes: maxBodySize });
74
59
  }
75
60
 
76
61
  /**
@@ -12,6 +12,8 @@ import { isThinMainContent } from '../scrape/_mainContent.js';
12
12
  import { safeFetch } from '../../utils/ssrfGuard.js';
13
13
  import { preflightFetch } from '../../utils/robotsGate.js';
14
14
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
15
+ import { readBody } from 'crawlforge-extractors';
16
+ import { config as appConfig } from '../../constants/config.js';
15
17
 
16
18
  const ExtractContentSchema = z.object({
17
19
  url: z.string().url(),
@@ -196,7 +198,9 @@ export class ExtractContentTool {
196
198
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
197
199
  }
198
200
 
199
- html = await response.text();
201
+ // readBody decodes with the body's real charset; response.text() is
202
+ // UTF-8-only and mangled Shift_JIS pages (R17, 2026-09-04).
203
+ html = await readBody(response, { maxBytes: appConfig.fetch.maxBodySize });
200
204
  pageTitle = this.extractTitleFromHTML(html);
201
205
  }
202
206
  }
@@ -56,6 +56,8 @@ function mainContentText($, html, url) {
56
56
 
57
57
  /** Characters of page text the model is shown; about 6k tokens. */
58
58
  const SHOWN_TEXT_BUDGET = 24000;
59
+ /** Characters of the whole-page text kept when main content would crowd it out. */
60
+ const PAGE_HEAD_RESERVE = 6000;
59
61
 
60
62
  /**
61
63
  * The text the model reads: main content first, then the whole page when both
@@ -78,11 +80,19 @@ const SHOWN_TEXT_BUDGET = 24000;
78
80
  * @param {string} textContent - whole-page text from fetchAndParse
79
81
  * @returns {string}
80
82
  */
81
- function shownText($, html, url, textContent) {
83
+ export function shownText($, html, url, textContent) {
82
84
  const main = mainContentText($, html, url);
83
85
  if (!main) return textContent;
84
- if (main.length + textContent.length > SHOWN_TEXT_BUDGET) return main;
85
- return `${main}\n\n${textContent}`;
86
+ if (main.length + textContent.length <= SHOWN_TEXT_BUDGET) return `${main}\n\n${textContent}`;
87
+ // Over budget: the article still leads, but the head of the whole-page
88
+ // text keeps its reserved slice. That head is the site chrome — the nav
89
+ // that reads "Install (6.3.3)" on swift.org while the main content's table
90
+ // is titled "Previous Releases" and starts at 6.3.2 — and the link-heavy
91
+ // lists Readability drops, such as the version list on gradle.org/releases,
92
+ // where main content alone answered nothing at all (R17, 2026-09-04).
93
+ const head = textContent.slice(0, PAGE_HEAD_RESERVE);
94
+ const mainCut = main.slice(0, Math.max(0, SHOWN_TEXT_BUDGET - head.length - 2));
95
+ return `${mainCut}\n\n${head}`;
86
96
  }
87
97
 
88
98
  /**
@@ -16,6 +16,7 @@ import { extractMainContent, isThinMainContent } from './_mainContent.js';
16
16
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
17
17
  import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
18
18
  import { extractBlockText } from '../basic/extractText.js';
19
+ import { pageTitle } from '../../utils/pageTitle.js';
19
20
 
20
21
  // ── Schema ────────────────────────────────────────────────────────────────────
21
22
 
@@ -132,7 +133,7 @@ function extractMetadataFromDom($, pageUrl) {
132
133
 
133
134
  const title =
134
135
  $('meta[property="og:title"]').attr('content') ||
135
- $('title').text().trim() ||
136
+ pageTitle($) ||
136
137
  $('h1').first().text().trim() || '';
137
138
 
138
139
  const ogTags = {};
@@ -40,6 +40,17 @@ const CHALLENGES = [
40
40
  title: /^access denied/i,
41
41
  markers: /errors\.edgesuite\.net/i,
42
42
  evidence: 'an Akamai access-denied page'
43
+ },
44
+ {
45
+ // Vercel's Attack Challenge Mode answers with HTTP 429, an
46
+ // x-vercel-mitigated: challenge header and a JavaScript interstitial
47
+ // titled "Vercel Security Checkpoint" (lesswrong.com, hashicorp.com,
48
+ // bombas.com, R17 2026-09-04). Chromium solves it and reloads; camoufox
49
+ // was left on the interstitial, which then read as a successful scrape.
50
+ vendor: 'vercel',
51
+ title: /^vercel security checkpoint/i,
52
+ markers: /vercel\.link\/security-checkpoint|_vercel\/challenge|x-vercel-challenge-token/i,
53
+ evidence: 'a Vercel Security Checkpoint page'
43
54
  }
44
55
  ];
45
56
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Document title from a cheerio root.
3
+ *
4
+ * A bare `$('title')` also matches inline `<svg><title>` elements in the body,
5
+ * and cheerio's `.text()` concatenates every match: roc-lang.org came back as
6
+ * "Roc — a fast, friendly, functional languageGitHubYouTubeTwitter..." in the
7
+ * R17 sweep (2026-09-04), the same defect extract_metadata fixed for MDN on
8
+ * 2026-08-26. Read the head title first and fall back to the first `<title>`
9
+ * anywhere for fragments without a head.
10
+ *
11
+ * @param {import('cheerio').CheerioAPI} $
12
+ * @returns {string} trimmed title or ''
13
+ */
14
+ export function pageTitle($) {
15
+ return $('head > title').first().text().trim() || $('title').first().text().trim();
16
+ }
17
+
18
+ export default pageTitle;