@fanboynz/network-scanner 2.0.51 → 2.0.53

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 (3) hide show
  1. package/lib/fingerprint.js +510 -157
  2. package/nwss.js +62 -24
  3. package/package.json +1 -1
@@ -54,9 +54,9 @@ const BUILT_IN_PROPERTIES = new Set([
54
54
 
55
55
  // User agent collections with latest versions
56
56
  const USER_AGENT_COLLECTIONS = Object.freeze(new Map([
57
- ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"],
58
- ['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"],
59
- ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"],
57
+ ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],
58
+ ['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],
59
+ ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],
60
60
  ['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0"],
61
61
  ['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0"],
62
62
  ['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0"],
@@ -64,6 +64,50 @@ const USER_AGENT_COLLECTIONS = Object.freeze(new Map([
64
64
  ]));
65
65
 
66
66
  // Timezone configuration with offsets
67
+
68
+ // GPU pool — realistic vendor/renderer combos per OS (used for WebGL spoofing)
69
+ const GPU_POOL = {
70
+ windows: [
71
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
72
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (Intel(R) UHD Graphics 770 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
73
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)' },
74
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (NVIDIA GeForce GTX 1650 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
75
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (NVIDIA GeForce GTX 1060 6GB Direct3D11 vs_5_0 ps_5_0, D3D11)' },
76
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
77
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (NVIDIA GeForce RTX 4060 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
78
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (AMD Radeon RX 580 Direct3D11 vs_5_0 ps_5_0, D3D11)' },
79
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (AMD Radeon(TM) Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)' },
80
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (AMD Radeon RX 6600 XT Direct3D11 vs_5_0 ps_5_0, D3D11)' },
81
+ ],
82
+ mac: [
83
+ { vendor: 'Apple', renderer: 'Apple M1' },
84
+ { vendor: 'Apple', renderer: 'Apple M1 Pro' },
85
+ { vendor: 'Apple', renderer: 'Apple M2' },
86
+ { vendor: 'Apple', renderer: 'Apple M3' },
87
+ { vendor: 'Intel Inc.', renderer: 'Intel(R) UHD Graphics 630' },
88
+ { vendor: 'Intel Inc.', renderer: 'Intel(R) Iris(TM) Plus Graphics 655' },
89
+ { vendor: 'Intel Inc.', renderer: 'Intel(R) Iris(TM) Plus Graphics' },
90
+ ],
91
+ linux: [
92
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (Intel(R) UHD Graphics 630, Mesa 23.2.1, OpenGL 4.6)' },
93
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (Intel(R) UHD Graphics 770, Mesa 24.0.3, OpenGL 4.6)' },
94
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (NVIDIA GeForce GTX 1080, NVIDIA 535.183.01, OpenGL 4.6.0)' },
95
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (NVIDIA GeForce RTX 3070, NVIDIA 545.29.06, OpenGL 4.6.0)' },
96
+ { vendor: 'Google Inc. (ANGLE)', renderer: 'ANGLE (AMD Radeon RX 580, Mesa 23.2.1, OpenGL 4.6)' },
97
+ ]
98
+ };
99
+
100
+ /**
101
+ * Select a GPU from the pool based on user agent string.
102
+ * Called once per browser session so the GPU stays consistent across page loads.
103
+ */
104
+ function selectGpuForUserAgent(userAgentString) {
105
+ let osKey = 'windows';
106
+ if (userAgentString && (userAgentString.includes('Macintosh') || userAgentString.includes('Mac OS X'))) osKey = 'mac';
107
+ else if (userAgentString && (userAgentString.includes('X11; Linux') || userAgentString.includes('Ubuntu'))) osKey = 'linux';
108
+ const pool = GPU_POOL[osKey];
109
+ return pool[Math.floor(Math.random() * pool.length)];
110
+ }
67
111
  const TIMEZONE_CONFIG = {
68
112
  'America/New_York': { offset: 300, abbr: 'EST' },
69
113
  'America/Los_Angeles': { offset: 480, abbr: 'PST' },
@@ -267,7 +311,11 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
267
311
  if (forceDebug) console.log(`[debug] Applying stealth protection for ${currentUrl}`);
268
312
 
269
313
  try {
270
- await page.evaluateOnNewDocument((userAgent, debugEnabled) => {
314
+ // Select GPU once per session — stays consistent across all page loads
315
+ const selectedGpu = selectGpuForUserAgent(ua);
316
+ if (forceDebug) console.log(`[debug] Selected GPU: ${selectedGpu.vendor} / ${selectedGpu.renderer}`);
317
+
318
+ await page.evaluateOnNewDocument((userAgent, debugEnabled, gpuConfig) => {
271
319
 
272
320
  // Apply inline error suppression first
273
321
  (function() {
@@ -394,7 +442,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
394
442
  try {
395
443
  delete navigator.webdriver;
396
444
  } catch (e) {}
397
- safeDefinePropertyLocal(navigator, 'webdriver', { get: () => undefined });
445
+ safeDefinePropertyLocal(navigator, 'webdriver', { get: () => false });
398
446
  }, 'webdriver removal');
399
447
 
400
448
  // Remove automation properties
@@ -427,83 +475,105 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
427
475
  //
428
476
  safeExecute(() => {
429
477
  if (!window.chrome) {
430
- window.chrome = {
431
- runtime: {
432
- onConnect: { addListener: () => {}, removeListener: () => {} },
433
- onMessage: { addListener: () => {}, removeListener: () => {} },
434
- sendMessage: () => {},
435
- connect: () => ({
436
- onMessage: { addListener: () => {}, removeListener: () => {} },
437
- postMessage: () => {},
438
- disconnect: () => {}
439
- }),
440
- getManifest: () => ({
441
- name: "Chrome",
442
- version: "142.0.0.0",
443
- manifest_version: 3,
444
- description: "Chrome Browser"
445
- }),
446
- getURL: (path) => `chrome-extension://invalid/${path}`,
447
- id: undefined,
448
- getPlatformInfo: (callback) => callback({
449
- os: navigator.platform.includes('Win') ? 'win' :
450
- navigator.platform.includes('Mac') ? 'mac' : 'linux',
451
- arch: 'x86-64',
452
- nacl_arch: 'x86-64'
453
- })
454
- },
455
- storage: {
456
- local: {
457
- get: (keys, callback) => callback && callback({}),
458
- set: (items, callback) => callback && callback(),
459
- remove: (keys, callback) => callback && callback(),
460
- clear: (callback) => callback && callback()
461
- },
462
- sync: {
463
- get: (keys, callback) => callback && callback({}),
464
- set: (items, callback) => callback && callback(),
465
- remove: (keys, callback) => callback && callback(),
466
- clear: (callback) => callback && callback()
467
- }
468
- },
469
- loadTimes: () => {
470
- const now = performance.now();
471
- return {
472
- commitLoadTime: now - Math.random() * 1000,
473
- connectionInfo: 'http/1.1',
474
- finishDocumentLoadTime: now - Math.random() * 500,
475
- finishLoadTime: now - Math.random() * 100,
476
- navigationType: 'Navigation'
477
- };
478
- },
479
- csi: () => ({
480
- onloadT: Date.now(),
481
- pageT: Math.random() * 1000,
482
- startE: Date.now() - Math.random() * 2000
478
+ window.chrome = {};
479
+ }
480
+
481
+ // Add runtime if missing headless Chrome has chrome object but no runtime
482
+ if (!window.chrome.runtime) {
483
+ window.chrome.runtime = {
484
+ onConnect: { addListener: () => {}, removeListener: () => {} },
485
+ onMessage: { addListener: () => {}, removeListener: () => {} },
486
+ sendMessage: () => {},
487
+ connect: () => ({
488
+ onMessage: { addListener: () => {}, removeListener: () => {} },
489
+ postMessage: () => {},
490
+ disconnect: () => {}
491
+ }),
492
+ getManifest: () => ({
493
+ name: "Chrome",
494
+ version: "145.0.0.0",
495
+ manifest_version: 3,
496
+ description: "Chrome Browser"
497
+ }),
498
+ getURL: (path) => `chrome-extension://invalid/${path}`,
499
+ id: undefined,
500
+ getPlatformInfo: (callback) => callback({
501
+ os: navigator.platform.includes('Win') ? 'win' :
502
+ navigator.platform.includes('Mac') ? 'mac' : 'linux',
503
+ arch: 'x86-64',
504
+ nacl_arch: 'x86-64'
483
505
  })
484
506
  };
485
-
486
- // Make chrome object non-enumerable to match real Chrome
487
- Object.defineProperty(window, 'chrome', {
488
- value: window.chrome,
489
- writable: false,
490
- enumerable: false,
491
- configurable: true
492
- });
493
-
494
- // Add Chrome-specific globals that Cloudflare might check
495
- if (!window.external) {
496
- window.external = {
497
- AddSearchProvider: () => {},
498
- IsSearchProviderInstalled: () => 0
499
- };
500
- }
501
-
502
- // Ensure chrome.runtime appears as a native object
503
507
  Object.defineProperty(window.chrome.runtime, 'toString', {
504
508
  value: () => '[object Object]'
505
509
  });
506
510
  }
511
+
512
+ // Add app if missing
513
+ if (!window.chrome.app) {
514
+ window.chrome.app = {
515
+ isInstalled: false,
516
+ InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' },
517
+ RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' },
518
+ getDetails: () => null,
519
+ getIsInstalled: () => false
520
+ };
521
+ }
522
+
523
+ // Add storage if missing
524
+ if (!window.chrome.storage) {
525
+ window.chrome.storage = {
526
+ local: {
527
+ get: (keys, callback) => callback && callback({}),
528
+ set: (items, callback) => callback && callback(),
529
+ remove: (keys, callback) => callback && callback(),
530
+ clear: (callback) => callback && callback()
531
+ },
532
+ sync: {
533
+ get: (keys, callback) => callback && callback({}),
534
+ set: (items, callback) => callback && callback(),
535
+ remove: (keys, callback) => callback && callback(),
536
+ clear: (callback) => callback && callback()
537
+ }
538
+ };
539
+ }
540
+
541
+ // Add loadTimes/csi if missing
542
+ if (!window.chrome.loadTimes) {
543
+ window.chrome.loadTimes = () => {
544
+ const now = performance.now();
545
+ return {
546
+ commitLoadTime: now - Math.random() * 1000,
547
+ connectionInfo: 'http/1.1',
548
+ finishDocumentLoadTime: now - Math.random() * 500,
549
+ finishLoadTime: now - Math.random() * 100,
550
+ navigationType: 'Navigation'
551
+ };
552
+ };
553
+ }
554
+ if (!window.chrome.csi) {
555
+ window.chrome.csi = () => ({
556
+ onloadT: Date.now(),
557
+ pageT: Math.random() * 1000,
558
+ startE: Date.now() - Math.random() * 2000
559
+ });
560
+ }
561
+
562
+ // Make chrome object non-enumerable to match real Chrome
563
+ Object.defineProperty(window, 'chrome', {
564
+ value: window.chrome,
565
+ writable: false,
566
+ enumerable: false,
567
+ configurable: true
568
+ });
569
+
570
+ // Add Chrome-specific globals that Cloudflare might check
571
+ if (!window.external) {
572
+ window.external = {
573
+ AddSearchProvider: () => {},
574
+ IsSearchProviderInstalled: () => 0
575
+ };
576
+ }
507
577
  }, 'Chrome runtime simulation');
508
578
 
509
579
  // Add realistic Chrome browser behavior
@@ -568,6 +638,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
568
638
  plugins = [];
569
639
  }
570
640
  // Create proper array-like object with enumerable indices and length
641
+ // Create proper PluginArray-like object with required methods
571
642
  const pluginsArray = {};
572
643
  plugins.forEach((plugin, index) => {
573
644
  pluginsArray[index] = plugin;
@@ -581,6 +652,13 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
581
652
  configurable: false
582
653
  });
583
654
 
655
+ // PluginArray methods that bot detectors check for
656
+ pluginsArray.item = function(i) { return plugins[i] || null; };
657
+ pluginsArray.namedItem = function(name) { return plugins.find(p => p.name === name) || null; };
658
+ pluginsArray.refresh = function() {};
659
+ pluginsArray[Symbol.iterator] = function*() { for (const p of plugins) yield p; };
660
+ pluginsArray[Symbol.toStringTag] = 'PluginArray';
661
+
584
662
  safeDefinePropertyLocal(navigator, 'plugins', { get: () => pluginsArray });
585
663
  }, 'plugins spoofing');
586
664
 
@@ -614,6 +692,71 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
614
692
  spoofNavigatorProperties(navigator, vendorProps);
615
693
  }, 'vendor/product spoofing');
616
694
 
695
+ // navigator.userAgentData — Chrome's Client Hints JS API
696
+ // Detection scripts check this; headless may have it missing or inconsistent
697
+ safeExecute(() => {
698
+ if (!userAgent.includes('Chrome/')) return; // Only for Chrome UAs
699
+
700
+ const chromeMatch = userAgent.match(/Chrome\/(\d+)/);
701
+ const majorVersion = chromeMatch ? chromeMatch[1] : '145';
702
+
703
+ let platform = 'Windows';
704
+ let platformVersion = '15.0.0';
705
+ let architecture = 'x86';
706
+ let model = '';
707
+ let bitness = '64';
708
+ if (userAgent.includes('Macintosh') || userAgent.includes('Mac OS X')) {
709
+ platform = 'macOS';
710
+ platformVersion = '13.5.0';
711
+ architecture = 'arm';
712
+ } else if (userAgent.includes('X11; Linux')) {
713
+ platform = 'Linux';
714
+ platformVersion = '6.5.0';
715
+ architecture = 'x86';
716
+ }
717
+
718
+ const brands = [
719
+ { brand: 'Not:A-Brand', version: '99' },
720
+ { brand: 'Google Chrome', version: majorVersion },
721
+ { brand: 'Chromium', version: majorVersion }
722
+ ];
723
+
724
+ const uaData = {
725
+ brands: brands,
726
+ mobile: false,
727
+ platform: platform,
728
+ getHighEntropyValues: function(hints) {
729
+ const result = {
730
+ brands: brands,
731
+ mobile: false,
732
+ platform: platform,
733
+ architecture: architecture,
734
+ bitness: bitness,
735
+ model: model,
736
+ platformVersion: platformVersion,
737
+ fullVersionList: [
738
+ { brand: 'Not:A-Brand', version: '99.0.0.0' },
739
+ { brand: 'Google Chrome', version: majorVersion + '.0.7632.160' },
740
+ { brand: 'Chromium', version: majorVersion + '.0.7632.160' }
741
+ ]
742
+ };
743
+ // Only return requested hints
744
+ const filtered = {};
745
+ for (const hint of hints) {
746
+ if (result.hasOwnProperty(hint)) filtered[hint] = result[hint];
747
+ }
748
+ return Promise.resolve(filtered);
749
+ },
750
+ toJSON: function() {
751
+ return { brands: brands, mobile: false, platform: platform };
752
+ }
753
+ };
754
+ Object.defineProperty(navigator, 'userAgentData', {
755
+ get: () => uaData,
756
+ configurable: true
757
+ });
758
+ }, 'navigator.userAgentData spoofing');
759
+
617
760
  // Enhanced OS fingerprinting protection based on actual user agent content
618
761
  //
619
762
  safeExecute(() => {
@@ -767,13 +910,18 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
767
910
  };
768
911
  }, 'fingerprinting mocks');
769
912
 
913
+ // GPU identity — selected once per browser session, passed from Node.js
914
+ const GPU_VENDOR = gpuConfig.vendor;
915
+ const GPU_RENDERER = gpuConfig.renderer;
916
+ if (debugEnabled) console.log(`[fingerprint] GPU: ${GPU_VENDOR} / ${GPU_RENDERER}`);
917
+
770
918
  // WebGL spoofing
771
919
  //
772
920
  safeExecute(() => {
773
921
  // Enhanced WebGL fingerprinting protection
774
922
  const webglParams = {
775
- 37445: 'Intel Inc.', // VENDOR
776
- 37446: 'Intel(R) UHD Graphics 630', // RENDERER (more realistic)
923
+ 37445: GPU_VENDOR, // VENDOR
924
+ 37446: GPU_RENDERER, // RENDERER
777
925
  7936: 'WebGL 1.0 (OpenGL ES 2.0 Chromium)', // VERSION
778
926
  35724: 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.00 Chromium)', // SHADING_LANGUAGE_VERSION
779
927
  34076: 16384, // MAX_TEXTURE_SIZE
@@ -798,6 +946,14 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
798
946
  }
799
947
  return getParameter.call(this, parameter);
800
948
  };
949
+ // Intercept getExtension to control WEBGL_debug_renderer_info
950
+ const getExtension = WebGLRenderingContext.prototype.getExtension;
951
+ WebGLRenderingContext.prototype.getExtension = function(name) {
952
+ if (name === 'WEBGL_debug_renderer_info') {
953
+ return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
954
+ }
955
+ return getExtension.call(this, name);
956
+ };
801
957
  // Spoof supported extensions
802
958
  const getSupportedExtensions = WebGLRenderingContext.prototype.getSupportedExtensions;
803
959
  WebGLRenderingContext.prototype.getSupportedExtensions = function() {
@@ -840,10 +996,72 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
840
996
  }
841
997
  return getParameter2.call(this, parameter);
842
998
  };
999
+ const getExtension2 = WebGL2RenderingContext.prototype.getExtension;
1000
+ WebGL2RenderingContext.prototype.getExtension = function(name) {
1001
+ if (name === 'WEBGL_debug_renderer_info') {
1002
+ return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
1003
+ }
1004
+ return getExtension2.call(this, name);
1005
+ };
843
1006
  }
844
1007
  }, 'WebGL spoofing');
845
1008
 
846
- // Permissions API spoofing
1009
+ // WebGL context patching — Proxy wrapper for real contexts + null-context safety
1010
+ safeExecute(() => {
1011
+ const webglSpoofParams = {
1012
+ 37445: GPU_VENDOR,
1013
+ 37446: GPU_RENDERER
1014
+ };
1015
+ const debugRendererExt = { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
1016
+
1017
+ const originalGetContext = HTMLCanvasElement.prototype.getContext;
1018
+ HTMLCanvasElement.prototype.getContext = function(type, attrs) {
1019
+ const ctx = originalGetContext.call(this, type, attrs);
1020
+ if (type !== 'webgl' && type !== 'experimental-webgl' && type !== 'webgl2') return ctx;
1021
+
1022
+ const noop = () => {};
1023
+
1024
+ // Null context — return mock to prevent crashes
1025
+ if (ctx === null) {
1026
+ return new Proxy({}, {
1027
+ get(_, prop) {
1028
+ if (prop === 'getShaderPrecisionFormat') return () => ({ rangeMin: 127, rangeMax: 127, precision: 23 });
1029
+ if (prop === 'getParameter') return (p) => webglSpoofParams[p] || 0;
1030
+ if (prop === 'getSupportedExtensions') return () => [];
1031
+ if (prop === 'getExtension') return (name) => {
1032
+ if (name === 'WEBGL_debug_renderer_info') return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
1033
+ return null;
1034
+ };
1035
+ if (prop === 'canvas') return this;
1036
+ if (prop === 'drawingBufferWidth') return 1920;
1037
+ if (prop === 'drawingBufferHeight') return 1080;
1038
+ return noop;
1039
+ }
1040
+ });
1041
+ }
1042
+
1043
+ // Real context — wrap in Proxy to intercept getParameter/getExtension
1044
+ // Direct property assignment fails silently on native WebGL objects
1045
+ return new Proxy(ctx, {
1046
+ get(target, prop, receiver) {
1047
+ if (prop === 'getParameter') {
1048
+ return function(param) {
1049
+ if (webglSpoofParams.hasOwnProperty(param)) return webglSpoofParams[param];
1050
+ return target.getParameter(param);
1051
+ };
1052
+ }
1053
+ if (prop === 'getExtension') {
1054
+ return function(name) {
1055
+ if (name === 'WEBGL_debug_renderer_info') return debugRendererExt;
1056
+ return target.getExtension(name);
1057
+ };
1058
+ }
1059
+ const val = Reflect.get(target, prop, receiver);
1060
+ return typeof val === 'function' ? val.bind(target) : val;
1061
+ }
1062
+ });
1063
+ };
1064
+ }, 'WebGL context patching'); // Permissions API spoofing
847
1065
  //
848
1066
  safeExecute(() => {
849
1067
  if (navigator.permissions?.query) {
@@ -895,6 +1113,118 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
895
1113
  };
896
1114
  }
897
1115
  }, 'media device spoofing');
1116
+
1117
+ // Window dimensions — headless Chrome reports 0 for outer dimensions
1118
+ safeExecute(() => {
1119
+ if (!window.outerWidth || window.outerWidth === 0 || window.outerWidth === window.innerWidth) {
1120
+ Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth + 16, configurable: true });
1121
+ }
1122
+ if (!window.outerHeight || window.outerHeight === 0 || window.outerHeight === window.innerHeight) {
1123
+ Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 88, configurable: true });
1124
+ }
1125
+ if (window.screenX === 0 && window.screenY === 0) {
1126
+ const sX = Math.floor(Math.random() * 200);
1127
+ const sY = Math.floor(Math.random() * 50) + 20;
1128
+ Object.defineProperty(window, 'screenX', { get: () => sX });
1129
+ Object.defineProperty(window, 'screenY', { get: () => sY });
1130
+ }
1131
+ }, 'window dimension spoofing');
1132
+
1133
+ // navigator.connection — missing or incomplete in headless
1134
+ safeExecute(() => {
1135
+ if (!navigator.connection) {
1136
+ Object.defineProperty(navigator, 'connection', {
1137
+ get: () => ({
1138
+ effectiveType: '4g',
1139
+ rtt: 50,
1140
+ downlink: 10,
1141
+ saveData: false,
1142
+ type: 'wifi',
1143
+ addEventListener: () => {},
1144
+ removeEventListener: () => {}
1145
+ })
1146
+ });
1147
+ }
1148
+ }, 'connection API spoofing');
1149
+
1150
+ // navigator.pdfViewerEnabled — missing in headless, true in real Chrome
1151
+ safeExecute(() => {
1152
+ if (navigator.pdfViewerEnabled === undefined) {
1153
+ Object.defineProperty(navigator, 'pdfViewerEnabled', {
1154
+ get: () => true, configurable: true
1155
+ });
1156
+ }
1157
+ }, 'pdfViewerEnabled spoofing');
1158
+
1159
+ // speechSynthesis — headless returns empty voices array
1160
+ safeExecute(() => {
1161
+ if (window.speechSynthesis) {
1162
+ const origGetVoices = speechSynthesis.getVoices.bind(speechSynthesis);
1163
+ speechSynthesis.getVoices = function() {
1164
+ const voices = origGetVoices();
1165
+ if (voices.length === 0) {
1166
+ return [{
1167
+ default: true, lang: 'en-US', localService: true,
1168
+ name: 'Microsoft David - English (United States)', voiceURI: 'Microsoft David - English (United States)'
1169
+ }, {
1170
+ default: false, lang: 'en-US', localService: true,
1171
+ name: 'Microsoft Zira - English (United States)', voiceURI: 'Microsoft Zira - English (United States)'
1172
+ }];
1173
+ }
1174
+ return voices;
1175
+ };
1176
+ }
1177
+ }, 'speechSynthesis spoofing');
1178
+
1179
+ // AudioContext — headless has distinct audio processing fingerprint
1180
+ safeExecute(() => {
1181
+ if (window.AudioContext || window.webkitAudioContext) {
1182
+ const OrigAudioContext = window.AudioContext || window.webkitAudioContext;
1183
+ const origCreateOscillator = OrigAudioContext.prototype.createOscillator;
1184
+ const origCreateDynamicsCompressor = OrigAudioContext.prototype.createDynamicsCompressor;
1185
+
1186
+ // Inject deterministic noise into audio output — consistent per session
1187
+ const audioNoiseSeed = Math.random() * 0.01 - 0.005;
1188
+ const compNoiseSeed = Math.random() * 0.1 - 0.05;
1189
+
1190
+ OrigAudioContext.prototype.createOscillator = function() {
1191
+ const osc = origCreateOscillator.call(this);
1192
+ const origFreq = osc.frequency.value;
1193
+ osc.frequency.value = origFreq + audioNoiseSeed;
1194
+ return osc;
1195
+ };
1196
+ OrigAudioContext.prototype.createDynamicsCompressor = function() {
1197
+ const comp = origCreateDynamicsCompressor.call(this);
1198
+ const origThreshold = comp.threshold.value;
1199
+ comp.threshold.value = origThreshold + compNoiseSeed;
1200
+ return comp;
1201
+ };
1202
+ }
1203
+ }, 'AudioContext fingerprint spoofing');
1204
+
1205
+ // Broken image dimensions — headless returns 0x0, real browsers show broken icon
1206
+ safeExecute(() => {
1207
+ // Patch Image constructor to intercept broken image detection
1208
+ const OrigImage = window.Image;
1209
+ window.Image = function(w, h) {
1210
+ const img = arguments.length ? new OrigImage(w, h) : new OrigImage();
1211
+ // After load attempt, if broken, fake dimensions
1212
+ const origWidthDesc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'width');
1213
+ const origHeightDesc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'height');
1214
+
1215
+ // Monitor for broken state
1216
+ img.addEventListener('error', function() {
1217
+ // Force non-zero dimensions for broken images
1218
+ Object.defineProperty(this, 'width', { get: () => 24, configurable: true });
1219
+ Object.defineProperty(this, 'height', { get: () => 24, configurable: true });
1220
+ Object.defineProperty(this, 'naturalWidth', { get: () => 24, configurable: true });
1221
+ Object.defineProperty(this, 'naturalHeight', { get: () => 24, configurable: true });
1222
+ });
1223
+ return img;
1224
+ };
1225
+ window.Image.prototype = OrigImage.prototype;
1226
+ Object.defineProperty(window, 'Image', { writable: true, configurable: true });
1227
+ }, 'broken image dimension spoofing');
898
1228
 
899
1229
  // Fetch Request Headers Normalization
900
1230
  //
@@ -938,21 +1268,9 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
938
1268
 
939
1269
  // CSS Media Query Spoofing
940
1270
  //
941
- safeExecute(() => {
942
- const originalMatchMedia = window.matchMedia;
943
- window.matchMedia = function(query) {
944
- const result = originalMatchMedia.call(this, query);
945
- // Add slight randomization to avoid fingerprinting for device queries
946
- if (query.includes('device-width') || query.includes('device-height') ||
947
- query.includes('aspect-ratio') || query.includes('color-gamut')) {
948
- Object.defineProperty(result, 'matches', {
949
- get: () => Math.random() > 0.1 ? originalMatchMedia.call(window, query).matches : !originalMatchMedia.call(window, query).matches,
950
- configurable: true
951
- });
952
- }
953
- return result;
954
- };
955
- }, 'CSS media query spoofing');
1271
+ // CSS media query — pass through normally. Screen dimensions are already spoofed
1272
+ // so matchMedia results will naturally reflect the spoofed values.
1273
+ // No modification needed here.
956
1274
 
957
1275
  // Enhanced WebRTC Spoofing
958
1276
  //
@@ -961,13 +1279,41 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
961
1279
  const OriginalRTC = window.RTCPeerConnection;
962
1280
  window.RTCPeerConnection = function(...args) {
963
1281
  const pc = new OriginalRTC(...args);
964
- const originalCreateOffer = pc.createOffer;
965
- pc.createOffer = function() {
966
- return Promise.reject(new Error('WebRTC disabled'));
1282
+
1283
+ // Intercept onicecandidate to strip local IP addresses
1284
+ const origAddEventListener = pc.addEventListener.bind(pc);
1285
+ pc.addEventListener = function(type, listener, ...rest) {
1286
+ if (type === 'icecandidate') {
1287
+ const wrappedListener = function(event) {
1288
+ if (event.candidate && event.candidate.candidate) {
1289
+ // Strip candidates containing local/private IPs
1290
+ const c = event.candidate.candidate;
1291
+ if (c.includes('.local') || /(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\./.test(c)) {
1292
+ return; // suppress local IP candidates
1293
+ }
1294
+ }
1295
+ listener.call(this, event);
1296
+ };
1297
+ return origAddEventListener(type, wrappedListener, ...rest);
1298
+ }
1299
+ return origAddEventListener(type, listener, ...rest);
967
1300
  };
1301
+
1302
+ // Also intercept the property-based handler
1303
+ let _onicecandidateHandler = null;
1304
+ Object.defineProperty(pc, 'onicecandidate', {
1305
+ get: () => _onicecandidateHandler,
1306
+ set: (handler) => {
1307
+ _onicecandidateHandler = handler;
1308
+ // No-op — the addEventListener wrapper above handles filtering
1309
+ },
1310
+ configurable: true
1311
+ });
1312
+
968
1313
  return pc;
969
1314
  };
970
1315
  Object.setPrototypeOf(window.RTCPeerConnection, OriginalRTC);
1316
+ window.RTCPeerConnection.prototype = OriginalRTC.prototype;
971
1317
  }
972
1318
  }, 'WebRTC spoofing');
973
1319
 
@@ -1039,62 +1385,15 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1039
1385
 
1040
1386
  CanvasRenderingContext2D.prototype.measureText = function(text) {
1041
1387
  const result = originalMeasureText.call(this, text);
1042
- // Add slight noise to text measurements to prevent precise fingerprinting
1388
+ // Deterministic slight noise to text measurements
1043
1389
  const originalWidth = result.width;
1390
+ const hash = text.length * 7 + (text.charCodeAt(0) || 0);
1391
+ const noise = ((hash * 2654435761 >>> 0) % 100 - 50) / 500; // -0.1 to +0.1, deterministic per text
1044
1392
  return Object.create(result, {
1045
- width: { get: () => originalWidth + (Math.random() - 0.5) * 0.1 }
1393
+ width: { get: () => originalWidth + noise }
1046
1394
  });
1047
1395
  };
1048
1396
 
1049
- // Comprehensive canvas fingerprinting protection
1050
- //
1051
- const originalGetImageData = CanvasRenderingContext2D.prototype.getImageData;
1052
- CanvasRenderingContext2D.prototype.getImageData = function(sx, sy, sw, sh) {
1053
- const imageData = originalGetImageData.call(this, sx, sy, sw, sh);
1054
- // Skip noise on large canvases (>500K pixels) � too expensive for minimal fingerprint benefit
1055
- if (imageData.data.length > 2000000) {
1056
- return imageData;
1057
- }
1058
- // Add subtle noise to pixel data
1059
- for (let i = 0; i < imageData.data.length; i += 4) {
1060
- if (Math.random() < 0.1) { // 10% chance to modify each pixel
1061
- imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + Math.floor(Math.random() * 3) - 1));
1062
- imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + Math.floor(Math.random() * 3) - 1));
1063
- imageData.data[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] + Math.floor(Math.random() * 3) - 1));
1064
- }
1065
- }
1066
- return imageData;
1067
- };
1068
-
1069
- // WebGL canvas context fingerprinting
1070
- const originalGetContext = HTMLCanvasElement.prototype.getContext;
1071
- HTMLCanvasElement.prototype.getContext = function(contextType, contextAttributes) {
1072
- const context = originalGetContext.call(this, contextType, contextAttributes);
1073
-
1074
- if (contextType === 'webgl' || contextType === 'webgl2' || contextType === 'experimental-webgl') {
1075
- // Override WebGL-specific fingerprinting methods
1076
- const originalGetShaderPrecisionFormat = context.getShaderPrecisionFormat;
1077
- context.getShaderPrecisionFormat = function(shaderType, precisionType) {
1078
- return {
1079
- rangeMin: 127,
1080
- rangeMax: 127,
1081
- precision: 23
1082
- };
1083
- };
1084
-
1085
- const originalGetExtension = context.getExtension;
1086
- context.getExtension = function(name) {
1087
- // Block access to fingerprinting-sensitive extensions
1088
- if (name === 'WEBGL_debug_renderer_info') {
1089
- return null;
1090
- }
1091
- return originalGetExtension.call(this, name);
1092
- };
1093
- }
1094
-
1095
- return context;
1096
- };
1097
-
1098
1397
  // Override font detection methods
