@fanboynz/network-scanner 2.0.52 → 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 +502 -170
  2. package/nwss.js +22 -8
  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,31 +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
- // WebGL null-context safety net prevents ad script crashes in headless
1009
+ // WebGL context patching Proxy wrapper for real contexts + null-context safety
847
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
+
848
1017
  const originalGetContext = HTMLCanvasElement.prototype.getContext;
849
1018
  HTMLCanvasElement.prototype.getContext = function(type, attrs) {
850
1019
  const ctx = originalGetContext.call(this, type, attrs);
851
- if (ctx !== null || (type !== 'webgl' && type !== 'experimental-webgl' && type !== 'webgl2')) return ctx;
852
- // Return minimal mock so scripts calling getShaderPrecisionFormat etc. don't crash
1020
+ if (type !== 'webgl' && type !== 'experimental-webgl' && type !== 'webgl2') return ctx;
1021
+
853
1022
  const noop = () => {};
854
- return new Proxy({}, {
855
- get(_, prop) {
856
- if (prop === 'getShaderPrecisionFormat') return () => ({ rangeMin: 127, rangeMax: 127, precision: 23 });
857
- if (prop === 'getParameter') return (p) => ({ 37445: 'Intel Inc.', 37446: 'Intel(R) UHD Graphics 630' }[p] || 0);
858
- if (prop === 'getSupportedExtensions') return () => [];
859
- if (prop === 'getExtension') return () => null;
860
- if (prop === 'canvas') return this;
861
- if (prop === 'drawingBufferWidth') return 1920;
862
- if (prop === 'drawingBufferHeight') return 1080;
863
- return 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;
864
1061
  }
865
1062
  });
866
1063
  };
867
- }, 'WebGL null-context safety net'); // Permissions API spoofing
1064
+ }, 'WebGL context patching'); // Permissions API spoofing
868
1065
  //
869
1066
  safeExecute(() => {
870
1067
  if (navigator.permissions?.query) {
@@ -916,6 +1113,118 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
916
1113
  };
917
1114
  }
918
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');
919
1228
 
920
1229
  // Fetch Request Headers Normalization
921
1230
  //
@@ -959,21 +1268,9 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
959
1268
 
960
1269
  // CSS Media Query Spoofing
961
1270
  //
962
- safeExecute(() => {
963
- const originalMatchMedia = window.matchMedia;
964
- window.matchMedia = function(query) {
965
- const result = originalMatchMedia.call(this, query);
966
- // Add slight randomization to avoid fingerprinting for device queries
967
- if (query.includes('device-width') || query.includes('device-height') ||
968
- query.includes('aspect-ratio') || query.includes('color-gamut')) {
969
- Object.defineProperty(result, 'matches', {
970
- get: () => Math.random() > 0.1 ? originalMatchMedia.call(window, query).matches : !originalMatchMedia.call(window, query).matches,
971
- configurable: true
972
- });
973
- }
974
- return result;
975
- };
976
- }, '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.
977
1274
 
978
1275
  // Enhanced WebRTC Spoofing
979
1276
  //
@@ -982,13 +1279,41 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
982
1279
  const OriginalRTC = window.RTCPeerConnection;
983
1280
  window.RTCPeerConnection = function(...args) {
984
1281
  const pc = new OriginalRTC(...args);
985
- const originalCreateOffer = pc.createOffer;
986
- pc.createOffer = function() {
987
- 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);
988
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
+
989
1313
  return pc;
990
1314
  };
991
1315
  Object.setPrototypeOf(window.RTCPeerConnection, OriginalRTC);
1316
+ window.RTCPeerConnection.prototype = OriginalRTC.prototype;
992
1317
  }
993
1318
  }, 'WebRTC spoofing');
994
1319
 
@@ -1060,62 +1385,15 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1060
1385
 
1061
1386
  CanvasRenderingContext2D.prototype.measureText = function(text) {
1062
1387
  const result = originalMeasureText.call(this, text);
1063
- // Add slight noise to text measurements to prevent precise fingerprinting
1388
+ // Deterministic slight noise to text measurements
1064
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
1065
1392
  return Object.create(result, {
1066
- width: { get: () => originalWidth + (Math.random() - 0.5) * 0.1 }
1393
+ width: { get: () => originalWidth + noise }
1067
1394
  });
1068
1395
  };
1069
1396
 
