crawlforge-mcp-server 5.6.1 → 5.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.1",
3
+ "version": "5.6.3",
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.1",
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
@@ -33,6 +33,7 @@ import { LocalizationManager } from "./src/core/LocalizationManager.js";
33
33
  // Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
34
34
  import * as cheerio from "cheerio";
35
35
  import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
36
+ import { detectChallengePage } from "./src/utils/challengeDetection.js";
36
37
  import { browserPreflight } from "./src/utils/robotsGate.js";
37
38
  import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
38
39
  import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
@@ -105,7 +106,7 @@ const taskStore = createTaskStore({ logger });
105
106
  // Create the server
106
107
  const server = new McpServer({
107
108
  name: "crawlforge",
108
- version: "5.6.1",
109
+ version: "5.6.3",
109
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.",
110
111
  homepage: "https://www.crawlforge.dev",
111
112
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1377,12 +1378,30 @@ registerToolIfEnabled("stealth_mode", {
1377
1378
  stealthConfig
1378
1379
  });
1379
1380
 
1381
+ // A bot-wall interstitial arrives as HTTP 200 with a title and prose
1382
+ // of its own; reported as a successful scrape it hid the block for
1383
+ // three rounds (R10 Q1 → R15). Name it — the content is still returned
1384
+ // so the caller can see what the site served.
1385
+ const challenge = detectChallengePage(scraped);
1380
1386
  result = {
1381
- success: true,
1387
+ success: !challenge,
1382
1388
  url: scraped.url,
1383
1389
  title: scraped.title,
1384
1390
  content: stealthScrapeFormats(formats, scraped)
1385
1391
  };
1392
+ if (challenge) {
1393
+ result.blocked = challenge;
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.';
1404
+ }
1386
1405
 
1387
1406
  // Screenshots follow the same crawlforge://screenshot/{id} pattern as
1388
1407
  // scrape and scrape_with_actions, so the base64 never bloats the result.
@@ -1452,6 +1471,12 @@ registerToolIfEnabled("stealth_mode", {
1452
1471
  ok: response ? response.ok() : null,
1453
1472
  title: await page.title().catch(() => null)
1454
1473
  };
1474
+ const challenge = detectChallengePage({
1475
+ title: navigation.title || '',
1476
+ html: await page.content().catch(() => ''),
1477
+ text: await page.innerText('body').catch(() => '')
1478
+ });
1479
+ if (challenge) navigation.blocked = challenge;
1455
1480
  }
1456
1481
  } finally {
1457
1482
  // No operation can ever reference this page again — keeping it open
@@ -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),
@@ -140,47 +140,50 @@ export class StealthBrowserManager {
140
140
  }
141
141
  };
142
142
 
143
- // Enhanced User agent pools with realistic patterns
143
+ // User agent pools. Chrome majors track the bundled Chromium (151 at
144
+ // playwright-core 1.62) and Firefox tracks the Camoufox build (135): a
145
+ // UA thirty majors behind the engine — the pool sat at 119–121 while
146
+ // Chrome 152 shipped — is a staleness tell for anything that reads
147
+ // sec-ch-ua (R15, 2026-09-04).
144
148
  this.userAgentPools = {
145
149
  chrome: {
146
150
  windows: [
147
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
148
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
149
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
150
- 'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
151
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
152
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
153
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
154
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'
151
155
  ],
152
156
  macos: [
153
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
154
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
155
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
157
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
158
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
159
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
156
160
  ],
157
161
  linux: [
158
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
159
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
162
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
163
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
164
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
160
165
  ]
161
166
  },
162
167
  firefox: {
163
168
  windows: [
164
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0',
165
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0'
169
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0'
166
170
  ],
167
171
  macos: [
168
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:122.0) Gecko/20100101 Firefox/122.0',
169
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0'
172
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0'
170
173
  ],
171
174
  linux: [
172
- 'Mozilla/5.0 (X11; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0',
173
- 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0'
175
+ 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0',
176
+ 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0'
174
177
  ]
175
178
  },
176
179
  safari: {
177
180
  macos: [
178
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
179
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15'
181
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15',
182
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15'
180
183
  ]
181
184
  }