1099
1398
  //
1100
1399
  const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth');
@@ -1104,7 +1403,10 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1104
1403
  Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
1105
1404
  get: function() {
1106
1405
  if (this.style && this.style.fontFamily) {
1107
- return Math.floor(originalOffsetWidth.get.call(this) + (Math.random() - 0.5) * 2);
1406
+ const w = originalOffsetWidth.get.call(this);
1407
+ // Deterministic per font family — same font always gets same offset
1408
+ const fontHash = this.style.fontFamily.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) & 0xffff, 0);
1409
+ return Math.floor(w + ((fontHash % 3) - 1));
1108
1410
  }
1109
1411
  return originalOffsetWidth.get.call(this);
1110
1412
  },
@@ -1125,24 +1427,75 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1125
1427
  // Canvas fingerprinting protection
1126
1428
  //
1127
1429
  safeExecute(() => {
1430
+ // Per-session seed — consistent within session, varies between sessions
1431
+ const canvasSeed = Math.floor(Math.random() * 2147483647);
1432
+
1433
+ // Simple seeded PRNG — deterministic for same input
1434
+ function seededNoise(seed) {
1435
+ let s = seed ^ 0x5DEECE66D;
1436
+ s = (s * 1103515245 + 12345) & 0x7fffffff;
1437
+ return s;
1438
+ }
1439
+
1440
+ const originalGetImageData = CanvasRenderingContext2D.prototype.getImageData;
1441
+ CanvasRenderingContext2D.prototype.getImageData = function(sx, sy, sw, sh) {
1442
+ const imageData = originalGetImageData.call(this, sx, sy, sw, sh);
1443
+ if (imageData.data.length > 2000000) return imageData;
1444
+ // Deterministic noise — same canvas content + position = same noise every call
1445
+ for (let i = 0; i < imageData.data.length; i += 4) {
1446
+ const n = seededNoise(canvasSeed ^ (i >> 2));
1447
+ if ((n & 0xf) < 2) { // ~12.5% of pixels
1448
+ const shift = (n >> 4 & 3) - 1; // -1, 0, or +1
1449
+ imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + shift));
1450
+ imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + shift));
1451
+ }
1452
+ }
1453
+ return imageData;
1454
+ };
1455
+
1128
1456
  const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
