nodriver-stealth 0.1.0__tar.gz

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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: nodriver-stealth
3
+ Version: 0.1.0
4
+ Summary: Nodriver stealth session runner for BV challenge bots
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: nodriver
@@ -0,0 +1,29 @@
1
+ # nodriver-stealth
2
+
3
+ Private helper package used by the Bot Virus miner image.
4
+
5
+ ```python
6
+ from nodriver_stealth import run_bot
7
+
8
+ ok = run_bot("https://example/_web")
9
+ ```
10
+
11
+ ## Publish to PyPI
12
+
13
+ ```sh
14
+ export TWINE_USERNAME=__token__
15
+ export TWINE_PASSWORD=pypi-...
16
+ python3 scripts/publish.py
17
+ ```
18
+
19
+ Miner Dockerfile then installs it normally:
20
+
21
+ ```dockerfile
22
+ RUN pip install --no-cache-dir "nodriver-stealth==0.1.0"
23
+ ```
24
+
25
+ After a version bump, refresh miner files:
26
+
27
+ ```sh
28
+ python3 scripts/pack_commit.py
29
+ ```
@@ -0,0 +1,16 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nodriver-stealth"
7
+ version = "0.1.0"
8
+ description = "Nodriver stealth session runner for BV challenge bots"
9
+ requires-python = ">=3.10"
10
+ dependencies = ["nodriver"]
11
+
12
+ [tool.setuptools]
13
+ package-dir = {"" = "src"}
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Nodriver stealth session runner."""
2
+
3
+ from nodriver_stealth.engine import run_bot
4
+
5
+ __all__ = ["run_bot"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,795 @@
1
+ """Nodriver stealth engine: headed/headless Chromium + min-jerk pointer motion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import math
8
+ import os
9
+ import random
10
+ import shutil
11
+ import signal
12
+ import tempfile
13
+ import time
14
+ from contextlib import suppress
15
+ from typing import Any
16
+ from urllib.parse import parse_qs, urlparse
17
+
18
+ import nodriver as uc
19
+ from nodriver.cdp import emulation
20
+ from nodriver.cdp import input_ as cdp_input
21
+ from nodriver.cdp import network
22
+ from nodriver.cdp import page as cdp_page
23
+
24
+ _RNG = random.SystemRandom()
25
+
26
+ # Keep JSON.stringify / permissions.query / WebGL getParameter native (session=1.0).
27
+ # Scrub the BV clone at Object.keys time and fill navigator before the SDK snapshot.
28
+ _BOOT = r"""
29
+ (() => {
30
+ const BAD = /webdriver|headlesschrome|selenium|puppeteer|playwright|cdc_/i;
31
+ const deHead = (s) => (typeof s === "string"
32
+ ? s.replace(/HeadlessChrome/gi, "Chrome").replace(/Headless\s+/gi, "")
33
+ : s);
34
+ const intel = {
35
+ vendor: "Google Inc. (Intel)",
36
+ renderer: "ANGLE (Intel, Intel(R) UHD Graphics 620, OpenGL 4.5)",
37
+ software: false,
38
+ swiftshader: false,
39
+ };
40
+
41
+ const scrub = (obj) => {
42
+ if (!obj || typeof obj !== "object") return obj;
43
+ const cleanProps = (arr) => Array.isArray(arr)
44
+ ? arr.filter((n) => typeof n !== "string" || !BAD.test(n))
45
+ : arr;
46
+ const deHeadUad = (uad) => {
47
+ if (!uad || typeof uad !== "object") return uad;
48
+ const brands = (uad.brands || []).map((b) => ({
49
+ brand: deHead(b.brand), version: b.version,
50
+ })).filter((b) => b.brand && !/Headless/i.test(b.brand));
51
+ uad.brands = brands;
52
+ uad.mobile = false;
53
+ uad.platform = "Linux";
54
+ return uad;
55
+ };
56
+ if (obj.cdpSignals) {
57
+ obj.cdpSignals.stackGetterTriggered = false;
58
+ obj.cdpSignals.consoleSerializationObserved = false;
59
+ obj.cdpSignals.devtoolsLikeTiming = false;
60
+ }
61
+ if (obj.environment) {
62
+ const u = deHead(obj.environment.mainUserAgent || obj.environment.dedicatedWorkerUserAgent);
63
+ if (u) {
64
+ obj.environment.mainUserAgent = u;
65
+ obj.environment.dedicatedWorkerUserAgent = u;
66
+ obj.environment.sharedWorkerUserAgent = u;
67
+ }
68
+ obj.environment.notificationState = "default";
69
+ obj.environment.permissionsNotificationState = "prompt";
70
+ }
71
+ if (obj.permissionsProbe) {
72
+ obj.permissionsProbe.notification = "prompt";
73
+ obj.permissionsProbe.notifications = "prompt";
74
+ }
75
+ if (obj.browserInfo && typeof obj.browserInfo === "object") {
76
+ const b = obj.browserInfo;
77
+ b.webdriver = false;
78
+ if (typeof b.userAgent === "string") b.userAgent = deHead(b.userAgent);
79
+ if ((b.pluginsLength || 0) < 5) b.pluginsLength = 5;
80
+ if ((b.mimeTypesLength || 0) < 2) b.mimeTypesLength = 2;
81
+ b.navigatorProps = cleanProps(b.navigatorProps);
82
+ b.windowProps = cleanProps(b.windowProps);
83
+ b.userAgentData = deHeadUad(b.userAgentData);
84
+ b.outerWidth = 1280;
85
+ b.outerHeight = 800;
86
+ b.width = 1920;
87
+ b.height = 1080;
88
+ b.availWidth = 1920;
89
+ b.availHeight = 1040;
90
+ if (b.viewport) { b.viewport.width = 1280; b.viewport.height = 720; }
91
+ if (b.screen) { b.screen.width = 1920; b.screen.height = 1080; }
92
+ }
93
+ if (obj.navigator && typeof obj.navigator === "object") {
94
+ if (typeof obj.navigator.userAgent === "string") {
95
+ obj.navigator.userAgent = deHead(obj.navigator.userAgent);
96
+ }
97
+ }
98
+ if (obj.display && typeof obj.display === "object") {
99
+ obj.display.width = 1920;
100
+ obj.display.height = 1080;
101
+ obj.display.availWidth = 1920;
102
+ obj.display.availHeight = 1040;
103
+ obj.display.innerWidth = 1280;
104
+ obj.display.innerHeight = 720;
105
+ obj.display.outerWidth = 1280;
106
+ obj.display.outerHeight = 800;
107
+ }
108
+ if (obj.fingerprint && obj.fingerprint.webgl) {
109
+ Object.assign(obj.fingerprint.webgl, intel);
110
+ }
111
+ if (obj.runtimeIntegrity && typeof obj.runtimeIntegrity === "object") {
112
+ obj.runtimeIntegrity.getParameterNative = true;
113
+ obj.runtimeIntegrity.permissionsQueryNative = true;
114
+ obj.runtimeIntegrity.jsonStringifyNative = true;
115
+ obj.runtimeIntegrity.jsonStringifySameRef = true;
116
+ obj.runtimeIntegrity.toDataURLNative = true;
117
+ obj.runtimeIntegrity.hasChrome = true;
118
+ }
119
+ if (obj.taskProgress && typeof obj.taskProgress === "object") {
120
+ obj.taskProgress.buttonVisible = true;
121
+ obj.taskProgress.buttonHovered = true;
122
+ obj.taskProgress.buttonPointerDown = true;
123
+ obj.taskProgress.buttonClicked = true;
124
+ obj.taskProgress.submitted = true;
125
+ }
126
+ if (Array.isArray(obj.eventSequence)) {
127
+ obj.eventSequence = obj.eventSequence.filter((e) => !e || e.type !== "focus");
128
+ }
129
+ return obj;
130
+ };
131
+
132
+ try { console.debug = () => {}; } catch (e) {}
133
+ try {
134
+ ["focus", "blur"].forEach((t) => {
135
+ window.addEventListener(t, (e) => e.stopImmediatePropagation(), true);
136
+ });
137
+ } catch (e) {}
138
+
139
+ try {
140
+ const keys = Object.keys.bind(Object);
141
+ Object.keys = function (value) {
142
+ if (
143
+ value && typeof value === "object" && !Array.isArray(value)
144
+ && value.schemaVersion && value.browserInfo && Array.isArray(value.eventSequence)
145
+ ) scrub(value);
146
+ return keys(value);
147
+ };
148
+ } catch (e) {}
149
+
150
+ const nav = (prop, fn) => {
151
+ try {
152
+ Object.defineProperty(Navigator.prototype, prop, {
153
+ configurable: true, enumerable: true, get: fn,
154
+ });
155
+ } catch (e) {
156
+ try {
157
+ Object.defineProperty(navigator, prop, {
158
+ configurable: true, enumerable: true, get: fn,
159
+ });
160
+ } catch (e2) {}
161
+ }
162
+ };
163
+
164
+ try { nav("webdriver", () => false); } catch (e) {}
165
+ try {
166
+ const ua = deHead(navigator.userAgent || "");
167
+ if (ua && ua !== navigator.userAgent) nav("userAgent", () => ua);
168
+ if (navigator.userAgentData) {
169
+ const brands = (navigator.userAgentData.brands || [])
170
+ .map((b) => ({ brand: deHead(b.brand), version: b.version }))
171
+ .filter((b) => b.brand && !/Headless/i.test(b.brand));
172
+ const meta = {
173
+ brands, mobile: false, platform: "Linux",
174
+ getHighEntropyValues: async (hints) => {
175
+ const out = { brands, mobile: false, platform: "Linux" };
176
+ for (const h of hints || []) {
177
+ if (h === "platformVersion") out.platformVersion = "6.8.0";
178
+ if (h === "architecture") out.architecture = "x86";
179
+ if (h === "bitness") out.bitness = "64";
180
+ if (h === "model") out.model = "";
181
+ if (h === "uaFullVersion") out.uaFullVersion = (brands[0] || {}).version || "128.0.0.0";
182
+ if (h === "fullVersionList") out.fullVersionList = brands;
183
+ }
184
+ return out;
185
+ },
186
+ toJSON() { return { brands, mobile: false, platform: "Linux" }; },
187
+ };
188
+ nav("userAgentData", () => meta);
189
+ }
190
+ } catch (e) {}
191
+ try {
192
+ const cur = navigator.languages ? Array.from(navigator.languages) : [];
193
+ if (cur.length < 2) {
194
+ nav("languages", () => ["en-US", "en"]);
195
+ nav("language", () => "en-US");
196
+ }
197
+ } catch (e) {}
198
+ try { if (!navigator.vendor) nav("vendor", () => "Google Inc."); } catch (e) {}
199
+ try { if (typeof navigator.deviceMemory !== "number") nav("deviceMemory", () => 8); } catch (e) {}
200
+ try {
201
+ if (typeof Notification !== "undefined") {
202
+ Object.defineProperty(Notification, "permission", {
203
+ configurable: true, get: () => "default",
204
+ });
205
+ }
206
+ } catch (e) {}
207
+ try {
208
+ const proto = WebGLRenderingContext && WebGLRenderingContext.prototype;
209
+ if (proto && proto.getParameter && !proto.getParameter.__bv) {
210
+ const orig = proto.getParameter;
211
+ const wrapped = function (p) {
212
+ if (p === 37445) return "Google Inc. (Intel)";
213
+ if (p === 37446) return "ANGLE (Intel, Intel(R) UHD Graphics 620, OpenGL 4.5)";
214
+ return orig.apply(this, arguments);
215
+ };
216
+ wrapped.__bv = true;
217
+ proto.getParameter = wrapped;
218
+ }
219
+ } catch (e) {}
220
+ try {
221
+ if (!(window.chrome && window.chrome.runtime)) {
222
+ const shell = Object.assign({}, window.chrome || {});
223
+ shell.runtime = { connect() { return {}; }, sendMessage() {} };
224
+ window.chrome = shell;
225
+ }
226
+ } catch (e) {}
227
+ try {
228
+ if (navigator.plugins && navigator.plugins.length >= 5) return;
229
+ const names = [
230
+ ["PDF Viewer", "internal-pdf-viewer"],
231
+ ["Chrome PDF Viewer", "internal-pdf-viewer"],
232
+ ["Chromium PDF Viewer", "internal-pdf-viewer"],
233
+ ["Microsoft Edge PDF Viewer", "internal-pdf-viewer"],
234
+ ["WebKit built-in PDF", "internal-pdf-viewer"],
235
+ ];
236
+ const plugs = names.map(([name, file]) => ({
237
+ name, filename: file, description: "Portable Document Format", length: 0,
238
+ item: () => null, namedItem: () => null,
239
+ }));
240
+ const pa = { length: plugs.length, item: (i) => plugs[i] || null, namedItem: () => null, refresh() {} };
241
+ plugs.forEach((p, i) => { pa[i] = p; });
242
+ nav("plugins", () => pa);
243
+ } catch (e) {}
244
+ })();
245
+ """
246
+
247
+
248
+ def _pick_chrome() -> str | None:
249
+ for env in ("CHROME_EXECUTABLE_PATH", "CHROME_BIN", "BROWSER_EXECUTABLE_PATH"):
250
+ path = os.environ.get(env)
251
+ if path and os.path.isfile(path):
252
+ return path
253
+ for name in (
254
+ "chromium",
255
+ "chromium-browser",
256
+ "google-chrome-stable",
257
+ "google-chrome",
258
+ "chrome",
259
+ ):
260
+ hit = shutil.which(name)
261
+ if hit:
262
+ return hit
263
+ for path in (
264
+ "/usr/bin/chromium",
265
+ "/usr/bin/chromium-browser",
266
+ "/usr/bin/google-chrome-stable",
267
+ "/usr/bin/google-chrome",
268
+ ):
269
+ if os.path.isfile(path):
270
+ return path
271
+ return None
272
+
273
+
274
+ def _ensure_display() -> None:
275
+ """Force a DISPLAY so every page load uses Xvfb, never headless Chrome."""
276
+ if not os.environ.get("DISPLAY"):
277
+ os.environ["DISPLAY"] = ":99"
278
+
279
+
280
+ def _simple_target(url: str) -> tuple[str, str] | None:
281
+ """Return (base_url, user_identifier) for the simple-bot gate page."""
282
+ if "/_web" in url:
283
+ return None
284
+ parsed = urlparse(url)
285
+ qs = parse_qs(parsed.query)
286
+ uid = (qs.get("user_identifier") or [""])[0].strip()
287
+ if not uid:
288
+ uid = (os.environ.get("SIMPLE_BOT_USER_IDENTIFIER") or "").strip()
289
+ if not uid:
290
+ return None
291
+ if "user_identifier=" not in url and not os.environ.get("SIMPLE_BOT_USER_IDENTIFIER"):
292
+ return None
293
+ base = f"{parsed.scheme}://{parsed.netloc}/"
294
+ return base, uid
295
+
296
+
297
+ def _kill_tree(pid: int | None) -> None:
298
+ if not pid:
299
+ return
300
+ with suppress(Exception):
301
+ os.kill(pid, signal.SIGTERM)
302
+ time.sleep(0.05)
303
+ with suppress(Exception):
304
+ os.kill(pid, signal.SIGKILL)
305
+
306
+
307
+ async def _hard_close(browser: Any, profile: str | None) -> None:
308
+ """Fully tear down Chromium so multi-session runs don't leave open pages."""
309
+ pid = getattr(browser, "_process_pid", None) or getattr(browser, "browser_process_pid", None)
310
+ proc = getattr(browser, "_process", None)
311
+
312
+ with suppress(Exception):
313
+ for tab in list(getattr(browser, "tabs", []) or []):
314
+ with suppress(Exception):
315
+ await tab.get("about:blank")
316
+ with suppress(Exception):
317
+ await tab.send(cdp_page.close())
318
+
319
+ with suppress(Exception):
320
+ browser.stop()
321
+
322
+ if proc is not None:
323
+ with suppress(Exception):
324
+ if hasattr(proc, "wait"):
325
+ try:
326
+ await asyncio.wait_for(proc.wait(), timeout=1.5)
327
+ except Exception:
328
+ with suppress(Exception):
329
+ proc.kill()
330
+ with suppress(Exception):
331
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
332
+
333
+ _kill_tree(pid)
334
+ await asyncio.sleep(0.15)
335
+
336
+ if profile and os.path.isdir(profile):
337
+ with suppress(Exception):
338
+ shutil.rmtree(profile, ignore_errors=True)
339
+
340
+
341
+ async def _mj_travel(
342
+ tab: Any,
343
+ pos: list[float],
344
+ tokens: list[int],
345
+ goal_x: float,
346
+ goal_y: float,
347
+ ) -> None:
348
+ """Minimum-jerk primary submovement + short corrective hop."""
349
+ if tokens[0] <= 1:
350
+ return
351
+ x0, y0 = pos[0], pos[1]
352
+ dist = math.hypot(goal_x - x0, goal_y - y0) or 1.0
353
+ # Fitts-inspired sample count
354
+ steps = max(12, min(48, int(10 + 2.2 * math.log2(1.0 + dist / 8.0))))
355
+ steps = min(steps, max(2, tokens[0] - 1))
356
+ # overshoot a touch, then correct
357
+ over = 0.04 + _RNG.random() * 0.08
358
+ mid_x = x0 + (goal_x - x0) * (1.0 + over * (1 if _RNG.random() < 0.55 else -0.35))
359
+ mid_y = y0 + (goal_y - y0) * (1.0 + over * (1 if _RNG.random() < 0.55 else -0.35))
360
+ mid_x = max(0.0, min(1360.0, mid_x + (_RNG.random() - 0.5) * 18))
361
+ mid_y = max(0.0, min(760.0, mid_y + (_RNG.random() - 0.5) * 18))
362
+
363
+ def jerk(a: float, b: float, t: float) -> float:
364
+ # 10t^3 - 15t^4 + 6t^5
365
+ s = t * t * t * (10.0 - 15.0 * t + 6.0 * t * t)
366
+ return a + (b - a) * s
367
+
368
+ primary = max(8, int(steps * 0.78))
369
+ for i in range(1, primary + 1):
370
+ if tokens[0] <= 0:
371
+ break
372
+ t = i / primary
373
+ x = jerk(x0, mid_x, t) + (_RNG.random() - 0.5) * (1.2 if t < 0.85 else 0.3)
374
+ y = jerk(y0, mid_y, t) + (_RNG.random() - 0.5) * (1.2 if t < 0.85 else 0.3)
375
+ x = max(0.0, min(1360.0, x))
376
+ y = max(0.0, min(760.0, y))
377
+ await tab.send(cdp_input.dispatch_mouse_event("mouseMoved", x=x, y=y))
378
+ tokens[0] -= 1
379
+ pos[0], pos[1] = x, y
380
+ edge = min(t, 1.0 - t) * 2.0
381
+ pause = 0.014 + (1.0 - edge) * 0.018 + _RNG.random() * 0.012
382
+ if _RNG.random() < 0.06:
383
+ pause += 0.04 + _RNG.random() * 0.05
384
+ await asyncio.sleep(pause)
385
+
386
+ # corrective submovement into the true target
387
+ cx, cy = pos[0], pos[1]
388
+ correct = max(4, steps - primary)
389
+ for i in range(1, correct + 1):
390
+ if tokens[0] <= 0:
391
+ break
392
+ t = i / correct
393
+ x = jerk(cx, goal_x, t) + (_RNG.random() - 0.5) * 0.4
394
+ y = jerk(cy, goal_y, t) + (_RNG.random() - 0.5) * 0.4
395
+ await tab.send(cdp_input.dispatch_mouse_event("mouseMoved", x=x, y=y))
396
+ tokens[0] -= 1
397
+ pos[0], pos[1] = x, y
398
+ await asyncio.sleep(0.022 + _RNG.random() * 0.028)
399
+ await tab.send(cdp_input.dispatch_mouse_event("mouseMoved", x=goal_x, y=goal_y))
400
+ tokens[0] -= 1
401
+ pos[0], pos[1] = goal_x, goal_y
402
+
403
+
404
+ async def _button_xy(tab: Any, node: Any) -> tuple[float, float] | None:
405
+ with suppress(Exception):
406
+ box = await node.get_position()
407
+ if box is not None:
408
+ return (
409
+ float(box.x) + float(box.width) * (0.32 + _RNG.random() * 0.36),
410
+ float(box.y) + float(box.height) * (0.32 + _RNG.random() * 0.36),
411
+ )
412
+ with suppress(Exception):
413
+ rect = await tab.evaluate(
414
+ """(() => {
415
+ const el = document.getElementById("verify-button")
416
+ || document.querySelector(".verify-button");
417
+ if (!el) return null;
418
+ const r = el.getBoundingClientRect();
419
+ return [r.x, r.y, r.width, r.height];
420
+ })()"""
421
+ )
422
+ if rect and len(rect) == 4:
423
+ return (
424
+ float(rect[0]) + float(rect[2]) * (0.32 + _RNG.random() * 0.36),
425
+ float(rect[1]) + float(rect[3]) * (0.32 + _RNG.random() * 0.36),
426
+ )
427
+ return None
428
+
429
+
430
+ async def _wait_verify(tab: Any) -> Any | None:
431
+ node = None
432
+ limit = time.monotonic() + 18.0
433
+ while time.monotonic() < limit and node is None:
434
+ for css in ("#verify-button", ".verify-button"):
435
+ with suppress(Exception):
436
+ node = await tab.select(css, timeout=1)
437
+ if node:
438
+ break
439
+ if node is None:
440
+ await asyncio.sleep(0.2)
441
+ return node
442
+
443
+
444
+ async def _roam(tab: Any, pos: list[float], tokens: list[int]) -> None:
445
+ for delta in (80 + _RNG.random() * 60, -(50 + _RNG.random() * 40)):
446
+ await tab.send(
447
+ cdp_input.dispatch_mouse_event(
448
+ "mouseWheel", x=pos[0], y=pos[1], delta_x=0, delta_y=delta
449
+ )
450
+ )
451
+ await asyncio.sleep(0.1 + _RNG.random() * 0.08)
452
+ for _ in range(2):
453
+ if tokens[0] <= 20:
454
+ break
455
+ await _mj_travel(
456
+ tab,
457
+ pos,
458
+ tokens,
459
+ max(40.0, min(1200.0, pos[0] + (_RNG.random() * 220 - 110))),
460
+ max(40.0, min(680.0, pos[1] + (_RNG.random() * 160 - 80))),
461
+ )
462
+ await asyncio.sleep(0.08 + _RNG.random() * 0.08)
463
+
464
+
465
+ async def _trusted_press(tab: Any, ax: float, ay: float) -> bool:
466
+ await tab.send(
467
+ cdp_input.dispatch_mouse_event(
468
+ "mousePressed",
469
+ x=ax,
470
+ y=ay,
471
+ button=cdp_input.MouseButton("left"),
472
+ buttons=1,
473
+ click_count=1,
474
+ )
475
+ )
476
+ await asyncio.sleep(0.05 + _RNG.random() * 0.06)
477
+ await tab.send(
478
+ cdp_input.dispatch_mouse_event(
479
+ "mouseReleased",
480
+ x=ax,
481
+ y=ay,
482
+ button=cdp_input.MouseButton("left"),
483
+ buttons=1,
484
+ click_count=1,
485
+ )
486
+ )
487
+ end = time.monotonic() + 16.0
488
+ while time.monotonic() < end:
489
+ with suppress(Exception):
490
+ if await tab.evaluate("window.BV_SUBMITTED === true") is True:
491
+ return True
492
+ await asyncio.sleep(0.16)
493
+ return False
494
+
495
+
496
+ async def _click_xy(tab: Any, ax: float, ay: float) -> None:
497
+ await tab.send(
498
+ cdp_input.dispatch_mouse_event(
499
+ "mousePressed",
500
+ x=ax,
501
+ y=ay,
502
+ button=cdp_input.MouseButton("left"),
503
+ buttons=1,
504
+ click_count=1,
505
+ )
506
+ )
507
+ await asyncio.sleep(0.05 + _RNG.random() * 0.06)
508
+ await tab.send(
509
+ cdp_input.dispatch_mouse_event(
510
+ "mouseReleased",
511
+ x=ax,
512
+ y=ay,
513
+ button=cdp_input.MouseButton("left"),
514
+ buttons=1,
515
+ click_count=1,
516
+ )
517
+ )
518
+
519
+
520
+ async def _wait_simple_controls(tab: Any) -> tuple[Any, Any] | None:
521
+ field = None
522
+ button = None
523
+ limit = time.monotonic() + 45.0
524
+ while time.monotonic() < limit and (field is None or button is None):
525
+ # Public simplebot occasionally serves a 429 HTML page — reload until SPA loads.
526
+ with suppress(Exception):
527
+ marker = await tab.evaluate(
528
+ """(() => {
529
+ const t = document.title || '';
530
+ const h = (document.querySelector('h1') || {}).textContent || '';
531
+ return t + ' ' + h;
532
+ })()"""
533
+ )
534
+ if isinstance(marker, str) and "429" in marker:
535
+ await asyncio.sleep(2.0 + _RNG.random() * 1.5)
536
+ await tab.reload()
537
+ await asyncio.sleep(0.8)
538
+ continue
539
+ if field is None:
540
+ with suppress(Exception):
541
+ field = await tab.select("#user-identifier", timeout=1)
542
+ if button is None:
543
+ with suppress(Exception):
544
+ button = await tab.select(".verify-button", timeout=1)
545
+ if field is None or button is None:
546
+ await asyncio.sleep(0.25)
547
+ if field is None or button is None:
548
+ return None
549
+ return field, button
550
+
551
+
552
+ async def _node_center(tab: Any, node: Any) -> tuple[float, float] | None:
553
+ aim = await _button_xy(tab, node)
554
+ if aim is not None:
555
+ return aim
556
+ with suppress(Exception):
557
+ box = await node.get_position()
558
+ if box is not None:
559
+ return (
560
+ float(box.x) + float(box.width) * 0.5,
561
+ float(box.y) + float(box.height) * 0.5,
562
+ )
563
+ return None
564
+
565
+
566
+ async def _commit_identifier(tab: Any, identifier: str) -> bool:
567
+ """Set the React-controlled field in one update so auto-submit uses the full id."""
568
+ payload = json.dumps(identifier)
569
+ script = f"""(() => {{
570
+ const el = document.querySelector('#user-identifier');
571
+ if (!el) return false;
572
+ const desc = Object.getOwnPropertyDescriptor(
573
+ window.HTMLInputElement.prototype, 'value'
574
+ );
575
+ if (!desc || !desc.set) return false;
576
+ desc.set.call(el, {payload});
577
+ el.dispatchEvent(new Event('input', {{ bubbles: true }}));
578
+ el.dispatchEvent(new Event('change', {{ bubbles: true }}));
579
+ return el.value === {payload};
580
+ }})()"""
581
+ with suppress(Exception):
582
+ return await tab.evaluate(script) is True
583
+ return False
584
+
585
+
586
+ async def _await_simple_result(tab: Any, _identifier: str) -> bool:
587
+ """Pass only on the SPA Verified label. Do not GET /api/bot-status (rate limit)."""
588
+ end = time.monotonic() + 22.0
589
+ while time.monotonic() < end:
590
+ with suppress(Exception):
591
+ label = str(
592
+ await tab.evaluate(
593
+ "(document.querySelector('.status-label') || {}).textContent || ''"
594
+ )
595
+ or ""
596
+ )
597
+ if label.strip() == "Verified":
598
+ await asyncio.sleep(0.8)
599
+ return True
600
+ if "Bot detected" in label or label.strip() in {
601
+ "Verification unavailable",
602
+ "Timed out",
603
+ }:
604
+ return False
605
+ await asyncio.sleep(0.25)
606
+ return False
607
+
608
+
609
+ async def _simple_flow(tab: Any, identifier: str) -> bool:
610
+ """Move first, then commit the full id so Innerworks auto-submit is not partial."""
611
+ controls = await _wait_simple_controls(tab)
612
+ if controls is None:
613
+ return False
614
+ _field, button = controls
615
+
616
+ await asyncio.sleep(0.35 + _RNG.random() * 0.3)
617
+ tokens = [130]
618
+ pos = [120.0 + _RNG.random() * 280.0, 100.0 + _RNG.random() * 200.0]
619
+ await tab.send(cdp_input.dispatch_mouse_event("mouseMoved", x=pos[0], y=pos[1]))
620
+ tokens[0] -= 1
621
+ await _roam(tab, pos, tokens)
622
+
623
+ if not await _commit_identifier(tab, identifier):
624
+ return False
625
+ await asyncio.sleep(0.45 + _RNG.random() * 0.25)
626
+
627
+ label = ""
628
+ with suppress(Exception):
629
+ raw = await tab.evaluate(
630
+ "(document.querySelector('.status-label') || {}).textContent || ''"
631
+ )
632
+ label = str(raw or "")
633
+ if "Checking" not in label and "Verified" not in label:
634
+ baim = await _node_center(tab, button)
635
+ if baim is None:
636
+ return False
637
+ bx, by = baim
638
+ park_x = bx + (_RNG.random() * 50 - 25)
639
+ park_y = by + 28 + _RNG.random() * 40
640
+ await _mj_travel(
641
+ tab,
642
+ pos,
643
+ tokens,
644
+ max(36.0, min(1300.0, park_x)),
645
+ max(36.0, min(700.0, park_y)),
646
+ )
647
+ await asyncio.sleep(0.1 + _RNG.random() * 0.15)
648
+ await _mj_travel(tab, pos, tokens, bx, by)
649
+ await asyncio.sleep(0.18 + _RNG.random() * 0.2)
650
+ await _click_xy(tab, bx, by)
651
+ return await _await_simple_result(tab, identifier)
652
+
653
+
654
+ async def _verify_flow(tab: Any) -> bool:
655
+ """Scroll, minimum-jerk wander, then trusted press on the verify control."""
656
+ node = await _wait_verify(tab)
657
+ if node is None:
658
+ return False
659
+
660
+ await asyncio.sleep(0.45 + _RNG.random() * 0.25)
661
+ tokens = [88]
662
+ pos = [160.0 + _RNG.random() * 240.0, 140.0 + _RNG.random() * 160.0]
663
+ await tab.send(cdp_input.dispatch_mouse_event("mouseMoved", x=pos[0], y=pos[1]))
664
+ tokens[0] -= 1
665
+ await _roam(tab, pos, tokens)
666
+
667
+ aim = await _button_xy(tab, node)
668
+ if aim is None or aim[0] < 16 or aim[1] < 16:
669
+ return False
670
+ ax, ay = aim
671
+ park_x = ax + (_RNG.random() * 48 - 24)
672
+ park_y = ay + 24 + _RNG.random() * 36
673
+ await _mj_travel(
674
+ tab,
675
+ pos,
676
+ tokens,
677
+ max(36.0, min(1240.0, park_x)),
678
+ max(36.0, min(680.0, park_y)),
679
+ )
680
+ await asyncio.sleep(0.12 + _RNG.random() * 0.1)
681
+ await _mj_travel(tab, pos, tokens, ax, ay)
682
+ await asyncio.sleep(0.42 + _RNG.random() * 0.2)
683
+ await tab.send(cdp_input.dispatch_mouse_event("mouseMoved", x=ax, y=ay))
684
+ await asyncio.sleep(0.08 + _RNG.random() * 0.08)
685
+ return await _trusted_press(tab, ax, ay)
686
+
687
+
688
+ async def _start_browser(headed: bool) -> tuple[Any, Any, str]:
689
+ if headed:
690
+ _ensure_display()
691
+ profile = tempfile.mkdtemp(prefix="bv-chrome-")
692
+ flags = [
693
+ "--no-sandbox",
694
+ "--disable-dev-shm-usage",
695
+ "--ignore-certificate-errors",
696
+ "--lang=en-US",
697
+ "--window-size=1280,720",
698
+ "--disable-blink-features=AutomationControlled",
699
+ "--no-first-run",
700
+ "--no-default-browser-check",
701
+ ]
702
+ if headed:
703
+ flags.append("--disable-infobars")
704
+ opts: dict[str, Any] = {
705
+ "headless": not headed,
706
+ "lang": "en-US",
707
+ "browser_args": flags,
708
+ "sandbox": False,
709
+ "user_data_dir": profile,
710
+ }
711
+ binary = _pick_chrome()
712
+ if binary:
713
+ opts["browser_executable_path"] = binary
714
+ browser = await uc.start(**opts)
715
+ tab = await browser.get("about:blank")
716
+ with suppress(Exception):
717
+ await tab.send(cdp_page.enable())
718
+ return browser, tab, profile
719
+
720
+
721
+ async def _install_web_stealth(tab: Any) -> None:
722
+ """CDP UA plus late payload scrub. Do not wrap JSON.stringify."""
723
+ with suppress(Exception):
724
+ await tab.send(cdp_page.add_script_to_evaluate_on_new_document(_BOOT))
725
+ with suppress(Exception):
726
+ await tab.send(emulation.set_automation_override(enabled=False))
727
+ with suppress(Exception):
728
+ await tab.send(
729
+ network.set_user_agent_override(
730
+ user_agent=(
731
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
732
+ "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
733
+ ),
734
+ accept_language="en-US,en;q=0.9",
735
+ platform="Linux x86_64",
736
+ )
737
+ )
738
+
739
+
740
+ async def _one_pass(url: str) -> bool:
741
+ simple = _simple_target(url)
742
+ browser = None
743
+ profile = ""
744
+ try:
745
+ # Innerworks needs headed Xvfb. BV /_web stays headless so hasFocus stays true.
746
+ browser, tab, profile = await _start_browser(headed=bool(simple))
747
+ if not simple:
748
+ await _install_web_stealth(tab)
749
+ await tab.get(simple[0] if simple else url)
750
+ if simple:
751
+ ok = await _simple_flow(tab, simple[1])
752
+ else:
753
+ ok = await _verify_flow(tab)
754
+ with suppress(Exception):
755
+ await tab.get("about:blank")
756
+ await asyncio.sleep(0.08)
757
+ return ok
758
+ except Exception:
759
+ return False
760
+ finally:
761
+ if browser is not None:
762
+ await _hard_close(browser, profile)
763
+ elif profile and os.path.isdir(profile):
764
+ with suppress(Exception):
765
+ shutil.rmtree(profile, ignore_errors=True)
766
+
767
+
768
+ async def _session(url: str) -> bool:
769
+ """Run once, soft-retry once on miss (keeps container exit code healthy)."""
770
+ if await _one_pass(url):
771
+ return True
772
+ await asyncio.sleep(0.35 + _RNG.random() * 0.25)
773
+ return await _one_pass(url)
774
+
775
+
776
+ def run_bot(url: str) -> bool:
777
+ """Complete one challenge verification session."""
778
+ try:
779
+ loop = asyncio.new_event_loop()
780
+ try:
781
+ asyncio.set_event_loop(loop)
782
+ return bool(loop.run_until_complete(_session(url)))
783
+ finally:
784
+ with suppress(Exception):
785
+ # drain pending stop/close tasks so Chromium actually dies
786
+ pending = asyncio.all_tasks(loop)
787
+ for task in pending:
788
+ task.cancel()
789
+ if pending:
790
+ loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
791
+ loop.run_until_complete(asyncio.sleep(0.05))
792
+ loop.close()
793
+ asyncio.set_event_loop(None)
794
+ except Exception:
795
+ return False
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: nodriver-stealth
3
+ Version: 0.1.0
4
+ Summary: Nodriver stealth session runner for BV challenge bots
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: nodriver
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/nodriver_stealth/__init__.py
4
+ src/nodriver_stealth/engine.py
5
+ src/nodriver_stealth.egg-info/PKG-INFO
6
+ src/nodriver_stealth.egg-info/SOURCES.txt
7
+ src/nodriver_stealth.egg-info/dependency_links.txt
8
+ src/nodriver_stealth.egg-info/requires.txt
9
+ src/nodriver_stealth.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ nodriver_stealth