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