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.
Files changed (71) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +9 -9
  3. package/package.json +2 -2
  4. package/server.js +175 -26
  5. package/src/cli/commands/stealth.js +7 -1
  6. package/src/constants/config.js +2 -1
  7. package/src/core/ActionExecutor.js +168 -16
  8. package/src/core/AlertNotificationSystem.js +2 -1
  9. package/src/core/AuthManager.js +19 -1
  10. package/src/core/ChangeTracker.js +34 -6
  11. package/src/core/LLMsTxtAnalyzer.js +94 -12
  12. package/src/core/LocalizationManager.js +2 -1
  13. package/src/core/ResearchOrchestrator.js +407 -86
  14. package/src/core/StealthBrowserManager.js +186 -105
  15. package/src/core/WebhookDispatcher.js +3 -4
  16. package/src/core/analysis/ContentAnalyzer.js +41 -15
  17. package/src/core/analysis/sentenceUtils.js +16 -5
  18. package/src/core/crawlers/BFSCrawler.js +44 -21
  19. package/src/core/llm/LLMManager.js +517 -13
  20. package/src/core/processing/BrowserProcessor.js +27 -0
  21. package/src/core/processing/ContentProcessor.js +11 -39
  22. package/src/core/processing/PDFProcessor.js +2 -3
  23. package/src/core/research/claimFilters.js +235 -0
  24. package/src/schemas/toolOutputSchemas.js +5 -1
  25. package/src/security/wave3-security.js +2 -1
  26. package/src/server/requestContext.js +23 -0
  27. package/src/server/withAuth.js +21 -5
  28. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  29. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  30. package/src/tools/advanced/batchScrape/schema.js +4 -0
  31. package/src/tools/advanced/batchScrape/worker.js +19 -10
  32. package/src/tools/basic/_fetch.js +19 -15
  33. package/src/tools/basic/extractLinks.js +8 -3
  34. package/src/tools/basic/extractMetadata.js +7 -3
  35. package/src/tools/basic/extractText.js +8 -3
  36. package/src/tools/basic/fetchUrl.js +7 -3
  37. package/src/tools/basic/scrapeStructured.js +76 -3
  38. package/src/tools/crawl/_sessionContext.js +10 -2
  39. package/src/tools/crawl/crawlDeep.js +29 -12
  40. package/src/tools/crawl/mapSite.js +39 -14
  41. package/src/tools/extract/_fetchAndParse.js +23 -8
  42. package/src/tools/extract/analyzeContent.js +5 -3
  43. package/src/tools/extract/extractContent.js +18 -4
  44. package/src/tools/extract/extractStructured.js +66 -12
  45. package/src/tools/extract/extractWithLlm.js +51 -4
  46. package/src/tools/extract/processDocument.js +45 -78
  47. package/src/tools/extract/summarizeContent.js +35 -1
  48. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  49. package/src/tools/research/deepResearch.js +2 -1
  50. package/src/tools/scrape/_brandingExtractor.js +42 -3
  51. package/src/tools/scrape/_mainContent.js +105 -0
  52. package/src/tools/scrape/unifiedScrape.js +21 -14
  53. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  54. package/src/tools/search/redditSearch.js +6 -3
  55. package/src/tools/search/searchWeb.js +26 -3
  56. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  57. package/src/tools/tracking/trackChanges/differ.js +26 -3
  58. package/src/tools/tracking/trackChanges/index.js +12 -5
  59. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  60. package/src/tools/tracking/trackChanges/schema.js +3 -0
  61. package/src/utils/complianceAudit.js +72 -0
  62. package/src/utils/contentUtils.js +12 -1
  63. package/src/utils/domainFilter.js +38 -19
  64. package/src/utils/fetchIdentity.js +62 -0
  65. package/src/utils/hostBlocklist.js +81 -0
  66. package/src/utils/hostRateLimiter.js +101 -2
  67. package/src/utils/robotsChecker.js +90 -43
  68. package/src/utils/robotsGate.js +206 -0
  69. package/src/utils/sitemapParser.js +33 -15
  70. package/src/utils/ssrfProtection.js +2 -1
  71. 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