1129
1457
  HTMLCanvasElement.prototype.toDataURL = function(...args) {
1130
- // Noise already applied by getImageData override
1458
+ // Apply same deterministic noise by reading through spoofed getImageData
1459
+ try {
1460
+ const ctx = this.getContext('2d');
1461
+ if (ctx && this.width > 0 && this.height > 0 && this.width * this.height < 500000) {
1462
+ const imageData = ctx.getImageData(0, 0, this.width, this.height);
1463
+ ctx.putImageData(imageData, 0, 0);
1464
+ }
1465
+ } catch (e) {} // WebGL or other context — skip
1131
1466
  return originalToDataURL.apply(this, args);
1132
1467
  };
1468
+
1469
+ const originalToBlob = HTMLCanvasElement.prototype.toBlob;
1470
+ if (originalToBlob) {
1471
+ HTMLCanvasElement.prototype.toBlob = function(callback, ...args) {
1472
+ try {
1473
+ const ctx = this.getContext('2d');
1474
+ if (ctx && this.width > 0 && this.height > 0 && this.width * this.height < 500000) {
1475
+ const imageData = ctx.getImageData(0, 0, this.width, this.height);
1476
+ ctx.putImageData(imageData, 0, 0);
1477
+ }
1478
+ } catch (e) {}
1479
+ return originalToBlob.call(this, callback, ...args);
1480
+ };
1481
+ }
1133
1482
  }, 'canvas fingerprinting protection');
