crawlforge-mcp-server 5.6.2 → 5.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.2",
3
+ "version": "5.6.4",
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",
@@ -114,7 +114,7 @@
114
114
  "cheerio": "^1.1.2",
115
115
  "commander": "^14.0.3",
116
116
  "compromise": "^14.14.4",
117
- "crawlforge-extractors": "^1.6.2",
117
+ "crawlforge-extractors": "^1.6.3",
118
118
  "diff": "^9.0.0",
119
119
  "dotenv": "^17.2.1",
120
120
  "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.2",
109
+ version: "5.6.4",
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",
@@ -1392,6 +1392,15 @@ registerToolIfEnabled("stealth_mode", {
1392
1392
  if (challenge) {
1393
1393
  result.blocked = challenge;
1394
1394
  result.error = `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); the stealth browser did not pass it.`;
1395
+ } else if (!scraped.title && !String(scraped.text || '').trim()) {
1396
+ // A document with no title and no text is not a scraped page; it
1397
+ // is a render that has not happened yet (or an empty response).
1398
+ // Reported as success it reads like a page with nothing on it.
1399
+ result.success = false;
1400
+ result.error =
1401
+ `The stealth browser reached ${scraped.url} but the document rendered no title and no text` +
1402
+ ` after ${wait_for || 0}ms of extra wait (${(scraped.html || '').length} bytes of HTML).` +
1403
+ ' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.';
1395
1404
  }
1396
1405
 
1397
1406
  // Screenshots follow the same crawlforge://screenshot/{id} pattern as
@@ -308,7 +308,7 @@ export class LocalizationManager extends EventEmitter {
308
308
 
309
309
  // Generate comprehensive browser locale with RTL support
310
310
  const browserLocale = {
311
- languages: [language, langCode, 'en'],
311
+ languages: [...new Set([language, langCode, 'en'])],
312
312
  timezone: timezone,
313
313
  locale: language,
314
314
  currency: currency,
@@ -777,7 +777,7 @@ export class LocalizationManager extends EventEmitter {
777
777
  async generateNavigatorProperties(localizationConfig) {
778
778
  return {
779
779
  language: localizationConfig.language,
780
- languages: [localizationConfig.language, localizationConfig.language.split('-')[0], 'en'],
780
+ languages: [...new Set([localizationConfig.language, localizationConfig.language.split('-')[0], 'en'])],
781
781
  platform: 'Win32',
782
782
  userAgent: localizationConfig.userAgent || this.generateUserAgent(localizationConfig.countryCode),
783
783
  cookieEnabled: true,
@@ -923,7 +923,7 @@ export class LocalizationManager extends EventEmitter {
923
923
  if (!config) return null;
924
924
 
925
925
  return {
926
- languages: [config.language, config.language.split("-")[0], "en"],
926
+ languages: [...new Set([config.language, config.language.split("-")[0], "en"])],
927
927
  timezone: config.timezone,
928
928
  currency: config.currency,
929
929
  dateFormat: this.getDateFormat(countryCode),
@@ -198,17 +198,27 @@ export class StealthBrowserManager {
198
198
  safari: 0.15
199
199
  };
200
200
 
201
- // Realistic viewport sizes with market distribution
201
+ // Screen sizes (CSS pixels) with market distribution. The screen is the
202
+ // root of the display — the window is derived from it in generateDisplay.
203
+ // 1536×864 and 1280×720 are 1920×1080 panels at 125% / 150% scaling, so
204
+ // each size names the device scale factors it is really seen at.
202
205
  this.viewportSizes = [
203
- { width: 1920, height: 1080, weight: 0.25 }, // Most common
204
- { width: 1366, height: 768, weight: 0.20 }, // Second most common
205
- { width: 1536, height: 864, weight: 0.15 },
206
- { width: 1440, height: 900, weight: 0.12 },
207
- { width: 1280, height: 720, weight: 0.10 },
208
- { width: 1600, height: 900, weight: 0.08 },
209
- { width: 1024, height: 768, weight: 0.05 }, // Legacy but still used
210
- { width: 2560, height: 1440, weight: 0.03 }, // High-res displays
211
- { width: 3840, height: 2160, weight: 0.02 } // 4K displays
206
+ { width: 1920, height: 1080, weight: 0.27, dpr: [1] }, // Most common
207
+ { width: 1366, height: 768, weight: 0.20, dpr: [1] }, // Second most common
208
+ { width: 1536, height: 864, weight: 0.15, dpr: [1.25] },
209
+ { width: 1440, height: 900, weight: 0.12, dpr: [1] },
210
+ { width: 1280, height: 720, weight: 0.10, dpr: [1.5, 1] },
211
+ { width: 1600, height: 900, weight: 0.08, dpr: [1] },
212
+ { width: 1024, height: 768, weight: 0.05, dpr: [1] }, // Legacy but still used
213
+ { width: 2560, height: 1440, weight: 0.03, dpr: [1] } // High-res displays
214
+ ];
215
+ // Retina Macs report their "looks like" size at a scale factor of 2.
216
+ this.macScreenSizes = [
217
+ { width: 1440, height: 900, weight: 0.35, dpr: [2] },
218
+ { width: 1512, height: 982, weight: 0.25, dpr: [2] },
219
+ { width: 1728, height: 1117, weight: 0.15, dpr: [2] },
220
+ { width: 1680, height: 1050, weight: 0.10, dpr: [2] },
221
+ { width: 1920, height: 1080, weight: 0.15, dpr: [1, 2] } // External display
212
222
  ];
213
223
 
214
224
  // Mobile viewport sizes for mobile emulation
@@ -516,14 +526,17 @@ export class StealthBrowserManager {
516
526
  const selectedOS = this.selectOS(config);
517
527
  const persona = this.selectLocalePersona(config);
518
528
  const userAgent = this.selectRealisticUserAgent(config, selectedOS);
529
+ const display = this.generateDisplay(selectedOS, config);
519
530
  const fingerprint = {
520
531
  userAgent,
521
532
  locale: persona.locale,
522
- viewport: config.customViewport || this.selectWeightedViewport(),
533
+ viewport: display.viewport,
523
534
  timezone: config.timezone || persona.timezone,
524
- deviceScaleFactor: this.randomFloat(1, 2, 1),
525
- isMobile: Math.random() < 0.1, // 10% mobile
526
- hasTouch: Math.random() < 0.15, // 15% touch
535
+ deviceScaleFactor: display.deviceScaleFactor,
536
+ // Every user agent in the pool is a desktop one; mobile emulation under
537
+ // it (10% of fingerprints until R16) contradicted the UA outright.
538
+ isMobile: false,
539
+ hasTouch: selectedOS === 'windows' && Math.random() < 0.15, // touch laptops
527
540
  colorScheme: Math.random() < 0.3 ? 'dark' : 'light',
528
541
  reducedMotion: Math.random() < 0.1 ? 'reduce' : 'no-preference',
529
542
  forcedColors: Math.random() < 0.05 ? 'active' : 'none',
@@ -537,7 +550,8 @@ export class StealthBrowserManager {
537
550
  fonts: this.generateAdvancedFontList(selectedOS),
538
551
  plugins: this.generateAdvancedPluginList(),
539
552
  geolocation: this.generateRealisticGeolocation(persona),
540
- screen: this.generateAdvancedScreenProperties(),
553
+ screen: display.screen,
554
+ window: display.window,
541
555
  battery: this.generateBatteryFingerprint()
542
556
  };
543
557
 
@@ -575,7 +589,17 @@ export class StealthBrowserManager {
575
589
  // An unmodelled locale still gets a coherent timezone/geolocation pair.
576
590
  const pool = exact.length ? exact : (sameLanguage.length ? sameLanguage : this.localePersonas);
577
591
 
578
- const persona = pool[Math.floor(Math.random() * pool.length)];
592
+ // The host's own zone is where the egress address is. A persona drawn at
593
+ // random put America/Chicago in JavaScript beside a Florida IP, and both
594
+ // pixelscan and iphey called the timezone spoofed (R16, 2026-09-04). So a
595
+ // persona in the host zone is preferred — from the requested locale's
596
+ // pool, or from any locale when nothing beyond the default was asked for.
597
+ // An explicit config.timezone still overrides in the caller.
598
+ const hostZone = this.hostTimezone();
599
+ const inHostZone = (personas) => (hostZone ? personas.find((p) => p.timezone === hostZone) : undefined);
600
+ const persona = inHostZone(pool)
601
+ || (requested.toLowerCase() === 'en-us' ? inHostZone(this.localePersonas) : undefined)
602
+ || pool[Math.floor(Math.random() * pool.length)];
579
603
  return { ...persona, locale: requested };
580
604
  }
581
605
 
@@ -648,10 +672,58 @@ export class StealthBrowserManager {
648
672
  }
649
673
 
650
674
  /**
651
- * Select viewport size based on weights
675
+ * One display for the whole fingerprint: the screen is drawn from the pool
676
+ * and the window derived from it as a maximised browser — viewport width is
677
+ * the screen width, viewport height the available height minus the browser
678
+ * chrome, outer size the available area. The screen and the viewport used
679
+ * to be drawn from the pool independently and the scale factor was a random
680
+ * float, so a 1536×864 window sat on a 1366×768 screen at a scale of 1.7
681
+ * (which Chromium reports as 1.7000000476837158 and a 1408.0000305175781px
682
+ * screen) — pixelscan's "Your browser fingerprint is inconsistent" (R16,
683
+ * 2026-09-04). A custom viewport gets the smallest pooled screen it fits on.
652
684
  */
653
- selectWeightedViewport() {
654
- return this.weightedRandomFromArray(this.viewportSizes);
685
+ generateDisplay(selectedOS, config = {}) {
686
+ const chrome = selectedOS === 'macos' ? 87 : 85;
687
+ const taskbar = selectedOS === 'macos' ? 25 : (selectedOS === 'linux' ? 27 : 40);
688
+ const pool = selectedOS === 'macos' ? this.macScreenSizes : this.viewportSizes;
689
+
690
+ let screen;
691
+ let viewport;
692
+ if (config.customViewport) {
693
+ viewport = { width: config.customViewport.width, height: config.customViewport.height };
694
+ const needed = { width: viewport.width, height: viewport.height + chrome + taskbar };
695
+ screen = pool
696
+ .filter((s) => s.width >= needed.width && s.height >= needed.height)
697
+ .sort((a, b) => a.width * a.height - b.width * b.height)[0]
698
+ || { ...needed, dpr: [1] };
699
+ } else {
700
+ screen = this.weightedRandomFromArray(pool);
701
+ viewport = { width: screen.width, height: screen.height - taskbar - chrome };
702
+ }
703
+
704
+ return {
705
+ viewport,
706
+ deviceScaleFactor: screen.dpr[Math.floor(Math.random() * screen.dpr.length)],
707
+ screen: {
708
+ width: screen.width,
709
+ height: screen.height,
710
+ availWidth: screen.width,
711
+ availHeight: screen.height - taskbar,
712
+ colorDepth: 24,
713
+ pixelDepth: 24,
714
+ orientation: { angle: 0, type: 'landscape-primary' }
715
+ },
716
+ window: { outerWidth: viewport.width, outerHeight: viewport.height + chrome }
717
+ };
718
+ }
719
+
720
+ /**
721
+ * The machine's own timezone, or null when it is the UTC of a container —
722
+ * set TZ on such a host to the zone its egress address geolocates to.
723
+ */
724
+ hostTimezone() {
725
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
726
+ return zone && zone !== 'UTC' && !/^(?:Etc|GMT)/.test(zone) ? zone : null;
655
727
  }
656
728
 
657
729
  /**
@@ -1139,25 +1211,6 @@ export class StealthBrowserManager {
1139
1211
  };
1140
1212
  }
1141
1213
 
1142
- /**
1143
- * Generate advanced screen properties
1144
- */
1145
- generateAdvancedScreenProperties() {
1146
- const viewport = this.selectWeightedViewport();
1147
-
1148
- return {
1149
- width: viewport.width,
1150
- height: viewport.height,
1151
- availWidth: viewport.width,
1152
- availHeight: viewport.height - (30 + Math.floor(Math.random() * 20)), // Account for taskbar
1153
- colorDepth: Math.random() < 0.95 ? 24 : 32,
1154
- pixelDepth: 24,
1155
- orientation: {
1156
- angle: 0,
1157
- type: 'landscape-primary'
1158
- }
1159
- };
1160
- }
1161
1214
 
1162
1215
  /**
1163
1216
  * Generate battery API fingerprint
@@ -1474,6 +1527,21 @@ export class StealthBrowserManager {
1474
1527
 
1475
1528
  return ctx;
1476
1529
  };
1530
+
1531
+ // A webgl2 context (and any OffscreenCanvas context) never passed
1532
+ // through the wrapper above: iphey read the GPU through webgl2 and
1533
+ // printed "SwiftShader" under a Windows user agent (R16, 2026-09-04).
1534
+ // The two unmasked parameters are answered on the prototypes, where
1535
+ // every context of either kind reads them.
1536
+ for (const Context of [window.WebGLRenderingContext, window.WebGL2RenderingContext]) {
1537
+ if (!Context || !Context.prototype) continue;
1538
+ const getParameter = Context.prototype.getParameter;
1539
+ Context.prototype.getParameter = function(parameter) {
1540
+ if (parameter === 37446) return webglConfig.renderer;
1541
+ if (parameter === 37445) return webglConfig.vendor;
1542
+ return getParameter.call(this, parameter);
1543
+ };
1544
+ }
1477
1545
  }, fingerprint.webGL);
1478
1546
  }
1479
1547
 
@@ -1568,7 +1636,7 @@ export class StealthBrowserManager {
1568
1636
  }
1569
1637
 
1570
1638
  // Screen resolution spoofing
1571
- await context.addInitScript((screenConfig) => {
1639
+ await context.addInitScript(({ screen: screenConfig, window: windowConfig }) => {
1572
1640
  Object.defineProperties(screen, {
1573
1641
  width: { value: screenConfig.width, configurable: true },
1574
1642
  height: { value: screenConfig.height, configurable: true },
@@ -1577,7 +1645,18 @@ export class StealthBrowserManager {
1577
1645
  colorDepth: { value: screenConfig.colorDepth, configurable: true },
1578
1646
  pixelDepth: { value: screenConfig.pixelDepth, configurable: true }
1579
1647
  });
1580
- }, fingerprint.screen);
1648
+ // Headless Chromium reports outerWidth/outerHeight equal to the inner
1649
+ // size — a window with no tabs and no toolbar.
1650
+ if (windowConfig) {
1651
+ Object.defineProperty(window, 'outerWidth', { get: () => windowConfig.outerWidth, configurable: true });
1652
+ Object.defineProperty(window, 'outerHeight', { get: () => windowConfig.outerHeight, configurable: true });
1653
+ }
1654
+ }, {
1655
+ screen: fingerprint.screen,
1656
+ // camoufox runs without a fixed viewport (see createStealthContext), so
1657
+ // an outer size derived from one would contradict the real window.
1658
+ window: this._launchedEngine === 'camoufox' ? null : fingerprint.window
1659
+ });
1581
1660
 
1582
1661
  // Timezone spoofing
1583
1662
  if (config.spoofTimezone) {
@@ -1853,16 +1932,34 @@ export class StealthBrowserManager {
1853
1932
  const { contextId } = await this.createStealthContext({ ...stealthConfig, engine });
1854
1933
  try {
1855
1934
  const page = await this.createStealthPage(contextId);
1935
+ let crashed = false;
1936
+ page.on('crash', () => { crashed = true; });
1856
1937
  // SSRF guard at the navigation boundary: the stealth engine resolves DNS
1857
1938
  // itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
1858
1939
  await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
1859
1940
  if (wait_for > 0) await page.waitForTimeout(wait_for);
1860
1941
 
1861
- const [title, html, text] = await Promise.all([
1862
- page.title().catch(() => ''),
1863
- page.content().catch(() => ''),
1864
- page.innerText('body').catch(() => '')
1865
- ]);
1942
+ // A failure to read the document is a failure. With every read wrapped
1943
+ // in .catch(() => ''), a renderer that crashed or a page closed during
1944
+ // the wait (iphey.com under software GL, R16 2026-09-04) came back as
1945
+ // success:true with an empty title and body — indistinguishable from a
1946
+ // page that rendered nothing. The body text is read through evaluate,
1947
+ // not innerText('body'): a frameset document has no <body>, and
1948
+ // innerText waited its full 30s for one before throwing.
1949
+ let title; let html; let text;
1950
+ try {
1951
+ [title, html, text] = await Promise.all([
1952
+ page.title(),
1953
+ page.content(),
1954
+ page.evaluate(() => (document.body ? document.body.innerText : ''))
1955
+ ]);
1956
+ } catch (error) {
1957
+ const what = crashed ? 'crashed' : (page.isClosed() ? 'was closed' : 'could not be read');
1958
+ throw new Error(
1959
+ `The stealth page ${what} before its content could be read (${String(error.message).split('\n')[0]}). ` +
1960
+ 'The page did not render; retry, or use scrape_with_actions for a page this heavy.'
1961
+ );
1962
+ }
1866
1963
  const shot = screenshot
1867
1964
  ? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
1868
1965
  : null;
@@ -398,7 +398,7 @@ export class ContentAnalyzer {
398
398
  const freq = {};
399
399
  const sentenceWords = sentences.map(sentence => {
400
400
  const words = nlp(sentence).terms().out('array')
401
- .map(w => w.toLowerCase().replace(/[^a-z0-9]/g, ''))
401
+ .map(w => w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''))
402
402
  .filter(w => w.length > 2 && !this.isStopWord(w));
403
403
  words.forEach(w => { freq[w] = (freq[w] || 0) + 1; });
404
404
  return words;
@@ -471,7 +471,7 @@ export class ContentAnalyzer {
471
471
 
472
472
  allPhrases.forEach(phrase => {
473
473
  // Strip edge punctuation and skip phrases made only of stop words
474
- const cleaned = phrase.toLowerCase().trim().replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '');
474
+ const cleaned = phrase.toLowerCase().trim().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
475
475
  const words = cleaned.split(/\s+/);
476
476
  if (cleaned.length > 2 && words.some(w => !this.isStopWord(w))) {
477
477
  phraseCount[cleaned] = (phraseCount[cleaned] || 0) + 1;
@@ -550,7 +550,7 @@ export class ContentAnalyzer {
550
550
  // (corporate suffix or organization-headed alias definition).
551
551
  const demotedAcronyms = [];
552
552
  organizations = organizations.filter(e => {
553
- if (/^[A-Z]{2,}$/.test(e) && !this.hasOrganizationEvidence(e, text)) {
553
+ if (/^\p{Lu}{2,}$/u.test(e) && !this.hasOrganizationEvidence(e, text)) {
554
554
  demotedAcronyms.push(e);
555
555
  return false;
556
556
  }
@@ -565,11 +565,14 @@ export class ContentAnalyzer {
565
565
  ...people, ...places, ...organizations, ...other
566
566
  ].map(e => e.toLowerCase()));
567
567
 
568
- const properNouns = (text.match(/\b[A-Z][a-zA-Z.]+(?:\s+[A-Z][a-zA-Z.]+)*/g) || [])
568
+ // Unicode classes, not [A-Z]: the ASCII version cut "Universität" to
569
+ // "Universit" and "Wisłą" to "Wis" and dropped "Środkowej" outright (R16).
570
+ // \b stays ASCII-only even under the u flag, hence the lookbehind.
571
+ const properNouns = (text.match(/(?<![\p{L}\p{N}])\p{Lu}[\p{L}.]+(?:\s+\p{Lu}[\p{L}.]+)*/gu) || [])
569
572
  // Split matches that crossed a sentence boundary ("XPath. In") — a
570
573
  // period after a lowercase letter followed by a capital is a sentence
571
574
  // end, while abbreviations ("U.S. Holdings") stay intact.
572
- .flatMap(n => n.split(/(?<=[a-z]\.)\s+(?=[A-Z])/));
575
+ .flatMap(n => n.split(/(?<=\p{Ll}\.)\s+(?=\p{Lu})/u));
573
576
  const supplemental = this.dedupeEntities(properNouns.map(n => this.cleanEntityText(n)))
574
577
  .filter(n => n.length > 1
575
578
  && !existingEntities.has(n.toLowerCase())
@@ -656,7 +659,7 @@ export class ContentAnalyzer {
656
659
 
657
660
  [...nouns, ...verbs, ...adjectives].forEach(term => {
658
661
  // Strip leading/trailing punctuation but preserve internal periods (e.g. Node.js)
659
- const cleaned = term.toLowerCase().trim().replace(/^[^a-z0-9]+|[^a-z0-9.]+$/gi, '').replace(/\.+$/, '');
662
+ const cleaned = term.toLowerCase().trim().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}.]+$/gu, '').replace(/\.+$/, '');
660
663
  if (cleaned.length > 2 && !this.isStopWord(cleaned)) {
661
664
  termFreq[cleaned] = (termFreq[cleaned] || 0) + 1;
662
665
 
@@ -893,7 +896,7 @@ export class ContentAnalyzer {
893
896
  entity = entity.replace(/[\s"'‘’“”`)\]}>,;:!?«»–—-]+$/, '');
894
897
  if (entity.endsWith('.')) {
895
898
  const lastToken = entity.split(/\s+/).pop();
896
- const isAbbreviation = /^(?:[A-Za-z]\.)+$/.test(lastToken)
899
+ const isAbbreviation = /^(?:\p{L}\.)+$/u.test(lastToken)
897
900
  || /^(?:inc|corp|ltd|co|llc|jr|sr|st|mr|mrs|ms|dr|no|vs?)\.$/i.test(lastToken);
898
901
  if (!isAbbreviation) {
899
902
  entity = entity.replace(/\.+$/, '');
@@ -944,13 +947,13 @@ export class ContentAnalyzer {
944
947
  if (entity.includes(' ')) return false;
945
948
  const lower = entity.toLowerCase();
946
949
  if (this.isStopWord(lower)) return true;
947
- if (!/^[A-Z][a-z]+$/.test(entity)) return false;
950
+ if (!/^\p{Lu}\p{Ll}+$/u.test(entity)) return false;
948
951
 
949
952
  // Word also appears in lowercase → ordinary word, capitalized only by position
950
- if (new RegExp(`(?:^|[^A-Za-z])${lower}(?:[^A-Za-z]|$)`).test(text)) return true;
953
+ if (new RegExp(`(?:^|\\P{L})${lower}(?:\\P{L}|$)`, 'u').test(text)) return true;
951
954
 
952
955
  // Keep only if at least one occurrence is NOT at a sentence start
953
- const occurrence = new RegExp(`\\b${entity}\\b`, 'g');
956
+ const occurrence = new RegExp(`(?<![\\p{L}\\p{N}])${entity}(?![\\p{L}\\p{N}])`, 'gu');
954
957
  let match;
955
958
  while ((match = occurrence.exec(text)) !== null) {
956
959
  const before = text.slice(0, match.index).replace(/[\s"'‘’“”()[\]]+$/, '');
@@ -975,13 +978,13 @@ export class ContentAnalyzer {
975
978
  * a corporate suffix follows it ("IBM Corp"), or it is introduced as the
976
979
  * alias of an organization-headed name ("American Airlines (AA)",
977
980
  * "French Data Protection Authority (CNIL)").
978
- * @param {string} acronym - ALL-CAPS candidate (already /^[A-Z]{2,}$/)
981
+ * @param {string} acronym - ALL-CAPS candidate (already /^\p{Lu}{2,}$/u)
979
982
  * @param {string} text - Full source text
980
983
  * @returns {boolean} - True if the text corroborates the org classification
981
984
  */
982
985
  hasOrganizationEvidence(acronym, text) {
983
986
  const corporateSuffix = new RegExp(
984
- `\\b${acronym},?\\s+(?:Inc|Corp|Corporation|Company|Co|Ltd|LLC|Group)\\.?(?:[^a-zA-Z]|$)`
987
+ `(?<!\\p{L})${acronym},?\\s+(?:Inc|Corp|Corporation|Company|Co|Ltd|LLC|Group)\\.?(?:\\P{L}|$)`, 'u'
985
988
  );
986
989
  if (corporateSuffix.test(text)) return true;
987
990
 
@@ -8,6 +8,7 @@ import { JSDOM } from 'jsdom';
8
8
  import * as cheerio from 'cheerio';
9
9
  import { z } from 'zod';
10
10
  import { ContentQualityAssessor } from '../../utils/contentUtils.js';
11
+ import { recoverDroppedTables } from '../../tools/scrape/_mainContent.js';
11
12
 
12
13
  const ContentProcessorSchema = z.object({
13
14
  html: z.string(),
@@ -30,7 +31,8 @@ const ReadabilityResult = z.object({
30
31
  byline: z.string().nullable(),
31
32
  dir: z.string().nullable(),
32
33
  siteName: z.string().nullable(),
33
- lang: z.string().nullable()
34
+ lang: z.string().nullable(),
35
+ tablesRecovered: z.number()
34
36
  });
35
37
 
36
38
  export class ContentProcessor {
@@ -78,16 +80,28 @@ export class ContentProcessor {
78
80
 
79
81
  const article = reader.parse();
80
82
  if (article) {
83
+ // Readability keeps one article candidate and drops every data
84
+ // table outside it — scrape_with_actions returned CoinMarketCap's
85
+ // historical-data page as its FAQ copy with no table (2026-09-04).
86
+ // Re-attach what it dropped, as scrape's main-content path does.
87
+ const recovered = recoverDroppedTables(html, url, article.textContent);
88
+ const content = recovered.length > 0
89
+ ? `${article.content}\n${recovered.map((table) => table.html).join('\n')}`
90
+ : article.content;
91
+ const textContent = recovered.length > 0
92
+ ? `${article.textContent}\n${recovered.map((table) => table.text).join('\n')}`
93
+ : article.textContent;
81
94
  result.readability = {
82
95
  title: article.title,
83
- content: article.content,
84
- textContent: article.textContent,
85
- length: article.length,
96
+ content,
97
+ textContent,
98
+ length: textContent.length,
86
99
  excerpt: article.excerpt,
87
100
  byline: article.byline,
88
101
  dir: article.dir,
89
102
  siteName: article.siteName,
90
- lang: article.lang
103
+ lang: article.lang,
104
+ tablesRecovered: recovered.length
91
105
  };
92
106
 
93
107
  // Calculate readability score
@@ -8,6 +8,7 @@ import { ContentProcessor } from '../../core/processing/ContentProcessor.js';
8
8
  import { BrowserProcessor } from '../../core/processing/BrowserProcessor.js';
9
9
  import { HTMLCleaner, ContentQualityAssessor } from '../../utils/contentUtils.js';
10
10
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js'; // D3.1
11
+ import { isThinMainContent } from '../scrape/_mainContent.js';
11
12
  import { safeFetch } from '../../utils/ssrfGuard.js';
12
13
  import { preflightFetch } from '../../utils/robotsGate.js';
13
14
  import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
@@ -215,8 +216,18 @@ export class ExtractContentTool {
215
216
  }
216
217
  });
217
218
 
218
- // Step 3: Extract and format content
219
- if (processingResult.readability) {
219
+ // Step 3: Extract and format content.
220
+ // Readability keeps one dense block; on a landing page that is a
221
+ // fraction of the visible text (libreoffice.org came back as its
222
+ // 509-character "Welcome" box at confidence 0.9, R16). A thin article
223
+ // is handed to the boilerplate-removal fallback and named as such.
224
+ const thin = processingResult.readability
225
+ ? isThinMainContent(processingResult.readability.content, html)
226
+ : null;
227
+ if (thin) {
228
+ processingResult.fallback_content = this.contentProcessor.extractFallbackContent(html);
229
+ }
230
+ if (processingResult.readability && !thin) {
220
231
  result.readability = processingResult.readability;
221
232
  result.content = {
222
233
  text: processingResult.readability.textContent || processingResult.readability.content,
@@ -224,6 +235,11 @@ export class ExtractContentTool {
224
235
  result.extractionMethod = 'readability';
225
236
  result.confidence = 0.9;
226
237
  result.finalUrl = url;
238
+ if (processingResult.readability.tablesRecovered > 0) {
239
+ (result.warnings ||= []).push(
240
+ `mainContent: re-attached ${processingResult.readability.tablesRecovered} data table(s) that main-content extraction had dropped`
241
+ );
242
+ }
227
243
 
228
244
  // Convert to markdown if requested
229
245
  if (options.outputFormat === 'markdown') {
@@ -234,7 +250,9 @@ export class ExtractContentTool {
234
250
  text: processingResult.fallback_content.content
235
251
  };
236
252
  result.extractionMethod = 'fallback_boilerplate_removal';
237
- result.fallback_reason = 'Readability did not detect an article; used boilerplate-removal fallback';
253
+ result.fallback_reason = thin
254
+ ? `Readability kept only ${thin.kept} of ${thin.visible} visible characters (a landing page, not an article); used boilerplate-removal fallback`
255
+ : 'Readability did not detect an article; used boilerplate-removal fallback';
238
256
  result.confidence = 0.5;
239
257
  result.finalUrl = url;
240
258
  } else {
@@ -35,11 +35,15 @@
35
35
  * 12th sits inside a collapsed `<details>`, which Readability drops whole
36
36
  * and which this deliberately does not try to rescue.
37
37
  *
38
- * Shared by unifiedScrape (wants HTML) and extractStructured (wants text).
38
+ * Shared by unifiedScrape (wants HTML) and extractStructured (wants text);
39
+ * the table recovery alone is also used by ContentProcessor, which runs its
40
+ * own Readability pass for extract_content, scrape_with_actions and
41
+ * process_document.
39
42
  */
40
43
 
41
44
  import { JSDOM } from 'jsdom';
42
45
  import { Readability } from '@mozilla/readability';
46
+ import { load } from 'cheerio';
43
47
 
44
48
  // How much of a table's text is compared against the article to decide whether
45
49
  // Readability already kept it. Long enough to be unique to that table.
@@ -168,6 +172,61 @@ function isDataTable(table) {
168
172
  return rows >= 10 || columns > 4;
169
173
  }
170
174
 
175
+ /**
176
+ * A data table's text with its structure kept: one line per row, cells joined
177
+ * by " | ". A bare `textContent` runs every cell together
178
+ * ("DateOpen*HighLowClose**…"), which is what scrape_with_actions' selector
179
+ * extraction handed back for CoinMarketCap's history table.
180
+ * @param {HTMLTableElement} table
181
+ * @returns {string}
182
+ */
183
+ function tableText(table) {
184
+ return Array.from(table.rows)
185
+ .map((row) => Array.from(row.cells).map((cell) => normalizeWhitespace(cell.textContent || '')).join(' | '))
186
+ .filter((line) => line.length > 0)
187
+ .join('\n');
188
+ }
189
+
190
+ /**
191
+ * The data tables in `html` that a Readability pass left out of its article.
192
+ *
193
+ * Readability mutates the document it is handed — the dropped tables are
194
+ * already gone by the time parse() returns — so this parses the original HTML
195
+ * again. Shared by every caller that runs Readability itself: extractMainContent
196
+ * below, and ContentProcessor, whose consumers (extract_content,
197
+ * scrape_with_actions, process_document) lost tables the same way until
198
+ * 2026-09-04, when scrape_with_actions returned CoinMarketCap's historical-data
199
+ * page as its FAQ copy — the wait for the table rows had succeeded, and the
200
+ * markdown then had no table at all.
201
+ *
202
+ * @param {string} html - the page's HTML
203
+ * @param {string} [url] - document URL, used as the base for relative links
204
+ * @param {string} [keptText] - the article's textContent, so a table Readability
205
+ * did keep is not attached a second time
206
+ * @returns {{ html: string, text: string }[]} each dropped table's outerHTML and
207
+ * its row-per-line text; empty when there is nothing to recover
208
+ */
209
+ export function recoverDroppedTables(html, url, keptText = '') {
210
+ // Nothing to recover, and no reason to pay for a second parse.
211
+ if (!/<table[\s>]/i.test(html)) return [];
212
+ try {
213
+ const fresh = new JSDOM(html, { url });
214
+ const kept = normalizeWhitespace(keptText || '');
215
+ return Array.from(fresh.window.document.querySelectorAll('table'))
216
+ // A nested table travels with its parent; re-attaching it separately
217
+ // would duplicate it.
218
+ .filter((table) => !table.parentElement?.closest('table'))
219
+ .filter(isDataTable)
220
+ .filter((table) => {
221
+ const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
222
+ return signature.length > 0 && !kept.includes(signature);
223
+ })
224
+ .map((table) => ({ html: table.outerHTML, text: tableText(table) }));
225
+ } catch {
226
+ return [];
227
+ }
228
+ }
229
+
171
230
  /**
172
231
  * Extract a page's main content, re-attaching data tables Readability dropped.
173
232
  *
@@ -192,32 +251,7 @@ export function extractMainContent(html, url) {
192
251
  if (!article || !article.content) return { html: null, title: '', tablesRecovered: 0 };
193
252
 
194
253
  const title = article.title || '';
195
-
196
- // Nothing to recover, and no reason to pay for a second parse.
197
- if (!/<table[\s>]/i.test(html)) {
198
- return { html: article.content, title, tablesRecovered: 0 };
199
- }
200
-
201
- // Readability mutates the document it is handed — the dropped tables are
202
- // already gone from `dom` by the time parse() returns — so recovery has to
203
- // parse the original HTML again.
204
- let recovered;
205
- try {
206
- const fresh = new JSDOM(html, { url });
207
- const kept = normalizeWhitespace(article.textContent || '');
208
- recovered = Array.from(fresh.window.document.querySelectorAll('table'))
209
- // A nested table travels with its parent; re-attaching it separately
210
- // would duplicate it.
211
- .filter((table) => !table.parentElement?.closest('table'))
212
- .filter(isDataTable)
213
- .filter((table) => {
214
- const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
215
- return signature.length > 0 && !kept.includes(signature);
216
- })
217
- .map((table) => table.outerHTML);
218
- } catch {
219
- return { html: article.content, title, tablesRecovered: 0 };
220
- }
254
+ const recovered = recoverDroppedTables(html, url, article.textContent).map((table) => table.html);
221
255
 
222
256
  return {
223
257
  html: recovered.length > 0 ? `${article.content}\n${recovered.join('\n')}` : article.content,
@@ -226,4 +260,34 @@ export function extractMainContent(html, url) {
226
260
  };
227
261
  }
228
262
 
263
+ // Below both of these, Readability's article is a fragment of the page, not
264
+ // its main content.
265
+ export const THIN_MAIN_CONTENT = { maxChars: 1500, maxShare: 0.35 };
266
+
267
+ function visibleTextLength(html) {
268
+ const $ = load(html);
269
+ $('script, style, noscript, template').remove();
270
+ return $('body').text().replace(/\s+/g, ' ').trim().length;
271
+ }
272
+
273
+ /**
274
+ * Readability keeps ONE dense block and drops the rest. On a landing page
275
+ * that block is a fraction of what the visitor reads: gnome.org came back as
276
+ * ~150 of 1,666 visible characters and libreoffice.org as its 509-character
277
+ * "Welcome" box, both reported at full confidence (R16, 2026-09-04). A short
278
+ * article on a page with much more visible text is not the main content.
279
+ *
280
+ * @param {string} mainHtml - Readability's article HTML
281
+ * @param {string} pageHtml - the whole page
282
+ * @returns {{ kept: number, visible: number }|null} the two lengths when the
283
+ * article is thin, null when it is the page's main content
284
+ */
285
+ export function isThinMainContent(mainHtml, pageHtml) {
286
+ const kept = visibleTextLength(mainHtml || '');
287
+ if (kept >= THIN_MAIN_CONTENT.maxChars) return null;
288
+ const visible = visibleTextLength(pageHtml || '');
289
+ if (visible === 0 || kept / visible >= THIN_MAIN_CONTENT.maxShare) return null;
290
+ return { kept, visible };
291
+ }
292
+
229
293
  export default extractMainContent;
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { z } from 'zod';
14
14
  import { fetchAndParse } from '../extract/_fetchAndParse.js';
15
- import { extractMainContent } from './_mainContent.js';
15
+ 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';
@@ -236,6 +236,15 @@ export class UnifiedScrapeTool {
236
236
  `mainContent: re-attached ${main.tablesRecovered} data table(s) that main-content extraction had dropped`
237
237
  );
238
238
  }
239
+ // A thin article on a landing page is not the main content (gnome.org
240
+ // came back as ~150 of 1,666 visible characters, R16): use the page.
241
+ const thin = main.html ? isThinMainContent(main.html, html) : null;
242
+ if (thin) {
243
+ mainHtml = html;
244
+ warnings.push(
245
+ `mainContent: main-content extraction kept ${thin.kept} of ${thin.visible} visible characters; the whole page is used instead`
246
+ );
247
+ }
239
248
  return mainHtml;
240
249
  }
241
250