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 +2 -2
- package/server.js +27 -2
- package/src/core/LocalizationManager.js +3 -3
- package/src/core/StealthBrowserManager.js +213 -104
- package/src/core/analysis/ContentAnalyzer.js +20 -14
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +6 -1
- package/src/tools/extract/extractContent.js +16 -3
- package/src/tools/scrape/_mainContent.js +31 -0
- package/src/tools/scrape/unifiedScrape.js +10 -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.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.
|
|
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.
|
|
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:
|
|
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
|
-
//
|
|
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,
|
|
@@ -195,17 +198,27 @@ export class StealthBrowserManager {
|
|
|
195
198
|
safari: 0.15
|
|
196
199
|
};
|
|
197
200
|
|
|
198
|
-
//
|
|
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.
|
|
201
|
-
{ width: 1366, height: 768, weight: 0.20 },
|
|
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 },
|
|
207
|
-
{ width: 2560, height: 1440, weight: 0.03 }
|
|
208
|
-
|
|
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
|
-
//
|
|
471
|
-
|
|
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:
|
|
533
|
+
viewport: display.viewport,
|
|
518
534
|
timezone: config.timezone || persona.timezone,
|
|
519
|
-
deviceScaleFactor:
|
|
520
|
-
|
|
521
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
649
|
-
|
|
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/
|
|
708
|
-
// Fall back to
|
|
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] : '
|
|
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
|
-
|
|
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
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(/[
|
|
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(/^[
|
|
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 (
|
|
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
|
-
|
|
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(/(
|
|
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(/^[
|
|
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 = /^(
|
|
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 (
|
|
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(`(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
*
|