1134
1483
 
1135
1484
  // Battery API spoofing
1136
1485
  //
1137
1486
  safeExecute(() => {
1138
1487
  if (navigator.getBattery) {
1488
+ const batteryState = {
1489
+ charging: Math.random() > 0.5,
1490
+ chargingTime: Math.random() > 0.5 ? Infinity : Math.floor(Math.random() * 3600),
1491
+ dischargingTime: Math.floor(Math.random() * 7200),
1492
+ level: Math.round((Math.random() * 0.7 + 0.25) * 100) / 100,
1493
+ addEventListener: () => {},
1494
+ removeEventListener: () => {},
1495
+ dispatchEvent: () => true
1496
+ };
1139
1497
  navigator.getBattery = function() {
1140
- return Promise.resolve({
1141
- charging: Math.random() > 0.5,
1142
- chargingTime: Math.random() > 0.5 ? Infinity : Math.random() * 3600,
1143
- dischargingTime: Math.random() * 7200,
1144
- level: Math.random() * 0.99 + 0.01
1145
- });
1498
+ return Promise.resolve(batteryState);
1146
1499
  };
1147
1500
  }
1148
1501
  }, 'battery API spoofing');
@@ -1265,7 +1618,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1265
1618
  });
1266
1619
  }, 'location URL masking');
