@fanboynz/network-scanner 3.0.3 → 3.1.2

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.
@@ -0,0 +1,94 @@
1
+ # `lib/fingerprint.js` — Fingerprint Spoofing Coverage
2
+
3
+ Bot-detection evasion for the scanner's headless Chromium. The goal is to make a
4
+ scanned page see a coherent, real-Chrome **Stable** desktop profile rather than a
5
+ headless/automation signature — and, just as important, to keep every spoofed
6
+ value **internally consistent** (JS ↔ HTTP, claimed-value ↔ observable reality)
7
+ so a detector cross-checking two surfaces can't catch a mismatch.
8
+
9
+ ## How it works
10
+
11
+ Spoofing is applied per page, before navigation, by `applyAllFingerprintSpoofing(page, siteConfig, …)`, which runs three stages:
12
+
13
+ | Stage | Gate (siteConfig) | What it covers |
14
+ |---|---|---|
15
+ | `applyUserAgentSpoofing` | **`userAgent`** (defaults to `"chrome"`) | Browser identity, automation/headless tells, and the bulk of the navigator/JS-API suite |
16
+ | `applyBraveSpoofing` | Brave-mode only | Brave-specific surfaces |
17
+ | `applyFingerprintProtection` | **`fingerprint_protection`** (`true` \| `"random"`) | Hardware fingerprint *values* (canvas/WebGL/audio noise, screen, memory) + CDP timezone. `"random"` seeds them per-domain (stable per site, varies across sites) |
18
+
19
+ HTTP **Client Hints** request headers are set separately in `nwss.js` (gated on a `chrome` userAgent). Identity is pinned to **Stable Chrome** via two constants in `fingerprint.js` (`CHROME_BUILD`, `CHROME_GREASE_BRAND`) + the major in `USER_AGENT_COLLECTIONS` — see `feedback_chrome_spoof_version_bump`.
20
+
21
+ **Gate legend:** `UA` = runs with `userAgent` set (on by default) · `FP` = runs with `fingerprint_protection` · `HTTP` = request header set in nwss.js.
22
+
23
+ ## Browser identity
24
+
25
+ | Surface | Mitigation | Gate |
26
+ |---|---|---|
27
+ | `navigator.userAgent` / `appVersion` | Pinned to Stable Chrome 148 desktop UA | UA |
28
+ | `navigator.userAgentData` (brands, platform, mobile) | Spoofed; brand order + GREASE string match real Chrome of the major exactly | UA |
29
+ | `getHighEntropyValues()` | Full set: architecture, bitness, model, **wow64**, platformVersion, **uaFullVersion**, fullVersionList, **formFactors** — build from `CHROME_BUILD`, consistent with HTTP | UA |
30
+ | `navigator.platform` / `vendor` / `productSub` / `vendorSub` | Spoofed UA-consistent (`Win32`, `Google Inc.`, `20030107`, `""`) | UA |
31
+ | `Sec-CH-UA`, `-Platform`, `-Platform-Version`, `-Mobile`, `-Arch`, `-Bitness`, `-WoW64`, `-Model`, `-Full-Version`, `-Full-Version-List`, `-Form-Factors` | Set to match the JS values (same brand order/grease/build) | HTTP |
32
+
33
+ ## Automation & headless tells
34
+
35
+ | Surface | Mitigation | Gate |
36
+ |---|---|---|
37
+ | `navigator.webdriver` | Forced `false` (launch flag + JS) | UA |
38
+ | `cdc_…` / `$cdc_…` / selenium / phantom props | Removed | UA |
39
+ | `window.chrome` + `chrome.runtime` | Provided / simulated | UA |
40
+ | `<html webdriver>` attribute | Stripped | UA |
41
+ | `navigator.plugins` / `mimeTypes` | Native 5-PDF set preserved (matches real Chrome) | UA |
42
+ | `navigator.bluetooth` | Stub added (`getAvailability()→false`) — real Chrome always exposes it | UA |
43
+ | `navigator.share` / `canShare` | Stubs added (Web Share; absent in headless) | UA |
44
+ | `speechSynthesis.getVoices()` | Claimed-OS voice set (Windows → Microsoft + Google, 22 voices) | UA |
45
+ | `Notification.permission` / `permissions.query` | `default` / consistent results | UA |
46
+ | `navigator.userActivation` / `getInstalledRelatedApps` / `document.hasStorageAccess` | Stubs (present in real Chrome) | UA |
47
+
48
+ ## Hardware & rendering
49
+
50
+ | Surface | Mitigation | Gate |
51
+ |---|---|---|
52
+ | WebGL `UNMASKED_VENDOR/RENDERER` | Spoofed GPU from an OS-appropriate pool (per-domain seeded) | UA + FP |
53
+ | Canvas (`toDataURL`/`getImageData`) | Per-canvas noise (WeakMap-cached) | UA + FP |
54
+ | AudioContext / `AudioBuffer` | `getChannelData`/`copyFromChannel` intercepted to defeat audio fingerprint | UA + FP |
55
+ | Fonts (`measureText`/offset probes) | Normalized font metrics | UA |
56
+ | `screen.*` (width/height/avail/colorDepth) | Spoofed (1920×1080, colorDepth 24) | UA + FP |
57
+ | `navigator.hardwareConcurrency` | Spoofed down to 4–8 (hides datacenter core count; no HTTP counterpart) | FP |
58
+ | `navigator.deviceMemory` (JS) + `Sec-CH-Device-Memory` (HTTP) | Both pinned to **8** (hides 32 GB host; JS = HTTP, gated together on FP) | FP / HTTP |
59
+ | `PerformanceNavigationTiming` | Jittered to defeat timing fingerprint | UA |
60
+
61
+ ## Sensors, locale & network
62
+
63
+ | Surface | Mitigation | Gate |
64
+ |---|---|---|
65
+ | Battery Status API | Plugged-in default (`charging:true, level:1, dischargingTime:Infinity`) — blends with the majority | UA |
66
+ | `navigator.connection` (rtt/downlink/effectiveType) | **Native** (left untouched when present) — truthful to the real network so it survives a timing cross-check | — |
67
+ | `navigator.languages` / `language` | `["en-US","en"]` / `en-US` | UA |
68
+ | **Timezone** (`Date`, `Intl`, `getTimezoneOffset`) | CDP `emulateTimezone()` — makes all three consistent + DST-correct (replaced broken JS overrides) | FP |
69
+ | `matchMedia` hover/pointer/color-scheme | Desktop-consistent (`hover`, `fine` pointer) | UA |
70
+ | `maxTouchPoints` | UA-consistent (`0` on desktop) | UA |
71
+ | WebRTC ICE candidates | All candidates stripped → no STUN public-IP leak past the proxy | UA |
72
+ | `mediaDevices.enumerateDevices` | Plausible device set | UA |
73
+
74
+ ## Anti-introspection
75
+
76
+ | Surface | Mitigation | Gate |
77
+ |---|---|---|
78
+ | `Function.prototype.toString` | Every overridden function masked to `function X() { [native code] }` (bulk + per-instance) | UA |
79
+ | `Error.stack` / `prepareStackTrace` | Sanitized so injected frames don't leak | UA |
80
+ | Console error noise from spoofs | Suppressed | UA |
81
+
82
+ ## Known limitations (not fixable at the browser layer)
83
+
84
+ | Vector | Why it's out of scope | Mitigation |
85
+ |---|---|---|
86
+ | **IP reputation** | A datacenter IP is the single biggest tell; no JS/header spoof touches it | Residential **proxy/VPN** (`lib/proxy.js`, `lib/wireguard_vpn.js`, `lib/openvpn_vpn.js`) |
87
+ | **TLS (JA3/JA4) + HTTP/2 fingerprint** | Negotiated below the JS layer | Puppeteer's Chromium already presents a genuine Chrome stack; a MITM proxy can alter it |
88
+ | **Timezone vs exit-IP geolocation** | Timezone is now internally consistent, but the *chosen* zone should match the proxy's country | Per-proxy geo config (not yet wired) |
89
+ | **Behavioural / mouse dynamics** | Statistical, not a property | `interact` / `ghost-cursor` config (`lib/interaction.js`) |
90
+
91
+ ## Verification
92
+
93
+ - **`scripts/test-stealth.js`** — automated smoke test against sannysoft / creepjs / browserleaks. Run before/after a spoof change and diff.
94
+ - **Manual reference diff** — launch with the spoof applied and compare each surface against a real Chrome of the pinned major (the coverage above was validated field-for-field against a live Chrome 148 desktop). The unspoofed deviations are deliberate: `hardwareConcurrency`/`deviceMemory` downscaled to hide the host, and `connection` left native.
@@ -67,6 +67,97 @@ function fastTimeout(ms) {
67
67
  return new Promise(resolve => setTimeout(resolve, ms));
68
68
  }
