@fanboynz/network-scanner 1.0.35

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/lib/output.js ADDED
@@ -0,0 +1,633 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { loadComparisonRules, filterUniqueRules } = require('./compare');
4
+ const { colorize, colors, messageColors, tags, formatLogMessage } = require('./colorize');
5
+
6
+ /**
7
+ * Check if domain matches any ignore patterns (supports wildcards)
8
+ * @param {string} domain - Domain to check
9
+ * @param {string[]} ignorePatterns - Array of ignore patterns
10
+ * @returns {boolean} True if domain should be ignored
11
+ */
12
+ function matchesIgnoreDomain(domain, ignorePatterns) {
13
+ if (!ignorePatterns || !Array.isArray(ignorePatterns) || ignorePatterns.length === 0) {
14
+ return false;
15
+ }
16
+
17
+ return ignorePatterns.some(pattern => {
18
+ if (pattern.includes('*')) {
19
+ // Convert wildcard pattern to regex
20
+ const regexPattern = pattern
21
+ .replace(/\./g, '\\.') // Escape dots
22
+ .replace(/\*/g, '.*'); // Convert * to .*
23
+ return new RegExp(`^${regexPattern}$`).test(domain);
24
+ }
25
+ return domain.endsWith(pattern);
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Extract domain from a formatted rule back to plain domain
31
+ * @param {string} rule - Formatted rule (e.g., "||domain.com^", "127.0.0.1 domain.com", etc.)
32
+ * @returns {string|null} Plain domain or null if cannot extract
33
+ */
34
+ function extractDomainFromRule(rule) {
35
+ if (!rule || rule.startsWith('!')) {
36
+ return null; // Skip comments
37
+ }
38
+
39
+ // Handle different output formats
40
+ if (rule.startsWith('||') && rule.includes('^')) {
41
+ // Adblock format: ||domain.com^ or ||domain.com^$script
42
+ return rule.substring(2).split('^')[0];
43
+ } else if (rule.match(/^(127\.0\.0\.1|0\.0\.0\.0)\s+/)) {
44
+ // Localhost format: 127.0.0.1 domain.com or 0.0.0.0 domain.com
45
+ return rule.split(/\s+/)[1];
46
+ } else if (rule.startsWith('local=/') && rule.endsWith('/')) {
47
+ // DNSmasq format: local=/domain.com/
48
+ return rule.substring(6, rule.length - 1);
49
+ } else if (rule.startsWith('server=/') && rule.endsWith('/')) {
50
+ // DNSmasq old format: server=/domain.com/
51
+ return rule.substring(7, rule.length - 1);
52
+ } else if (rule.startsWith('local-zone: "') && rule.includes('" always_null')) {
53
+ // Unbound format: local-zone: "domain.com." always_null
54
+ const domain = rule.substring(13).split('"')[0];
55
+ return domain.endsWith('.') ? domain.slice(0, -1) : domain;
56
+ } else if (rule.startsWith('{ +block } .')) {
57
+ // Privoxy format: { +block } .domain.com
58
+ return rule.substring(12);
59
+ } else if (rule.match(/^\(\^\|\\?\.\)/)) {
60
+ // Pi-hole regex format: (^|\.)domain\.com$
61
+ return rule.replace(/^\(\^\|\\?\.\)/, '').replace(/\\\./g, '.').replace(/\$$/, '');
62
+ }
63
+
64
+ // If no format matches, assume it's already a plain domain
65
+ return rule.includes('.') ? rule : null;
66
+ }
67
+
68
+ /**
69
+ * Formats a domain according to the specified output mode
70
+ * @param {string} domain - The domain to format
71
+ * @param {object} options - Formatting options
72
+ * @param {boolean} options.localhost - Use 127.0.0.1 format
73
+ * @param {boolean} options.localhostAlt - Use 0.0.0.0 format
74
+ * @param {boolean} options.plain - Use plain domain format (no adblock syntax)
75
+ * @param {boolean} options.adblockRules - Generate adblock filter rules with resource types
76
+ * @param {boolean} options.dnsmasq - Use dnsmasq local format
77
+ * @param {boolean} options.dnsmasqOld - Use dnsmasq old server format
78
+ * @param {boolean} options.unbound - Use unbound local-zone format
79
+ * @param {boolean} options.privoxy - Use Privoxy block format
80
+ * @param {boolean} options.pihole - Use Pi-hole regex format
81
+ * @param {string} options.resourceType - Resource type for adblock rules (script, xhr, iframe, css, image, etc.)
82
+ * @returns {string} The formatted domain
83
+ */
84
+ function formatDomain(domain, options = {}) {
85
+ const { localhost = false, localhostAlt = false, plain = false, adblockRules = false, dnsmasq = false, dnsmasqOld = false, unbound = false, privoxy = false, pihole = false, resourceType = null } = options;
86
+
87
+
88
+ // Validate domain length and format
89
+ if (!domain || domain.length <= 6 || !domain.includes('.')) {
90
+ return null;
91
+ }
92
+
93
+ // If plain is true, always return just the domain regardless of other options
94
+ if (plain) {
95
+ return domain;
96
+ }
97
+
98
+ // Apply specific format based on output mode
99
+ if (pihole) {
100
+ // Escape dots for regex and use Pi-hole format: (^|\.)domain\.com$
101
+ const escapedDomain = domain.replace(/\./g, '\\.');
102
+ return `(^|\\.)${escapedDomain}$`;
103
+ } else if (privoxy) {
104
+ return `{ +block } .${domain}`;
105
+ } else if (dnsmasq) {
106
+ return `local=/${domain}/`;
107
+ } else if (dnsmasqOld) {
108
+ return `server=/${domain}/`;
109
+ } else if (unbound) {
110
+ return `local-zone: "${domain}." always_null`;
111
+ } else if (localhost) {
112
+ return `127.0.0.1 ${domain}`;
113
+ } else if (localhostAlt) {
114
+ return `0.0.0.0 ${domain}`;
115
+ } else if (adblockRules && resourceType) {
116
+ // Generate adblock filter rules with resource type modifiers
117
+ return `||${domain}^${resourceType}`;
118
+ } else {
119
+ return `||${domain}^`;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Maps Puppeteer resource types to adblock filter modifiers
125
+ * @param {string} resourceType - Puppeteer resource type
126
+ * @returns {string|null} Adblock filter modifier, or null if should be ignored
127
+ */
128
+ function mapResourceTypeToAdblockModifier(resourceType) {
129
+ const typeMap = {
130
+ 'script': 'script',
131
+ 'xhr': 'xmlhttprequest',
132
+ 'fetch': 'xmlhttprequest',
133
+ 'stylesheet': 'stylesheet',
134
+ 'image': 'image',
135
+ 'font': 'font',
136
+ 'document': 'document',
137
+ 'subdocument': 'subdocument',
138
+ 'iframe': 'subdocument',
139
+ 'websocket': 'websocket',
140
+ 'media': 'media',
141
+ 'ping': 'ping',
142
+ 'other': null // Ignore 'other' type - return null
143
+ };
144
+
145
+ return typeMap[resourceType] || null; // Return null for unknown types too
146
+ }
147
+
148
+ /**
149
+ * Formats an array of domains according to site and global settings
150
+ * @param {Set<string>|Map<string, Set<string>>} matchedDomains - Set of matched domains or Map of domain -> resource types
151
+ * @param {object} siteConfig - Site-specific configuration
152
+ * @param {object} globalOptions - Global formatting options
153
+ * @returns {string[]} Array of formatted rules
154
+ */
155
+ function formatRules(matchedDomains, siteConfig = {}, globalOptions = {}) {
156
+ const {
157
+ localhostMode = false,
158
+ localhostModeAlt = false,
159
+ plainOutput = false,
160
+ adblockRulesMode = false,
161
+ dnsmasqMode = false,
162
+ dnsmasqOldMode = false,
163
+ unboundMode = false,
164
+ privoxyMode = false,
165
+ piholeMode = false
166
+ } = globalOptions;
167
+
168
+ // Site-level overrides
169
+ const siteLocalhost = siteConfig.localhost === true;
170
+ const siteLocalhostAlt = siteConfig.localhost_0_0_0_0 === true;
171
+ const sitePlainSetting = siteConfig.plain === true;
172
+ const siteAdblockRules = siteConfig.adblock_rules === true;
173
+ const siteDnsmasq = siteConfig.dnsmasq === true;
174
+ const siteDnsmasqOld = siteConfig.dnsmasq_old === true;
175
+ const siteUnbound = siteConfig.unbound === true;
176
+ const sitePrivoxy = siteConfig.privoxy === true;
177
+ const sitePihole = siteConfig.pihole === true;
178
+
179
+ // Validate output format compatibility - silently ignore incompatible combinations
180
+ const activeFormats = [
181
+ dnsmasqMode || siteDnsmasq,
182
+ dnsmasqOldMode || siteDnsmasqOld,
183
+ unboundMode || siteUnbound,
184
+ privoxyMode || sitePrivoxy,
185
+ piholeMode || sitePihole,
186
+ adblockRulesMode || siteAdblockRules,
187
+ localhostMode || siteLocalhost,
188
+ localhostModeAlt || siteLocalhostAlt,
189
+ plainOutput || sitePlainSetting
190
+ ].filter(Boolean).length;
191
+
192
+ if (activeFormats > 1) {
193
+ // Multiple formats specified - fall back to standard adblock format
194
+ const formatOptions = {
195
+ localhost: false,
196
+ localhostAlt: false,
197
+ plain: false,
198
+ adblockRules: false,
199
+ dnsmasq: false,
200
+ dnsmasqOld: false,
201
+ unbound: false,
202
+ privoxy: false,
203
+ pihole: false
204
+ };
205
+
206
+ const formattedRules = [];
207
+ const domainsToProcess = matchedDomains instanceof Set ? matchedDomains : new Set(matchedDomains.keys());
208
+ domainsToProcess.forEach(domain => {
209
+ const formatted = formatDomain(domain, formatOptions);
210
+ if (formatted) {
211
+ formattedRules.push(formatted);
212
+ }
213
+ });
214
+ return formattedRules;
215
+ }
216
+
217
+ // Determine final formatting options
218
+ const formatOptions = {
219
+ localhost: localhostMode || siteLocalhost,
220
+ localhostAlt: localhostModeAlt || siteLocalhostAlt,
221
+ plain: plainOutput || sitePlainSetting,
222
+ adblockRules: adblockRulesMode || siteAdblockRules,
223
+ dnsmasq: dnsmasqMode || siteDnsmasq,
224
+ dnsmasqOld: dnsmasqOldMode || siteDnsmasqOld,
225
+ unbound: unboundMode || siteUnbound,
226
+ privoxy: privoxyMode || sitePrivoxy,
227
+ pihole: piholeMode || sitePihole
228
+ };
229
+
230
+ const formattedRules = [];
231
+
232
+ if (matchedDomains instanceof Map && formatOptions.adblockRules) {
233
+ // Handle Map format with resource types for --adblock-rules
234
+ matchedDomains.forEach((resourceTypes, domain) => {
235
+ if (resourceTypes.size > 0) {
236
+ let hasValidResourceType = false;
237
+
238
+ // Generate one rule per resource type found for this domain
239
+ resourceTypes.forEach(resourceType => {
240
+ const adblockModifier = mapResourceTypeToAdblockModifier(resourceType);
241
+ // Skip if modifier is null (e.g., 'other' type)
242
+ if (adblockModifier) {
243
+ hasValidResourceType = true;
244
+ const formatted = formatDomain(domain, {
245
+ ...formatOptions,
246
+ resourceType: adblockModifier
247
+ });
248
+ if (formatted) {
249
+ formattedRules.push(formatted);
250
+ }
251
+ }
252
+ });
253
+
254
+ // If no valid resource types were found, add a generic rule
255
+ if (!hasValidResourceType) {
256
+ const formatted = formatDomain(domain, formatOptions);
257
+ if (formatted) {
258
+ formattedRules.push(formatted);
259
+ }
260
+ }
261
+ } else {
262
+ // Fallback to generic rule if no resource types
263
+ const formatted = formatDomain(domain, formatOptions);
264
+ if (formatted) {
265
+ formattedRules.push(formatted);
266
+ }
267
+ }
268
+ });
269
+ } else {
270
+ // Handle Set format (legacy behavior) or other modes (including privoxy and pihole)
271
+ const domainsToProcess = matchedDomains instanceof Set ? matchedDomains : new Set(matchedDomains.keys());
272
+ domainsToProcess.forEach(domain => {
273
+ const formatted = formatDomain(domain, formatOptions);
274
+ if (formatted) {
275
+ formattedRules.push(formatted);
276
+ }
277
+ });
278
+ }
279
+
280
+ return formattedRules;
281
+ }
282
+
283
+ /**
284
+ * Removes duplicate rules while preserving comments (lines starting with !)
285
+ * @param {string[]} lines - Array of output lines
286
+ * @returns {string[]} Array with duplicates removed
287
+ */
288
+ function removeDuplicates(lines) {
289
+ const uniqueLines = [];
290
+ const seenRules = new Set();
291
+
292
+ for (const line of lines) {
293
+ if (line.startsWith('!') || !seenRules.has(line)) {
294
+ uniqueLines.push(line);
295
+ if (!line.startsWith('!')) {
296
+ seenRules.add(line);
297
+ }
298
+ }
299
+ }
300
+
301
+ return uniqueLines;
302
+ }
303
+
304
+ /**
305
+ * Builds the final output lines from processing results
306
+ * @param {Array} results - Array of processing results from processUrl
307
+ * @param {object} options - Output options
308
+ * @param {boolean} options.showTitles - Include URL titles in output
309
+ * @param {boolean} options.removeDupes - Remove duplicate rules
310
+ * @param {string[]} options.ignoreDomains - Domains to filter out from final output
311
+ * @param {boolean} options.forLogFile - Include titles regardless of showTitles (for log files)
312
+ * @returns {object} Object containing outputLines and outputLinesWithTitles
313
+ */
314
+ function buildOutputLines(results, options = {}) {
315
+ const { showTitles = false, removeDupes = false, ignoreDomains = [], forLogFile = false } = options;
316
+
317
+ // Filter and collect successful results with rules
318
+ const finalSiteRules = [];
319
+ let successfulPageLoads = 0;
320
+
321
+ results.forEach(result => {
322
+ if (result) {
323
+ if (result.success) {
324
+ successfulPageLoads++;
325
+ }
326
+ if (result.rules && result.rules.length > 0) {
327
+ finalSiteRules.push({ url: result.url, rules: result.rules });
328
+ }
329
+ }
330
+ });
331
+
332
+ // Build output lines
333
+ const outputLines = [];
334
+ const outputLinesWithTitles = [];
335
+ let filteredOutCount = 0;
336
+
337
+ for (const { url, rules } of finalSiteRules) {
338
+ if (rules.length > 0) {
339
+ // Regular output (for -o files and console) - only add titles if --titles flag used
340
+ if (showTitles) {
341
+ outputLines.push(`! ${url}`);
342
+ }
343
+
344
+ // Filter out ignored domains from rules
345
+ const filteredRules = rules.filter(rule => {
346
+ const domain = extractDomainFromRule(rule);
347
+ if (domain && matchesIgnoreDomain(domain, ignoreDomains)) {
348
+ filteredOutCount++;
349
+
350
+ // Log each filtered domain
351
+ if (options.forceDebug) {
352
+ console.log(formatLogMessage('debug', `[output-filter] Removed rule matching ignoreDomains: ${rule} (domain: ${domain})`));
353
+ } else if (!options.silentMode) {
354
+ console.log(formatLogMessage('info', `Filtered out: ${domain}`));
355
+ }
356
+
357
+ return false;
358
+ }
359
+ return true;
360
+ });
361
+
362
+ outputLines.push(...filteredRules);
363
+
364
+ // Output with titles (for auto-saved log files) - always add titles
365
+ outputLinesWithTitles.push(`! ${url}`);
366
+ outputLinesWithTitles.push(...filteredRules);
367
+ }
368
+ }
369
+
370
+ // Log filtered domains if any were removed
371
+ if (filteredOutCount > 0) {
372
+ if (options.forceDebug) {
373
+ console.log(formatLogMessage('debug', `[output-filter] Total: ${filteredOutCount} rules filtered out matching ignoreDomains patterns`));
374
+ } else if (!options.silentMode) {
375
+ console.log(formatLogMessage('info', `${filteredOutCount} domains filtered out by ignoreDomains`));
376
+ }
377
+ }
378
+
379
+ // Remove duplicates if requested
380
+ const finalOutputLines = removeDupes ? removeDuplicates(outputLines) : outputLines;
381
+
382
+ return {
383
+ outputLines: finalOutputLines,
384
+ outputLinesWithTitles,
385
+ successfulPageLoads,
386
+ totalRules: finalOutputLines.filter(line => !line.startsWith('!')).length,
387
+ filteredOutCount
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Writes output to file or console
393
+ * @param {string[]} lines - Lines to output
394
+ * @param {string|null} outputFile - File path to write to, or null for console output
395
+ * @param {boolean} silentMode - Suppress console messages
396
+ * @returns {boolean} Success status
397
+ */
398
+ function writeOutput(lines, outputFile = null, silentMode = false) {
399
+ try {
400
+ if (outputFile) {
401
+ // Ensure output directory exists
402
+ const outputDir = path.dirname(outputFile);
403
+ if (outputDir !== '.' && !fs.existsSync(outputDir)) {
404
+ fs.mkdirSync(outputDir, { recursive: true });
405
+ }
406
+
407
+ fs.writeFileSync(outputFile, lines.join('\n') + '\n');
408
+ if (!silentMode) {
409
+ console.log(`\n${messageColors.success('Rules saved to')} ${outputFile}`);
410
+ }
411
+ } else {
412
+ // Console output
413
+ if (lines.length > 0 && !silentMode) {
414
+ console.log(`\n${messageColors.highlight('--- Generated Rules ---')}`);
415
+ }
416
+ console.log(lines.join('\n'));
417
+ }
418
+ return true;
419
+ } catch (error) {
420
+ console.error(`? Failed to write output: ${error.message}`);
421
+ return false;
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Main output handler that combines all output operations
427
+ * @param {Array} results - Processing results from scanner
428
+ * @param {object} config - Output configuration
429
+ * @param {string[]} config.ignoreDomains - Domains to filter out from final output
430
+ * @returns {object} Output statistics and file paths
431
+ */
432
+ function handleOutput(results, config = {}) {
433
+ const {
434
+ outputFile = null,
435
+ compareFile = null,
436
+ appendMode = false,
437
+ showTitles = false,
438
+ removeDupes = false,
439
+ silentMode = false,
440
+ dumpUrls = false,
441
+ adblockRulesLogFile = null,
442
+ forceDebug = false,
443
+ ignoreDomains = []
444
+ } = config;
445
+
446
+ // Handle append mode
447
+ if (outputFile && appendMode) {
448
+ try {
449
+ // Build output lines first
450
+ const {
451
+ outputLines,
452
+ outputLinesWithTitles,
453
+ successfulPageLoads,
454
+ totalRules,
455
+ filteredOutCount
456
+ } = buildOutputLines(results, { showTitles, removeDupes, ignoreDomains: config.ignoreDomains, forceDebug: config.forceDebug });
457
+
458
+ // Apply remove-dupes to new results if requested (before comparing to existing file)
459
+ const deduplicatedOutputLines = removeDupes ? removeDuplicates(outputLines) : outputLines;
460
+ if (removeDupes && forceDebug) console.log(formatLogMessage('debug', `Applied --remove-dupes to new scan results before append comparison`));
461
+
462
+ // Read existing file content
463
+ let existingContent = '';
464
+ if (fs.existsSync(outputFile)) {
465
+ existingContent = fs.readFileSync(outputFile, 'utf8');
466
+ } else {
467
+ // File doesn't exist - append mode should create it
468
+ if (forceDebug) console.log(formatLogMessage('debug', `Append mode: Creating new file ${outputFile}`));
469
+ }
470
+
471
+ // Parse existing rules for comparison (exclude comments)
472
+ const existingRules = new Set();
473
+ if (existingContent.trim()) {
474
+ const lines = existingContent.trim().split('\n');
475
+ lines.forEach(line => {
476
+ const cleanLine = line.trim();
477
+ if (cleanLine && !cleanLine.startsWith('!') && !cleanLine.startsWith('#')) {
478
+ existingRules.add(cleanLine);
479
+ }
480
+ });
481
+ }
482
+
483
+ // Filter out rules that already exist (exclude comments from filtering)
484
+ const newRules = deduplicatedOutputLines.filter(rule => {
485
+ return rule.startsWith('!') || !existingRules.has(rule);
486
+ });
487
+
488
+ if (newRules.length > 0) {
489
+ // Prepare content to append
490
+ let appendContent = '';
491
+
492
+ // Ensure there's a newline before appending if file has content
493
+ if (existingContent && !existingContent.endsWith('\n')) {
494
+ appendContent = '\n';
495
+ }
496
+
497
+ // Add new rules
498
+ appendContent += newRules.join('\n') + '\n';
499
+
500
+ // Append to file
501
+ fs.appendFileSync(outputFile, appendContent);
502
+
503
+ const newRuleCount = newRules.filter(rule => !rule.startsWith('!')).length;
504
+ if (!silentMode) {
505
+ console.log(`${messageColors.success('? Appended')} ${newRuleCount} new rules to: ${outputFile} (${existingRules.size} rules already existed${removeDupes ? ', duplicates removed' : ''})`);
506
+ }
507
+ } else {
508
+ if (!silentMode) {
509
+ const ruleCount = deduplicatedOutputLines.filter(rule => !rule.startsWith('!')).length;
510
+ console.log(`${messageColors.info('?')} No new rules to append - all ${ruleCount} rules already exist in: ${outputFile}`);
511
+ }
512
+ }
513
+
514
+ // Write log file output if --dumpurls is enabled
515
+ let logSuccess = true;
516
+ if (dumpUrls && adblockRulesLogFile) {
517
+ logSuccess = writeOutput(outputLinesWithTitles, adblockRulesLogFile, silentMode);
518
+ }
519
+
520
+ const newRuleCount = newRules.filter(rule => !rule.startsWith('!')).length;
521
+ return {
522
+ success: logSuccess,
523
+ outputFile,
524
+ adblockRulesLogFile,
525
+ successfulPageLoads,
526
+ totalRules: newRuleCount,
527
+ filteredOutCount,
528
+ totalLines: newRules.length,
529
+ outputLines: null,
530
+ appendedRules: newRuleCount,
531
+ existingRules: existingRules.size
532
+ };
533
+
534
+ } catch (appendErr) {
535
+ console.error(`? Failed to append to ${outputFile}: ${appendErr.message}`);
536
+ return { success: false };
537
+ }
538
+ }
539
+
540
+ // Build output lines
541
+ const {
542
+ outputLines,
543
+ outputLinesWithTitles,
544
+ successfulPageLoads,
545
+ totalRules,
546
+ filteredOutCount
547
+ } = buildOutputLines(results, { showTitles, removeDupes, ignoreDomains: config.ignoreDomains, forceDebug: config.forceDebug });
548
+
549
+ // Apply comparison filtering if compareFile is specified
550
+ let filteredOutputLines = outputLines;
551
+ if (compareFile && outputLines.length > 0) {
552
+ try {
553
+ const comparisonRules = loadComparisonRules(compareFile, config.forceDebug);
554
+ const originalCount = outputLines.filter(line => !line.startsWith('!')).length;
555
+ filteredOutputLines = filterUniqueRules(outputLines, comparisonRules, config.forceDebug);
556
+
557
+ if (!silentMode) {
558
+ console.log(formatLogMessage('compare', `Filtered ${originalCount - filteredOutputLines.filter(line => !line.startsWith('!')).length} existing rules, ${filteredOutputLines.filter(line => !line.startsWith('!')).length} unique rules remaining`));
559
+
560
+ }
561
+ } catch (compareError) {
562
+ console.error(messageColors.error('❌ Compare operation failed:') + ` ${compareError.message}`);
563
+ return { success: false, totalRules: 0, successfulPageLoads: 0 };
564
+ }
565
+ }
566
+
567
+ // Write main output
568
+ const mainSuccess = writeOutput(filteredOutputLines, outputFile, silentMode);
569
+
570
+ // Write log file output if --dumpurls is enabled
571
+ let logSuccess = true;
572
+ if (dumpUrls && adblockRulesLogFile) {
573
+ logSuccess = writeOutput(outputLinesWithTitles, adblockRulesLogFile, silentMode);
574
+ }
575
+
576
+ return {
577
+ success: mainSuccess && logSuccess,
578
+ outputFile,
579
+ adblockRulesLogFile,
580
+ successfulPageLoads,
581
+ totalRules: filteredOutputLines.filter(line => !line.startsWith('!')).length,
582
+ filteredOutCount,
583
+ totalLines: filteredOutputLines.length,
584
+ outputLines: outputFile ? null : filteredOutputLines // Only return lines if not written to file
585
+ };
586
+ }
587
+
588
+ /**
589
+ * Get output format description for debugging/logging
590
+ * @param {object} options - Format options
591
+ * @returns {string} Human-readable format description
592
+ */
593
+ function getFormatDescription(options = {}) {
594
+ const { localhost = false, localhostAlt = false, plain = false, adblockRules = false, dnsmasq = false, dnsmasqOld = false, unbound = false, privoxy = false, pihole = false } = options;
595
+
596
+ // Plain always takes precedence
597
+ if (plain) {
598
+ return 'Plain domains only';
599
+ }
600
+
601
+ if (pihole) {
602
+ return 'Pi-hole regex format ((^|\\.)domain\\.com$)';
603
+ } else if (privoxy) {
604
+ return 'Privoxy format ({ +block } .domain.com)';
605
+ } else if (dnsmasq) {
606
+ return 'DNSmasq format (local=/domain.com/)';
607
+ } else if (dnsmasqOld) {
608
+ return 'DNSmasq old format (server=/domain.com/)';
609
+ } else if (unbound) {
610
+ return 'Unbound format (local-zone: "domain.com." always_null)';
611
+ } else if (adblockRules) {
612
+ return 'Adblock filter rules with resource type modifiers (||domain.com^$script)';
613
+ } else if (localhost) {
614
+ return 'Localhost format (127.0.0.1 domain.com)';
615
+ } else if (localhostAlt) {
616
+ return 'Localhost format (0.0.0.0 domain.com)';
617
+ } else {
618
+ return 'Adblock format (||domain.com^)';
619
+ }
620
+ }
621
+
622
+ module.exports = {
623
+ formatDomain,
624
+ formatRules,
625
+ removeDuplicates,
626
+ buildOutputLines,
627
+ writeOutput,
628
+ handleOutput,
629
+ getFormatDescription,
630
+ mapResourceTypeToAdblockModifier,
631
+ matchesIgnoreDomain,
632
+ extractDomainFromRule
633
+ };