crawlforge-mcp-server 5.6.1 → 5.6.2

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.2",
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.2",
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.2",
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,21 @@ 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
+ }
1386
1396
 
1387
1397
  // Screenshots follow the same crawlforge://screenshot/{id} pattern as
1388
1398
  // scrape and scrape_with_actions, so the base64 never bloats the result.
@@ -1452,6 +1462,12 @@ registerToolIfEnabled("stealth_mode", {
1452
1462
  ok: response ? response.ok() : null,
1453
1463
  title: await page.title().catch(() => null)
1454
1464
  };
1465
+ const challenge = detectChallengePage({
1466
+ title: navigation.title || '',
1467
+ html: await page.content().catch(() => ''),
1468
+ text: await page.innerText('body').catch(() => '')
1469
+ });
1470
+ if (challenge) navigation.blocked = challenge;
1455
1471
  }
1456
1472
  } finally {
1457
1473
  // No operation can ever reference this page again — keeping it open
@@ -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,
@@ -467,8 +470,10 @@ export class StealthBrowserManager {
467
470
  height: fingerprint.screen.height
468
471
  },
469
472
 
470
- // Bypass various detections
471
- bypassCSP: true,
473
+ // bypassCSP is deliberately NOT set: ignoring a page's Content Security
474
+ // Policy is "invalid behavior for a normal browser" and rebrowser's
475
+ // bot detector flags it red (R15, 2026-09-04). Every init script here
476
+ // goes through addInitScript/evaluate, which need no CSP bypass.
472
477
  javaScriptEnabled: true
473
478
  };
474
479
 
@@ -704,10 +709,10 @@ export class StealthBrowserManager {
704
709
  * @param {string} [userAgent] — the selected user agent string
705
710
  */
706
711
  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.
712
+ // Extract Chrome major version from the UA (e.g. "Chrome/151.0.0.0" → "151").
713
+ // Fall back to the bundled Chromium major if the UA is not a Chrome UA.
709
714
  const match = userAgent.match(/Chrome\/(\d+)/i);
710
- const version = match ? match[1] : '121';
715
+ const version = match ? match[1] : '151';
711
716
 
712
717
  const brands = [
713
718
  { brand: 'Not_A Brand', version: '8' },
@@ -1282,6 +1287,26 @@ export class StealthBrowserManager {
1282
1287
  Object.defineProperty(pluginArray, plugin.name, { value: plugin });
1283
1288
  });
1284
1289
  Object.defineProperty(navigator, 'plugins', { get: () => pluginArray });
1290
+
1291
+ // Real Chrome pairs those plugins with two navigator.mimeTypes entries
1292
+ // (application/pdf, text/pdf). Five plugins beside an empty mimeTypes
1293
+ // is its own tell — infosimples' detect-headless read "5 plugins /
1294
+ // 0 mime types" (R15, 2026-09-04).
1295
+ if (typeof MimeTypeArray !== 'undefined') {
1296
+ const mimeTypeArray = Object.create(MimeTypeArray.prototype);
1297
+ const mimeTypes = Array.from(plugins[0]);
1298
+ Object.defineProperties(mimeTypeArray, {
1299
+ length: { value: mimeTypes.length },
1300
+ item: { value: (index) => mimeTypes[index] || null },
1301
+ namedItem: { value: (type) => mimeTypes.find((m) => m.type === type) || null },
1302
+ [Symbol.iterator]: { value: function* () { yield* mimeTypes; } }
1303
+ });
1304
+ mimeTypes.forEach((mimeType, index) => {
1305
+ Object.defineProperty(mimeTypeArray, index, { value: mimeType, enumerable: true });
1306
+ Object.defineProperty(mimeTypeArray, mimeType.type, { value: mimeType });
1307
+ });
1308
+ Object.defineProperty(navigator, 'mimeTypes', { get: () => mimeTypeArray });
1309
+ }
1285
1310
  }
1286
1311
 
1287
1312
  // Override languages with the fingerprint's own locale — a hardcoded
@@ -1848,47 +1873,34 @@ export class StealthBrowserManager {
1848
1873
  }
1849
1874
  }
1850
1875
 
1876
+ /**
1877
+ * Whether a request is dropped before it leaves the browser. Only the
1878
+ * advanced level sheds assets, never by URL and never the document itself.
1879
+ * @param {string} resourceType - Playwright's request.resourceType()
1880
+ * @param {string} level - stealth level
1881
+ * @returns {boolean}
1882
+ */
1883
+ static shouldAbortRequest(resourceType, level) {
1884
+ if (level !== 'advanced') return false;
1885
+ if (!['image', 'font', 'stylesheet'].includes(resourceType)) return false;
1886
+ // Allow some images/fonts to maintain realism
1887
+ return Math.random() >= 0.3;
1888
+ }
1889
+
1851
1890
  /**
1852
1891
  * Apply page-level stealth measures
1853
1892
  */
1854
1893
  async applyPageStealthMeasures(page, config, fingerprint) {
1855
- // Enhanced resource blocking with stealth considerations
1894
+ // Resource routing. Nothing is aborted by URL any more: the old list
1895
+ // blocked challenges.cloudflare.com and every URL containing "selenium",
1896
+ // "webdriver" or "puppeteer" — the first meant a Cloudflare challenge
1897
+ // could never complete (the interstitial named the blocked host), the
1898
+ // second killed the top-level navigation to www.selenium.dev on every
1899
+ // stealth path (R15, 2026-09-04). A vendor script that never loads is a
1900
+ // louder tell than one that runs.
1856
1901
  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))) {
1870
- 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')) {
1902
+ if (StealthBrowserManager.shouldAbortRequest(route.request().resourceType(), config.level)) {
1876
1903
  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
1904
  } else {
1893
1905
  route.continue();
1894
1906
  }
@@ -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
  }
@@ -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();
@@ -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
  *