1267
1620
 
1268
- }, ua, forceDebug);
1621
+ }, ua, forceDebug, selectedGpu);
1269
1622
  } catch (stealthErr) {
1270
1623
  if (stealthErr.message.includes('Session closed') ||
1271
1624
  stealthErr.message.includes('addScriptToEvaluateOnNewDocument timed out') ||
package/nwss.js CHANGED
@@ -99,9 +99,9 @@ const CONCURRENCY_LIMITS = Object.freeze({
99
99
 
100
100
  // V8 Optimization: Use Map for user agent lookups instead of object
101
101
  const USER_AGENTS = Object.freeze(new Map([
102
- ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"],
103
- ['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"],
104
- ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"],
102
+ ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],
103
+ ['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],
104
+ ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],
105
105
  ['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0"],
106
106
  ['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0"],
107
107
  ['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0"],
@@ -619,7 +619,7 @@ Redirect Handling Options:
619
619
  source: true/false Save page source HTML after load
620
620
  firstParty: true/false Allow first-party matches (default: false)
621
621
  thirdParty: true/false Allow third-party matches (default: true)
622
- screenshot: true/false Capture screenshot on load failure
622
+ screenshot: true/false/\"force\" Capture screenshot (true=on failure, \"force\"=always)
623
623
  headful: true/false Launch browser with GUI for this site
624
624
  fingerprint_protection: true/false/"random" Enable fingerprint spoofing: true/false/"random"
625
625
  adblock_rules: true/false Generate adblock filter rules with resource types for this site
@@ -1367,9 +1367,17 @@ function setupFrameHandling(page, forceDebug) {
1367
1367
  if (launchHeadless) {
1368
1368
  const puppeteerInfo = detectPuppeteerVersion();
1369
1369
 
1370
+ // Check if any site needs fingerprint protection — use stealth-friendly headless mode
1371
+ const needsStealth = sites.some(site => site.fingerprint_protection);
1372
+
1370
1373
  if (puppeteerInfo.useShellMode) {
1371
- headlessMode = 'shell'; // Use fast chrome-headless-shell for 22.x+
1372
- if (forceDebug) console.log(formatLogMessage('debug', `Using chrome-headless-shell (Puppeteer ${puppeteerInfo.version || 'v' + puppeteerInfo.majorVersion + '.x'})`));
1374
+ if (needsStealth) {
1375
+ headlessMode = 'new'; // Full Chrome in headless harder to detect than chrome-headless-shell
1376
+ if (forceDebug) console.log(formatLogMessage('debug', `Using headless=new for stealth (fingerprint_protection detected)`));
1377
+ } else {
1378
+ headlessMode = 'shell'; // Use fast chrome-headless-shell for 22.x+
1379
+ if (forceDebug) console.log(formatLogMessage('debug', `Using chrome-headless-shell (Puppeteer ${puppeteerInfo.version || 'v' + puppeteerInfo.majorVersion + '.x'})`));
1380
+ }
1373
1381
  } else {
1374
1382
  headlessMode = true; // Use regular headless for older versions
1375
1383
  if (forceDebug) console.log(formatLogMessage('debug', 'Could not detect Puppeteer version, using regular headless mode'));
@@ -1439,7 +1447,7 @@ function setupFrameHandling(page, forceDebug) {
1439
1447
  '--disable-features=SafeBrowsing',
1440
1448
  '--disable-dev-shm-usage',
1441
1449
  '--disable-sync',
1442
- '--disable-gpu',
1450
+ '--use-gl=swiftshader', // Software WebGL — prevents ad script crashes in headless
1443
1451
  '--mute-audio',
1444
1452
  '--disable-translate',
1445
1453
  '--window-size=1920,1080',
@@ -1494,7 +1502,9 @@ function setupFrameHandling(page, forceDebug) {
1494
1502
 
1495
1503
  // Log which headless mode is being used
1496
1504
  if (forceDebug && launchHeadless) {
1497
- console.log(formatLogMessage('debug', `Using chrome-headless-shell for maximum performance`));
1505
+ const needsStealth = sites.some(site => site.fingerprint_protection);
1506
+ const modeLabel = needsStealth ? 'headless=new (stealth mode)' : 'chrome-headless-shell (performance mode)';
1507
+ console.log(formatLogMessage('debug', `Using ${modeLabel}`));
1498
1508
  }
1499
1509
 
1500
1510
  // Initial cleanup of any existing Chrome temp files - always comprehensive on startup
@@ -1652,6 +1662,11 @@ function setupFrameHandling(page, forceDebug) {
1652
1662
  let cdpSessionManager = null;
1653
1663
  // Use Map to track domains and their resource types for --adblock-rules or --dry-run
1654
1664
  const matchedDomains = (adblockRulesMode || siteConfig.adblock_rules || dryRunMode) ? new Map() : new Set();
1665
+
1666
+ // Local domain dedup scoped to THIS processUrl call only
1667
+ // Prevents cross-config contamination from the global domain cache
1668
+ const localDetectedDomains = new Set();
1669
+ const isLocallyDetected = (domain) => localDetectedDomains.has(domain);
1655
1670
 
1656
1671
  // Initialize dry run matches collection
1657
1672
  if (dryRunMode) {
@@ -2159,14 +2174,14 @@ function setupFrameHandling(page, forceDebug) {
2159
2174
  }
2160
2175
 
2161
2176
  await page.setExtraHTTPHeaders({
2162
- 'Sec-CH-UA': '"Chromium";v="142", "Not=A?Brand";v="24", "Google Chrome";v="142"',
2177
+ 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
2163
2178
  'Sec-CH-UA-Platform': `"${platform}"`,
2164
2179
  'Sec-CH-UA-Platform-Version': `"${platformVersion}"`,
2165
2180
  'Sec-CH-UA-Mobile': '?0',
2166
2181
  'Sec-CH-UA-Arch': `"${arch}"`,
2167
2182
  'Sec-CH-UA-Bitness': '"64"',
2168
- 'Sec-CH-UA-Full-Version': '"142.0.7444,69"',
2169
- 'Sec-CH-UA-Full-Version-List': '"Chromium";v="142.0.7444,69", "Not=A?Brand";v="24.0.0.0", "Google Chrome";v="142.0.7444,69"'
2183
+ 'Sec-CH-UA-Full-Version': '"145.0.7632.160"',
2184
+ 'Sec-CH-UA-Full-Version-List': '"Not:A-Brand";v="99.0.0.0", "Google Chrome";v="145.0.7632.160", "Chromium";v="145.0.7632.160"'
2170
2185
  });
2171
2186
  }
2172
2187
  } catch (fingerprintErr) {
@@ -2430,6 +2445,7 @@ function setupFrameHandling(page, forceDebug) {
2430
2445
 
2431
2446
  // Mark full subdomain as detected for future reference
2432
2447
  markDomainAsDetected(cacheKey);
2448
+ localDetectedDomains.add(cacheKey);
2433
2449
 
2434
2450
  // Also mark in smart cache with context (if cache is enabled)
2435
2451
  if (smartCache) {
@@ -2534,7 +2550,7 @@ function setupFrameHandling(page, forceDebug) {
2534
2550
  if (forceDebug) {
2535
2551
  console.log(formatLogMessage('debug', `${messageColors.blocked('[adblock]')} ${checkedUrl} (${result.reason})`));
2536
2552
  }
2537
- request.abort();
2553
+ request.abort('blockedbyclient');
2538
2554
  return;
2539
2555
  }
2540
2556
  adblockStats.allowed++;
@@ -2614,7 +2630,7 @@ function setupFrameHandling(page, forceDebug) {
2614
2630
  }
2615
2631
  }
2616
2632
 
2617
- request.abort();
2633
+ request.abort('blockedbyclient');
2618
2634
  return;
2619
2635
  }
2620
2636
 
@@ -2724,7 +2740,7 @@ function setupFrameHandling(page, forceDebug) {
2724
2740
  dryRunCallback: dryRunMode ? createEnhancedDryRunCallback(matchedDomains, forceDebug) : null,
2725
2741
  matchedDomains,
2726
2742
  addMatchedDomain,
2727
- isDomainAlreadyDetected,
2743
+ isDomainAlreadyDetected: isLocallyDetected,
2728
2744
  onWhoisResult: smartCache ? (domain, result) => smartCache.cacheNetTools(domain, 'whois', result) : undefined,
2729
2745
  onDigResult: smartCache ? (domain, result, recordType) => smartCache.cacheNetTools(domain, 'dig', result, recordType) : undefined,
2730
2746
  cachedWhois: smartCache ? smartCache.getCachedNetTools(reqDomain, 'whois') : null,
@@ -2773,8 +2789,8 @@ function setupFrameHandling(page, forceDebug) {
2773
2789
  }
2774
2790
  } else if (hasNetTools && !hasSearchString && !hasSearchStringAnd) {
2775
2791
  // If nettools are configured (whois/dig), perform checks on the domain
2776
- // Skip nettools check if full subdomain was already detected
2777
- if (isDomainAlreadyDetected(fullSubdomain)) {
2792
+ // Skip nettools check if full subdomain was already detected in THIS scan
2793
+ if (localDetectedDomains.has(fullSubdomain)) {
2778
2794
  if (forceDebug) {
2779
2795
  console.log(formatLogMessage('debug', `Skipping nettools check for already detected subdomain: ${fullSubdomain}`));
2780
2796
  }
@@ -2833,7 +2849,7 @@ function setupFrameHandling(page, forceDebug) {
2833
2849
  dryRunCallback: dryRunMode ? createEnhancedDryRunCallback(matchedDomains, forceDebug) : null,
2834
2850
  matchedDomains,
2835
2851
  addMatchedDomain,
2836
- isDomainAlreadyDetected,
2852
+ isDomainAlreadyDetected: isLocallyDetected,
2837
2853
  // Add cache callbacks if smart cache is available and caching is enabled
2838
2854
  onWhoisResult: smartCache ? (domain, result) => {
2839
2855
  smartCache.cacheNetTools(domain, 'whois', result);
@@ -2863,8 +2879,8 @@ function setupFrameHandling(page, forceDebug) {
2863
2879
  }
2864
2880
  } else {
2865
2881
  // If searchstring or searchstring_and IS defined (with or without nettools), queue for content checking
2866
- // Skip searchstring check if full subdomain was already detected
2867
- if (isDomainAlreadyDetected(fullSubdomain)) {
2882
+ // Skip searchstring check if full subdomain was already detected in THIS scan
2883
+ if (localDetectedDomains.has(fullSubdomain)) {
2868
2884
  if (forceDebug) {
2869
2885
  console.log(formatLogMessage('debug', `Skipping searchstring check for already detected subdomain: ${fullSubdomain}`));
2870
2886
  }
@@ -2920,7 +2936,7 @@ function setupFrameHandling(page, forceDebug) {
2920
2936
  searchStringsAnd,
2921
2937
  matchedDomains,
2922
2938
  addMatchedDomain, // Pass the helper function
2923
- isDomainAlreadyDetected,
2939
+ isDomainAlreadyDetected: isLocallyDetected,
2924
2940
  onContentFetched: smartCache && !ignoreCache ? (url, content) => {
2925
2941
  // Only cache if not bypassing cache
2926
2942
  if (!shouldBypassCacheForUrl(url, siteConfig)) {
@@ -2956,7 +2972,7 @@ function setupFrameHandling(page, forceDebug) {
2956
2972
  regexes,
2957
2973
  matchedDomains,
2958
2974
  addMatchedDomain,
2959
- isDomainAlreadyDetected,
2975
+ isDomainAlreadyDetected: isLocallyDetected,
2960
2976
  onContentFetched: smartCache && !ignoreCache ? (url, content) => {
2961
2977
  // Only cache if not bypassing cache
2962
2978
  if (!shouldBypassCacheForUrl(url, siteConfig)) {
@@ -3027,7 +3043,7 @@ function setupFrameHandling(page, forceDebug) {
3027
3043
  matchedDomains,
3028
3044
  addMatchedDomain, // Pass the helper function
3029
3045
  bypassCache: (url) => shouldBypassCacheForUrl(url, siteConfig),
3030
- isDomainAlreadyDetected,
3046
+ isDomainAlreadyDetected: isLocallyDetected,
3031
3047
  onContentFetched: smartCache && !ignoreCache ? (url, content) => {
3032
3048
  // Only cache if not bypassing cache
3033
3049
  if (!shouldBypassCacheForUrl(url, siteConfig)) {
@@ -3374,8 +3390,16 @@ function setupFrameHandling(page, forceDebug) {
3374
3390
 
3375
3391
  // Mark page as processing during interactions
3376
3392
  updatePageUsage(page, true);
3377
- // Use enhanced interaction module
3378
- await performPageInteraction(page, currentUrl, interactionConfig, forceDebug);
3393
+ // Use enhanced interaction module with hard abort timeout
3394
+ const INTERACTION_HARD_TIMEOUT = 15000;
3395
+ try {
3396
+ await Promise.race([
3397
+ performPageInteraction(page, currentUrl, interactionConfig, forceDebug),
3398
+ new Promise((_, reject) => setTimeout(() => reject(new Error('interaction hard timeout')), INTERACTION_HARD_TIMEOUT))
3399
+ ]);
3400
+ } catch (interactTimeoutErr) {
3401
+ if (forceDebug) console.log(formatLogMessage('debug', `[interaction] Aborted after ${INTERACTION_HARD_TIMEOUT}ms: ${interactTimeoutErr.message}`));
3402
+ }
3379
3403
  }
3380
3404
 
3381
3405
  const delayMs = DEFAULT_DELAY;
@@ -3799,6 +3823,20 @@ function setupFrameHandling(page, forceDebug) {
3799
3823
  });
3800
3824
  } catch (gcErr) { /* ignore */ }
3801
3825
 
3826
+ // Force screenshot — always capture regardless of success/failure
3827
+ if (siteConfig.screenshot === 'force' && page && !page.isClosed()) {
3828
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
3829
+ const safeUrl = currentUrl.replace(/https?:\/\//, '').replace(/[^a-zA-Z0-9]/g, '_').substring(0, 80);
3830
+ const filename = `screenshots/${safeUrl}-${timestamp}.png`;
3831
+ try {
3832
+ if (!fs.existsSync('screenshots')) fs.mkdirSync('screenshots', { recursive: true });
3833
+ await page.screenshot({ path: filename, type: 'png', fullPage: true });
3834
+ console.log(formatLogMessage('info', `Screenshot saved: ${filename}`));
3835
+ } catch (screenshotErr) {
3836
+ console.warn(messageColors.warn(`[screenshot failed] ${currentUrl}: ${screenshotErr.message}`));
3837
+ }
3838
+ }
3839
+
3802
3840
  try {
3803
3841
  await page.close();
3804
3842
  if (forceDebug) console.log(formatLogMessage('debug', `Page closed for ${currentUrl}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanboynz/network-scanner",
3
- "version": "2.0.51",
3
+ "version": "2.0.53",
4
4
  "description": "A Puppeteer-based network scanner for analyzing web traffic, generating adblock filter rules, and identifying third-party requests. Features include fingerprint spoofing, Cloudflare bypass, content analysis with curl/grep, and multiple output formats.",
5
5
  "main": "nwss.js",
6
6
  "scripts": {