crawlforge-mcp-server 5.2.8 → 5.3.0
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/CLAUDE.md +13 -1
- package/README.md +9 -9
- package/package.json +2 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +407 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +517 -13
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- package/src/utils/webBotAuth.js +193 -0
|
@@ -199,21 +199,23 @@ export class StealthBrowserManager {
|
|
|
199
199
|
{ width: 320, height: 568, weight: 0.05 } // iPhone 5s (legacy)
|
|
200
200
|
];
|
|
201
201
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
'
|
|
210
|
-
'
|
|
211
|
-
'
|
|
212
|
-
'
|
|
213
|
-
'
|
|
214
|
-
'
|
|
215
|
-
'
|
|
216
|
-
'
|
|
202
|
+
// Locale personas: timezone, country and a plausible city centre drawn
|
|
203
|
+
// together, so one fingerprint cannot claim Asia/Tokyo, a Beijing
|
|
204
|
+
// geolocation and en-US at the same time. A self-contradicting fingerprint
|
|
205
|
+
// is a stronger detection signal than no spoofing at all — these are picked
|
|
206
|
+
// once per fingerprint and threaded through timezone, geolocation and
|
|
207
|
+
// Accept-Language.
|
|
208
|
+
this.localePersonas = [
|
|
209
|
+
{ locale: 'en-US', timezone: 'America/New_York', country: 'US', latitude: 40.7128, longitude: -74.0060 },
|
|
210
|
+
{ locale: 'en-US', timezone: 'America/Chicago', country: 'US', latitude: 41.8781, longitude: -87.6298 },
|
|
211
|
+
{ locale: 'en-US', timezone: 'America/Denver', country: 'US', latitude: 39.7392, longitude: -104.9903 },
|
|
212
|
+
{ locale: 'en-US', timezone: 'America/Los_Angeles', country: 'US', latitude: 34.0522, longitude: -118.2437 },
|
|
213
|
+
{ locale: 'en-GB', timezone: 'Europe/London', country: 'GB', latitude: 51.5074, longitude: -0.1278 },
|
|
214
|
+
{ locale: 'de-DE', timezone: 'Europe/Berlin', country: 'DE', latitude: 52.5200, longitude: 13.4050 },
|
|
215
|
+
{ locale: 'fr-FR', timezone: 'Europe/Paris', country: 'FR', latitude: 48.8566, longitude: 2.3522 },
|
|
216
|
+
{ locale: 'es-ES', timezone: 'Europe/Madrid', country: 'ES', latitude: 40.4168, longitude: -3.7038 },
|
|
217
|
+
{ locale: 'ja-JP', timezone: 'Asia/Tokyo', country: 'JP', latitude: 35.6762, longitude: 139.6503 },
|
|
218
|
+
{ locale: 'en-AU', timezone: 'Australia/Sydney', country: 'AU', latitude: -33.8688, longitude: 151.2093 }
|
|
217
219
|
];
|
|
218
220
|
|
|
219
221
|
// WebRTC leak prevention IPs
|
|
@@ -479,29 +481,35 @@ export class StealthBrowserManager {
|
|
|
479
481
|
* Generate advanced browser fingerprint with enhanced randomization
|
|
480
482
|
*/
|
|
481
483
|
generateAdvancedFingerprint(config = {}) {
|
|
482
|
-
// Select the OS
|
|
483
|
-
//
|
|
484
|
+
// Select the OS and the locale persona once, then thread both through every
|
|
485
|
+
// generator. The OS drives UA, headers, hardware, device labels, fonts and
|
|
486
|
+
// WebGL; the persona drives timezone, geolocation and Accept-Language. The
|
|
487
|
+
// user agent is resolved here rather than twice, so sec-ch-ua cannot report
|
|
488
|
+
// a different Chrome version than the User-Agent header.
|
|
484
489
|
const selectedOS = this.selectOS(config);
|
|
490
|
+
const persona = this.selectLocalePersona(config);
|
|
491
|
+
const userAgent = this.selectRealisticUserAgent(config, selectedOS);
|
|
485
492
|
const fingerprint = {
|
|
486
|
-
userAgent
|
|
493
|
+
userAgent,
|
|
494
|
+
locale: persona.locale,
|
|
487
495
|
viewport: config.customViewport || this.selectWeightedViewport(),
|
|
488
|
-
timezone: config.timezone ||
|
|
496
|
+
timezone: config.timezone || persona.timezone,
|
|
489
497
|
deviceScaleFactor: this.randomFloat(1, 2, 1),
|
|
490
498
|
isMobile: Math.random() < 0.1, // 10% mobile
|
|
491
499
|
hasTouch: Math.random() < 0.15, // 15% touch
|
|
492
500
|
colorScheme: Math.random() < 0.3 ? 'dark' : 'light',
|
|
493
501
|
reducedMotion: Math.random() < 0.1 ? 'reduce' : 'no-preference',
|
|
494
502
|
forcedColors: Math.random() < 0.05 ? 'active' : 'none',
|
|
495
|
-
headers: this.generateAdvancedHeaders(config, selectedOS),
|
|
503
|
+
headers: this.generateAdvancedHeaders(config, selectedOS, persona, userAgent),
|
|
496
504
|
webRTC: this.generateWebRTCConfig(config),
|
|
497
505
|
canvas: this.generateAdvancedCanvasFingerprint(),
|
|
498
|
-
webGL: this.generateAdvancedWebGLFingerprint(),
|
|
506
|
+
webGL: this.generateAdvancedWebGLFingerprint(selectedOS),
|
|
499
507
|
audioContext: this.generateAudioContextFingerprint(),
|
|
500
|
-
mediaDevices: this.generateMediaDevicesFingerprint(),
|
|
508
|
+
mediaDevices: this.generateMediaDevicesFingerprint(selectedOS),
|
|
501
509
|
hardware: this.generateHardwareFingerprint(selectedOS),
|
|
502
|
-
fonts: this.generateAdvancedFontList(),
|
|
510
|
+
fonts: this.generateAdvancedFontList(selectedOS),
|
|
503
511
|
plugins: this.generateAdvancedPluginList(),
|
|
504
|
-
geolocation: this.generateRealisticGeolocation(),
|
|
512
|
+
geolocation: this.generateRealisticGeolocation(persona),
|
|
505
513
|
screen: this.generateAdvancedScreenProperties(),
|
|
506
514
|
battery: this.generateBatteryFingerprint()
|
|
507
515
|
};
|
|
@@ -509,6 +517,41 @@ export class StealthBrowserManager {
|
|
|
509
517
|
return fingerprint;
|
|
510
518
|
}
|
|
511
519
|
|
|
520
|
+
/**
|
|
521
|
+
* The parts of a fingerprint a caller can act on. The full object is ~4 KB of
|
|
522
|
+
* canvas noise arrays and WebGL extension lists that no caller reads, so
|
|
523
|
+
* create_context returns this by default and the full object only on request.
|
|
524
|
+
*/
|
|
525
|
+
summarizeFingerprint(fingerprint) {
|
|
526
|
+
return {
|
|
527
|
+
userAgent: fingerprint.userAgent,
|
|
528
|
+
platform: fingerprint.hardware.platform,
|
|
529
|
+
locale: fingerprint.locale,
|
|
530
|
+
timezone: fingerprint.timezone,
|
|
531
|
+
// width/height only — the pool's selection weight is an internal.
|
|
532
|
+
viewport: { width: fingerprint.viewport.width, height: fingerprint.viewport.height }
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Pick the locale persona (timezone + country + city) for a fingerprint.
|
|
538
|
+
* The caller's `locale` stays authoritative — it only narrows which personas
|
|
539
|
+
* are eligible, so a caller asking for de-DE gets a Berlin timezone and
|
|
540
|
+
* geolocation rather than a Denver one.
|
|
541
|
+
*/
|
|
542
|
+
selectLocalePersona(config = {}) {
|
|
543
|
+
const requested = String(config.locale || 'en-US');
|
|
544
|
+
const language = requested.toLowerCase().split('-')[0];
|
|
545
|
+
|
|
546
|
+
const exact = this.localePersonas.filter(p => p.locale.toLowerCase() === requested.toLowerCase());
|
|
547
|
+
const sameLanguage = this.localePersonas.filter(p => p.locale.toLowerCase().startsWith(`${language}-`));
|
|
548
|
+
// An unmodelled locale still gets a coherent timezone/geolocation pair.
|
|
549
|
+
const pool = exact.length ? exact : (sameLanguage.length ? sameLanguage : this.localePersonas);
|
|
550
|
+
|
|
551
|
+
const persona = pool[Math.floor(Math.random() * pool.length)];
|
|
552
|
+
return { ...persona, locale: requested };
|
|
553
|
+
}
|
|
554
|
+
|
|
512
555
|
/**
|
|
513
556
|
* Choose a single OS ('windows' | 'macos' | 'linux') for a fingerprint.
|
|
514
557
|
* A custom UA pins the OS to whatever that UA reports; a non-random UA pins
|
|
@@ -577,29 +620,29 @@ export class StealthBrowserManager {
|
|
|
577
620
|
}
|
|
578
621
|
|
|
579
622
|
/**
|
|
580
|
-
*
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* Generate advanced HTTP headers with realistic patterns
|
|
623
|
+
* Generate advanced HTTP headers with realistic patterns.
|
|
624
|
+
* @param {Object} config
|
|
625
|
+
* @param {string} selectedOS — the OS chosen for this fingerprint
|
|
626
|
+
* @param {Object} persona — the locale persona chosen for this fingerprint
|
|
627
|
+
* @param {string} resolvedUA — the UA already chosen for this fingerprint
|
|
588
628
|
*/
|
|
589
|
-
generateAdvancedHeaders(config, selectedOS) {
|
|
590
|
-
//
|
|
591
|
-
|
|
629
|
+
generateAdvancedHeaders(config, selectedOS, persona, resolvedUA) {
|
|
630
|
+
// Accept-Language follows the persona, so the header and navigator.language
|
|
631
|
+
// agree with the timezone and geolocation the same persona picked.
|
|
632
|
+
const language = persona.locale.split('-')[0];
|
|
592
633
|
|
|
593
634
|
const headers = {
|
|
594
|
-
'Accept-Language': `${
|
|
635
|
+
'Accept-Language': `${persona.locale},${language};q=0.9`,
|
|
595
636
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
|
596
637
|
'Accept-Encoding': 'gzip, deflate, br',
|
|
597
638
|
'Cache-Control': 'max-age=0',
|
|
598
639
|
'Upgrade-Insecure-Requests': '1',
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
640
|
+
// No Sec-Fetch-* here. They are per-request values the browser computes
|
|
641
|
+
// itself (a stylesheet is Sec-Fetch-Dest: style, not document), and
|
|
642
|
+
// forcing navigation values onto every request through
|
|
643
|
+
// setExtraHTTPHeaders made Chromium reject each subresource with
|
|
644
|
+
// ERR_INVALID_ARGUMENT — jQuery never loaded, so a JS-rendered page came
|
|
645
|
+
// back as a title and an empty body.
|
|
603
646
|
'sec-ch-ua-mobile': '?0',
|
|
604
647
|
'sec-ch-ua-platform': this.generateSecChUaPlatform(selectedOS)
|
|
605
648
|
};
|
|
@@ -666,7 +709,10 @@ export class StealthBrowserManager {
|
|
|
666
709
|
*/
|
|
667
710
|
generateWebRTCConfig(config) {
|
|
668
711
|
return {
|
|
669
|
-
|
|
712
|
+
// A "public" IP inside RFC1918 space is a contradiction any WebRTC probe
|
|
713
|
+
// can spot — the local candidates are the private ones, the public one
|
|
714
|
+
// has to be routable.
|
|
715
|
+
publicIP: config.webRTCPublicIP || this.generatePublicIPv4(),
|
|
670
716
|
localIPs: config.webRTCLocalIPs || [
|
|
671
717
|
'192.168.1.' + Math.floor(Math.random() * 255),
|
|
672
718
|
'10.0.0.' + Math.floor(Math.random() * 255)
|
|
@@ -674,6 +720,18 @@ export class StealthBrowserManager {
|
|
|
674
720
|
};
|
|
675
721
|
}
|
|
676
722
|
|
|
723
|
+
/**
|
|
724
|
+
* Random routable IPv4 address, drawn from /8s that carry ordinary
|
|
725
|
+
* residential traffic (no RFC1918, loopback, link-local, CGNAT, multicast or
|
|
726
|
+
* documentation ranges).
|
|
727
|
+
*/
|
|
728
|
+
generatePublicIPv4() {
|
|
729
|
+
const residentialPrefixes = [24, 47, 62, 71, 73, 86, 90, 92, 108, 176];
|
|
730
|
+
const first = residentialPrefixes[Math.floor(Math.random() * residentialPrefixes.length)];
|
|
731
|
+
const octet = () => Math.floor(Math.random() * 254) + 1;
|
|
732
|
+
return `${first}.${octet()}.${Math.floor(Math.random() * 256)}.${octet()}`;
|
|
733
|
+
}
|
|
734
|
+
|
|
677
735
|
/**
|
|
678
736
|
* Advanced Canvas fingerprinting protection with noise injection
|
|
679
737
|
*/
|
|
@@ -725,17 +783,32 @@ export class StealthBrowserManager {
|
|
|
725
783
|
/**
|
|
726
784
|
* Enhanced WebGL fingerprinting with realistic spoofing
|
|
727
785
|
*/
|
|
728
|
-
generateAdvancedWebGLFingerprint() {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
786
|
+
generateAdvancedWebGLFingerprint(selectedOS) {
|
|
787
|
+
// A Direct3D11 renderer on a Mac user agent is a contradiction, so the GPU
|
|
788
|
+
// string follows the OS: D3D11 on Windows, Metal on macOS, OpenGL on Linux.
|
|
789
|
+
const gpuVendorsByOS = {
|
|
790
|
+
windows: [
|
|
791
|
+
{ vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1060 6GB Direct3D11 vs_5_0 ps_5_0, D3D11)' },
|
|
792
|
+
{ vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
|
|
793
|
+
{ vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, Intel(R) HD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
|
|
794
|
+
{ vendor: 'Google Inc. (AMD)', renderer: 'ANGLE (AMD, AMD Radeon RX 580 Series Direct3D11 vs_5_0 ps_5_0, D3D11)' },
|
|
795
|
+
{ vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)' }
|
|
796
|
+
],
|
|
797
|
+
macos: [
|
|
798
|
+
{ vendor: 'Google Inc. (Apple)', renderer: 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Unspecified Version)' },
|
|
799
|
+
{ vendor: 'Google Inc. (Apple)', renderer: 'ANGLE (Apple, ANGLE Metal Renderer: Apple M2 Pro, Unspecified Version)' },
|
|
800
|
+
{ vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, ANGLE Metal Renderer: Intel(R) Iris(TM) Plus Graphics 640, Unspecified Version)' }
|
|
801
|
+
],
|
|
802
|
+
linux: [
|
|
803
|
+
{ vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, Mesa Intel(R) UHD Graphics 620 (KBL GT2), OpenGL 4.6)' },
|
|
804
|
+
{ vendor: 'Google Inc. (AMD)', renderer: 'ANGLE (AMD, AMD Radeon RX 6600 (radeonsi, navi23, LLVM 15.0.7), OpenGL 4.6)' },
|
|
805
|
+
{ vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060/PCIe/SSE2, OpenGL 4.6)' }
|
|
806
|
+
]
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const gpuVendors = gpuVendorsByOS[selectedOS] || gpuVendorsByOS.windows;
|
|
737
810
|
const selectedGpu = gpuVendors[Math.floor(Math.random() * gpuVendors.length)];
|
|
738
|
-
|
|
811
|
+
|
|
739
812
|
return {
|
|
740
813
|
vendor: selectedGpu.vendor,
|
|
741
814
|
renderer: selectedGpu.renderer,
|
|
@@ -850,24 +923,41 @@ export class StealthBrowserManager {
|
|
|
850
923
|
/**
|
|
851
924
|
* Enhanced media devices spoofing
|
|
852
925
|
*/
|
|
853
|
-
generateMediaDevicesFingerprint() {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
926
|
+
generateMediaDevicesFingerprint(selectedOS) {
|
|
927
|
+
// Device labels are OS-specific strings: a FaceTime HD Camera on a Win32
|
|
928
|
+
// navigator.platform is a giveaway, so the labels follow the chosen OS.
|
|
929
|
+
const labelsByOS = {
|
|
930
|
+
windows: {
|
|
931
|
+
video: ['HD Pro Webcam C920 (046d:082d)', 'Integrated Camera (04f2:b6d9)'],
|
|
932
|
+
audioinput: ['Microphone (Realtek(R) Audio)', 'Microphone Array (Intel® Smart Sound Technology)'],
|
|
933
|
+
audiooutput: ['Speakers (Realtek(R) Audio)', 'Headphones (Realtek(R) Audio)']
|
|
934
|
+
},
|
|
935
|
+
macos: {
|
|
936
|
+
video: ['FaceTime HD Camera', 'FaceTime HD Camera (Built-in)'],
|
|
937
|
+
audioinput: ['MacBook Pro Microphone', 'External Microphone'],
|
|
938
|
+
audiooutput: ['MacBook Pro Speakers', 'External Headphones']
|
|
939
|
+
},
|
|
940
|
+
linux: {
|
|
941
|
+
video: ['Integrated Camera: Integrated C', 'USB2.0 HD UVC WebCam'],
|
|
942
|
+
audioinput: ['Built-in Audio Analog Stereo', 'Monitor of Built-in Audio Analog Stereo'],
|
|
943
|
+
audiooutput: ['Built-in Audio Analog Stereo', 'HDMI / DisplayPort']
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const labels = labelsByOS[selectedOS] || labelsByOS.windows;
|
|
948
|
+
const pick = (list) => list[Math.floor(Math.random() * list.length)];
|
|
949
|
+
const device = (kind, label) => ({
|
|
950
|
+
deviceId: crypto.randomUUID(),
|
|
951
|
+
kind,
|
|
952
|
+
label,
|
|
953
|
+
groupId: crypto.randomUUID()
|
|
954
|
+
});
|
|
955
|
+
|
|
867
956
|
const selectedDevices = [];
|
|
868
|
-
if (Math.random() < 0.8) selectedDevices.push(
|
|
869
|
-
|
|
870
|
-
|
|
957
|
+
if (Math.random() < 0.8) selectedDevices.push(device('videoinput', pick(labels.video)));
|
|
958
|
+
selectedDevices.push(device('audioinput', pick(labels.audioinput)));
|
|
959
|
+
if (Math.random() < 0.9) selectedDevices.push(device('audiooutput', pick(labels.audiooutput)));
|
|
960
|
+
|
|
871
961
|
return selectedDevices;
|
|
872
962
|
}
|
|
873
963
|
|
|
@@ -917,7 +1007,7 @@ export class StealthBrowserManager {
|
|
|
917
1007
|
/**
|
|
918
1008
|
* Generate advanced font list with realistic variation
|
|
919
1009
|
*/
|
|
920
|
-
generateAdvancedFontList() {
|
|
1010
|
+
generateAdvancedFontList(selectedOS) {
|
|
921
1011
|
const baseFonts = [
|
|
922
1012
|
'Arial', 'Helvetica', 'Times New Roman', 'Courier New', 'Verdana',
|
|
923
1013
|
'Georgia', 'Palatino', 'Garamond', 'Bookman', 'Tahoma', 'Geneva'
|
|
@@ -937,12 +1027,11 @@ export class StealthBrowserManager {
|
|
|
937
1027
|
// Start with base fonts
|
|
938
1028
|
const fonts = [...baseFonts];
|
|
939
1029
|
|
|
940
|
-
// Add system-specific fonts
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1030
|
+
// Add system-specific fonts for the OS this fingerprint claims to run.
|
|
1031
|
+
// (This used to call selectRealisticPlatform() with no OS, which always
|
|
1032
|
+
// returned Win32 — so a macOS persona shipped a Windows-only font list.)
|
|
1033
|
+
const osKey = systemFonts[selectedOS] ? selectedOS : 'windows';
|
|
1034
|
+
|
|
946
1035
|
systemFonts[osKey].forEach(font => {
|
|
947
1036
|
if (Math.random() < 0.8) { // 80% chance to include
|
|
948
1037
|
fonts.push(font);
|
|
@@ -999,32 +1088,16 @@ export class StealthBrowserManager {
|
|
|
999
1088
|
}
|
|
1000
1089
|
|
|
1001
1090
|
/**
|
|
1002
|
-
* Generate realistic geolocation data
|
|
1091
|
+
* Generate realistic geolocation data for a locale persona.
|
|
1092
|
+
* The city comes from the persona (not a second independent draw), so the
|
|
1093
|
+
* coordinates always sit in the country whose timezone the fingerprint
|
|
1094
|
+
* reports.
|
|
1095
|
+
* @param {{latitude:number, longitude:number}} persona
|
|
1003
1096
|
*/
|
|
1004
|
-
generateRealisticGeolocation() {
|
|
1005
|
-
// Random coordinates in major cities with realistic distribution
|
|
1006
|
-
const cities = [
|
|
1007
|
-
{ latitude: 40.7128, longitude: -74.0060, weight: 0.15 }, // New York
|
|
1008
|
-
{ latitude: 34.0522, longitude: -118.2437, weight: 0.12 }, // Los Angeles
|
|
1009
|
-
{ latitude: 51.5074, longitude: -0.1278, weight: 0.10 }, // London
|
|
1010
|
-
{ latitude: 48.8566, longitude: 2.3522, weight: 0.08 }, // Paris
|
|
1011
|
-
{ latitude: 35.6762, longitude: 139.6503, weight: 0.07 }, // Tokyo
|
|
1012
|
-
{ latitude: -33.8688, longitude: 151.2093, weight: 0.05 }, // Sydney
|
|
1013
|
-
{ latitude: 52.5200, longitude: 13.4050, weight: 0.05 }, // Berlin
|
|
1014
|
-
{ latitude: 37.7749, longitude: -122.4194, weight: 0.08 }, // San Francisco
|
|
1015
|
-
{ latitude: 41.8781, longitude: -87.6298, weight: 0.06 }, // Chicago
|
|
1016
|
-
{ latitude: 55.7558, longitude: 37.6176, weight: 0.04 }, // Moscow
|
|
1017
|
-
{ latitude: 39.9042, longitude: 116.4074, weight: 0.06 }, // Beijing
|
|
1018
|
-
{ latitude: 28.6139, longitude: 77.2090, weight: 0.05 }, // Delhi
|
|
1019
|
-
{ latitude: -23.5505, longitude: -46.6333, weight: 0.04 }, // São Paulo
|
|
1020
|
-
{ latitude: 19.4326, longitude: -99.1332, weight: 0.05 } // Mexico City
|
|
1021
|
-
];
|
|
1022
|
-
|
|
1023
|
-
const city = this.weightedRandomFromArray(cities);
|
|
1024
|
-
|
|
1097
|
+
generateRealisticGeolocation(persona) {
|
|
1025
1098
|
return {
|
|
1026
|
-
latitude:
|
|
1027
|
-
longitude:
|
|
1099
|
+
latitude: persona.latitude + (Math.random() - 0.5) * 0.05, // ±0.025 degrees (~2.8km)
|
|
1100
|
+
longitude: persona.longitude + (Math.random() - 0.5) * 0.05,
|
|
1028
1101
|
accuracy: Math.floor(Math.random() * 50) + 20 // 20-70m accuracy
|
|
1029
1102
|
};
|
|
1030
1103
|
}
|
|
@@ -1066,7 +1139,7 @@ export class StealthBrowserManager {
|
|
|
1066
1139
|
*/
|
|
1067
1140
|
async applyAdvancedStealthConfigurations(context, config, fingerprint) {
|
|
1068
1141
|
// Enhanced initialization script with comprehensive stealth measures
|
|
1069
|
-
await context.addInitScript(() => {
|
|
1142
|
+
await context.addInitScript((locale) => {
|
|
1070
1143
|
// Remove webdriver property completely
|
|
1071
1144
|
Object.defineProperty(navigator, 'webdriver', {
|
|
1072
1145
|
get: () => undefined,
|
|
@@ -1100,9 +1173,14 @@ export class StealthBrowserManager {
|
|
|
1100
1173
|
originalQuery(parameters)
|
|
1101
1174
|
);
|
|
1102
1175
|
|
|
1103
|
-
// Hide headless indicators
|
|
1176
|
+
// Hide headless indicators. configurable: true because the hardware
|
|
1177
|
+
// spoofing script below redefines this with the fingerprint's own core
|
|
1178
|
+
// count — without it that redefinition throws "Cannot redefine property"
|
|
1179
|
+
// and takes navigator.platform and deviceMemory down with it, so the
|
|
1180
|
+
// page saw a Win32 platform on every persona.
|
|
1104
1181
|
Object.defineProperty(navigator, 'hardwareConcurrency', {
|
|
1105
|
-
get: () => 4
|
|
1182
|
+
get: () => 4,
|
|
1183
|
+
configurable: true
|
|
1106
1184
|
});
|
|
1107
1185
|
|
|
1108
1186
|
// Spoof connection
|
|
@@ -1139,10 +1217,13 @@ export class StealthBrowserManager {
|
|
|
1139
1217
|
}
|
|
1140
1218
|
});
|
|
1141
1219
|
|
|
1142
|
-
// Override languages with
|
|
1220
|
+
// Override languages with the fingerprint's own locale — a hardcoded
|
|
1221
|
+
// en-US here contradicts navigator.language and Accept-Language whenever
|
|
1222
|
+
// the persona is not American.
|
|
1143
1223
|
Object.defineProperty(navigator, 'languages', {
|
|
1144
1224
|
get: function() {
|
|
1145
|
-
|
|
1225
|
+
const primary = locale.split('-')[0];
|
|
1226
|
+
return primary === locale ? [locale] : [locale, primary];
|
|
1146
1227
|
}
|
|
1147
1228
|
});
|
|
1148
1229
|
|
|
@@ -1188,7 +1269,7 @@ export class StealthBrowserManager {
|
|
|
1188
1269
|
return originalPrepareStackTrace.call(this, error, filteredStack);
|
|
1189
1270
|
};
|
|
1190
1271
|
}
|
|
1191
|
-
});
|
|
1272
|
+
}, fingerprint.locale || config.locale || 'en-US');
|
|
1192
1273
|
|
|
1193
1274
|
// WebRTC leak prevention with advanced spoofing
|
|
1194
1275
|
if (config.blockWebRTC) {
|
|
@@ -9,6 +9,7 @@ import { promises as fs } from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import RetryManager from '../utils/RetryManager.js';
|
|
11
11
|
import { safeFetch } from '../utils/ssrfGuard.js';
|
|
12
|
+
import { identityHeaders } from '../utils/fetchIdentity.js';
|
|
12
13
|
|
|
13
14
|
export class WebhookDispatcher extends EventEmitter {
|
|
14
15
|
constructor(options = {}) {
|
|
@@ -374,7 +375,7 @@ export class WebhookDispatcher extends EventEmitter {
|
|
|
374
375
|
|
|
375
376
|
// Add standard headers
|
|
376
377
|
headers['Content-Type'] = 'application/json';
|
|
377
|
-
headers
|
|
378
|
+
Object.assign(headers, identityHeaders({ role: 'webhook' }));
|
|
378
379
|
headers['X-Webhook-Event'] = event.type;
|
|
379
380
|
headers['X-Webhook-ID'] = event.id;
|
|
380
381
|
headers['X-Webhook-Timestamp'] = event.timestamp.toString();
|
|
@@ -550,9 +551,7 @@ export class WebhookDispatcher extends EventEmitter {
|
|
|
550
551
|
const response = await safeFetch(url, {
|
|
551
552
|
method: 'HEAD',
|
|
552
553
|
signal: AbortSignal.timeout(config.timeout / 2), // Use half timeout for health checks
|
|
553
|
-
headers: {
|
|
554
|
-
'User-Agent': 'WebhookDispatcher-HealthCheck/1.0'
|
|
555
|
-
}
|
|
554
|
+
headers: identityHeaders({ role: 'health-check' })
|
|
556
555
|
});
|
|
557
556
|
|
|
558
557
|
const duration = Date.now() - startTime;
|
|
@@ -65,8 +65,10 @@ const AnalysisResult = z.object({
|
|
|
65
65
|
type: z.string()
|
|
66
66
|
})).optional(),
|
|
67
67
|
readability: z.object({
|
|
68
|
-
score
|
|
69
|
-
|
|
68
|
+
// score/level are absent when Flesch does not apply (CJK) — see notApplicable
|
|
69
|
+
score: z.number().optional(),
|
|
70
|
+
level: z.string().optional(),
|
|
71
|
+
notApplicable: z.string().optional(),
|
|
70
72
|
metrics: z.object({
|
|
71
73
|
sentences: z.number(),
|
|
72
74
|
words: z.number(),
|
|
@@ -244,6 +246,19 @@ export class ContentAnalyzer {
|
|
|
244
246
|
return words;
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Split text into words: dictionary segmentation for CJK, whitespace
|
|
251
|
+
* elsewhere. Shared by the statistics and readability blocks so the two
|
|
252
|
+
* cannot report different word counts for the same text.
|
|
253
|
+
* @param {string} text - Text to tokenize
|
|
254
|
+
* @returns {string[]} - Word tokens
|
|
255
|
+
*/
|
|
256
|
+
tokenizeWords(text) {
|
|
257
|
+
return this.isCjkText(text)
|
|
258
|
+
? this.segmentWords(text)
|
|
259
|
+
: text.split(/\s+/).filter(w => w.length > 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
247
262
|
/**
|
|
248
263
|
* Analyze text content with multiple NLP techniques
|
|
249
264
|
* @param {Object} params - Analysis parameters
|
|
@@ -833,7 +848,8 @@ export class ContentAnalyzer {
|
|
|
833
848
|
async calculateReadability(text) {
|
|
834
849
|
try {
|
|
835
850
|
const sentences = splitSentences(text);
|
|
836
|
-
const
|
|
851
|
+
const isCjk = this.isCjkText(text);
|
|
852
|
+
const words = this.tokenizeWords(text);
|
|
837
853
|
const characters = text.length;
|
|
838
854
|
const charactersNoSpaces = text.replace(/\s/g, '').length;
|
|
839
855
|
|
|
@@ -851,21 +867,33 @@ export class ContentAnalyzer {
|
|
|
851
867
|
const avgCharsPerWord = charactersNoSpaces / Math.max(words.length, 1);
|
|
852
868
|
const avgSyllablesPerWord = totalSyllables / Math.max(words.length, 1);
|
|
853
869
|
|
|
870
|
+
const metrics = {
|
|
871
|
+
sentences: sentences.length,
|
|
872
|
+
words: words.length,
|
|
873
|
+
characters,
|
|
874
|
+
avgWordsPerSentence: Math.round(avgWordsPerSentence * 100) / 100,
|
|
875
|
+
avgCharsPerWord: Math.round(avgCharsPerWord * 100) / 100,
|
|
876
|
+
complexWords,
|
|
877
|
+
syllables: totalSyllables
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// Flesch is syllable-based and says nothing about CJK scripts. Report the
|
|
881
|
+
// metrics with an explicit reason instead of a fabricated score — a null
|
|
882
|
+
// return above already means "failed", so the two stay distinguishable.
|
|
883
|
+
if (isCjk) {
|
|
884
|
+
return {
|
|
885
|
+
notApplicable: 'flesch-requires-syllable-based-language',
|
|
886
|
+
metrics
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
854
890
|
// Flesch Reading Ease Score
|
|
855
891
|
const fleschScore = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord);
|
|
856
892
|
|
|
857
893
|
return {
|
|
858
894
|
score: Math.round(Math.max(0, Math.min(100, fleschScore)) * 100) / 100,
|
|
859
895
|
level: this.getReadabilityLevel(fleschScore),
|
|
860
|
-
metrics
|
|
861
|
-
sentences: sentences.length,
|
|
862
|
-
words: words.length,
|
|
863
|
-
characters,
|
|
864
|
-
avgWordsPerSentence: Math.round(avgWordsPerSentence * 100) / 100,
|
|
865
|
-
avgCharsPerWord: Math.round(avgCharsPerWord * 100) / 100,
|
|
866
|
-
complexWords,
|
|
867
|
-
syllables: totalSyllables
|
|
868
|
-
}
|
|
896
|
+
metrics
|
|
869
897
|
};
|
|
870
898
|
|
|
871
899
|
} catch (error) {
|
|
@@ -940,9 +968,7 @@ export class ContentAnalyzer {
|
|
|
940
968
|
const characters = text.length;
|
|
941
969
|
const charactersNoSpaces = text.replace(/\s/g, '').length;
|
|
942
970
|
// CJK text has no whitespace between words — segment by dictionary instead
|
|
943
|
-
const words = this.
|
|
944
|
-
? this.segmentWords(text)
|
|
945
|
-
: text.split(/\s+/).filter(w => w.length > 0);
|
|
971
|
+
const words = this.tokenizeWords(text);
|
|
946
972
|
const sentences = splitSentences(text);
|
|
947
973
|
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0);
|
|
948
974
|
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* domain names, and other common patterns that contain periods.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
// CJK / fullwidth sentence terminators. Unlike the ASCII '.' these are
|
|
7
|
+
// unambiguous — no abbreviation, decimal or initial uses them — and CJK text
|
|
8
|
+
// puts no whitespace after them, so they split on a zero-width boundary.
|
|
9
|
+
const CJK_TERMINATORS = '。.!?;';
|
|
10
|
+
|
|
6
11
|
// Common abbreviations that should not trigger sentence splits
|
|
7
12
|
const ABBREVIATIONS = new Set([
|
|
8
13
|
'mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'st', 'ave', 'blvd',
|
|
@@ -26,17 +31,18 @@ export function splitSentences(text) {
|
|
|
26
31
|
const sentences = [];
|
|
27
32
|
let current = '';
|
|
28
33
|
|
|
29
|
-
// Split by potential sentence boundaries: . ! ?
|
|
34
|
+
// Split by potential sentence boundaries: . ! ? and the CJK terminators
|
|
30
35
|
// But be smart about abbreviations, numbers, and domain-like patterns
|
|
31
|
-
const tokens = text.split(/(?<=[.!?])\s
|
|
36
|
+
const tokens = text.split(/(?<=[.!?])\s+|(?<=[。.!?;])\s*/);
|
|
32
37
|
|
|
33
38
|
for (const token of tokens) {
|
|
34
39
|
const combined = current ? current + ' ' + token : token;
|
|
35
40
|
|
|
36
41
|
// Check if the current chunk ends with something that looks like a sentence end
|
|
37
|
-
|
|
42
|
+
const endMatch = combined.match(/([.!?。.!?;])\s*$/);
|
|
43
|
+
if (endMatch) {
|
|
38
44
|
// Check if the period is likely NOT a sentence boundary
|
|
39
|
-
const beforePeriod = combined.replace(/[
|
|
45
|
+
const beforePeriod = combined.replace(/[.!?。.!?;]\s*$/, '');
|
|
40
46
|
const lastWord = beforePeriod.split(/\s+/).pop() || '';
|
|
41
47
|
const lastWordLower = lastWord.toLowerCase().replace(/[^a-z]/g, '');
|
|
42
48
|
|
|
@@ -48,7 +54,12 @@ export function splitSentences(text) {
|
|
|
48
54
|
// Single letter followed by period (initials like "A. Smith")
|
|
49
55
|
const isInitial = /^[A-Z]\.$/.test(lastWord);
|
|
50
56
|
|
|
51
|
-
|
|
57
|
+
// Those four checks are ASCII-oriented (they strip anything outside
|
|
58
|
+
// [a-z]), so a CJK terminator must never be judged by them — otherwise
|
|
59
|
+
// "使用Node.js开发。" is swallowed by hasInternalPeriods.
|
|
60
|
+
const isAmbiguous = !CJK_TERMINATORS.includes(endMatch[1]);
|
|
61
|
+
|
|
62
|
+
if (isAmbiguous && (isAbbreviation || hasInternalPeriods || isDecimal || isInitial)) {
|
|
52
63
|
// Not a real sentence boundary — accumulate
|
|
53
64
|
current = combined;
|
|
54
65
|
} else {
|