69
69
 
70
+ /**
71
+ * Human-timed click — page.mouse.click() fires mousedown+mouseup ~10-30ms
72
+ * apart, which many ad-network popunder loaders (AdsCore/PropellerAds
73
+ * family) specifically filter as a bot signal: real users hold the
74
+ * button 50-150ms. This helper splits the click into explicit
75
+ * mousedown / hold / mouseup with realistic hold timing, plus optional
76
+ * hover-before-click pause and small click-offset jitter so clicks
77
+ * don't land pixel-perfect at the same (x,y) every time.
78
+ *
79
+ * Drop-in replacement for `page.mouse.click(x, y)` at popunder-trigger
80
+ * sites; bounded per-call cost is ~300-700ms (hover pause + hold + jitter)
81
+ * vs ~30ms for plain .click().
82
+ *
83
+ * @param {object} page - Puppeteer page
84
+ * @param {number} x - target X
85
+ * @param {number} y - target Y
86
+ * @param {object} options
87
+ * @param {number} options.offsetRange - ± px jitter from (x,y); default 5
88
+ * @param {number} options.hoverMin - min hover pause ms; default 150
89
+ * @param {number} options.hoverMax - max hover pause ms; default 450
90
+ * @param {number} options.holdMin - min mouse-down hold ms; default 50
91
+ * @param {number} options.holdMax - max mouse-down hold ms; default 150
92
+ * @param {boolean} options.realistic - emit hold-tremor + mouseup drift;
93
+ * default false. Opt-in for sites that score mouse-click realism
94
+ * (DataDome, Akamai BM, PerimeterX). Adds ~0ms latency (events fit
95
+ * inside the existing hold) but generates 1–3 extra mousemove events
96
+ * between mousedown and mouseup at ±1px tremor, plus a final ±1.5px
97
+ * drift before mouseup so mousedown.x !== mouseup.x. Pure event-stream
98
+ * change — no behavioral difference for the click itself.
99
+ */
100
+ async function humanClick(page, x, y, options = {}) {
101
+ const {
102
+ offsetRange = 5,
103
+ hoverMin = 150, hoverMax = 450,
104
+ holdMin = 50, holdMax = 150,
105
+ forceDebug = false,
106
+ realistic = false
107
+ } = options;
108
+ // ±offsetRange-px jitter so we don't click pixel-perfect (x,y) every
109
+ // time -- real users have spatial scatter even when aiming for the
110
+ // 'same' visible button.
111
+ const jx = x + (Math.random() - 0.5) * 2 * offsetRange;
112
+ const jy = y + (Math.random() - 0.5) * 2 * offsetRange;
113
+ try {
114
+ // Hover/move first -- many bot detectors check that mouse position
115
+ // matches the click point at mousedown time (browser fires mousemove
116
+ // before mousedown for real cursor hardware).
117
+ await page.mouse.move(jx, jy);
118
+ await fastTimeout(hoverMin + Math.random() * (hoverMax - hoverMin));
119
+ await page.mouse.down();
120
+
121
+ if (realistic) {
122
+ // Split the hold into (tremorCount + 1) chunks; emit a ±1px micromove
123
+ // between each chunk so the page sees mousemove events during the
124
+ // press window (real human hand tremor). Then drift ±MOUSEUP_DRIFT_PX
125
+ // before firing mouseup so mousedown.x/y !== mouseup.x/y.
126
+ const holdMs = holdMin + Math.random() * (holdMax - holdMin);
127
+ const tremorCount = CONTENT_CLICK.TREMOR_COUNT_MIN +
128
+ Math.floor(Math.random() * (CONTENT_CLICK.TREMOR_COUNT_MAX - CONTENT_CLICK.TREMOR_COUNT_MIN + 1));
129
+ const chunkMs = holdMs / (tremorCount + 1);
130
+ for (let i = 0; i < tremorCount; i++) {
131
+ await fastTimeout(chunkMs);
132
+ const tjx = jx + (Math.random() - 0.5) * 2 * CONTENT_CLICK.TREMOR_RANGE_PX;
133
+ const tjy = jy + (Math.random() - 0.5) * 2 * CONTENT_CLICK.TREMOR_RANGE_PX;
134
+ await page.mouse.move(tjx, tjy);
135
+ }
136
+ await fastTimeout(chunkMs);
137
+ // Final drift before mouseup. Move first (mouseup fires at current
138
+ // position) so the up event lands at slightly different coords than
139
+ // the down event — real humans almost always drift during the hold.
140
+ const ux = jx + (Math.random() - 0.5) * 2 * CONTENT_CLICK.MOUSEUP_DRIFT_PX;
141
+ const uy = jy + (Math.random() - 0.5) * 2 * CONTENT_CLICK.MOUSEUP_DRIFT_PX;
142
+ await page.mouse.move(ux, uy);
143
+ await page.mouse.up();
144
+ } else {
145
+ await fastTimeout(holdMin + Math.random() * (holdMax - holdMin));
146
+ await page.mouse.up();
147
+ }
148
+ } catch (err) {
149
+ // Page closed / target detached mid-click is the expected non-fatal
150
+ // path; everything else is unusual enough to surface in debug mode so
151
+ // a site silently failing 100% of clicks (CSP, broken input pipeline,
152
+ // CDP session collapse) is at least visible without --headful.
153
+ if (forceDebug && !/closed|detached|Target|Session closed|Protocol error/i.test(err.message || '')) {
154
+ try {
155
+ console.log(formatLogMessage('debug', `${INTERACTION_TAG} humanClick failed at (${jx.toFixed(0)}, ${jy.toFixed(0)}): ${err.message}`));
156
+ } catch (_) { /* logging itself shouldn't throw, but belt-and-braces */ }
157
+ }
158
+ }
159
+ }
160
+
70
161
  // === VIEWPORT AND COORDINATE CONSTANTS ===
