@fanboynz/network-scanner
v4.1.0
Published
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.
Maintainers
Readme
A Puppeteer-based tool for scanning websites to find third-party (or optionally first-party) network requests matching specified patterns, and generate Adblock-formatted rules.
Features
- Scan websites and detect matching third-party or first-party resources
- Output Adblock-formatted blocking rules
- Support for multiple filters per site
- Grouped titles (! ) before site matches, including redirect source and matching regex
- Ignore unwanted domains (global and per-site)
- Block unwanted domains during scan (simulate adblock)
- Support Chrome, Firefox, Safari user agents (desktop or mobile)
- Advanced fingerprint spoofing and referrer header simulation
- Delay, timeout, reload options per site
- Verbose and debug modes
- Dump matched full URLs into
matched_urls.log - Save output in normal Adblock format or localhost (127.0.0.1/0.0.0.0)
- Subdomain handling (collapse to root or full subdomain)
- Optionally match only first-party, third-party, or both
- Enhanced redirect handling with JavaScript and meta refresh detection
- Capture and drive popup/popunder chains (
capture_popups+interact_popups) so domains reachable only via a clicked popup still match - Per-site proxy routing (SOCKS5, SOCKS4, HTTP, HTTPS) with pre-flight health checks
Command Line Arguments
Output Options
| Argument | Description |
|:---------------------------|:------------|
| -o, --output <file> | Output file for rules. If omitted, prints to console |
| --compare <file> | Remove rules that already exist in this file before output |
| --no-color, --no-colour | Disable colored console output (colors enabled by default) |
| --append | Append new rules to output file instead of overwriting (requires -o) |
Output Format Options
| Argument | Description |
|:---------------------------|:------------|
| --localhost[=IP] | Output as IP domain.com (default: 127.0.0.1) |
| | Examples: --localhost, --localhost=0.0.0.0, --localhost=192.168.1.1 |
| --plain | Output just domains (no adblock formatting) |
| --dnsmasq | Output as local=/domain.com/ (dnsmasq format) |
| --dnsmasq-old | Output as server=/domain.com/ (dnsmasq old format) |
| --unbound | Output as local-zone: "domain.com." always_null (unbound format) |
| --privoxy | Output as { +block } .domain.com (Privoxy format) |
| --pihole | Output as (^\|\\.)domain\\.com$ (Pi-hole regex format) |
| --adblock-rules | Generate adblock filter rules with resource type modifiers (requires -o) |
General Options
| Argument | Description |
|:---------------------------|:------------|
| --debug | Force debug mode globally |
| --silent | Suppress normal console logs |
| --titles | Add ! <url> title before each site's group |
| --dumpurls | Dump matched URLs into matched_urls.log |
| --remove-tempfiles | Remove Chrome/Puppeteer temporary files before exit |
| --compress-logs | Compress log files with gzip (requires --dumpurls) |
| --sub-domains | Output full subdomains instead of collapsing to root |
| --no-interact | Disable page interactions globally |
| --ghost-cursor | Use ghost-cursor Bezier mouse movements globally (requires npm i ghost-cursor) |
| --custom-json <file> | Use a custom config JSON file instead of config.json |
| --headful | Launch browser with GUI (not headless) |
| --keep-open | Keep browser and tabs open after scan completes (use with --headful for debugging) |
| --use-puppeteer-core | Use puppeteer-core with system Chrome instead of bundled Chromium |
| --use-obscura | Connect to running Obscura CDP server (ws://127.0.0.1:9222 or OBSCURA_WS env). Skips fingerprint injection — Obscura provides built-in stealth |
| --load-extension <path> | Load unpacked Chrome extension from directory (can be used multiple times) |
| --dns-cache | Persist dig/whois results to disk between runs (28-day TTL for dig, 36hr for whois; 10000-entry cap for dig, 2000 for whois; .digcache/.whoiscache), plus the DNS pre-check negative cache (NXDOMAIN/ENODATA only — never resolver errors — 12h TTL, .dnsnegcache) so known-dead hosts aren't re-resolved next run. Disk writes are atomic (tmp + rename); corrupt cache files are detected on load with a [dns-cache] warn line and reset cleanly. Over a 28-day dig window two answers stay pinned: a changed A-record (domain leaves a matched IP range) and an NXDOMAIN (a re-registered domain still reads as dead) — delete .digcache when either matters. Transient failures (timeout/SERVFAIL/REFUSED) are never cached. |
| --no-dns-precheck | Disable per-URL DNS resolution check before page navigation. By default, hosts that dig/whois have already proven live (within the dig/whois cache TTL) skip their c-ares pre-check via a positive-resolution index. |
| --block-ads=<files> | Block ads/trackers during the scan using EasyList-format filter list(s) (\|\|domain^, /ads/*, etc.). Comma-separated for multiple: --block-ads=easylist.txt,easyprivacy.txt. See Blocking ads during the scan. |
| --adblock-engine=<js\|rust> | Matcher backend for --block-ads. Default: auto — uses rust (Brave's adblock-rs) when it's installed, else js. Pass either value to pin it. |
| --cdp | Enable Chrome DevTools Protocol logging (now per-page if enabled) |
| --remove-dupes | Remove duplicate domains from output (only with -o) |
| --dry-run | Console output only: show matching regex, titles, whois/dig/searchstring results, and adblock rules |
| --eval-on-doc | Globally enable evaluateOnNewDocument() for Fetch/XHR interception |
| --help, -h | Show this help menu |
| --version | Show script version |
| --max-concurrent <number> | Maximum concurrent site processing (1-50, overrides config/default) |
| --dns <ip[:port][,ip...]> | Resolver(s) for the DNS pre-check, nettools' dig, and — when they map to a known public DoH provider — Chrome's page navigation via DNS-over-HTTPS on direct connections (one pins, several rotate per query; overrides /etc/resolv.conf; does not affect whois). Chrome normally ignores --dns and reads resolv.conf directly, so a broken system resolver could fail a domain the pre-check already resolved; pinning Chrome's secure-DNS (automatic mode) to the matching provider closes that gap. Mapped providers: Google, Cloudflare, Quad9, OpenDNS, AdGuard, CleanBrowsing, DNS.SB, Mullvad (incl. malware/family/unfiltered variants). Skipped under a proxy/VPN (the exit/tunnel resolves); unmapped resolvers (custom/ISP, per-account, IPv6) fall back to system DNS with a warning. An explicit port (127.0.0.1:5353, [2001:db8::1]:5353) is honoured by both the pre-check and dig — a resolver on a non-standard port is queried there, not on 53; out-of-range ports are ignored. The DoH mapping always uses 443 |
| --doh-disable | Opt out of the Chrome-navigation DoH pinning (default: off). Chrome resolves page navigation via system resolv.conf even when --dns maps to a known provider; the pre-check and dig still honor --dns. Use when DoH adds latency, is blocked on the network, or you want system-path resolution |
| --dig-max-concurrent <number> | Cap concurrent dig subprocesses (default 6, 0 = uncapped). Under a high --max-concurrent the scanner can otherwise fire that many simultaneous lookups at the same resolvers, provoking rate-limit drops (rough on WSL2's UDP-through-NAT path). Only genuine cache-miss lookups contend for a slot; cache hits and deduplicated lookups bypass it. The slot is held around the dig subprocess only — a lookup waiting out a --dig-retry-failed backoff releases it, so one failing domain can't block the others while nothing is running. Also dig_max_concurrent in .nwssconfig |
| --dig-retry-failed [number] | On total dig failure (UDP failover + TCP fallback both empty), make N extra TCP attempts with a ~3s backoff before giving up (bare flag or dig_retry_failed: true = 2; 0 disables; capped at 5). For bursty/flaky resolvers where a fresh attempt seconds later succeeds; the recovered result is captured this run via the normal match path. Off by default; extra latency applies to already-failing lookups only. Also dig_retry_failed in .nwssconfig |
| --show-dead-domains | At end of scan, list hostnames that did not resolve / were unreachable (NXDOMAIN/ENODATA + ERR_NAME_NOT_RESOLVED/ERR_ADDRESS_UNREACHABLE). Excludes blocks/timeouts (those mean the domain is alive). For pruning dead URLs. |
| --cleanup-interval <number> | Browser restart interval in URLs processed (1-1000, overrides config/default) |
Validation Options
| Argument | Description |
|:---------------------------|:------------|
| --cache-requests | Cache HTTP requests to avoid re-requesting same URLs within scan |
| --validate-config | Validate config.json file and exit |
| --validate-rules [file] | Validate rule file format (uses --output/--compare files if no file specified) |
| --clean-rules [file] | Clean rule files by removing invalid lines and optionally duplicates (uses --output/--compare files if no file specified) |
| --test-validation | Run domain validation tests and exit |
| --clear-cache | Clear persistent cache before scanning (improves fresh start performance) |
| --ignore-cache | Bypass all smart caching functionality during scanning |
Blocking ads during the scan
--block-ads makes the scanner block matching requests during the scan (separate from capturing rules) — to keep ad/tracker noise out of the page, speed up loads, or test that a list catches what it should.
Adding lists. Pass one or more EasyList-format filter lists (same syntax as uBlock Origin / EasyList):
# Single list
node nwss.js --block-ads=easylist.txt
# Multiple lists — comma-separated, no spaces
node nwss.js --block-ads=easylist.txt,easyprivacy.txt,mylist.txtLists are plain-text network rules — ||doubleclick.net^, /ads/*, ||example.com^$script, etc. Element-hiding/cosmetic rules (##…) don't apply to request blocking and are ignored. The scanned page's own top-level document is never blocked (only sub-resources), so a site whose own domain is in a list still loads.
Engine — js vs rust (--adblock-engine, default auto):
By default the scanner picks rust when adblock-rs is importable and falls back to js otherwise, so you get the faster matcher without a flag and without a Rust toolchain becoming a requirement. Pass an explicit value to pin the choice.
| Engine | Flag | Backend | When |
|---|---|---|---|
| auto (default) | (no flag) | rust if available, else js | What you want almost always |
| js | --adblock-engine=js | lib/adblock.js — pure-JS, no extra deps | Pin when you want zero native deps, or to A/B against rust |
| rust | --adblock-engine=rust | lib/adblock-rust.js — Brave's adblock-rs | Pin when the Rust engine is required — errors out if adblock-rs is missing instead of quietly downgrading |
Why rust is preferred: measured over easylist.txt (66,100 rules), a URL that misses the O(1) domain map costs ~148 µs on js versus ~4 µs on rust. That miss path is most real traffic — ordinary page requests match no rule and fall through ~1,400 path/script rules linearly. The result cache hides it until URLs are unique (cache-busting query strings), where throughput drops from ~262k to ~6.5k URL/s.
The two engines are interchangeable — same rule format, same blocking result. That was verified, not assumed: they agreed on 4,118 / 4,118 verdicts over 3,118 real blocked domains sampled from EasyList plus 1,000 non-matching hosts, across several resource types. An auto-selected rust that fails to load warns and falls back to js; an explicit --adblock-engine=rust still errors, so a deliberate choice is never silently downgraded.
# Uses the Rust engine automatically once adblock-rs is present
npm install adblock-rs
node nwss.js --block-ads=easylist.txt,easyprivacy.txt
# Pin the pure-JS matcher
node nwss.js --block-ads=easylist.txt --adblock-engine=jsconfig.json Format
Example:
{
"ignoreDomains": [
"googleapis.com",
"googletagmanager.com"
],
"ignoreDomainsByUrl": [
"\\/jwplayer\\/"
],
"blockDomainsByUrl": [
"\\/tracker\\/"
],
"sites": [
{
"url": "https://example.com/",
"userAgent": "chrome",
"filterRegex": "ads|analytics",
"resourceTypes": ["script", "xhr", "image"],
"reload": 2,
"delay": 5000,
"timeout": 30000,
"verbose": 1,
"debug": 1,
"interact": true,
"fingerprint_protection": "random",
"referrer_headers": {
"mode": "random_search",
"search_terms": ["example reviews", "best deals"]
},
"custom_headers": {
"X-Custom-Header": "value"
},
"firstParty": 0,
"thirdParty": 1,
"subDomains": 0,
"blocked": [
"googletagmanager.com",
".*tracking.*"
]
}
]
}config.json Field Table
Basic Configuration
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| url | String or Array | - | Website URL(s) to scan |
| userAgent | chrome, chrome_mac, chrome_linux, firefox, firefox_mac, firefox_linux, safari | - | User agent for page |
| filterRegex | String or Array | .* | Regex or list of regexes to match requests |
| regex_and | Boolean | false | Use AND logic for multiple filterRegex patterns - ALL patterns must match the same URL |
| output_regex | String | — | Regex applied to each matched URL to build the rule body: capture group 1 (or whole match) becomes \|\|<capture> instead of \|\|host^. E.g. ^https?:\/\/([^\/]+\/[^\/]+\/) turns https://host.com/script/abc.js into \|\|host.com/script/. The capture must include the host. No match → falls back to \|\|host^. Adblock-only; domain formats (dnsmasq/pihole/hosts/plain) emit the bare host |
| comments | String or Array | - | String of comments or references |
| resourceTypes | String or Array | ["script", "xhr", "image", "stylesheet"] | What resource types to monitor. A bare string is treated as one type; an empty string means no filtering (all types) |
| reload | Integer | 1 | Total page loads, not extra reloads — the initial load counts as the first. 1 (the default) loads once and never reloads; 2 gives one reload |
| delay | Milliseconds | 4000 | Wait time after loading/reloading |
| timeout | Milliseconds | 30000 | Timeout for page load |
| verbose | 0 or 1 | 0 | Enable verbose output per site |
| debug | 0 or 1 | 0 | Dump matching URLs for the site |
| interact | true or false | false | Simulate user interaction (hover, click) |
| firstParty | 0 or 1 | 0 | Match first-party requests |
| thirdParty | 0 or 1 | 1 | Match third-party requests |
| redirect_first_party | Boolean | true | Whether redirect-destination domains count as first-party. Set false to keep redirect targets (and chain hops) third-party, so filterRegex/dig can match them under thirdParty:true — e.g. capturing the end domain of an ad/cloak redirect. The originally-scanned domain stays first-party either way. Note: this also un-excludes the redirect target's own same-domain resources (more captured) |
| subDomains | 0 or 1 | 0 | 1 = preserve subdomains in output |
| blocked | String or Array | - | Domains or regexes to block during scanning. A bare string is treated as one pattern; an empty string means none |
| even_blocked | Boolean | false | Add matching rules even if requests are blocked |
| bypass_cache | Boolean | false | Skip all caching for this site's URLs |
| window_cleanup | Boolean or String | false | Close old/unused browser windows/tabs after entire URL group completes |
| window_cleanup_threshold | Integer | 8 | For "realtime" mode: max pages to keep open before cleanup |
Window cleanup modes: false (disabled), true (conservative - closes obvious leftovers), "realtime" (continuously cleanup oldest pages when threshold exceeded), "all" (aggressive - closes all content pages after group). Both active modes preserve the main Puppeteer window and wait 16 seconds before cleanup to avoid interfering with active operations.
Redirect Handling Options
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| max_redirects | Integer | 10 | Cap on how many redirects nwss tracks — not whether they are followed, which Chrome does regardless of this value. 0 tracks none, 1 tracks one, the default 10 tracks ten. It counts committed navigations, so a server-side 30x chain is a single hop however many times it bounces (Chrome commits once), while JS and meta-refresh redirects each count separately. A tracked destination is added to the first-party set (unless redirect_first_party: false) and updates the final URL used for output attribution; an untracked destination stays third-party and so remains eligible for filterRegex/dig capture |
| js_redirect_timeout | Milliseconds | 5000 | Time to wait for JavaScript redirects. The effective spend is a third of this value, on every URL whether it redirects or not (the poll stops at the first quiet check). 0 skips the wait entirely — the cheapest setting. What it gives up is narrower than it sounds: only redirects that land after navigation settles stop being tracked, while HTTP 30x and inline location.href redirects commit during load and are still caught. The landed page's requests are captured either way |
| detect_js_patterns | Boolean | true | Analyze page source for redirect patterns |
| redirect_timeout_multiplier | Number | 1.5 | Increase timeout for redirected URLs |
When a page redirects to a new domain, first-party/third-party detection is based on the final redirected domain, and all intermediate redirect domains (like bit.ly, t.co) are automatically excluded from the generated rules.
Advanced Stealth & Fingerprinting
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| fingerprint_protection | true, false, "random" | false | Enable navigator/device spoofing |
| referrer_headers | String, Array, or Object | - | Set referrer header for realistic traffic sources |
| custom_headers | Object | - | Add custom HTTP headers to requests |
Referrer Header Options
Simple formats:
"referrer_headers": "https://google.com/search?q=example"
"referrer_headers": ["url1", "url2"]Smart modes:
"referrer_headers": {"mode": "random_search", "search_terms": ["reviews"]}
"referrer_headers": {"mode": "social_media"}
"referrer_headers": {"mode": "direct_navigation"}
"referrer_headers": {"mode": "custom", "custom": ["https://news.ycombinator.com/"]}Disable referrer for specific URLs:
"referrer_disable": ["https://example.com/no-ref", "sensitive-site.com"]Protection Bypassing
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| cloudflare_phish | Boolean | false | Auto-click through Cloudflare phishing warnings |
| cloudflare_bypass | Boolean | false | Auto-solve Cloudflare "Verify you are human" challenges |
| cloudflare_parallel_detection | Boolean | true | Log which challenge types a page carries, under --debug (diagnostics only) |
| cloudflare_max_retries | Integer | 2 | Maximum retry attempts for Cloudflare operations |
| cloudflare_retry_on_error | Boolean | true | Enable retry logic for Cloudflare operations |
| flowproxy_detection | Boolean | false | Enable flowProxy protection detection and handling |
| flowproxy_page_timeout | Milliseconds | 45000 | Page timeout for flowProxy sites |
| flowproxy_nav_timeout | Milliseconds | 45000 | Navigation timeout for flowProxy sites |
| flowproxy_js_timeout | Milliseconds | 15000 | JavaScript challenge timeout |
| flowproxy_delay | Milliseconds | 30000 | Delay for rate limiting |
| flowproxy_additional_delay | Milliseconds | 5000 | Additional processing delay |
WHOIS/DNS Analysis Options
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| whois | Array | - | Check whois data for ALL specified terms (AND logic) |
| whois-or | Array | - | Check whois data for ANY specified term (OR logic) |
| whois_delay | Integer | 3000 | Base delay between whois attempts, to avoid throttling. Multiplied into a progressive backoff on retries (attempt 2 waits delay*1 + 4s, attempt 3 delay*2 + 6s), not a flat per-request pause |
| whois_server | String or Array | - | Custom whois server(s) - single server or randomized list |
| whois_server_mode | String | "random" | Server selection mode: "random" or "cycle" |
| whois_max_retries | Integer | 2 | Maximum retry attempts per domain |
| whois_timeout_multiplier | Number | 1.5 | Timeout increase multiplier per retry |
| whois_use_fallback | Boolean | true | Add TLD-specific fallback servers |
| whois_retry_on_timeout | Boolean | true | Retry on timeout errors |
| whois_retry_on_error | Boolean | true | Retry on connection/other errors |
| dig | Array | - | Check dig output for ALL specified terms (AND logic) |
| dig-or | Array | - | Check dig output for ANY specified term (OR logic) |
| dig_subdomain | Boolean | false | Use subdomain for dig lookup instead of root domain |
| digRecordType | String | "A" | DNS record type for dig (A, CNAME, MX, etc.) |
.dnsignore — an optional user-maintained file in the project root (one domain per line; # comments and blanks ignored). Any dig-gated candidate that equals, or is a subdomain of, a listed entry is skipped before the dig runs — no lookup, no SERVFAIL/failure line, not captured. Use it to silence known-dead cloak/ad domains that were captured while live, added to your list, and taken down since (they SERVFAIL every run because pages still reference them). Unlike ignoreDomains (which runs the dig, then drops the capture), this skips the dig itself. Gitignored; absent = no-op. Add --dnsignore-auto (dnsignore_auto) to have the scanner auto-append newly-detected dead domains (SERVFAIL/REFUSED only — never timeout) to .dnsignore after each run, deduped (only new ones).
Content Analysis Options
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| searchstring | String or Array | - | Text to search in response content (OR logic) |
| searchstring_and | String or Array | - | Text to search with AND logic - ALL terms must be present |
| curl | Boolean | false | Use curl to download content for analysis |
| grep | Boolean | false | Use grep instead of JavaScript for pattern matching (requires curl=true) |
Advanced Browser Options
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| goto_options | Object | {"waitUntil": "load"} | Custom page.goto() options |
| cookies | Object or Array | – | Cookies set before the page loads — for sites that gate on a cookie during the initial document load. Short form {"consent": "granted"} scopes each cookie to the whole site at path / — .domain.com, the registrable domain of the URL resolved with psl, so it survives an apex↔www redirect and matches the breadth local_storage/session_storage use. The leading dot is what makes Chrome store a cookie domain-scoped; a "domain" written without one is host-only (exact host, never a subdomain), which is how to ask for host-only on purpose — nwss warns when such a host-only domain is a parent of the URL's host, since it could then never be sent at all. A host with no wider scope (an IP, localhost) stays host-only. Long form [{"name":…,"value":…,"domain":…,"path":…,"secure":…,"httpOnly":…,"sameSite":"Strict\|Lax\|None","expires":…}] gives full control. path must be absolute — a relative one is rejected by the browser, so nwss warns and uses / (the only path a load-time gate is read at). expires is seconds since the epoch (Math.floor(Date.now()/1000)) — a past value makes the browser discard the cookie and a millisecond value gets clamped, so both are warned about, as are unrecognised per-cookie keys. Non-string values are stringified. Applied after clear_sitedata and re-seeded before each reload, so the cookie is present on every load. One cookie the browser refuses (say an unusable domain) costs only that cookie — the rest are retried individually and the rejected one is named. Scoped to the entry: seeded cookies are removed when the URL finishes, so a later site on the same host does not silently inherit them |
| local_storage | Object or Array | – | Entries written to localStorage before the page loads, for gates that read Web Storage instead of a cookie. Short form {"rta_terms_accepted": true}; long form [{"name":…,"value":…}]. Numbers/booleans are stringified, objects/arrays are JSON.stringifyd. Installed via evaluateOnNewDocument, so it runs before the page's own scripts on every document — reloads and clear_sitedata need no re-seed. Written only in the top document, never in a cross-origin frame, and matched on the registrable domain (via psl) rather than the origin, so an http→https upgrade, a port change or a redirect to another subdomain is still seeded — the same breadth a seeded cookies entry has (both resolve it through lib/site-scope.js). A redirect to a different registrable domain is refused. Scoped to the entry: removed when the URL finishes, across every origin the site was seen on (reference-counted, so concurrent URLs of the same site are safe). Removal needs a live page on the origin — if the seeding page has already closed, a concurrent URL of the same site is used instead, and when nothing is left on that origin the entries survive the entry and a warning names it |
| session_storage | Object or Array | – | Same as local_storage but writes sessionStorage. Needs no teardown — sessionStorage is scoped to the page, which closes when the URL finishes |
| clear_sitedata | Boolean | false | Clear all cookies, cache, storage before each load |
| forcereload | Boolean or Array | false | Force cache-clearing reload for all URLs (true) or specific domains (["domain1.com"]) |
| isBrave | Boolean | false | Spoof Brave browser detection |
| evaluateOnNewDocument | Boolean | false | Inject fetch/XHR interceptor in page. Also enables reload-loop reporting: if the page navigates to its own URL more times than reload asks for (plus 2), nwss warns once that it looks like a reload loop and leaves the page running — that URL may then spend its whole timeout reloading. Counts navigations, so it sees location.reload, replace, href and <meta refresh> alike |
| cdp | Boolean | false | Enable CDP logging for this site |
| cdp_specific | String or Array | - | Enable CDP logging only for specific domains in the URL list. A bare string is treated as one domain |
| css_blocked | String or Array | - | CSS selectors to hide elements. A bare string is treated as one selector |
| source | Boolean | false | Save page source HTML after load |
| screenshot | Boolean | false | Capture screenshot on load failure |
| headful | Boolean | false | Launch browser with GUI for this site |
| localhost | String | - | Force custom IP format for this site (e.g., "127.0.0.1", "0.0.0.0", "192.168.1.1") |
| adblock_rules | Boolean | false | Generate adblock filter rules with resource types for this site |
| interact_duration | Milliseconds | 2000 | Duration of interaction simulation |
| interact_scrolling | Boolean | true | Enable scrolling simulation |
| interact_clicks | Boolean | false | Enable element clicking simulation |
| interact_click_count | Integer | 3 | Number of random content-zone clicks per load (capped at 20). Default 3 = primary + 2 backups, since ad SDKs sometimes suppress the 1st/2nd click as warmup |
| realistic_click | Boolean | false | Higher click fidelity: denser mouse approach (15 steps), ±1px hand-tremor micro-moves during the press, and ±1.5px mouseup drift (so mousedown≠mouseup coords) — for sites that score click realism. Costs ~80–120ms/click |
| interact_typing | Boolean | false | Enable typing simulation |
| click_elements | String or String[] | - | After load, click these CSS selectors in order, searched across the main frame and any iframe — reach content via organic navigation/gesture instead of a direct load (e.g. ["a[href*='/movie/']", ".play"] to click a link then a play button). The request interceptor stays attached, so the post-click page's requests are matched against filterRegex/dig as usual. A click that navigates is followed; later selectors query the resulting page. Honors realistic_click and cursor_mode: "ghost" (Bezier travel to the element); missing elements are skipped (never fails the scan) |
| click_wait | Integer | 5000 | Per click: max time (ms) to wait for the element to appear/be visible (waitForSelector) and the settle/navigation wait after it; capped at half the per-URL timeout |
| interact_intensity | String | "medium" | Interaction simulation intensity: "low", "medium", "high" |
| cursor_mode | "ghost" | - | Use ghost-cursor Bezier mouse movements (requires npm i ghost-cursor) |
| ghost_cursor_speed | Number | auto | Ghost-cursor movement speed multiplier |
| ghost_cursor_hesitate | Milliseconds | 50 | Delay before ghost-cursor clicks |
| ghost_cursor_overshoot | Pixels | auto | Max ghost-cursor overshoot distance before correcting |
| ghost_cursor_duration | Milliseconds | interact_duration or 2000 | How long ghost-cursor movements run |
| dnsmasq | Boolean | false | Force dnsmasq output for this site |
| dnsmasq_old | Boolean | false | Force dnsmasq old format output for this site |
| unbound | Boolean | false | Force unbound output for this site |
| privoxy | Boolean | false | Force Privoxy output for this site |
| pihole | Boolean | false | Force Pi-hole regex output for this site |
| ignore_similar | Boolean | - | Override global ignore_similar setting for this site |
| ignore_similar_threshold | Integer | - | Override global similarity threshold for this site |
| ignore_similar_ignored_domains | Boolean | - | Override global ignore_similar_ignored_domains for this site |
Popup Capture Options
Capture (and optionally drive) the popup/popunder windows that ad and redirect
scripts open, so domains reachable only via a popup chain still match filterRegex.
The same filterRegex applies to the whole chain — it must contain every pattern
you expect along it. Popup capture only fires when the main page is actually
clicking, so set interact: true and interact_clicks: true as well.
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| capture_popups | Boolean | false | Capture popup windows opened during the scan and evaluate their landing URL + in-popup requests against filterRegex/dig/whois (requires interact + interact_clicks to fire user-gesture clicks) |
| interact_popups | Boolean | false | Mouse-click inside captured popups (3 content-zone clicks) so the chain cascades to its next redirect/ad. Requires capture_popups. Clicks popups up to capture_popups_max_depth − 1 (the deepest captured popup is observed, not clicked) |
| capture_popups_max_depth | Integer | 4 | Max popup-chain depth to capture (site → p1 → p2 → p3 → destination). Each extra level multiplies popups + time |
| capture_popups_window_ms | Integer | 5000 | Per-popup capture window (ms) before the popup is auto-closed |
| capture_popups_signal | Boolean | false | Count a $popup pattern from the --block-ads lists as a match, so a popup landing on a known popunder endpoint is captured even when filterRegex misses it. Neither engine can block a $popup rule, but most such patterns appear nowhere else in a list, so as a set they catalogue known popunder endpoints. Promoted hits take the same path a filterRegex match does (ignoreDomains, party gates and whois/dig validation still apply — so on a site that configures whois/dig, a hit triggers those lookups for the popup's domain and is only captured if they pass). Requires capture_popups + --block-ads. Off, hits are still tallied and reported at scan end |
VPN Options
Route traffic through a VPN for specific sites. Requires sudo privileges. The VPN connection is established before scanning and torn down after the site completes.
Note: VPN modifies system-level routing. During concurrent scanning, all traffic routes through the active tunnel — not just the site that requested it. For isolated per-site VPN, run sites sequentially or use the same VPN config for all concurrent sites.
WireGuard
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| vpn | String or Object | - | WireGuard VPN configuration |
| vpn.config | String | - | Path to .conf file or interface name in /etc/wireguard/ |
| vpn.config_inline | String | - | Inline WireGuard config content |
| vpn.interface | String | auto | Interface name (auto-derived from config filename) |
| vpn.health_check | Boolean | true | Ping through tunnel to verify connectivity |
| vpn.test_host | String | "1.1.1.1" | Host to ping for health check |
| vpn.retry | Boolean | true | Retry on connection failure |
| vpn.max_retries | Integer | 2 | Maximum retry attempts |
OpenVPN
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| openvpn | String or Object | - | OpenVPN configuration |
| openvpn.config | String | - | Path to .ovpn file |
| openvpn.config_inline | String | - | Inline OpenVPN config content |
| openvpn.name | String | auto | Connection name (auto-derived from config filename) |
| openvpn.username | String | - | VPN username (written to secure temp file) |
| openvpn.password | String | - | VPN password (written to secure temp file) |
| openvpn.auth_file | String | - | Path to existing auth credentials file |
| openvpn.health_check | Boolean | true | Ping through tunnel to verify connectivity |
| openvpn.test_host | String | "1.1.1.1" | Host to ping for health check |
| openvpn.retry | Boolean | true | Retry on connection failure |
| openvpn.max_retries | Integer | 2 | Maximum retry attempts |
| openvpn.connect_timeout | Milliseconds | 30000 | Timeout for connection establishment |
| openvpn.extra_args | Array | - | Additional OpenVPN command line arguments |
| openvpn.verbosity | String | "3" | OpenVPN log verbosity level |
Authentication: If the
.ovpnfile already contains credentials (viaauth-user-pass /path/to/fileor an inline<auth-user-pass>block), no additional config is needed — just provide the config path. Theusername/passwordfields are only needed when the.ovpnfile has a bareauth-user-passdirective that expects interactive input.
Proxy Options
Route traffic through a proxy for specific sites. Supports SOCKS5, SOCKS4, HTTP, and HTTPS proxies. Unlike VPN, proxy routing is per-site-group — only URLs in the same config block use the proxy; other sites connect directly.
Note: Chromium's
--proxy-serverflag is browser-wide. Sites requiring different proxies (or direct vs proxied) are automatically separated into different browser instances. Tasks are sorted so proxy groups are contiguous to minimise restarts.
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| proxy | String | - | Proxy URL: socks5://host:port, http://host:port, https://host:port, or http://user:pass@host:port |
| proxy_bypass | Array | [] | Domains that skip the proxy (e.g. ["localhost", "127.0.0.1", "*.local"]) |
| proxy_remote_dns | Boolean | true | Resolve DNS through the proxy (SOCKS only — prevents DNS leaks) |
| proxy_debug | Boolean | false | Print proxy diagnostics: launch args, auth, health checks, error codes |
Legacy aliases (socks5_proxy, socks5_bypass, socks5_remote_dns, socks5_debug) are supported for backwards compatibility.
Proxy Examples
SOCKS5 — no auth:
{
"url": ["https://blocked-site.com/", "https://another-blocked.com/"],
"proxy": "socks5://127.0.0.1:1080",
"search_string": ["tracking.js"]
}HTTP proxy with credentials:
{
"url": ["https://geo-restricted.com/"],
"proxy": "http://user:[email protected]:3128",
"search_string": ["analytics"]
}SOCKS5 with bypass list and debug:
{
"url": ["https://target-site.com/"],
"proxy": "socks5://user:[email protected]:9050",
"proxy_bypass": ["localhost", "127.0.0.1", "*.internal.corp"],
"proxy_remote_dns": true,
"proxy_debug": true,
"search_string": ["tracker"]
}Mixed direct + proxied in one config:
[
{
"url": ["https://direct-site.com/"],
"search_string": ["ads"]
},
{
"url": ["https://blocked-site.com/"],
"proxy": "socks5://127.0.0.1:1080",
"search_string": ["ads"]
}
]Proxy Error Handling
If a proxy is unreachable, the batch is skipped with a clear error before any navigation is attempted:
[error] [proxy] Unreachable: socks5://127.0.0.1:1080 — Connection refused
[error] [proxy] Skipping 5 URL(s) in this batchIf a proxy fails mid-scan, Chromium's error code is detected and diagnosed:
[error] [proxy] ERR_SOCKS_CONNECTION_FAILED — proxy: socks5://127.0.0.1:1080 — URL: https://example.com/
[error] [proxy] Check: is the proxy running? Are credentials correct? Is the target reachable from the proxy?Detected error codes: ERR_PROXY_CONNECTION_FAILED, ERR_SOCKS_CONNECTION_FAILED, ERR_TUNNEL_CONNECTION_FAILED, ERR_PROXY_AUTH_UNSUPPORTED, ERR_PROXY_AUTH_REQUESTED, ERR_SOCKS_CONNECTION_HOST_UNREACHABLE, ERR_PROXY_CERTIFICATE_INVALID, ERR_NO_SUPPORTED_PROXIES.
.nwssconfig — Per-Config Settings
Create a .nwssconfig file in the project root to define CLI settings per config file. When a config filename matches a key, those settings are automatically applied. CLI flags merge with and override .nwssconfig settings.
{
"configs": {
"config-clean1.json": {
"output": "outputfile.txt",
"max_concurrent": 30,
"dns_cache": true,
"cache_requests": true,
"dumpurls": true,
"remove_tempfiles": true,
"color": true
},
"config-clean2.json": {
"output": "outputfile.txt",
"max_concurrent": 15,
"dns_cache": true,
"cache_requests": true,
"dumpurls": true,
"remove_tempfiles": true,
"color": true,
"debug": true,
"block_ads": "easylist.txt,easyprivacy.txt"
}
}
}Usage:
node nwss.js config-clean1.json # uses .nwssconfig settings
node nwss.js config-clean2.json --debug # .nwssconfig + debug override
node nwss.js config-other.json --max-concurrent 5 # no match in .nwssconfig, uses CLI flagsSupported settings: output, max_concurrent, dns_cache, cache_requests, dumpurls, remove_tempfiles, color, remove_dupes, compress_logs, debug, silent, verbose, headful, keep_open, dry_run, titles, sub_domains, no_interact, ghost_cursor, plain, cdp, dnsmasq, unbound, privoxy, pihole, eval_on_doc, use_puppeteer_core, use_obscura, ignore_cache, clear_cache, block_ads, compare, localhost, append.
Priority: CLI flags > .nwssconfig > hardcoded defaults.
Global Configuration Options
These options go at the root level of your config.json:
| Field | Values | Default | Description |
|:---------------------|:-------|:-------:|:------------|
| ignoreDomains | Array | - | Domains to completely ignore (supports wildcards like *.ads.com). Subdomains of any listed entry are also ignored via parent-walk (e.g. example.com ignores cdn.example.com and a.b.example.com). |
| ignoreDomainsByUrl | Array | - | Regex patterns; if a request URL matches, the request's root domain is dynamically ignored for the rest of the scan AND any subsequent request to its subdomains (cascade matches the static ignoreDomains semantic). Example: ["\\/jwplayer\\/", "\\/build\\/assets\\/"] |
| blockDomainsByUrl | Array | - | Symmetric to ignoreDomainsByUrl but for active blocking. Regex patterns; if a request URL matches, the request's root domain is added to a dynamic block set and ALL subsequent requests on that root (and subdomains) are aborted via Puppeteer for the rest of the scan. The triggering request itself is also aborted. Use when seeing a trigger URL is sufficient evidence the whole host is hostile. |
| blocked | Array | - | Global regex patterns to block requests (combined with per-site blocked). Patterns that fail to compile are warned about at scan start ([config] blocked (global) pattern dropped (compile error): ...) instead of crashing startup or silently disappearing. Per-pattern hit counts are reported at scan end via [blocked-stats] lines so stale patterns are easy to spot. |
| js_redirect_timeout | Milliseconds | – | Default JS-redirect wait for any site that doesn't set its own; per-site values win. That wait costs roughly a third of its value on every URL whether or not the URL redirects, so this exists to avoid repeating the same line for every site in a large config. 0 disables it — see the per-site entry for what that gives up |
| whois_server_mode | String | "random" | Default server selection mode for all sites |
| ignore_similar | Boolean | true | Ignore domains similar to already found domains |
| ignore_similar_threshold | Integer | 80 | Similarity threshold percentage for ignore_similar |
| ignore_similar_ignored_domains | Boolean | true | Ignore domains similar to ignoreDomains list |
| max_concurrent_sites | Integer | 6 | Maximum concurrent site processing (1-50) |
| resource_cleanup_interval | Integer | 80 | Browser restart interval in URLs processed (1-1000) |
| cache_path | String | ".cache" | Where --clear-cache looks for a leftover on-disk cache. Nothing writes one: only v1.0.57 did, and the caches are in-memory now |
| cache_max_size | Integer | 5000 | Maximum number of entries in cache |
| cache_requests | Boolean | false | Enable HTTP request response caching |
Special Characters in searchstring
The searchstring parameter supports all characters including special symbols. Only double quotes need JSON escaping:
{
"searchstring": [
")}return n}function N(n,e,r){try{\"function\"==typeof",
"addEventListener(\"click\",function(){",
"{\"status\":\"success\",\"data\":[",
"console.log('Debug: ' + value);",
"`API endpoint: ${baseUrl}/users`",
"@media screen and (max-width: 768px)",
"if(e&&e.preventDefault){e.preventDefault()}",
"__webpack_require__(/*! ./module */ \"./src/module.js\")",
"console.log('Hello world')",
"#header { background-color: #ff0000; }",
"$(document).ready(function() {",
"completion: 85% @ $1,500 budget",
"SELECT * FROM users WHERE id = *",
"regex: ^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$",
"typeof window !== 'undefined'"
]
}Character escaping rules:
"becomes\"(required in JSON)\becomes\\(if searching for literal backslashes)- All other characters are used literally:
'`@#$%*^[]{}();=!?:
Usage Examples
Basic Scanning
# Scan with default config and output to console
node nwss.js
# Scan and save rules to file
node nwss.js -o blocklist.txt
# Append new rules to existing file
node nwss.js --append -o blocklist.txt
# Clean existing rules and append new ones
node nwss.js --clean-rules --append -o blocklist.txtAdvanced Options
# Debug mode with URL dumping and colored output
node nwss.js --debug --dumpurls --color -o rules.txt
# Dry run to see what would be matched
node nwss.js --dry-run --debug
# Validate configuration before running
node nwss.js --validate-config
# Clean rule files
node nwss.js --clean-rules existing_rules.txt
# Maximum stealth scanning
node nwss.js --debug --color -o stealth_rules.txtPerformance Tuning
# High-performance scanning with custom concurrency
node nwss.js --max-concurrent 12 --cleanup-interval 300 -o rules.txtStealth Configuration Examples
Memory Management with Window Cleanup
{
"url": [
"https://popup-heavy-site1.com",
"https://popup-heavy-site2.com",
"https://popup-heavy-site3.com"
],
"filterRegex": "\\.(space|website|tech)\\b",
"window_cleanup": "all",
"interact": true,
"reload": 2,
"resourceTypes": ["script", "fetch"],
"comments": "Aggressive cleanup for sites that open many popups"
}Conservative Memory Management
{
"url": "https://complex-site.com",
"filterRegex": "analytics|tracking",
"window_cleanup": true,
"interact": true,
"delay": 8000,
"reload": 3,
"comments": [
"Conservative cleanup preserves potentially active content",
"Good for sites with complex iframe structures"
]
}Ghost Cursor (Advanced Bezier Mouse)
{
"url": "https://anti-bot-site.com",
"interact": true,
"interact_clicks": true,
"cursor_mode": "ghost",
"realistic_click": true,
"interact_click_count": 3,
"ghost_cursor_duration": 5000,
"ghost_cursor_speed": 1.2,
"fingerprint_protection": "random",
"filterRegex": "tracking|analytics",
"comments": "ghost-cursor uses Bezier curves with overshoot for realistic mouse paths"
}Or enable globally via CLI:
node nwss.js --ghost-cursor --debug -o rules.txtGhost-cursor clicks. The cursor moves with cursor_mode: "ghost", but it only clicks when both interact: true and interact_clicks: true are set (same rule as the built-in path). Click behavior:
realistic_click: true— each press adds hand-tremor during the hold and a mouseup drift, somousedown≠mouseupcoordinates (the press is routed through the samehumanClickthe built-in content clicks use).interact_click_count— number of clicks per load (default3, capped at20). The default of 3 matters because some ad SDKs swallow the 1st/2nd click as warmup.- Duration vs. clicks: realistic clicks take ~600–700ms each, and the bezier movement loop reserves up to half of
ghost_cursor_durationfor them. So the defaultghost_cursor_duration: 2000only fits ~1 click — raise it to roughlyinteract_click_count × 700 + movement(e.g.5000–8000) to fit all of them.
Note: ghost-cursor is an optional dependency. Install with
npm install ghost-cursor. If not installed, the scanner falls back to the built-in mouse simulation automatically.
E-commerce Site Scanning
{
"url": "https://shopping-site.com",
"userAgent": "chrome",
"fingerprint_protection": "random",
"referrer_headers": {
"mode": "random_search",
"search_terms": ["product reviews", "best deals", "price comparison"]
},
"interact": true,
"delay": 6000,
"filterRegex": "analytics|tracking|ads"
}News Site Analysis
{
"url": "https://news-site.com",
"userAgent": "firefox",
"fingerprint_protection": true,
"referrer_headers": {"mode": "social_media"},
"custom_headers": {
"Accept-Language": "en-US,en;q=0.9"
},
"filterRegex": "doubleclick|googletagmanager"
}Tech Blog with Custom Referrers
{
"url": "https://tech-blog.com",
"fingerprint_protection": "random",
"referrer_headers": {
"mode": "custom",
"custom": [
"https://news.ycombinator.com/",
"https://www.reddit.com/r/programming/",
"https://lobste.rs/"
]
}
}Scanning through OpenVPN
{
"url": "https://geo-restricted-site.com",
"openvpn": "/etc/openvpn/us-server.ovpn",
"filterRegex": "tracking|analytics",
"userAgent": "chrome",
"fingerprint_protection": "random"
}OpenVPN with Credentials
{
"url": "https://region-locked-site.com",
"openvpn": {
"config": "/etc/openvpn/eu-server.ovpn",
"username": "vpn_user",
"password": "vpn_pass",
"connect_timeout": 45000
},
"filterRegex": "ads|tracking"
}Scanning through WireGuard
{
"url": "https://another-site.com",
"vpn": "/etc/wireguard/wg-us.conf",
"filterRegex": "analytics",
"userAgent": "firefox"
}Memory Management
The scanner includes intelligent window management to prevent memory accumulation during long scans:
- Conservative cleanup (
window_cleanup: true): Selectively closes pages that appear to be leftovers from previous scans - Aggressive cleanup (
window_cleanup: "all"): Closes all content pages from previous operations for maximum memory recovery - Main window preservation: Both modes always preserve the main Puppeteer browser window to maintain stability
- Popup window handling: Automatically detects and closes popup windows created by previous site scans
- Timing protection: 16-second delay ensures no active operations are interrupted during cleanup
- Active page protection: Never affects pages currently being processed by concurrent scanning operations
- Memory reporting: Reports estimated memory freed from closed windows for performance monitoring
Use aggressive cleanup for sites that open many popups or when processing large numbers of URLs. Use conservative cleanup when you want to preserve potentially active content but still free obvious leftovers.
INSTALL
(Ubuntu as example). NOTE: Use Chrome and not Chromium for best compatibility.
Add Google's signing key
wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpgAdd Google Chrome repository
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.listUpdate and install
sudo apt update
sudo apt install google-chrome-stabledig & whois (needed for network checks)
sudo apt install bind9-dnsutils whoisOpenVPN (optional, for per-site VPN routing)
sudo apt install openvpnGrant passwordless sudo for OpenVPN operations:
sudo visudo -f /etc/sudoers.d/openvpn-nwssAdd:
your_username ALL=(root) NOPASSWD: /usr/sbin/openvpn, /usr/bin/kill, /usr/bin/pgrep, /usr/bin/pkillOn WSL2, load the TUN module (required each reboot):
sudo modprobe tunWireGuard (optional, for per-site VPN routing)
sudo apt install wireguardGrant passwordless sudo for WireGuard operations:
sudo visudo -f /etc/sudoers.d/wg-nwssAdd:
your_username ALL=(root) NOPASSWD: /usr/bin/wg-quick, /usr/bin/wgNotes
- If both
firstParty: 0andthirdParty: 0are set for a site, it will be skipped. ignoreDomainsapplies globally across all sites.ignoreDomainssupports wildcards (e.g.,*.ads.commatchestracker.ads.com)- Blocking (
blocked) can match full domains or regex. - If a site's
blockedfield is missing, no extra blocking is applied. --clean-ruleswith--appendwill clean existing files first, then append new rules--remove-dupesworks with all output modes and removes duplicates from final output- Validation tools help ensure rule files are properly formatted before use
--remove-tempfilesremoves Chrome/Puppeteer temporary files before exiting, avoids disk space issues- For maximum stealth, combine
fingerprint_protection: "random"with appropriatereferrer_headersmodes - User agents are automatically updated to latest versions (Chrome 131, Firefox 133, Safari 18.2)
- Referrer headers work independently from fingerprint protection - use both for best results
- VPN connections (
vpn/openvpn) are established before scanning and torn down after the site completes - If an
.ovpnfile contains embedded credentials, no additional auth config is needed in the JSON - VPN affects system-level routing — all concurrent scans will route through the active tunnel
- Both
vpn(WireGuard) andopenvpncan be set, butvpntakes precedence - Ghost-cursor (
cursor_mode: "ghost") is optional — install withnpm i ghost-cursor. Falls back to built-in mouse if not installed - Ghost-cursor duration defaults to
interact_duration(or 2000ms), capped by the 15s hard timeout
Stealth Testing
scripts/test-stealth.js is a developer-facing smoke test for the fingerprint spoofing stack in lib/fingerprint.js. It launches Puppeteer with the same applyAllFingerprintSpoofing call that nwss.js uses, navigates to public bot-detection pages, and reports what they concluded — pass/warn/fail counts from sannysoft, trust score from creepjs, raw navigator values from browserleaks.
Use it to A/B a stealth change: run before the edit, run after, diff the output. Not a unit test — it doesn't assert; it reports.
node scripts/test-stealth.js # all targets, human-readable
node scripts/test-stealth.js sannysoft # one target only
node scripts/test-stealth.js --no-spoof # baseline (spoof disabled)
node scripts/test-stealth.js --ua=firefox # spoof a different UA family
node scripts/test-stealth.js --format=json # machine-readable for diff/jq
node scripts/test-stealth.js --help # full flag listSet PUPPETEER_NO_SANDBOX=1 when running as root (CI containers, some Docker setups). Off by default so local dev doesn't silently drop the Chromium sandbox.
Injection / Seeding Tests
scripts/test-seeding.js is an assertion-based regression suite for everything nwss injects into a page before its own scripts run — cookies, local_storage, session_storage, css_blocked, Fetch/XHR interception and the $popup capture signal. Unlike the stealth harness it does assert: it exits 0 when every check passes and 1 on any failure, so it works as a gate before a release.
It runs at three levels: pure-function checks (scope derivation, config normalisation, signal matching), Puppeteer harnesses that exercise the real browser behaviour, and end-to-end nwss.js scans driven from generated configs. Fixture servers bind ephemeral ports and every config and filter list is generated into a temp directory, so it needs no scan config and no downloaded filter list.
node scripts/test-seeding.js # everything (~1 min, mostly the scans)
node scripts/test-seeding.js --group=unit # unit | browser | e2e
node scripts/test-seeding.js cookie # only checks matching "cookie"
node scripts/test-seeding.js --list # check names
node scripts/test-seeding.js --verbose # per-check detail
node scripts/test-seeding.js --keep # keep the temp fixtures for inspectionscripts/test-adblock-parity.js is the companion suite for the two adblock engines: it asserts that lib/adblock.js and lib/adblock-rust.js reach the same verdict, since --adblock-engine defaults to whichever is installed and a disagreement between them silently changes what a scan blocks. It runs in under a second with no browser, skips when adblock-rs isn't installed, and its asymmetries group records the differences that genuinely exist rather than hiding them.
node scripts/test-adblock-parity.jsEvery check exists because a review pass found a real bug, and most pin something static reading got wrong: a cookie domain without a leading dot is host-only on the CDP path (unlike the same string in a Set-Cookie header), one rejected cookie used to reject the whole batch, DOMStorage removal resolves through the inspected target's frame tree so a closed seeding page stranded the keys, and a throwing signal matcher discarded a capture the page had already matched. Add a check here rather than a comment when fixing anything in this area.
