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 +2 -2
- package/server.js +18 -2
- package/src/core/StealthBrowserManager.js +71 -59
- package/src/core/analysis/ContentAnalyzer.js +5 -2
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +6 -1
- package/src/utils/challengeDetection.js +68 -0
- package/src/utils/languageDetection.js +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.6.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
//
|
|
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/
|
|
148
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
149
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
150
|
-
'Mozilla/5.0 (Windows NT
|
|
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/
|
|
154
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
155
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X
|
|
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/
|
|
159
|
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
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:
|
|
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:
|
|
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:
|
|
173
|
-
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:
|
|
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/
|
|
179
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/
|
|
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
|
-
//
|
|
471
|
-
|
|
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/
|
|
708
|
-
// Fall back to
|
|
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] : '
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|