71
162
  // These control the default viewport assumptions and coordinate generation
72
163
  const DEFAULT_VIEWPORT = {
@@ -138,7 +229,8 @@ const ELEMENT_INTERACTION = {
138
229
  // NOTE: No preDelay needed — mouse movements + scrolling already provide ~1s
139
230
  // of activity before clicks fire, which is enough for async ad script registration
140
231
  const CONTENT_CLICK = {
141
- CLICK_COUNT: 2, // Two attempts (primary + backup if first suppressed)
232
+ CLICK_COUNT: 3, // Three attempts (primary + 2 backup; ad SDKs sometimes suppress first OR second click as warmup before firing)
233
+ CLICK_COUNT_MAX: 20, // Hard cap when overridden via siteConfig.interact_click_count — a typo of 500 shouldn't run for minutes
142
234
  INTER_CLICK_MIN: 300, // Minimum ms between clicks (above Monetag 250ms cooldown)
143
235
  INTER_CLICK_MAX: 500, // Maximum ms between clicks
144
236
  // PRE_CLICK_DELAY: most ad scripts register document-level listeners
@@ -147,7 +239,22 @@ const CONTENT_CLICK = {
147
239
  // 300ms buffer here was mostly defensive. Reduced to 100ms.
148
240
  PRE_CLICK_DELAY: 100,
149
241
  VIEWPORT_INSET: 0.2, // Avoid outer 20% of viewport (menus, overlays)
150
- MOUSE_APPROACH_STEPS: 3 // Minimal steps — just enough for non-instant movement
242
+ MOUSE_APPROACH_STEPS: 3, // Minimal steps — just enough for non-instant movement
243
+ // Realistic-mode opt-in (siteConfig.realistic_click). Higher step count
244
+ // raises the mousemove event rate to ~80–125Hz (real mouse minimum is
245
+ // 125Hz USB default) so per-event movementX/Y deltas land in the 5–30px
246
+ // range a real cursor produces — fixes the strongest movement tell.
247
+ // Cost: +~80–120ms per click over the approach. Off by default.
248
+ MOUSE_APPROACH_STEPS_REALISTIC: 15,
249
+ // Realistic-mode hold tremor: 1–3 ±1px micromoves spread across the
250
+ // mousedown→mouseup hold to defeat the "zero events during hold" tell.
251
+ TREMOR_COUNT_MIN: 1,
252
+ TREMOR_COUNT_MAX: 3,
253
+ TREMOR_RANGE_PX: 1,
254
+ // Realistic-mode mouseup drift: real human clicks drift 0–2px between
255
+ // mousedown and mouseup, especially with longer holds. Without this,
256
+ // mousedown.x === mouseup.x is a robotic signal.
257
+ MOUSEUP_DRIFT_PX: 1.5
151
258
  };
152
259
 
153
260
  // === INTENSITY SETTINGS ===
@@ -358,16 +465,19 @@ async function humanLikeMouseMove(page, fromX, fromY, toX, toY, options = {}) {
358
465
  minDelay = MOUSE_MOVEMENT.MIN_DELAY,
359
466
  maxDelay = MOUSE_MOVEMENT.MAX_DELAY,
360
467
  curve = MOUSE_MOVEMENT.DEFAULT_CURVE,
361
- jitter = MOUSE_MOVEMENT.DEFAULT_JITTER
468
+ jitter = MOUSE_MOVEMENT.DEFAULT_JITTER,
469
+ realistic = false // bypass MAX_STEPS / MAX_TOTAL_MS caps for high-cadence approach
362
470
  } = options;
363
471
 
364
472
  const distance = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2);
365
473
 
366
474
  // Step count: caller-provided value capped at MAX_STEPS, otherwise derived
367
- // from distance and clamped to [MIN_STEPS, DEFAULT_STEPS].
475
+ // from distance and clamped to [MIN_STEPS, DEFAULT_STEPS]. Realistic mode
476
+ // skips the MAX_STEPS cap so callers can push 12–15 steps to match real
477
+ // mouse hardware event rates (~125Hz vs the default's ~30–60Hz).
368
478
  let actualSteps;
369
479
  if (options.steps) {
370
- actualSteps = Math.min(options.steps, MOUSE_MOVEMENT.MAX_STEPS);
480
+ actualSteps = realistic ? options.steps : Math.min(options.steps, MOUSE_MOVEMENT.MAX_STEPS);
371
481
  } else {
372
482
  const calculatedSteps = Math.floor(distance / MOUSE_MOVEMENT.DISTANCE_STEP_RATIO);
373
483
  actualSteps = Math.max(
@@ -377,10 +487,16 @@ async function humanLikeMouseMove(page, fromX, fromY, toX, toY, options = {}) {
377
487
  }
378
488
 
379
489
  // Emergency cap on total movement time — if step count × max-per-step delay
380
- // would exceed the budget, reduce step count to fit.
490
+ // would exceed the budget, reduce step count to fit. Realistic mode raises
491
+ // the cap to 600ms so the higher step count survives the trim.
492
+ // Floor-clamp to MIN_STEPS: if a caller passes a maxDelay larger than
493
+ // totalMsLimit (e.g. maxDelay: 1000), the floor division yields 0, and the
494
+ // i=0 iteration then computes progress = 0/0 = NaN, propagating into
495
+ // page.mouse.move(NaN, NaN). Clamping preserves at least MIN_STEPS moves.
496
+ const totalMsLimit = realistic ? 600 : MOUSE_MOVEMENT.MAX_TOTAL_MS;
381
497
  const estimatedTime = actualSteps * maxDelay;
382
- if (estimatedTime > MOUSE_MOVEMENT.MAX_TOTAL_MS) {
383
- actualSteps = Math.floor(MOUSE_MOVEMENT.MAX_TOTAL_MS / maxDelay);
498
+ if (estimatedTime > totalMsLimit) {
499
+ actualSteps = Math.max(MOUSE_MOVEMENT.MIN_STEPS, Math.floor(totalMsLimit / maxDelay));
384
500
  }
385
501
 
386
502
  for (let i = 0; i <= actualSteps; i++) {
@@ -398,8 +514,12 @@ async function humanLikeMouseMove(page, fromX, fromY, toX, toY, options = {}) {
398
514
  let currentX = fromX + (toX - fromX) * easedProgress;
399
515
  let currentY = fromY + (toY - fromY) * easedProgress;
400
516
 
401
- // Add slight curve to movement (more human-like)
402
- if (curve > 0 && i > 0 && i < actualSteps) {
517
+ // Add slight curve to movement (more human-like).
518
+ // distance > 0 guard: when fromX === toX AND fromY === toY (integer-quantized
519
+ // random targets in performContentClicks can collide; or external caller passes
520
+ // from === to deliberately) the perpX/perpY divisions become -0/0 = NaN and
521
+ // poison currentX/currentY, causing page.mouse.move(NaN, NaN) to reject via CDP.
522
+ if (curve > 0 && distance > 0 && i > 0 && i < actualSteps) {
403
523
  const curveIntensity = Math.sin((i / actualSteps) * Math.PI) * curve * distance * MOUSE_MOVEMENT.CURVE_INTENSITY_RATIO;
404
524
  const perpX = -(toY - fromY) / distance;
405
525
  const perpY = (toX - fromX) / distance;
@@ -547,7 +667,9 @@ async function interactWithElements(page, options = {}) {
547
667
  maxAttempts = ELEMENT_INTERACTION.MAX_ATTEMPTS,
548
668
  elementTypes = ['button', 'a', '[role="button"]'],
549
669
  avoidDestructive = true,
550
- timeout = ELEMENT_INTERACTION.TIMEOUT
670
+ timeout = ELEMENT_INTERACTION.TIMEOUT,
671
+ forceDebug = false,
672
+ realistic = false
551
673
  } = options;
552
674
 
553
675
  try {
@@ -555,22 +677,23 @@ async function interactWithElements(page, options = {}) {
555
677
  try {
556
678
  // Check if page is closed before attempting interaction
557
679
  if (page.isClosed()) {
558
- if (options.forceDebug) {
680
+ if (forceDebug) {
559
681
  console.log(formatLogMessage('debug', `${INTERACTION_TAG} Page is closed, skipping element interaction`));
560
682
  }
561
683
  return;
562
684
  }
563
685
 
564
- // Very short timeout since page should already be loaded.
565
- // Explicitly dispose the returned handle rather than relying on
566
- // Puppeteer's FinalizationRegistry matches the dispose pattern
567
- // already used in performPageInteraction's final-hover block.
568
- const bodyHandle = await page.waitForSelector('body', { timeout: 1000 });
686
+ // Body wait honors the caller-provided timeout option (default 2000ms
687
+ // via ELEMENT_INTERACTION.TIMEOUT) -- was previously hardcoded to 1000
688
+ // and silently ignored the option. Explicitly dispose the returned handle
689
+ // rather than relying on Puppeteer's FinalizationRegistry -- matches the
690
+ // dispose pattern already used in performPageInteraction's final-hover block.
691
+ const bodyHandle = await page.waitForSelector('body', { timeout });
569
692
  if (bodyHandle) { try { await bodyHandle.dispose(); } catch (_) {} }
570
693
  // Re-check after async wait — page may have closed during selector wait
571
694
  if (page.isClosed()) return;
572
695
  } catch (bodyWaitErr) {
573
- if (options.forceDebug) {
696
+ if (forceDebug) {
574
697
  console.log(formatLogMessage('debug', `${INTERACTION_TAG} Page not ready for element interaction: ${bodyWaitErr.message}`));
575
698
  }
576
699
  return;
@@ -598,7 +721,12 @@ async function interactWithElements(page, options = {}) {
598
721
 
599
722
  if (isVisible) {
600
723
  const text = (el.textContent || el.alt || el.title || '').toLowerCase();
601
- const shouldAvoid = avoidWords && avoidWords.some(word => text.includes(word));
724
+ // Word-boundary regex match -- prior `text.includes(word)`
725
+ // produced false positives like 'submit' matching
726
+ // 'resubmit'/'submitter', filtering out legitimate
727
+ // clickables. \b ensures whole-word matches only.
728
+ const shouldAvoid = avoidWords && avoidWords.length > 0 &&
729
+ new RegExp('\\b(' + avoidWords.join('|') + ')\\b').test(text);
602
730
 
603
731
  if (!shouldAvoid) {
604
732
  clickableElements.push({
@@ -627,7 +755,7 @@ async function interactWithElements(page, options = {}) {
627
755
  // Brief pause before clicking
628
756
  await fastTimeout(TIMING.CLICK_PAUSE_MIN + Math.random() * (TIMING.CLICK_PAUSE_MAX - TIMING.CLICK_PAUSE_MIN));
629
757
 
630
- await page.mouse.click(element.x, element.y);
758
+ await humanClick(page, element.x, element.y, { forceDebug, realistic });
631
759
 
632
760
  // Brief pause after clicking
633
761
  await fastTimeout(TIMING.POST_CLICK_MIN + Math.random() * (TIMING.POST_CLICK_MAX - TIMING.POST_CLICK_MIN));
@@ -679,8 +807,12 @@ async function performContentClicks(page, options = {}) {
679
807
  preDelay = CONTENT_CLICK.PRE_CLICK_DELAY,
680
808
  interClickMin = CONTENT_CLICK.INTER_CLICK_MIN,
681
809
  interClickMax = CONTENT_CLICK.INTER_CLICK_MAX,
682
- forceDebug = false
810
+ forceDebug = false,
811
+ realistic = false // siteConfig.realistic_click — denser approach + hold tremor + mouseup drift
683
812
  } = options;
813
+ const approachSteps = realistic
814
+ ? CONTENT_CLICK.MOUSE_APPROACH_STEPS_REALISTIC
815
+ : CONTENT_CLICK.MOUSE_APPROACH_STEPS;
684
816
 
685
817
  try {
686
818
  if (page.isClosed()) return;
@@ -707,14 +839,15 @@ async function performContentClicks(page, options = {}) {
707
839
 
708
840
  // Natural mouse approach (few steps, no need for elaborate curves)
709
841
  await humanLikeMouseMove(page, lastX, lastY, targetX, targetY, {
710
- steps: CONTENT_CLICK.MOUSE_APPROACH_STEPS,
842
+ steps: approachSteps,
711
843
  curve: 0.03 + Math.random() * 0.04,
712
- jitter: 1
844
+ jitter: 1,
845
+ realistic
713
846
  });
714
847
 
715
848
  // Brief human-like pause, then click
716
849
  await fastTimeout(TIMING.CLICK_PAUSE_MIN + Math.random() * (TIMING.CLICK_PAUSE_MAX - TIMING.CLICK_PAUSE_MIN));
717
- await page.mouse.click(targetX, targetY);
850
+ await humanClick(page, targetX, targetY, { forceDebug, realistic });
718
851
 
719
852
  if (forceDebug) {
720
853
  console.log(formatLogMessage('debug', `${INTERACTION_TAG} Content click ${i + 1}/${clicks} at (${targetX}, ${targetY})`));
@@ -792,7 +925,82 @@ async function performContentClicks(page, options = {}) {
792
925
  * includeElementClicks: false
793
926
  * });
794
927
  */
928
+ /**
929
+ * Work-aware ceiling (ms) for a single interaction pass.
930
+ *
931
+ * Interaction is a sequence of awaited steps (mouse moves, scrolls, content
932
+ * clicks); under event-loop/CDP contention from many concurrent URLs each step
933
+ * stretches well past its intrinsic cost (a default 3-click pass measured ~4s
934
+ * solo but ~22s at the default concurrency of 6). A FLAT ceiling therefore
935
+ * either truncates legitimate high interact_click_count / realistic_click
936
+ * configs — dropping the very popunder clicks the pass exists to fire — or sits
937
+ * loosely over light runs. Scale by the actual work envelope instead, same
938
+ * philosophy as nwss's per-URL timeout. Per-unit allowances are sized to absorb
939
+ * up to ~default-concurrency contention; the result is a SAFETY ceiling, not a
940
+ * target — interaction returns as soon as its work is done, so a generous
941
+ * ceiling never slows a fast pass, it only bounds a stuck one.
942
+ *
943
+ * @param {Object} options - same shape performPageInteraction receives
944
+ * @returns {number} ceiling in ms (floored at 15000, the prior flat budget)
945
+ */
946
+ function computeInteractionCeilingMs(options = {}) {
947
+ const {
948
+ intensity = 'medium',
949
+ mouseMovements,
950
+ includeScrolling = true,
951
+ includeElementClicks = false,
952
+ clickCount,
953
+ realistic = false
954
+ } = options;
955
+
956
+ const settings = INTENSITY_SETTINGS[String(intensity).toUpperCase()] || INTENSITY_SETTINGS.MEDIUM;
957
+ const movements = mouseMovements !== undefined ? mouseMovements : settings.movements;
958
+ const scrolls = includeScrolling ? settings.scrolls : 0;
959
+ const clicks = includeElementClicks
960
+ ? (clickCount ? Math.min(Math.floor(clickCount), CONTENT_CLICK.CLICK_COUNT_MAX) : CONTENT_CLICK.CLICK_COUNT)
961
+ : 0;
962
+
963
+ const BASE_MS = 6000; // setup, viewport, final move, slack
964
+ const PER_MOVE_MS = 700;
965
+ const PER_SCROLL_MS = 800;
966
+ const PER_CLICK_MS = realistic ? 7000 : 4000; // realistic clicks are denser (15-step approach + tremor)
967
+
968
+ return Math.max(
969
+ 15000, // floor = the prior flat budget, so light/default configs are unchanged
970
+ BASE_MS + movements * PER_MOVE_MS + scrolls * PER_SCROLL_MS + clicks * PER_CLICK_MS
971
+ );
972
+ }
973
+
795
974
  async function performPageInteraction(page, currentUrl, options = {}, forceDebug = false) {
975
+ // Hard wall-clock ceiling on the whole interaction. The impl's internal
976
+ // checkTimeout() is cooperative — only evaluated BETWEEN steps — so a single
977
+ // blocking await (a CDP round-trip, or a fastTimeout that fires late once
978
+ // many URLs saturate the one event loop / CDP pipe) sails right past the 15s
979
+ // soft budget; that's how interaction was clocking 21-22s under concurrency.
980
+ // Racing the work against a real timer enforces the ceiling no matter where
981
+ // the time actually goes. The timer RESOLVES (never rejects) — interaction
982
+ // failures must not break the scan — and the impl is .catch()'d so the
983
+ // orphaned run can't surface an unhandled rejection after the race settles.
984
+ // Keeps nwss's per-URL INTERACTION_OVERHEAD_MS budget honest: one cycle now
985
+ // stays <= the ceiling even under heavy contention.
986
+ const HARD_CAP_MS = computeInteractionCeilingMs(options); // work-aware: scales with clicks/realistic/intensity
987
+ let capTimer;
988
+ let capped = false;
989
+ const work = _performPageInteractionImpl(page, currentUrl, options, forceDebug).catch(() => {});
990
+ try {
991
+ await Promise.race([
992
+ work,
993
+ new Promise(resolve => { capTimer = setTimeout(() => { capped = true; resolve(); }, HARD_CAP_MS); })
994
+ ]);
995
+ } finally {
996
+ if (capTimer) clearTimeout(capTimer);
997
+ }
998
+ if (capped && forceDebug) {
999
+ console.log(formatLogMessage('debug', `${INTERACTION_TAG} Interaction hard-capped at ${HARD_CAP_MS}ms for ${currentUrl} (event-loop/CDP contention)`));
1000
+ }
1001
+ }
1002
+
1003
+ async function _performPageInteractionImpl(page, currentUrl, options = {}, forceDebug = false) {
796
1004
  // mouseMovements deliberately has no default in the destructure: we want
797
1005
  // to distinguish 'caller didn't pass it' from 'caller explicitly passed 3'
798
1006
  // so the actualMovements calculation below can let intensity drive the
@@ -803,7 +1011,9 @@ async function performPageInteraction(page, currentUrl, options = {}, forceDebug
803
1011
  includeScrolling = true,
804
1012
  includeElementClicks = false,
805
1013
  duration = TIMING.DEFAULT_INTERACTION_DURATION,
806
- intensity = 'medium'
1014
+ intensity = 'medium',
1015
+ clickCount, // optional override; undefined -> performContentClicks uses CONTENT_CLICK.CLICK_COUNT default
1016
+ realistic = false // siteConfig.realistic_click — propagated to performContentClicks
807
1017
  } = options;
808
1018
 
809
1019
  try {
@@ -910,7 +1120,13 @@ async function performPageInteraction(page, currentUrl, options = {}, forceDebug
910
1120
  // interactWithElements is still exported for callers that want it.
911
1121
  if (includeElementClicks) {
912
1122
  if (checkTimeout()) return; // Emergency timeout check
913
- await performContentClicks(page, { forceDebug });
1123
+ // Pass clickCount only when caller set it (via siteConfig.interact_click_count)
1124
+ // -- omit otherwise so performContentClicks's default destructure
1125
+ // falls through to CONTENT_CLICK.CLICK_COUNT. realistic is always
1126
+ // forwarded (defaults to false at every layer).
1127
+ const ccOpts = { forceDebug, realistic };
1128
+ if (clickCount) ccOpts.clicks = clickCount;
1129
+ await performContentClicks(page, ccOpts);
914
1130
  }
915
1131
 
916
1132
  // Final resting position — single mouse.move instead of the previous
@@ -1037,6 +1253,25 @@ function createInteractionConfig(url, siteConfig = {}) {
1037
1253
  if (siteConfig.interact_clicks !== undefined) {
1038
1254
  config.includeElementClicks = siteConfig.interact_clicks;
1039
1255
  }
1256
+ // interact_click_count: per-site override of how many random
1257
+ // content-zone clicks performContentClicks fires. Cap at
1258
+ // CLICK_COUNT_MAX to prevent runaway from typos. Coerce to integer
1259
+ // and clamp >= 1 (count of 0 should be expressed via
1260
+ // interact_clicks: false, not interact_click_count: 0).
1261
+ if (typeof siteConfig.interact_click_count === 'number' && siteConfig.interact_click_count > 0) {
1262
+ config.clickCount = Math.min(
1263
+ Math.floor(siteConfig.interact_click_count),
1264
+ CONTENT_CLICK.CLICK_COUNT_MAX
1265
+ );
1266
+ }
1267
+ // realistic_click: opt-in for sites that score click realism
1268
+ // (DataDome, Akamai BM, PerimeterX). Adds ~80–120ms per click for the
1269
+ // denser approach; hold-tremor and mouseup-drift fit inside the
1270
+ // existing hold window so they're free. Off by default since ad-network
1271
+ // popunder discovery doesn't need it and we'd rather keep scans fast.
1272
+ if (siteConfig.realistic_click === true) {
1273
+ config.realistic = true;
1274
+ }
1040
1275
 
1041
1276
  return config;
1042
1277
  } catch (urlErr) {
@@ -1091,6 +1326,7 @@ module.exports = {
1091
1326
  // Main interaction functions
1092
1327
  performPageInteraction,
1093
1328
  createInteractionConfig,
1329
+ computeInteractionCeilingMs,
1094
1330
  getViewport,
1095
1331
  // Component functions for custom implementations
1096
1332
  humanLikeMouseMove,