- // Timezone options
203
- this.timezones = [
204
- 'America/New_York',
205
- 'America/Los_Angeles',
206
- 'America/Chicago',
207
- 'America/Denver',
208
- 'Europe/London',
209
- 'Europe/Berlin',
210
- 'Europe/Paris',
211
- 'Europe/Madrid',
212
- 'Asia/Tokyo',
213
- 'Asia/Shanghai',
214
- 'Asia/Seoul',
215
- 'Australia/Sydney',
216
- 'Australia/Melbourne'
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 once and thread it through UA, headers, and hardware so
483
- // navigator.platform / sec-ch-ua-platform / userAgent stay consistent.
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: this.selectRealisticUserAgent(config, selectedOS),
493
+ userAgent,
494
+ locale: persona.locale,
487
495
  viewport: config.customViewport || this.selectWeightedViewport(),
488
- timezone: config.timezone || this.selectTimezone(),
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
- * Select timezone
581
- */
582
- selectTimezone() {
583
- return this.timezones[Math.floor(Math.random() * this.timezones.length)];
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
- // Resolve the UA first so sec-ch-ua brand version can match.
591
- const resolvedUA = this.selectRealisticUserAgent(config, selectedOS);
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': `${(config.locale || 'en-US').toLowerCase()},en;q=0.9`,
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
- 'Sec-Fetch-Dest': 'document',
600
- 'Sec-Fetch-Mode': 'navigate',
601
- 'Sec-Fetch-Site': 'none',
602
- 'Sec-Fetch-User': '?1',
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
- publicIP: config.webRTCPublicIP || '192.168.1.' + Math.floor(Math.random() * 255),
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
- const gpuVendors = [
730
- { vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1060 6GB Direct3D11 vs_5_0 ps_5_0, D3D11)' },
731
- { vendor: 'Google Inc. (NVIDIA)', renderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
732
- { vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, Intel(R) HD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
733
- { vendor: 'Google Inc. (AMD)', renderer: 'ANGLE (AMD, AMD Radeon RX 580 Series Direct3D11 vs_5_0 ps_5_0, D3D11)' },
734
- { vendor: 'Google Inc. (Intel)', renderer: 'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)' }
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
- const videoDevices = [
855
- { deviceId: crypto.randomUUID(), kind: 'videoinput', label: 'HD Pro Webcam C920 (046d:082d)', groupId: crypto.randomUUID() },
856
- { deviceId: crypto.randomUUID(), kind: 'videoinput', label: 'FaceTime HD Camera', groupId: crypto.randomUUID() },
857
- { deviceId: crypto.randomUUID(), kind: 'videoinput', label: 'Integrated Camera', groupId: crypto.randomUUID() }
858
- ];
859
-
860
- const audioDevices = [
861
- { deviceId: crypto.randomUUID(), kind: 'audioinput', label: 'Default - Internal Microphone', groupId: crypto.randomUUID() },
862
- { deviceId: crypto.randomUUID(), kind: 'audiooutput', label: 'Default - Internal Speakers', groupId: crypto.randomUUID() },
863
- { deviceId: crypto.randomUUID(), kind: 'audioinput', label: 'Communications - Internal Microphone', groupId: crypto.randomUUID() }
864
- ];
865
-
866
- // Randomly select devices
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(videoDevices[Math.floor(Math.random() * videoDevices.length)]);
869
- if (Math.random() < 0.9) selectedDevices.push(...audioDevices.slice(0, Math.floor(Math.random() * audioDevices.length) + 1));
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 based on platform
941
- const platform = this.selectRealisticPlatform();
942
- let osKey = 'windows';
943
- if (platform.includes('Mac')) osKey = 'macos';
944
- else if (platform.includes('Linux')) osKey = 'linux';
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: city.latitude + (Math.random() - 0.5) * 0.05, // ±0.025 degrees (~2.8km)
1027
- longitude: city.longitude + (Math.random() - 0.5) * 0.05,
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 realistic patterns
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
- return ['en-US', 'en'];
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['User-Agent'] = 'WebhookDispatcher/1.0';
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: z.number(),
69
- level: z.string(),
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 words = text.split(/\s+/).filter(w => w.length > 0);
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.isCjkText(text)
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
- if (/[.!?]\s*$/.test(combined)) {
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(/[.!?]\s*$/, '');
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
- if (isAbbreviation || hasInternalPeriods || isDecimal || isInitial) {
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 {