1070
- // Comprehensive canvas fingerprinting protection
1071
- //
1072
- const originalGetImageData = CanvasRenderingContext2D.prototype.getImageData;
1073
- CanvasRenderingContext2D.prototype.getImageData = function(sx, sy, sw, sh) {
1074
- const imageData = originalGetImageData.call(this, sx, sy, sw, sh);
1075
- // Skip noise on large canvases (>500K pixels) � too expensive for minimal fingerprint benefit
1076
- if (imageData.data.length > 2000000) {
1077
- return imageData;
1078
- }
1079
- // Add subtle noise to pixel data
1080
- for (let i = 0; i < imageData.data.length; i += 4) {
1081
- if (Math.random() < 0.1) { // 10% chance to modify each pixel
1082
- imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + Math.floor(Math.random() * 3) - 1));
1083
- imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + Math.floor(Math.random() * 3) - 1));
1084
- imageData.data[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] + Math.floor(Math.random() * 3) - 1));
1085
- }
1086
- }
1087
- return imageData;
1088
- };
1089
-
1090
- // WebGL canvas context fingerprinting
1091
- const originalGetContext = HTMLCanvasElement.prototype.getContext;
1092
- HTMLCanvasElement.prototype.getContext = function(contextType, contextAttributes) {
1093
- const context = originalGetContext.call(this, contextType, contextAttributes);
1094
-
1095
- if (contextType === 'webgl' || contextType === 'webgl2' || contextType === 'experimental-webgl') {
1096
- // Override WebGL-specific fingerprinting methods
1097
- const originalGetShaderPrecisionFormat = context.getShaderPrecisionFormat;
1098
- context.getShaderPrecisionFormat = function(shaderType, precisionType) {
1099
- return {
1100
- rangeMin: 127,
1101
- rangeMax: 127,
1102
- precision: 23
1103
- };
1104
- };
1105
-
1106
- const originalGetExtension = context.getExtension;
1107
- context.getExtension = function(name) {
1108
- // Block access to fingerprinting-sensitive extensions
1109
- if (name === 'WEBGL_debug_renderer_info') {
1110
- return null;
1111
- }
1112
- return originalGetExtension.call(this, name);
1113
- };
1114
- }
1115
-
1116
- return context;
1117
- };
1118
-
1119
1397
  // Override font detection methods
1120
1398
  //
1121
1399
  const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth');
@@ -1125,7 +1403,10 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1125
1403
  Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
1126
1404
  get: function() {
1127
1405
  if (this.style && this.style.fontFamily) {
1128
- 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));
1129
1410
  }
1130
1411
  return originalOffsetWidth.get.call(this);
1131
1412
  },
@@ -1146,24 +1427,75 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1146
1427
  // Canvas fingerprinting protection
1147
1428
  //
1148
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
+
1149
1456
  const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
1150
1457
  HTMLCanvasElement.prototype.toDataURL = function(...args) {
1151
- // 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
1152
1466
  return originalToDataURL.apply(this, args);
1153
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
+ }
1154
1482
  }, 'canvas fingerprinting protection');
1155
1483
 
1156
1484
  // Battery API spoofing
1157
1485
  //
1158
1486
  safeExecute(() => {
1159
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
+ };
1160
1497
  navigator.getBattery = function() {
1161
- return Promise.resolve({
1162
- charging: Math.random() > 0.5,
1163
- chargingTime: Math.random() > 0.5 ? Infinity : Math.random() * 3600,
1164
- dischargingTime: Math.random() * 7200,
1165
- level: Math.random() * 0.99 + 0.01
1166
- });
1498
+ return Promise.resolve(batteryState);
1167
1499
  };
1168
1500
  }
1169
1501
  }, 'battery API spoofing');
@@ -1286,7 +1618,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
1286
1618
  });
1287
1619
  }, 'location URL masking');
1288
1620
 
1289
- }, ua, forceDebug);
1621
+ }, ua, forceDebug, selectedGpu);
1290
1622
  } catch (stealthErr) {
1291
1623
  if (stealthErr.message.includes('Session closed') ||
1292
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
@@ -2174,14 +2174,14 @@ function setupFrameHandling(page, forceDebug) {
2174
2174
  }
2175
2175
 
2176
2176
  await page.setExtraHTTPHeaders({
2177
- '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"',
2178
2178
  'Sec-CH-UA-Platform': `"${platform}"`,
2179
2179
  'Sec-CH-UA-Platform-Version': `"${platformVersion}"`,
2180
2180
  'Sec-CH-UA-Mobile': '?0',
2181
2181
  'Sec-CH-UA-Arch': `"${arch}"`,
2182
2182
  'Sec-CH-UA-Bitness': '"64"',
2183
- 'Sec-CH-UA-Full-Version': '"142.0.7444,69"',
2184
- '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"'
2185
2185
  });
2186
2186
  }
2187
2187
  } catch (fingerprintErr) {
@@ -2515,7 +2515,7 @@ function setupFrameHandling(page, forceDebug) {
2515
2515
  if (forceDebug) {
2516
2516
  console.log(formatLogMessage('debug', `Blocking potential infinite iframe loop: ${checkedUrl}`));
2517
2517
  }
2518
- request.abort('blockedbyclient');
2518
+ request.abort();
2519
2519
  return;
2520
2520
  }
2521
2521
 
@@ -3823,6 +3823,20 @@ function setupFrameHandling(page, forceDebug) {
3823
3823
  });
3824
3824
  } catch (gcErr) { /* ignore */ }
3825
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
+
3826
3840
  try {
3827
3841
  await page.close();
3828
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.52",
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": {