182
185
  };
183
-
186
+
184
187
  // Operating system distributions for realistic user agent selection
185
188
  this.osDistribution = {
186
189
  windows: 0.75,
@@ -195,17 +198,27 @@ export class StealthBrowserManager {
195
198
  safari: 0.15
196
199
  };
197
200
 
198
- // 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.
199
205
  this.viewportSizes = [
200
- { width: 1920, height: 1080, weight: 0.25 }, // Most common
201
- { width: 1366, height: 768, weight: 0.20 }, // Second most common
202
- { width: 1536, height: 864, weight: 0.15 },
203
- { width: 1440, height: 900, weight: 0.12 },
204
- { width: 1280, height: 720, weight: 0.10 },
205
- { width: 1600, height: 900, weight: 0.08 },
206
- { width: 1024, height: 768, weight: 0.05 }, // Legacy but still used
207
- { width: 2560, height: 1440, weight: 0.03 }, // High-res displays
208
- { 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
209
222
  ];
210
223
 
211
224
  // Mobile viewport sizes for mobile emulation
@@ -467,8 +480,10 @@ export class StealthBrowserManager {
467
480
  height: fingerprint.screen.height
468
481
  },
469
482
 
470
- // Bypass various detections
471
- bypassCSP: true,
483
+ // bypassCSP is deliberately NOT set: ignoring a page's Content Security
484
+ // Policy is "invalid behavior for a normal browser" and rebrowser's
485
+ // bot detector flags it red (R15, 2026-09-04). Every init script here
486
+ // goes through addInitScript/evaluate, which need no CSP bypass.
472
487
  javaScriptEnabled: true
473
488
  };
474
489
 
@@ -511,14 +526,17 @@ export class StealthBrowserManager {
511
526
  const selectedOS = this.selectOS(config);
512
527
  const persona = this.selectLocalePersona(config);
513
528
  const userAgent = this.selectRealisticUserAgent(config, selectedOS);
529
+ const display = this.generateDisplay(selectedOS, config);
514
530
  const fingerprint = {
515
531
  userAgent,
516
532
  locale: persona.locale,
517
- viewport: config.customViewport || this.selectWeightedViewport(),
533
+ viewport: display.viewport,
518
534
  timezone: config.timezone || persona.timezone,
519
- deviceScaleFactor: this.randomFloat(1, 2, 1),
520
- isMobile: Math.random() < 0.1, // 10% mobile
521
- 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
522
540
  colorScheme: Math.random() < 0.3 ? 'dark' : 'light',
523
541
  reducedMotion: Math.random() < 0.1 ? 'reduce' : 'no-preference',
524
542
  forcedColors: Math.random() < 0.05 ? 'active' : 'none',
@@ -532,7 +550,8 @@ export class StealthBrowserManager {
532
550
  fonts: this.generateAdvancedFontList(selectedOS),
533
551
  plugins: this.generateAdvancedPluginList(),
534
552
  geolocation: this.generateRealisticGeolocation(persona),
535
- screen: this.generateAdvancedScreenProperties(),
553
+ screen: display.screen,
554
+ window: display.window,
536
555
  battery: this.generateBatteryFingerprint()
537
556
  };
538
557
 
@@ -570,7 +589,17 @@ export class StealthBrowserManager {
570
589
  // An unmodelled locale still gets a coherent timezone/geolocation pair.
571
590
  const pool = exact.length ? exact : (sameLanguage.length ? sameLanguage : this.localePersonas);
572
591
 
573
- 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)];
574
603
  return { ...persona, locale: requested };
575
604
  }
576
605
 
@@ -643,10 +672,58 @@ export class StealthBrowserManager {
643
672
  }
644
673
 
645
674
  /**
646
- * 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.
684
+ */
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.
647
723
  */
648
- selectWeightedViewport() {
649
- return this.weightedRandomFromArray(this.viewportSizes);
724
+ hostTimezone() {
725
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
726
+ return zone && zone !== 'UTC' && !/^(?:Etc|GMT)/.test(zone) ? zone : null;
650
727
  }
651
728
 
652
729
  /**
@@ -704,10 +781,10 @@ export class StealthBrowserManager {
704
781
  * @param {string} [userAgent] — the selected user agent string
705
782
  */
706
783
  generateSecChUaHeader(userAgent = '') {
707
- // Extract Chrome major version from the UA (e.g. "Chrome/121.0.0.0" → "121").
708
- // Fall back to 121 if the UA is not a Chrome UA.
784
+ // Extract Chrome major version from the UA (e.g. "Chrome/151.0.0.0" → "151").
785
+ // Fall back to the bundled Chromium major if the UA is not a Chrome UA.
709
786
  const match = userAgent.match(/Chrome\/(\d+)/i);
710
- const version = match ? match[1] : '121';
787
+ const version = match ? match[1] : '151';
711
788
 
712
789
  const brands = [
713
790
  { brand: 'Not_A Brand', version: '8' },
@@ -1134,25 +1211,6 @@ export class StealthBrowserManager {
1134
1211
  };
1135
1212
  }
1136
1213
 
1137
- /**
1138
- * Generate advanced screen properties
1139
- */
1140
- generateAdvancedScreenProperties() {
1141
- const viewport = this.selectWeightedViewport();
1142
-
1143
- return {
1144
- width: viewport.width,
1145
- height: viewport.height,
1146
- availWidth: viewport.width,
1147
- availHeight: viewport.height - (30 + Math.floor(Math.random() * 20)), // Account for taskbar
1148
- colorDepth: Math.random() < 0.95 ? 24 : 32,
1149
- pixelDepth: 24,
1150
- orientation: {
1151
- angle: 0,
1152
- type: 'landscape-primary'
1153
- }
1154
- };
1155
- }
1156
1214
 
1157
1215
  /**
1158
1216
  * Generate battery API fingerprint
@@ -1282,6 +1340,26 @@ export class StealthBrowserManager {
1282
1340
  Object.defineProperty(pluginArray, plugin.name, { value: plugin });
1283
1341
  });
1284
1342
  Object.defineProperty(navigator, 'plugins', { get: () => pluginArray });
1343
+
1344
+ // Real Chrome pairs those plugins with two navigator.mimeTypes entries
1345
+ // (application/pdf, text/pdf). Five plugins beside an empty mimeTypes
1346
+ // is its own tell — infosimples' detect-headless read "5 plugins /
1347
+ // 0 mime types" (R15, 2026-09-04).
1348
+ if (typeof MimeTypeArray !== 'undefined') {
1349
+ const mimeTypeArray = Object.create(MimeTypeArray.prototype);
1350
+ const mimeTypes = Array.from(plugins[0]);
1351
+ Object.defineProperties(mimeTypeArray, {
1352
+ length: { value: mimeTypes.length },
1353
+ item: { value: (index) => mimeTypes[index] || null },
1354
+ namedItem: { value: (type) => mimeTypes.find((m) => m.type === type) || null },
1355
+ [Symbol.iterator]: { value: function* () { yield* mimeTypes; } }
1356
+ });
1357
+ mimeTypes.forEach((mimeType, index) => {
1358
+ Object.defineProperty(mimeTypeArray, index, { value: mimeType, enumerable: true });
1359
+ Object.defineProperty(mimeTypeArray, mimeType.type, { value: mimeType });
1360
+ });
1361
+ Object.defineProperty(navigator, 'mimeTypes', { get: () => mimeTypeArray });
1362
+ }
1285
1363
  }
1286
1364
 
1287
1365
  // Override languages with the fingerprint's own locale — a hardcoded
@@ -1449,6 +1527,21 @@ export class StealthBrowserManager {
1449
1527
 
1450
1528
  return ctx;
1451
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
+ }
1452
1545
  }, fingerprint.webGL);
1453
1546
  }
1454
1547
 
@@ -1543,7 +1636,7 @@ export class StealthBrowserManager {
1543
1636
  }
1544
1637
 
1545
1638
  // Screen resolution spoofing
1546
- await context.addInitScript((screenConfig) => {
1639
+ await context.addInitScript(({ screen: screenConfig, window: windowConfig }) => {
1547
1640
  Object.defineProperties(screen, {
1548
1641
  width: { value: screenConfig.width, configurable: true },
1549
1642
  height: { value: screenConfig.height, configurable: true },
@@ -1552,7 +1645,18 @@ export class StealthBrowserManager {
1552
1645
  colorDepth: { value: screenConfig.colorDepth, configurable: true },
1553
1646
  pixelDepth: { value: screenConfig.pixelDepth, configurable: true }
1554
1647
  });
1555
- }, 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
+ });
1556
1660
 
1557
1661
  // Timezone spoofing
1558
1662
  if (config.spoofTimezone) {
@@ -1828,16 +1932,34 @@ export class StealthBrowserManager {
1828
1932
  const { contextId } = await this.createStealthContext({ ...stealthConfig, engine });
1829
1933
  try {
1830
1934
  const page = await this.createStealthPage(contextId);
1935
+ let crashed = false;
1936
+ page.on('crash', () => { crashed = true; });
1831
1937
  // SSRF guard at the navigation boundary: the stealth engine resolves DNS
1832
1938
  // itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
1833
1939
  await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
1834
1940
  if (wait_for > 0) await page.waitForTimeout(wait_for);
1835
1941
 
1836
- const [title, html, text] = await Promise.all([
1837
- page.title().catch(() => ''),
1838
- page.content().catch(() => ''),
1839
- page.innerText('body').catch(() => '')
1840
- ]);
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
+ }
1841
1963
  const shot = screenshot
1842
1964
  ? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
1843
1965
  : null;
@@ -1848,47 +1970,34 @@ export class StealthBrowserManager {
1848
1970
  }
1849
1971
  }
1850
1972
 
1973
+ /**
1974
+ * Whether a request is dropped before it leaves the browser. Only the
1975
+ * advanced level sheds assets, never by URL and never the document itself.
1976
+ * @param {string} resourceType - Playwright's request.resourceType()
1977
+ * @param {string} level - stealth level
1978
+ * @returns {boolean}
1979
+ */
1980
+ static shouldAbortRequest(resourceType, level) {
1981
+ if (level !== 'advanced') return false;
1982
+ if (!['image', 'font', 'stylesheet'].includes(resourceType)) return false;
1983
+ // Allow some images/fonts to maintain realism
1984
+ return Math.random() >= 0.3;
1985
+ }
1986
+
1851
1987
  /**
1852
1988
  * Apply page-level stealth measures
1853
1989
  */
1854
1990
  async applyPageStealthMeasures(page, config, fingerprint) {
1855
- // Enhanced resource blocking with stealth considerations
1991
+ // Resource routing. Nothing is aborted by URL any more: the old list
1992
+ // blocked challenges.cloudflare.com and every URL containing "selenium",
1993
+ // "webdriver" or "puppeteer" — the first meant a Cloudflare challenge
1994
+ // could never complete (the interstitial named the blocked host), the
1995
+ // second killed the top-level navigation to www.selenium.dev on every
1996
+ // stealth path (R15, 2026-09-04). A vendor script that never loads is a
1997
+ // louder tell than one that runs.
1856
1998
  await page.route('**/*', route => {
1857
- const resourceType = route.request().resourceType();
1858
- const url = route.request().url();
1859
-
1860
- // Block known bot detection resources
1861
- const blockedDomains = [
1862
- 'botd.fpjs.io',
1863
- 'challenges.cloudflare.com',
1864
- 'datadome.co',
1865
- 'perimeterx.net',
1866
- 'distilnetworks.com'
1867
- ];
1868
-
1869
- if (blockedDomains.some(domain => url.includes(domain))) {
1999
+ if (StealthBrowserManager.shouldAbortRequest(route.request().resourceType(), config.level)) {
1870
2000
  route.abort();
1871
- return;
1872
- }
1873
-
1874
- // Don't block detection-related resources that might be expected
1875
- if (url.includes('webdriver') || url.includes('selenium') || url.includes('puppeteer')) {
1876
- route.abort();
1877
- return;
1878
- }
1879
-
1880
- // Selective resource blocking based on level
1881
- if (config.level === 'advanced') {
1882
- if (['image', 'font', 'stylesheet'].includes(resourceType)) {
1883
- // Allow some images/fonts to maintain realism
1884
- if (Math.random() < 0.3) {
1885
- route.continue();
1886
- } else {
1887
- route.abort();
1888
- }
1889
- } else {
1890
- route.continue();
1891
- }
1892
2001
  } else {
1893
2002
  route.continue();
1894
2003
  }
@@ -6,7 +6,8 @@
6
6
  import {
7
7
  LANGUAGE_NAMES,
8
8
  cjkScriptCounts as sharedCjkScriptCounts,
9
- detectLanguage as sharedDetectLanguage
9
+ detectLanguage as sharedDetectLanguage,
10
+ needsWordSegmentation
10
11
  } from '../../utils/languageDetection.js';
11
12
  import nlp from 'compromise';
12
13
  import { z } from 'zod';
@@ -168,7 +169,9 @@ export class ContentAnalyzer {
168
169
  * @returns {string[]} - Word tokens
169
170
  */
170
171
  tokenizeWords(text) {
171
- return this.isCjkText(text)
172
+ // CJK and the unspaced South-East Asian scripts (Thai, Lao, Khmer,
173
+ // Burmese) have no word spaces to split on.
174
+ return needsWordSegmentation(text)
172
175
  ? this.segmentWords(text)
173
176
  : text.split(/\s+/).filter(w => w.length > 0);
174
177
  }
@@ -395,7 +398,7 @@ export class ContentAnalyzer {
395
398
  const freq = {};
396
399
  const sentenceWords = sentences.map(sentence => {
397
400
  const words = nlp(sentence).terms().out('array')
398
- .map(w => w.toLowerCase().replace(/[^a-z0-9]/g, ''))
401
+ .map(w => w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''))
399
402
  .filter(w => w.length > 2 && !this.isStopWord(w));
400
403
  words.forEach(w => { freq[w] = (freq[w] || 0) + 1; });
401
404
  return words;
@@ -468,7 +471,7 @@ export class ContentAnalyzer {
468
471
 
469
472
  allPhrases.forEach(phrase => {
470
473
  // Strip edge punctuation and skip phrases made only of stop words
471
- 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, '');
472
475
  const words = cleaned.split(/\s+/);
473
476
  if (cleaned.length > 2 && words.some(w => !this.isStopWord(w))) {
474
477
  phraseCount[cleaned] = (phraseCount[cleaned] || 0) + 1;
@@ -547,7 +550,7 @@ export class ContentAnalyzer {
547
550
  // (corporate suffix or organization-headed alias definition).
548
551
  const demotedAcronyms = [];
549
552
  organizations = organizations.filter(e => {
550
- if (/^[A-Z]{2,}$/.test(e) && !this.hasOrganizationEvidence(e, text)) {
553
+ if (/^\p{Lu}{2,}$/u.test(e) && !this.hasOrganizationEvidence(e, text)) {
551
554
  demotedAcronyms.push(e);
552
555
  return false;
553
556
  }
@@ -562,11 +565,14 @@ export class ContentAnalyzer {
562
565
  ...people, ...places, ...organizations, ...other
563
566
  ].map(e => e.toLowerCase()));
564
567
 
565
- 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) || [])
566
572
  // Split matches that crossed a sentence boundary ("XPath. In") — a
567
573
  // period after a lowercase letter followed by a capital is a sentence
568
574
  // end, while abbreviations ("U.S. Holdings") stay intact.
569
- .flatMap(n => n.split(/(?<=[a-z]\.)\s+(?=[A-Z])/));
575
+ .flatMap(n => n.split(/(?<=\p{Ll}\.)\s+(?=\p{Lu})/u));
570
576
  const supplemental = this.dedupeEntities(properNouns.map(n => this.cleanEntityText(n)))
571
577
  .filter(n => n.length > 1
572
578
  && !existingEntities.has(n.toLowerCase())
@@ -653,7 +659,7 @@ export class ContentAnalyzer {
653
659
 
654
660
  [...nouns, ...verbs, ...adjectives].forEach(term => {
655
661
  // Strip leading/trailing punctuation but preserve internal periods (e.g. Node.js)
656
- 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(/\.+$/, '');
657
663
  if (cleaned.length > 2 && !this.isStopWord(cleaned)) {
658
664
  termFreq[cleaned] = (termFreq[cleaned] || 0) + 1;
659
665
 
@@ -890,7 +896,7 @@ export class ContentAnalyzer {
890
896
  entity = entity.replace(/[\s"'‘’“”`)\]}>,;:!?«»–—-]+$/, '');
891
897
  if (entity.endsWith('.')) {
892
898
  const lastToken = entity.split(/\s+/).pop();
893
- const isAbbreviation = /^(?:[A-Za-z]\.)+$/.test(lastToken)
899
+ const isAbbreviation = /^(?:\p{L}\.)+$/u.test(lastToken)
894
900
  || /^(?:inc|corp|ltd|co|llc|jr|sr|st|mr|mrs|ms|dr|no|vs?)\.$/i.test(lastToken);
895
901
  if (!isAbbreviation) {
896
902
  entity = entity.replace(/\.+$/, '');
@@ -941,13 +947,13 @@ export class ContentAnalyzer {
941
947
  if (entity.includes(' ')) return false;
942
948
  const lower = entity.toLowerCase();
943
949
  if (this.isStopWord(lower)) return true;
944
- if (!/^[A-Z][a-z]+$/.test(entity)) return false;
950
+ if (!/^\p{Lu}\p{Ll}+$/u.test(entity)) return false;
945
951
 
946
952
  // Word also appears in lowercase → ordinary word, capitalized only by position
947
- 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;
948
954
 
949
955
  // Keep only if at least one occurrence is NOT at a sentence start
950
- const occurrence = new RegExp(`\\b${entity}\\b`, 'g');
956
+ const occurrence = new RegExp(`(?<![\\p{L}\\p{N}])${entity}(?![\\p{L}\\p{N}])`, 'gu');
951
957
  let match;
952
958
  while ((match = occurrence.exec(text)) !== null) {
953
959
  const before = text.slice(0, match.index).replace(/[\s"'‘’“”()[\]]+$/, '');
@@ -972,13 +978,13 @@ export class ContentAnalyzer {
972
978
  * a corporate suffix follows it ("IBM Corp"), or it is introduced as the
973
979
  * alias of an organization-headed name ("American Airlines (AA)",
974
980
  * "French Data Protection Authority (CNIL)").
975
- * @param {string} acronym - ALL-CAPS candidate (already /^[A-Z]{2,}$/)
981
+ * @param {string} acronym - ALL-CAPS candidate (already /^\p{Lu}{2,}$/u)
976
982
  * @param {string} text - Full source text
977
983
  * @returns {boolean} - True if the text corroborates the org classification
978
984
  */
979
985
  hasOrganizationEvidence(acronym, text) {
980
986
  const corporateSuffix = new RegExp(
981
- `\\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'
982
988
  );
983
989
  if (corporateSuffix.test(text)) return true;
984
990
 
@@ -618,6 +618,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
618
618
  for (const stateData of capturedStates) {
619
619
  try {
620
620
  const $ = load(stateData.html);
621
+ // Never rendered as text — the same strip the batch worker does (R15).
622
+ $('script, style, noscript, template').remove();
621
623
 
622
624
  const state = {
623
625
  capturePoint: stateData.afterActionIndex + 1,
@@ -688,6 +690,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
688
690
  // over the post-action DOM here, the one place every caller reads.
689
691
  if (chainResult?.finalHtml && (params.extractionOptions?.selectors || params.formats?.includes('text'))) {
690
692
  const $ = load(chainResult.finalHtml);
693
+ // Never rendered as text — the same strip the batch worker does (R15).
694
+ $('script, style, noscript, template').remove();
691
695
  extractResult.content = extractResult.content || {};
692
696
  if (params.extractionOptions?.selectors) {
693
697
  extractResult.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors);
@@ -120,7 +120,12 @@ function extractStructuredData($, selectors) {
120
120
  return extracted;
121
121
  }
122
122
 
123
- function generateFormats($, html, formats) {
123
+ export function generateFormats($, html, formats) {
124
+ // Script, style, noscript and template bodies are never rendered text.
125
+ // Left in, the text format carried an entire theme-toggle script
126
+ // (erlang.org/downloads) and CSS rules (opennet.ru) — R15, 2026-09-04.
127
+ // `html` below is the raw document and stays untouched.
128
+ $('script, style, noscript, template').remove();
124
129
  const content = {};
125
130
  if (formats.includes('html')) content.html = html;
126
131
  if (formats.includes('text')) content.text = $('body').text().replace(/\s+/g, ' ').trim();
@@ -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,
@@ -234,7 +245,9 @@ export class ExtractContentTool {
234
245
  text: processingResult.fallback_content.content
235
246
  };
236
247
  result.extractionMethod = 'fallback_boilerplate_removal';
237
- result.fallback_reason = 'Readability did not detect an article; used boilerplate-removal fallback';
248
+ result.fallback_reason = thin
249
+ ? `Readability kept only ${thin.kept} of ${thin.visible} visible characters (a landing page, not an article); used boilerplate-removal fallback`
250
+ : 'Readability did not detect an article; used boilerplate-removal fallback';
238
251
  result.confidence = 0.5;
239
252
  result.finalUrl = url;
240
253
  } else {
@@ -40,6 +40,7 @@
40
40
 
41
41
  import { JSDOM } from 'jsdom';
42
42
  import { Readability } from '@mozilla/readability';
43
+ import { load } from 'cheerio';
43
44
 
44
45
  // How much of a table's text is compared against the article to decide whether
45
46
  // Readability already kept it. Long enough to be unique to that table.
@@ -226,4 +227,34 @@ export function extractMainContent(html, url) {
226
227
  };
227
228
  }
228
229
 
230
+ // Below both of these, Readability's article is a fragment of the page, not
231
+ // its main content.
232
+ export const THIN_MAIN_CONTENT = { maxChars: 1500, maxShare: 0.35 };
233
+
234
+ function visibleTextLength(html) {
235
+ const $ = load(html);
236
+ $('script, style, noscript, template').remove();
237
+ return $('body').text().replace(/\s+/g, ' ').trim().length;
238
+ }
239
+
240
+ /**
241
+ * Readability keeps ONE dense block and drops the rest. On a landing page
242
+ * that block is a fraction of what the visitor reads: gnome.org came back as
243
+ * ~150 of 1,666 visible characters and libreoffice.org as its 509-character
244
+ * "Welcome" box, both reported at full confidence (R16, 2026-09-04). A short
245
+ * article on a page with much more visible text is not the main content.
246
+ *
247
+ * @param {string} mainHtml - Readability's article HTML
248
+ * @param {string} pageHtml - the whole page
249
+ * @returns {{ kept: number, visible: number }|null} the two lengths when the
250
+ * article is thin, null when it is the page's main content
251
+ */
252
+ export function isThinMainContent(mainHtml, pageHtml) {
253
+ const kept = visibleTextLength(mainHtml || '');
254
+ if (kept >= THIN_MAIN_CONTENT.maxChars) return null;
255
+ const visible = visibleTextLength(pageHtml || '');
256
+ if (visible === 0 || kept / visible >= THIN_MAIN_CONTENT.maxShare) return null;
257
+ return { kept, visible };
258
+ }
259
+
229
260
  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
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * challengeDetection.js — recognise a bot-wall interstitial served as a page.
3
+ *
4
+ * Cloudflare, Amazon, DataDome, PerimeterX and Akamai all answer a blocked
5
+ * request with HTTP 200 and a page of their own: a title, some prose and a
6
+ * challenge script. Reported as a successful scrape, that page hides the
7
+ * block — producthunt.com came back "success:true, title: Just a moment..."
8
+ * for three regression rounds (R10 Q1 → R15, 2026-09-04). A title match is
9
+ * definitive; a script or form marker is definitive only on a short page,
10
+ * because a real page can legitimately embed a Turnstile widget.
11
+ */
12
+
13
+ const SHORT_PAGE_CHARS = 4000;
14
+
15
+ const CHALLENGES = [
16
+ {
17
+ vendor: 'cloudflare',
18
+ title: /^just a moment/i,
19
+ markers: /challenges\.cloudflare\.com|cf-chl-|_cf_chl_opt|cf_chl_rc_|window\._cf_chl/i,
20
+ evidence: 'a Cloudflare challenge script'
21
+ },
22
+ {
23
+ vendor: 'amazon',
24
+ // The robot check is the only Amazon page whose form posts to validateCaptcha.
25
+ definitive: /action="[^"]*validateCaptcha/i,
26
+ evidence: 'the validateCaptcha form'
27
+ },
28
+ {
29
+ vendor: 'datadome',
30
+ markers: /captcha-delivery\.com\/captcha|geo\.captcha-delivery\.com|dd\.captcha/i,
31
+ evidence: 'a DataDome captcha frame'
32
+ },
33
+ {
34
+ vendor: 'perimeterx',
35
+ markers: /px-captcha|_pxCaptcha|human-challenge/i,
36
+ evidence: 'a PerimeterX / HUMAN challenge element'
37
+ },
38
+ {
39
+ vendor: 'akamai',
40
+ title: /^access denied/i,
41
+ markers: /errors\.edgesuite\.net/i,
42
+ evidence: 'an Akamai access-denied page'
43
+ }
44
+ ];
45
+
46
+ /**
47
+ * @param {{ title?: string, html?: string, text?: string }} page
48
+ * @returns {{ vendor: string, evidence: string } | null}
49
+ */
50
+ export function detectChallengePage({ title = '', html = '', text = '' } = {}) {
51
+ const cleanTitle = (title || '').trim();
52
+ const visible = (text || '').replace(/\s+/g, ' ').trim();
53
+ const shortPage = visible.length < SHORT_PAGE_CHARS;
54
+ for (const challenge of CHALLENGES) {
55
+ if (challenge.title && challenge.title.test(cleanTitle)) {
56
+ return { vendor: challenge.vendor, evidence: `title "${cleanTitle}"` };
57
+ }
58
+ if (challenge.definitive && challenge.definitive.test(html)) {
59
+ return { vendor: challenge.vendor, evidence: challenge.evidence };
60
+ }
61
+ if (shortPage && challenge.markers && challenge.markers.test(html)) {
62
+ return { vendor: challenge.vendor, evidence: `${challenge.evidence} on a ${visible.length}-character page` };
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export default detectChallengePage;
@@ -158,6 +158,26 @@ export function isCjkText(text) {
158
158
  return cjkScriptCounts(text).share >= CJK_SCRIPT_THRESHOLD;
159
159
  }
160
160
 
161
+ // Scripts written without spaces between words. Thai, Lao, Khmer and Burmese
162
+ // need the same dictionary segmentation CJK gets; on the whitespace split a
163
+ // 290-character Thai paragraph counted as 4 words (R15, 2026-09-04).
164
+ const UNSPACED_SCRIPT_RE = /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/gu;
165
+
166
+ /**
167
+ * True when the text needs Intl.Segmenter to find word boundaries — a CJK
168
+ * script, or one of the South-East Asian scripts that write no word spaces.
169
+ *
170
+ * @param {string} text
171
+ * @returns {boolean}
172
+ */
173
+ export function needsWordSegmentation(text) {
174
+ if (isCjkText(text)) return true;
175
+ const letters = (text.match(/\p{L}/gu) || []).length;
176
+ if (letters === 0) return false;
177
+ const unspaced = (text.match(UNSPACED_SCRIPT_RE) || []).length;
178
+ return unspaced / letters >= CJK_SCRIPT_THRESHOLD;
179
+ }
180
+
161
181
  /**
162
182
  * Detect the language of a piece of text.
163
183
  *