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.
- nodriver_stealth-0.1.0/PKG-INFO +6 -0
- nodriver_stealth-0.1.0/README.md +29 -0
- nodriver_stealth-0.1.0/pyproject.toml +16 -0
- nodriver_stealth-0.1.0/setup.cfg +4 -0
- nodriver_stealth-0.1.0/src/nodriver_stealth/__init__.py +6 -0
- nodriver_stealth-0.1.0/src/nodriver_stealth/engine.py +795 -0
- nodriver_stealth-0.1.0/src/nodriver_stealth.egg-info/PKG-INFO +6 -0
- nodriver_stealth-0.1.0/src/nodriver_stealth.egg-info/SOURCES.txt +9 -0
- nodriver_stealth-0.1.0/src/nodriver_stealth.egg-info/dependency_links.txt +1 -0
- nodriver_stealth-0.1.0/src/nodriver_stealth.egg-info/requires.txt +1 -0
- nodriver_stealth-0.1.0/src/nodriver_stealth.egg-info/top_level.txt +1 -0
|
@@ -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,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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodriver
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodriver_stealth
|