@fanboynz/network-scanner 2.0.59 → 2.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/nwss.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  // puppeteer for browser automation, fs for file system operations, psl for domain parsing.
4
4
  // const pLimit = require('p-limit'); // Will be dynamically imported
5
- const usePuppeteerCore = process.argv.includes('--use-puppeteer-core');
5
+ const useObscura = process.argv.includes('--use-obscura');
6
+ const usePuppeteerCore = process.argv.includes('--use-puppeteer-core') || useObscura;
6
7
  const puppeteer = usePuppeteerCore ? require('puppeteer-core') : require('puppeteer');
7
8
  const fs = require('fs');
8
9
  const os = require('os');
@@ -32,7 +33,7 @@ const { shouldIgnoreSimilarDomain, calculateSimilarity } = require('./lib/ignore
32
33
  // Graceful exit
33
34
  const { handleBrowserExit, cleanupChromeTempFiles } = require('./lib/browserexit');
34
35
  // Whois & Dig
35
- const { createNetToolsHandler, createEnhancedDryRunCallback, validateWhoisAvailability, validateDigAvailability } = require('./lib/nettools');
36
+ const { createNetToolsHandler, createEnhancedDryRunCallback, validateWhoisAvailability, validateDigAvailability, enableDiskCache, getDnsCacheStats } = require('./lib/nettools');
36
37
  // File compare
37
38
  const { loadComparisonRules, filterUniqueRules } = require('./lib/compare');
38
39
  // CDP functionality
@@ -104,12 +105,12 @@ const CONCURRENCY_LIMITS = Object.freeze({
104
105
 
105
106
  // V8 Optimization: Use Map for user agent lookups instead of object
106
107
  const USER_AGENTS = Object.freeze(new Map([
107
- ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],
108
- ['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"],
109
- ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"],
110
- ['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0"],
111
- ['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0"],
112
- ['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0"],
108
+ ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
109
+ ['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
110
+ ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
111
+ ['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0"],
112
+ ['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"],
113
+ ['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"],
113
114
  ['safari', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15"]
114
115
  ]));
115
116
 
@@ -177,6 +178,82 @@ if (args.length === 0) {
177
178
  args.push('--help');
178
179
  }
179
180
 
181
+ // --- .nwssconfig support: inject per-config settings into args ---
182
+ const NWSSCONFIG_PATH = path.join(__dirname, '.nwssconfig');
183
+ if (fs.existsSync(NWSSCONFIG_PATH)) {
184
+ try {
185
+ const nwssConfig = JSON.parse(fs.readFileSync(NWSSCONFIG_PATH, 'utf-8'));
186
+ // Find which config file is being used (--custom-json <file> or positional .json arg)
187
+ const customJsonIdx = args.findIndex(arg => arg === '--custom-json');
188
+ const configFilename = (customJsonIdx !== -1 && args[customJsonIdx + 1])
189
+ ? args[customJsonIdx + 1]
190
+ : args.find(a => a.endsWith('.json') && !a.startsWith('--'));
191
+
192
+ if (configFilename && nwssConfig.configs && nwssConfig.configs[configFilename]) {
193
+ const settings = nwssConfig.configs[configFilename];
194
+ const originalArgs = args.join(' ');
195
+
196
+ // Map settings keys to CLI flags — only inject if not already in args
197
+ const settingsMap = {
198
+ output: ['-o', '--output'],
199
+ max_concurrent: ['--max-concurrent'],
200
+ dns_cache: ['--dns-cache'],
201
+ cache_requests: ['--cache-requests'],
202
+ dumpurls: ['--dumpurls'],
203
+ remove_tempfiles: ['--remove-tempfiles'],
204
+ color: ['--color'],
205
+ remove_dupes: ['--remove-dupes', '--remove-dubes'],
206
+ 'remove-dupes': ['--remove-dupes', '--remove-dubes'],
207
+ 'remove-dubes': ['--remove-dupes', '--remove-dubes'],
208
+ compress_logs: ['--compress-logs'],
209
+ debug: ['--debug'],
210
+ silent: ['--silent'],
211
+ verbose: ['--verbose'],
212
+ headful: ['--headful'],
213
+ keep_open: ['--keep-open'],
214
+ dry_run: ['--dry-run'],
215
+ titles: ['--titles'],
216
+ sub_domains: ['--sub-domains'],
217
+ no_interact: ['--no-interact'],
218
+ ghost_cursor: ['--ghost-cursor'],
219
+ plain: ['--plain'],
220
+ cdp: ['--cdp'],
221
+ dnsmasq: ['--dnsmasq'],
222
+ unbound: ['--unbound'],
223
+ privoxy: ['--privoxy'],
224
+ pihole: ['--pihole'],
225
+ eval_on_doc: ['--eval-on-doc'],
226
+ use_puppeteer_core: ['--use-puppeteer-core'],
227
+ ignore_cache: ['--ignore-cache'],
228
+ clear_cache: ['--clear-cache'],
229
+ block_ads: ['--block-ads'],
230
+ compare: ['--compare'],
231
+ localhost: ['--localhost'],
232
+ append: ['--append']
233
+ };
234
+
235
+ for (const [key, flags] of Object.entries(settingsMap)) {
236
+ // Support both underscore and hyphen variants (e.g. dns_cache or dns-cache)
237
+ const value = settings[key] !== undefined ? settings[key]
238
+ : settings[key.replace(/_/g, '-')] !== undefined ? settings[key.replace(/_/g, '-')]
239
+ : settings[key.replace(/-/g, '_')] !== undefined ? settings[key.replace(/-/g, '_')]
240
+ : undefined;
241
+ if (value === undefined) continue;
242
+ // Skip if any variant of the flag is already in CLI args
243
+ if (flags.some(f => originalArgs.includes(f))) continue;
244
+
245
+ if (typeof value === 'boolean') {
246
+ if (value) args.push(flags[flags.length - 1]);
247
+ } else if (typeof value === 'string' || typeof value === 'number') {
248
+ args.push(flags[flags.length - 1], String(value));
249
+ }
250
+ }
251
+ }
252
+ } catch (e) {
253
+ console.error(`Warning: Failed to parse .nwssconfig: ${e.message}`);
254
+ }
255
+ }
256
+
180
257
  const headfulMode = args.includes('--headful');
181
258
  const SOURCES_FOLDER = 'sources';
182
259
 
@@ -208,6 +285,12 @@ if (localhostIndex !== -1) {
208
285
  localhostIP = args[localhostIndex].includes('=') ? args[localhostIndex].split('=')[1] : '127.0.0.1';
209
286
  }
210
287
  const keepBrowserOpen = args.includes('--keep-open');
288
+ const loadExtensionPaths = [];
289
+ args.forEach((arg, idx) => {
290
+ if (arg === '--load-extension' && args[idx + 1] && !args[idx + 1].startsWith('--')) {
291
+ loadExtensionPaths.push(path.resolve(args[idx + 1]));
292
+ }
293
+ });
211
294
  const disableInteract = args.includes('--no-interact');
212
295
  const globalGhostCursor = args.includes('--ghost-cursor');
213
296
  const plainOutput = args.includes('--plain');
@@ -229,6 +312,8 @@ let cleanRules = args.includes('--clean-rules');
229
312
  const clearCache = args.includes('--clear-cache');
230
313
  const ignoreCache = args.includes('--ignore-cache');
231
314
  const cacheRequests = args.includes('--cache-requests');
315
+ const dnsCacheMode = args.includes('--dns-cache');
316
+ if (dnsCacheMode) enableDiskCache();
232
317
 
233
318
  let validateRulesFile = null;
234
319
  const validateRulesIndex = args.findIndex(arg => arg === '--validate-rules');
@@ -499,22 +584,38 @@ if (validateRules || validateRulesFile) {
499
584
  }
500
585
  }
501
586
 
502
- // Parse --block-ads argument for request-level ad blocking
587
+ // Parse --block-ads argument for request-level ad blocking (supports comma-separated lists)
503
588
  const blockAdsIndex = args.findIndex(arg => arg.startsWith('--block-ads'));
504
589
  if (blockAdsIndex !== -1) {
505
- const rulesFile = args[blockAdsIndex].includes('=')
506
- ? args[blockAdsIndex].split('=')[1]
590
+ const rulesArg = args[blockAdsIndex].includes('=')
591
+ ? args[blockAdsIndex].split('=')[1]
507
592
  : args[blockAdsIndex + 1];
508
-
509
- if (!rulesFile || !fs.existsSync(rulesFile)) {
510
- console.log(`Error: Adblock rules file not found: ${rulesFile || '(not specified)'}`);
593
+
594
+ if (!rulesArg) {
595
+ console.log('Error: No adblock rules file specified');
511
596
  process.exit(1);
512
597
  }
513
-
598
+
599
+ const rulesFiles = rulesArg.split(',').map(f => f.trim()).filter(f => f);
600
+ for (const file of rulesFiles) {
601
+ if (!fs.existsSync(file)) {
602
+ console.log(`Error: Adblock rules file not found: ${file}`);
603
+ process.exit(1);
604
+ }
605
+ }
606
+
607
+ // Concatenate multiple lists into a single temp file for the parser
608
+ let rulesFile = rulesFiles[0];
609
+ if (rulesFiles.length > 1) {
610
+ rulesFile = path.join(os.tmpdir(), `nwss-adblock-combined-${Date.now()}.txt`);
611
+ const combined = rulesFiles.map(f => fs.readFileSync(f, 'utf-8')).join('\n');
612
+ fs.writeFileSync(rulesFile, combined);
613
+ }
614
+
514
615
  adblockEnabled = true;
515
616
  adblockMatcher = parseAdblockRules(rulesFile, { enableLogging: forceDebug });
516
617
  const stats = adblockMatcher.getStats();
517
- if (!silentMode) console.log(messageColors.success(`Adblock enabled: Loaded ${stats.total} blocking rules from ${rulesFile}`));
618
+ if (!silentMode) console.log(messageColors.success(`Adblock enabled: Loaded ${stats.total} blocking rules from ${rulesFiles.length} list${rulesFiles.length > 1 ? 's' : ''}`));
518
619
  }
519
620
 
520
621
  if (args.includes('--help') || args.includes('-h')) {
@@ -541,6 +642,12 @@ Request Blocking:
541
642
  --block-ads=<file> Block ads/trackers using EasyList format rules (||domain.com^, /ads/*, etc)
542
643
  Works at request-level for maximum performance
543
644
 
645
+ Per-config settings file (.nwssconfig):
646
+ Place a .nwssconfig file in the project root to define per-config settings.
647
+ When a config filename matches a key in .nwssconfig, those settings are used.
648
+ CLI flags merge with and override .nwssconfig settings.
649
+ See README.md for format details.
650
+
544
651
  General Options:
545
652
  --verbose Force verbose mode globally
546
653
  --debug Force debug mode globally
@@ -556,6 +663,9 @@ General Options:
556
663
  --headful Launch browser with GUI (not headless)
557
664
  --keep-open Keep browser open after scan completes (use with --headful)
558
665
  --use-puppeteer-core Use puppeteer-core with system Chrome instead of bundled Chromium
666
+ --use-obscura Connect to running Obscura CDP server (ws://127.0.0.1:9222 or OBSCURA_WS env)
667
+ Skips fingerprint injection — Obscura provides built-in stealth
668
+ --load-extension <path> Load unpacked Chrome extension from directory
559
669
  --cdp Enable Chrome DevTools Protocol logging (now per-page if enabled)
560
670
  --remove-dupes Remove duplicate domains from output (only with -o)
561
671
  --eval-on-doc Globally enable evaluateOnNewDocument() for Fetch/XHR interception
@@ -567,6 +677,7 @@ General Options:
567
677
 
568
678
  Validation Options:
569
679
  --cache-requests Cache HTTP requests to avoid re-requesting same URLs within scan
680
+ --dns-cache Persist dig/whois results to disk between runs (3hr/4hr TTL)
570
681
  --validate-config Validate config.json file and exit
571
682
  --validate-rules [file] Validate rule file format (uses --output/--compare files if no file specified)
572
683
  --clean-rules [file] Clean rule files by removing invalid lines and optionally duplicates (uses --output/--compare files if no file specified)
@@ -583,6 +694,7 @@ Global config.json options:
583
694
  ignore_similar_ignored_domains: true/false Ignore domains similar to ignoreDomains list (default: true)
584
695
  max_concurrent_sites: 8 Maximum concurrent site processing (1-50, default: 8)
585
696
  resource_cleanup_interval: 80 Browser restart interval in URLs processed (1-1000, default: 80)
697
+ disable_ad_tagging: true/false Disable Chrome AdTagging to prevent ad frame throttling (default: true)
586
698
 
587
699
  Per-site config.json options:
588
700
  url: "site" or ["site1", "site2"] Single URL or list of URLs
@@ -748,7 +860,8 @@ const {
748
860
  whois_server_mode = 'random',
749
861
  ignore_similar = true,
750
862
  ignore_similar_threshold = 80,
751
- ignore_similar_ignored_domains = true,
863
+ ignore_similar_ignored_domains = true,
864
+ disable_ad_tagging = true,
752
865
  max_concurrent_sites = 6,
753
866
  resource_cleanup_interval = 80,
754
867
  comments: globalComments,
@@ -760,6 +873,23 @@ const globalBlockedRegexes = Array.isArray(globalBlocked)
760
873
  ? globalBlocked.map(pattern => new RegExp(pattern))
761
874
  : [];
762
875
 
876
+ // Cache compiled regexes by pattern string — avoids recompiling same patterns across URLs
877
+ const _compiledRegexCache = new Map();
878
+ function getCompiledRegex(pattern) {
879
+ let compiled = _compiledRegexCache.get(pattern);
880
+ if (!compiled) {
881
+ compiled = new RegExp(pattern.replace(/^\/(.*)\/$/, '$1'));
882
+ if (_compiledRegexCache.size > 2000) _compiledRegexCache.clear();
883
+ _compiledRegexCache.set(pattern, compiled);
884
+ }
885
+ return compiled;
886
+ }
887
+ function getCompiledRegexes(patterns) {
888
+ if (!patterns) return [];
889
+ const arr = Array.isArray(patterns) ? patterns : [patterns];
890
+ return arr.map(p => getCompiledRegex(p));
891
+ }
892
+
763
893
  // Pre-split ignoreDomains into exact Set (O(1) lookup) and wildcard array
764
894
  const _ignoreDomainsExact = new Set();
765
895
  const _ignoreDomainsWildcard = [];
@@ -1088,12 +1218,19 @@ if (forceDebug && globalComments) {
1088
1218
  * @param {string} url - The URL string to parse.
1089
1219
  * @returns {string} The root domain, or the original hostname if parsing fails (e.g., for IP addresses or invalid URLs), or an empty string on error.
1090
1220
  */
1221
+ const _rootDomainCache = new Map();
1091
1222
  function getRootDomain(url) {
1223
+ const cached = _rootDomainCache.get(url);
1224
+ if (cached !== undefined) return cached;
1092
1225
  try {
1093
1226
  const { hostname } = new URL(url);
1094
1227
  const parsed = psl.parse(hostname);
1095
- return parsed.domain || hostname;
1228
+ const result = parsed.domain || hostname;
1229
+ if (_rootDomainCache.size > 5000) _rootDomainCache.clear();
1230
+ _rootDomainCache.set(url, result);
1231
+ return result;
1096
1232
  } catch {
1233
+ _rootDomainCache.set(url, '');
1097
1234
  return '';
1098
1235
  }
1099
1236
  }
@@ -1388,6 +1525,23 @@ function setupFrameHandling(page, forceDebug) {
1388
1525
  * @returns {Promise<import('puppeteer').Browser>} Browser instance
1389
1526
  */
1390
1527
  async function createBrowser(extraArgs = []) {
1528
+ // Obscura mode: connect to a running Obscura CDP server instead of launching Chrome
1529
+ if (useObscura) {
1530
+ const obscuraEndpoint = process.env.OBSCURA_WS || 'ws://127.0.0.1:9222/devtools/browser';
1531
+ if (forceDebug) console.log(formatLogMessage('debug', `Connecting to Obscura at ${obscuraEndpoint}`));
1532
+ try {
1533
+ const browser = await puppeteer.connect({ browserWSEndpoint: obscuraEndpoint });
1534
+ if (!silentMode) console.log(messageColors.success(`Connected to Obscura CDP at ${obscuraEndpoint}`));
1535
+ browser._nwssUserDataDir = null; // No temp dir to clean
1536
+ browser._nwssIsObscura = true;
1537
+ return browser;
1538
+ } catch (err) {
1539
+ console.error(formatLogMessage('error', `Failed to connect to Obscura: ${err.message}`));
1540
+ console.error(formatLogMessage('error', `Start Obscura first: obscura serve --port 9222 --stealth`));
1541
+ process.exit(1);
1542
+ }
1543
+ }
1544
+
1391
1545
  // Create temporary user data directory that we can fully control and clean up
1392
1546
  const tempUserDataDir = path.join(os.tmpdir(), `puppeteer-${Date.now()}-${Math.random().toString(36).substring(7)}`);
1393
1547
  userDataDir = tempUserDataDir; // Store for cleanup tracking (use outer scope variable)
@@ -1460,7 +1614,7 @@ function setupFrameHandling(page, forceDebug) {
1460
1614
  '--disable-blink-features=AutomationControlled',
1461
1615
  '--no-first-run',
1462
1616
  '--disable-default-apps',
1463
- '--disable-component-extensions-with-background-pages',
1617
+ ...(keepBrowserOpen ? [] : ['--disable-component-extensions-with-background-pages']),
1464
1618
  // HIGH IMPACT: Normal Chrome behavior simulation
1465
1619
  '--password-store=basic',
1466
1620
  '--use-mock-keychain',
@@ -1474,30 +1628,29 @@ function setupFrameHandling(page, forceDebug) {
1474
1628
  '--disable-background-downloads',
1475
1629
  // DISK I/O REDUCTION: Eliminate unnecessary Chrome disk writes
1476
1630
  '--disable-breakpad', // No crash dump files
1477
- '--disable-component-update', // No component update downloads
1631
+ ...(keepBrowserOpen ? [] : ['--disable-component-update']), // No component update downloads
1478
1632
  '--disable-logging', // No Chrome internal log files
1479
1633
  '--log-level=3', // Fatal errors only (suppresses verbose disk logging)
1480
1634
  '--no-service-autorun', // No background service disk activity
1481
1635
  '--disable-domain-reliability', // No reliability monitor disk writes
1482
- // PERFORMANCE: Enhanced Puppeteer 23.x optimizations
1483
- '--disable-features=AudioServiceOutOfProcess,VizDisplayCompositor',
1484
- '--disable-features=TranslateUI,BlinkGenPropertyTrees,Translate',
1485
- '--disable-features=BackForwardCache,AcceptCHFrame',
1636
+ // PERFORMANCE: Disable non-essential Chrome features in a single flag
1637
+ // IMPORTANT: Chrome only reads the LAST --disable-features flag, so combine all into one
1638
+ `--disable-features=AudioServiceOutOfProcess,VizDisplayCompositor,TranslateUI,BlinkGenPropertyTrees,Translate,BackForwardCache,AcceptCHFrame,SafeBrowsing,HttpsFirstBalancedModeAutoEnable,site-per-process,PaintHolding${disable_ad_tagging ? ',AdTagging' : ''}`,
1486
1639
  '--disable-ipc-flooding-protection',
1487
1640
  '--aggressive-cache-discard',
1488
1641
  '--memory-pressure-off',
1489
1642
  '--max_old_space_size=2048', // V8 heap limit
1490
1643
  '--disable-prompt-on-repost', // Fixes form popup on page reload
1491
- '--disable-background-networking',
1644
+ ...(keepBrowserOpen ? [] : ['--disable-background-networking']),
1492
1645
  '--no-sandbox',
1493
1646
  '--disable-setuid-sandbox',
1494
- '--disable-features=SafeBrowsing',
1495
1647
  '--disable-dev-shm-usage',
1496
- '--disable-sync',
1648
+ ...(keepBrowserOpen ? [] : ['--disable-sync']),
1497
1649
  '--mute-audio',
1498
1650
  '--disable-translate',
1499
1651
  '--window-size=1920,1080',
1500
- '--disable-extensions',
1652
+ ...(keepBrowserOpen ? [] : ['--disable-extensions', '--disable-component-update']),
1653
+ ...(loadExtensionPaths.length ? [`--load-extension=${loadExtensionPaths.join(',')}`, '--enable-extensions'] : []),
1501
1654
  '--no-default-browser-check',
1502
1655
  '--safebrowsing-disable-auto-update',
1503
1656
  '--ignore-ssl-errors',
@@ -1506,18 +1659,15 @@ function setupFrameHandling(page, forceDebug) {
1506
1659
  '--ignore-certificate-errors-ca-list',
1507
1660
  '--disable-web-security',
1508
1661
  '--allow-running-insecure-content',
1509
- '--disable-features=HttpsFirstBalancedModeAutoEnable',
1510
1662
  // Puppeteer 23.x: Enhanced performance and stability args
1511
1663
  '--disable-renderer-backgrounding',
1512
1664
  '--disable-backgrounding-occluded-windows',
1513
1665
  '--disable-background-timer-throttling',
1514
- '--disable-features=site-per-process', // Better for single-site scanning
1515
1666
  '--no-zygote', // Better process isolation
1516
1667
  // PERFORMANCE: Process and memory reduction for high concurrency
1517
1668
  '--renderer-process-limit=10', // Cap renderer processes (default: unlimited)
1518
1669
  '--disable-accelerated-2d-canvas', // Software canvas only (we spoof it anyway)
1519
1670
  '--disable-hang-monitor', // Remove per-renderer hang check overhead
1520
- '--disable-features=PaintHolding', // Don't hold frames in renderer memory
1521
1671
  '--js-flags=--max-old-space-size=512', // Cap V8 heap per renderer to 512MB
1522
1672
  ...extraArgs,
1523
1673
  ],
@@ -2207,11 +2357,16 @@ function setupFrameHandling(page, forceDebug) {
2207
2357
  }
2208
2358
 
2209
2359
  // --- Apply all fingerprint spoofing (user agent, Brave, fingerprint protection) ---
2360
+ // Skip when using Obscura — it has built-in stealth that conflicts with our injection
2210
2361
  try {
2211
- await applyAllFingerprintSpoofing(page, siteConfig, forceDebug, currentUrl);
2362
+ if (!useObscura) {
2363
+ await applyAllFingerprintSpoofing(page, siteConfig, forceDebug, currentUrl);
2364
+ } else if (forceDebug) {
2365
+ console.log(formatLogMessage('debug', `Skipping fingerprint injection — Obscura provides built-in stealth`));
2366
+ }
2212
2367
 
2213
- // Client Hints protection for Chrome user agents
2214
- if (siteConfig.userAgent && siteConfig.userAgent.toLowerCase().includes('chrome')) {
2368
+ // Client Hints protection for Chrome user agents (skipped under Obscura — it sets its own)
2369
+ if (!useObscura && siteConfig.userAgent && siteConfig.userAgent.toLowerCase().includes('chrome')) {
2215
2370
  const userAgentKey = siteConfig.userAgent.toLowerCase();
2216
2371
  let platform = 'Windows';
2217
2372
  let platformVersion = '15.0.0';
@@ -2228,14 +2383,14 @@ function setupFrameHandling(page, forceDebug) {
2228
2383
  }
2229
2384
 
2230
2385
  await page.setExtraHTTPHeaders({
2231
- 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"',
2386
+ 'Sec-CH-UA': '"Not:A-Brand";v="99", "Google Chrome";v="146", "Chromium";v="146"',
2232
2387
  'Sec-CH-UA-Platform': `"${platform}"`,
2233
2388
  'Sec-CH-UA-Platform-Version': `"${platformVersion}"`,
2234
2389
  'Sec-CH-UA-Mobile': '?0',
2235
2390
  'Sec-CH-UA-Arch': `"${arch}"`,
2236
2391
  'Sec-CH-UA-Bitness': '"64"',
2237
- 'Sec-CH-UA-Full-Version': '"145.0.7632.160"',
2238
- '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"'
2392
+ 'Sec-CH-UA-Full-Version': '"146.0.0.0"',
2393
+ 'Sec-CH-UA-Full-Version-List': '"Not:A-Brand";v="99.0.0.0", "Google Chrome";v="146.0.0.0", "Chromium";v="146.0.0.0"'
2239
2394
  });
2240
2395
  }
2241
2396
  } catch (fingerprintErr) {
@@ -2248,11 +2403,7 @@ function setupFrameHandling(page, forceDebug) {
2248
2403
  }
2249
2404
  }
2250
2405
 
2251
- const regexes = Array.isArray(siteConfig.filterRegex)
2252
- ? siteConfig.filterRegex.map(r => new RegExp(r.replace(/^\/(.*)\/$/, '$1')))
2253
- : siteConfig.filterRegex
2254
- ? [new RegExp(siteConfig.filterRegex.replace(/^\/(.*)\/$/, '$1'))]
2255
- : [];
2406
+ const regexes = getCompiledRegexes(siteConfig.filterRegex);
2256
2407
 
2257
2408
  // NEW: Get regex_and setting (defaults to false for backward compatibility)
2258
2409
  const useRegexAnd = siteConfig.regex_and === true;
@@ -2400,7 +2551,7 @@ function setupFrameHandling(page, forceDebug) {
2400
2551
  }
2401
2552
 
2402
2553
  const blockedRegexes = Array.isArray(siteConfig.blocked)
2403
- ? siteConfig.blocked.map(pattern => new RegExp(pattern))
2554
+ ? siteConfig.blocked.map(pattern => getCompiledRegex(pattern))
2404
2555
  : [];
2405
2556
 
2406
2557
  // Combine site-specific with pre-compiled global blocked patterns
@@ -3177,7 +3328,23 @@ function setupFrameHandling(page, forceDebug) {
3177
3328
  ? { ...defaultGotoOptions, ...siteConfig.goto_options } : defaultGotoOptions;
3178
3329
 
3179
3330
  // Enhanced navigation with redirect handling - passes existing gotoOptions
3180
- const navigationResult = await navigateWithRedirectHandling(page, currentUrl, siteConfig, gotoOptions, forceDebug, formatLogMessage);
3331
+ let navigationResult;
3332
+ try {
3333
+ navigationResult = await navigateWithRedirectHandling(page, currentUrl, siteConfig, gotoOptions, forceDebug, formatLogMessage);
3334
+ } catch (navErr) {
3335
+ // Only retry on genuine timeouts, not chrome-error:// redirects
3336
+ let pageUrl = '';
3337
+ try { if (!page.isClosed()) pageUrl = page.url(); } catch {}
3338
+ const isPopupFailure = navErr.message.includes('chrome-error://') || navErr.message.includes('invalid URL') ||
3339
+ pageUrl.startsWith('chrome-error://') || pageUrl === 'about:blank';
3340
+ if ((navErr.message.includes('timeout') || navErr.message.includes('Timeout')) && !isPopupFailure) {
3341
+ if (forceDebug) console.log(formatLogMessage('debug', `Navigation timeout, retrying with waitUntil:networkidle2 for ${currentUrl}`));
3342
+ const fallbackOptions = { ...gotoOptions, waitUntil: 'networkidle2', timeout: Math.min(timeout, 10000) };
3343
+ navigationResult = await navigateWithRedirectHandling(page, currentUrl, siteConfig, fallbackOptions, forceDebug, formatLogMessage);
3344
+ } else {
3345
+ throw navErr;
3346
+ }
3347
+ }
3181
3348
 
3182
3349
  const { finalUrl, redirected, redirectChain, originalUrl, redirectDomains } = navigationResult;
3183
3350
 
@@ -3233,7 +3400,8 @@ function setupFrameHandling(page, forceDebug) {
3233
3400
  }
3234
3401
 
3235
3402
  if (originalDomain !== finalDomain) {
3236
- if (!silentMode) {
3403
+ const isPopupRedirect = !finalUrl || finalUrl === 'about:blank' || finalUrl.startsWith('chrome-error://');
3404
+ if (!silentMode && !isPopupRedirect) {
3237
3405
  console.log(`🔄 Redirect detected: ${originalDomain} → ${finalDomain}`);
3238
3406
  }
3239
3407
 
@@ -3260,13 +3428,11 @@ function setupFrameHandling(page, forceDebug) {
3260
3428
  }
3261
3429
  }
3262
3430
  } else {
3263
- // Invalid final URL - don't update currentUrl, treat as failed redirect
3264
- console.warn(`⚠ Redirect to invalid URL ignored: ${originalDomain} → ${finalUrl}`);
3431
+ // Invalid final URL (ad popup redirect) - continue with original URL
3265
3432
  if (forceDebug) {
3266
- console.log(formatLogMessage('debug', `Redirect chain ended with invalid URL, keeping original: ${originalUrl}`));
3433
+ console.log(formatLogMessage('debug', `Popup redirect ignored: ${originalDomain} ${finalUrl}, keeping original: ${originalUrl}`));
3267
3434
  }
3268
- // Keep processing with the original URL or throw an error
3269
- throw new Error(`Redirect resulted in invalid URL: ${finalUrl}`);
3435
+ // Continue with original URL requests captured before the redirect are still valid
3270
3436
  }
3271
3437
  }
3272
3438
  }
@@ -3407,7 +3573,10 @@ function setupFrameHandling(page, forceDebug) {
3407
3573
  const timeoutResult = await handleRedirectTimeout(page, currentUrl, err, safeGetDomain, forceDebug, formatLogMessage);
3408
3574
 
3409
3575
  if (timeoutResult.success) {
3410
- console.log(`⚠ Partial redirect timeout recovered: ${safeGetDomain(currentUrl)} → ${safeGetDomain(timeoutResult.finalUrl)}`);
3576
+ const isPopupRedirect = timeoutResult.finalUrl && (timeoutResult.finalUrl === 'about:blank' || timeoutResult.finalUrl.startsWith('chrome-error://'));
3577
+ if (!isPopupRedirect) {
3578
+ console.log(`⚠ Partial redirect timeout recovered: ${safeGetDomain(currentUrl)} → ${safeGetDomain(timeoutResult.finalUrl)}`);
3579
+ }
3411
3580
  currentUrl = timeoutResult.finalUrl; // Use the partial redirect URL
3412
3581
  siteCounter++;
3413
3582
  // Continue processing with the redirected URL instead of throwing error
@@ -4036,6 +4205,10 @@ function setupFrameHandling(page, forceDebug) {
4036
4205
  }
4037
4206
  }
4038
4207
 
4208
+ // Track domain timeout counts — skip domain after 3 failures
4209
+ const domainTimeoutCounts = new Map();
4210
+ const DOMAIN_TIMEOUT_THRESHOLD = 3;
4211
+
4039
4212
  // Enhanced hang detection with browser restart recovery
4040
4213
  let currentBatchInfo = { batchStart: 0, batchSize: 0 };
4041
4214
  let lastProcessedCount = 0;
@@ -4261,8 +4434,17 @@ function setupFrameHandling(page, forceDebug) {
4261
4434
  console.log(formatLogMessage('debug', `[CONCURRENCY] Starting ${batchSize} concurrent tasks with limit ${MAX_CONCURRENT_SITES}`));
4262
4435
  }
4263
4436
 
4264
- // Create tasks with timeout protection
4265
- const batchTasks = currentBatch.map(task => originalLimit(() => processUrl(task.url, task.config, browser)));
4437
+ // Create tasks with timeout protection — skip domains that repeatedly timed out
4438
+ const batchTasks = currentBatch.map(task => originalLimit(() => {
4439
+ try {
4440
+ const taskDomain = new URL(task.url).hostname;
4441
+ if ((domainTimeoutCounts.get(taskDomain) || 0) >= DOMAIN_TIMEOUT_THRESHOLD) {
4442
+ if (!silentMode) console.log(formatLogMessage('info', `Skipping ${task.url} — ${taskDomain} timed out ${DOMAIN_TIMEOUT_THRESHOLD} times`));
4443
+ return { url: task.url, rules: [], success: false, error: 'Domain repeatedly timed out', skipped: true };
4444
+ }
4445
+ } catch {}
4446
+ return processUrl(task.url, task.config, browser);
4447
+ }));
4266
4448
 
4267
4449
  let batchResults;
4268
4450
  try {
@@ -4292,6 +4474,16 @@ function setupFrameHandling(page, forceDebug) {
4292
4474
  }
4293
4475
  }
4294
4476
 
4477
+ // Track domain timeout counts — skip after threshold
4478
+ for (const result of batchResults) {
4479
+ if (!result.success && !result.skipped && result.error && result.error.includes('timeout')) {
4480
+ try {
4481
+ const domain = new URL(result.url).hostname;
4482
+ domainTimeoutCounts.set(domain, (domainTimeoutCounts.get(domain) || 0) + 1);
4483
+ } catch {}
4484
+ }
4485
+ }
4486
+
4295
4487
  // IMPROVED: Much more conservative emergency restart logic
4296
4488
  const criticalRestartCount = batchResults.filter(r => r.needsImmediateRestart).length;
4297
4489
  // Require either:
@@ -4601,9 +4793,19 @@ function setupFrameHandling(page, forceDebug) {
4601
4793
  // Keep browser open if --keep-open flag is set (useful with --headful for inspection)
4602
4794
  if (keepBrowserOpen && !launchHeadless) {
4603
4795
  console.log(messageColors.info('Browser kept open.') + ' Close the browser window or press Ctrl+C to exit.');
4796
+ const cleanup = async () => {
4797
+ try {
4798
+ if (browser.isConnected()) await browser.close();
4799
+ } catch {}
4800
+ process.exit(0);
4801
+ };
4802
+ process.on('SIGINT', cleanup);
4803
+ process.on('SIGTERM', cleanup);
4604
4804
  await new Promise((resolve) => {
4605
4805
  browser.on('disconnected', resolve);
4606
4806
  });
4807
+ process.removeListener('SIGINT', cleanup);
4808
+ process.removeListener('SIGTERM', cleanup);
4607
4809
  }
4608
4810
 
4609
4811
  // Perform comprehensive final cleanup using enhanced browserexit module
@@ -4617,15 +4819,23 @@ function setupFrameHandling(page, forceDebug) {
4617
4819
  if (forceDebug) console.log(formatLogMessage('debug', `Browser connection check failed: ${connErr.message}`));
4618
4820
  }
4619
4821
 
4620
- const cleanupResult = await handleBrowserExit(browser, {
4621
- forceDebug,
4622
- timeout: 10000,
4623
- exitOnFailure: true,
4624
- cleanTempFiles: true,
4625
- comprehensiveCleanup: removeTempFiles, // Use --remove-tempfiles flag
4626
- userDataDir: browser._nwssUserDataDir,
4627
- verbose: !silentMode && removeTempFiles // Show verbose output only if removing temp files and not silent
4628
- });
4822
+ // Obscura: just disconnect, don't kill — we don't own the browser process
4823
+ let cleanupResult;
4824
+ if (browser._nwssIsObscura) {
4825
+ try { await browser.disconnect(); } catch {}
4826
+ cleanupResult = { success: true, browserClosed: true, tempFilesCleanedCount: 0, userDataCleaned: false, errors: [] };
4827
+ if (forceDebug) console.log(formatLogMessage('debug', `Disconnected from Obscura (process left running)`));
4828
+ } else {
4829
+ cleanupResult = await handleBrowserExit(browser, {
4830
+ forceDebug,
4831
+ timeout: 10000,
4832
+ exitOnFailure: true,
4833
+ cleanTempFiles: true,
4834
+ comprehensiveCleanup: removeTempFiles,
4835
+ userDataDir: browser._nwssUserDataDir,
4836
+ verbose: !silentMode && removeTempFiles
4837
+ });
4838
+ }
4629
4839
 
4630
4840
  if (forceDebug) {
4631
4841
  console.log(formatLogMessage('debug', `Final cleanup results: ${cleanupResult.success ? 'success' : 'failed'}`));
@@ -4680,6 +4890,24 @@ function setupFrameHandling(page, forceDebug) {
4680
4890
  if (ignoreCache && forceDebug) {
4681
4891
  console.log(messageColors.info('Cache:') + ` Smart caching was disabled`);
4682
4892
  }
4893
+ // DNS cache statistics
4894
+ const dnsStats = getDnsCacheStats();
4895
+ if (dnsStats.digHits + dnsStats.digMisses > 0 || dnsStats.whoisHits + dnsStats.whoisMisses > 0) {
4896
+ const parts = [];
4897
+ if (dnsStats.digHits + dnsStats.digMisses > 0) {
4898
+ parts.push(`${messageColors.success(dnsStats.digHits)} dig cached, ${messageColors.timing(dnsStats.digMisses)} fresh`);
4899
+ }
4900
+ if (dnsStats.whoisHits + dnsStats.whoisMisses > 0) {
4901
+ parts.push(`${messageColors.success(dnsStats.whoisHits)} whois cached, ${messageColors.timing(dnsStats.whoisMisses)} fresh`);
4902
+ }
4903
+ console.log(messageColors.info('DNS cache:') + ` ${parts.join(' | ')}`);
4904
+ if (dnsStats.freshDig.length > 0) {
4905
+ console.log(messageColors.info(' Fresh dig:') + ` ${dnsStats.freshDig.join(', ')}`);
4906
+ }
4907
+ if (dnsStats.freshWhois.length > 0) {
4908
+ console.log(messageColors.info(' Fresh whois:') + ` ${dnsStats.freshWhois.join(', ')}`);
4909
+ }
4910
+ }
4683
4911
  }
4684
4912
 
4685
4913
  // Clean process termination
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanboynz/network-scanner",
3
- "version": "2.0.59",
3
+ "version": "2.0.61",
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": {