grewbrowser 0.1.0__py3-none-any.whl
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.
- grewbrowser/__init__.py +101 -0
- grewbrowser/__main__.py +248 -0
- grewbrowser/_driver.py +61 -0
- grewbrowser/behavior/__init__.py +43 -0
- grewbrowser/behavior/config.py +197 -0
- grewbrowser/behavior/curves.py +266 -0
- grewbrowser/behavior/human.py +657 -0
- grewbrowser/behavior/keyboard.py +199 -0
- grewbrowser/behavior/world.py +264 -0
- grewbrowser/core/__init__.py +69 -0
- grewbrowser/core/binary_manager.py +382 -0
- grewbrowser/core/download.py +548 -0
- grewbrowser/core/flags.py +253 -0
- grewbrowser/core/profile.py +407 -0
- grewbrowser/diagnostics.py +617 -0
- grewbrowser/exceptions.py +111 -0
- grewbrowser/fingerprint/__init__.py +21 -0
- grewbrowser/fingerprint/applier.py +206 -0
- grewbrowser/fingerprint/scripts.py +483 -0
- grewbrowser/launcher.py +951 -0
- grewbrowser/network/__init__.py +20 -0
- grewbrowser/network/geoip.py +379 -0
- grewbrowser/network/proxy.py +144 -0
- grewbrowser/network/relay.py +419 -0
- grewbrowser/session/__init__.py +17 -0
- grewbrowser/session/pool.py +343 -0
- grewbrowser/session/teardown.py +317 -0
- grewbrowser-0.1.0.dist-info/METADATA +531 -0
- grewbrowser-0.1.0.dist-info/RECORD +32 -0
- grewbrowser-0.1.0.dist-info/WHEEL +4 -0
- grewbrowser-0.1.0.dist-info/entry_points.txt +2 -0
- grewbrowser-0.1.0.dist-info/licenses/LICENSE +21 -0
grewbrowser/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""grewbrowser — a stealth Playwright launcher for Chromium."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .behavior.config import HumanConfig, resolve_config
|
|
6
|
+
from .behavior.human import humanize_context, humanize_context_async, install
|
|
7
|
+
from .core.binary_manager import ChromiumBinary, binary_info, resolve_binary
|
|
8
|
+
from .core.download import (
|
|
9
|
+
InstalledBuild,
|
|
10
|
+
ensure_chromium,
|
|
11
|
+
installed_builds,
|
|
12
|
+
cache_dir as browser_cache_dir,
|
|
13
|
+
)
|
|
14
|
+
from .diagnostics import Check, evaluate_checks, observe, run_checks
|
|
15
|
+
from .core.flags import IGNORE_DEFAULT_ARGS, build_args, build_stealth_args
|
|
16
|
+
from .core.profile import BrowserProfile, ScreenProfile, random_seed
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
ActionabilityError,
|
|
19
|
+
BinaryNotFoundError,
|
|
20
|
+
FingerprintError,
|
|
21
|
+
GeoIPError,
|
|
22
|
+
GrewBrowserError,
|
|
23
|
+
IsolatedWorldError,
|
|
24
|
+
LaunchError,
|
|
25
|
+
PoolClosedError,
|
|
26
|
+
ProxyConfigError,
|
|
27
|
+
RelayError,
|
|
28
|
+
)
|
|
29
|
+
from .fingerprint.scripts import build_fingerprint_script
|
|
30
|
+
from .launcher import (
|
|
31
|
+
AsyncBrowserSession,
|
|
32
|
+
BrowserSession,
|
|
33
|
+
launch,
|
|
34
|
+
launch_async,
|
|
35
|
+
launch_persistent_context,
|
|
36
|
+
launch_persistent_context_async,
|
|
37
|
+
)
|
|
38
|
+
from .network.geoip import GeoIdentity, resolve_exit_ip, resolve_geo
|
|
39
|
+
from .network.proxy import ProxyConfig, parse_proxy
|
|
40
|
+
from .network.relay import AuthRelay, RelayHandle, start_relay, start_relay_async
|
|
41
|
+
from .session.pool import BrowserPool, SyncBrowserPool
|
|
42
|
+
from .session.teardown import TeardownGuard, global_guard, guard
|
|
43
|
+
|
|
44
|
+
__version__ = "0.1.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"launch",
|
|
48
|
+
"launch_async",
|
|
49
|
+
"launch_persistent_context",
|
|
50
|
+
"launch_persistent_context_async",
|
|
51
|
+
"BrowserSession",
|
|
52
|
+
"AsyncBrowserSession",
|
|
53
|
+
"BrowserProfile",
|
|
54
|
+
"ScreenProfile",
|
|
55
|
+
"random_seed",
|
|
56
|
+
"build_stealth_args",
|
|
57
|
+
"build_args",
|
|
58
|
+
"IGNORE_DEFAULT_ARGS",
|
|
59
|
+
"build_fingerprint_script",
|
|
60
|
+
"ChromiumBinary",
|
|
61
|
+
"resolve_binary",
|
|
62
|
+
"binary_info",
|
|
63
|
+
"ensure_chromium",
|
|
64
|
+
"installed_builds",
|
|
65
|
+
"InstalledBuild",
|
|
66
|
+
"browser_cache_dir",
|
|
67
|
+
"run_checks",
|
|
68
|
+
"evaluate_checks",
|
|
69
|
+
"observe",
|
|
70
|
+
"Check",
|
|
71
|
+
"HumanConfig",
|
|
72
|
+
"resolve_config",
|
|
73
|
+
"humanize_context",
|
|
74
|
+
"humanize_context_async",
|
|
75
|
+
"install",
|
|
76
|
+
"ProxyConfig",
|
|
77
|
+
"parse_proxy",
|
|
78
|
+
"GeoIdentity",
|
|
79
|
+
"resolve_geo",
|
|
80
|
+
"resolve_exit_ip",
|
|
81
|
+
"AuthRelay",
|
|
82
|
+
"RelayHandle",
|
|
83
|
+
"start_relay",
|
|
84
|
+
"start_relay_async",
|
|
85
|
+
"BrowserPool",
|
|
86
|
+
"SyncBrowserPool",
|
|
87
|
+
"TeardownGuard",
|
|
88
|
+
"guard",
|
|
89
|
+
"global_guard",
|
|
90
|
+
"GrewBrowserError",
|
|
91
|
+
"BinaryNotFoundError",
|
|
92
|
+
"LaunchError",
|
|
93
|
+
"ProxyConfigError",
|
|
94
|
+
"RelayError",
|
|
95
|
+
"GeoIPError",
|
|
96
|
+
"FingerprintError",
|
|
97
|
+
"IsolatedWorldError",
|
|
98
|
+
"ActionabilityError",
|
|
99
|
+
"PoolClosedError",
|
|
100
|
+
"__version__",
|
|
101
|
+
]
|
grewbrowser/__main__.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Command line interface: ``python -m grewbrowser <command>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .core.binary_manager import binary_info, clear_cache
|
|
12
|
+
from .core.download import (
|
|
13
|
+
cache_dir,
|
|
14
|
+
clear_downloads,
|
|
15
|
+
ensure_chromium,
|
|
16
|
+
installed_builds,
|
|
17
|
+
platform_tag,
|
|
18
|
+
remove_build,
|
|
19
|
+
verify_build,
|
|
20
|
+
)
|
|
21
|
+
from .exceptions import GrewBrowserError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _install(args: argparse.Namespace) -> int:
|
|
25
|
+
"""Download grewbrowser's own browser build."""
|
|
26
|
+
build = ensure_chromium(args.version, source=args.source, force=args.force)
|
|
27
|
+
print(f"installed {build.version} ({build.source})")
|
|
28
|
+
print(f" executable: {build.executable}")
|
|
29
|
+
print(f" sha256 : {build.archive_sha256}")
|
|
30
|
+
print("\nIt is now the default for channel='auto', so launch() needs no arguments.")
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _list(args: argparse.Namespace) -> int:
|
|
35
|
+
"""List cached builds."""
|
|
36
|
+
builds = installed_builds()
|
|
37
|
+
print(f"cache: {cache_dir()} (platform {platform_tag()})")
|
|
38
|
+
if not builds:
|
|
39
|
+
print(" no builds yet -- run 'python -m grewbrowser install'")
|
|
40
|
+
return 0
|
|
41
|
+
for build in builds:
|
|
42
|
+
state = "ok" if verify_build(build) else "BROKEN"
|
|
43
|
+
print(f" {build.version:<20} {state:<7} {build.source:<20} {build.executable}")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _info(args: argparse.Namespace) -> int:
|
|
48
|
+
"""Report which browser a launch would resolve to."""
|
|
49
|
+
clear_cache()
|
|
50
|
+
for key, value in binary_info(channel=args.channel).items():
|
|
51
|
+
print(f"{key:9} {value}")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _open(args: argparse.Namespace) -> int:
|
|
56
|
+
"""Open a URL in a real window, to eyeball the result."""
|
|
57
|
+
from . import launch
|
|
58
|
+
|
|
59
|
+
with launch(headless=args.headless, channel=args.channel, geoip=False) as session:
|
|
60
|
+
print(f"browser : {session.binary.channel} {session.binary.version}")
|
|
61
|
+
print(
|
|
62
|
+
f"identity: seed={session.seed} {session.profile.os_family} "
|
|
63
|
+
f"{session.profile.locale} {session.profile.timezone_id}"
|
|
64
|
+
)
|
|
65
|
+
page = session.new_page()
|
|
66
|
+
page.goto(args.url, wait_until="domcontentloaded")
|
|
67
|
+
print(f"title : {page.title()}")
|
|
68
|
+
if not args.headless:
|
|
69
|
+
input("Press Enter to close...")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check(args: argparse.Namespace) -> int:
|
|
74
|
+
"""Run the self-checks and compare headless against headful."""
|
|
75
|
+
from .diagnostics import run_checks
|
|
76
|
+
|
|
77
|
+
modes = {"both": (True, False), "headless": (True,), "headful": (False,)}[args.mode]
|
|
78
|
+
results: dict[bool, list] = {}
|
|
79
|
+
for headless in modes:
|
|
80
|
+
label = "headless" if headless else "headful"
|
|
81
|
+
print(f"running {label} ...", flush=True)
|
|
82
|
+
profile, checks = run_checks(
|
|
83
|
+
headless=headless,
|
|
84
|
+
url=args.url,
|
|
85
|
+
channel=args.channel,
|
|
86
|
+
seed=args.seed,
|
|
87
|
+
include_behaviour=not args.fast,
|
|
88
|
+
)
|
|
89
|
+
results[headless] = checks
|
|
90
|
+
print(
|
|
91
|
+
f" identity: seed={profile.seed} {profile.os_family} "
|
|
92
|
+
f"Chrome/{profile.chrome_major} {profile.screen.width}x{profile.screen.height}\n"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
width = max(len(check.name) for checks in results.values() for check in checks)
|
|
96
|
+
header = f"{'check'.ljust(width)} " + " ".join(
|
|
97
|
+
("headless" if headless else "headful ") for headless in modes
|
|
98
|
+
)
|
|
99
|
+
print(header)
|
|
100
|
+
print("-" * len(header))
|
|
101
|
+
|
|
102
|
+
core_failures = 0
|
|
103
|
+
env_failures = 0
|
|
104
|
+
differences = 0
|
|
105
|
+
annotations: list[str] = []
|
|
106
|
+
by_name: dict[str, dict[bool, Any]] = {}
|
|
107
|
+
order: list[str] = []
|
|
108
|
+
for headless, checks in results.items():
|
|
109
|
+
for check in checks:
|
|
110
|
+
if check.name not in by_name:
|
|
111
|
+
order.append(check.name)
|
|
112
|
+
by_name.setdefault(check.name, {})[headless] = check
|
|
113
|
+
|
|
114
|
+
def render(tier: str) -> None:
|
|
115
|
+
nonlocal core_failures, env_failures, differences
|
|
116
|
+
printed = False
|
|
117
|
+
for name in order:
|
|
118
|
+
per_mode = by_name[name]
|
|
119
|
+
sample = next(iter(per_mode.values()))
|
|
120
|
+
if sample.tier != tier:
|
|
121
|
+
continue
|
|
122
|
+
if not printed:
|
|
123
|
+
heading = "coherence" if tier == "core" else "this machine"
|
|
124
|
+
print(f"\n[{heading}]")
|
|
125
|
+
printed = True
|
|
126
|
+
cells = []
|
|
127
|
+
verdicts = set()
|
|
128
|
+
for headless in modes:
|
|
129
|
+
check = per_mode.get(headless)
|
|
130
|
+
if check is None:
|
|
131
|
+
cells.append(" -- ")
|
|
132
|
+
continue
|
|
133
|
+
verdicts.add(check.ok)
|
|
134
|
+
if not check.ok:
|
|
135
|
+
if tier == "core":
|
|
136
|
+
core_failures += 1
|
|
137
|
+
else:
|
|
138
|
+
env_failures += 1
|
|
139
|
+
cells.append(
|
|
140
|
+
" pass " if check.ok else (" FAIL " if tier == "core" else " n/a ")
|
|
141
|
+
)
|
|
142
|
+
differing = len(verdicts) > 1
|
|
143
|
+
differences += 1 if differing else 0
|
|
144
|
+
marker = " <- differs" if differing else ""
|
|
145
|
+
print(f"{name.ljust(width)} " + " ".join(cells) + marker)
|
|
146
|
+
if not all(verdicts):
|
|
147
|
+
for headless in modes:
|
|
148
|
+
check = per_mode.get(headless)
|
|
149
|
+
if check is not None and not check.ok:
|
|
150
|
+
label = "headless" if headless else "headful"
|
|
151
|
+
print(f"{' '.ljust(width)} {label}: {check.detail}")
|
|
152
|
+
if tier == "core":
|
|
153
|
+
annotations.append(f"{name} [{label}]: {check.detail}")
|
|
154
|
+
|
|
155
|
+
render("core")
|
|
156
|
+
render("environment")
|
|
157
|
+
|
|
158
|
+
total = sum(len(checks) for checks in results.values())
|
|
159
|
+
passed = total - core_failures - env_failures
|
|
160
|
+
print(f"\n{passed}/{total} checks passed")
|
|
161
|
+
if env_failures:
|
|
162
|
+
print(
|
|
163
|
+
f"{env_failures} depend on the host, not on grewbrowser "
|
|
164
|
+
"(no GPU, missing fonts, no speech voices on a CI box)"
|
|
165
|
+
)
|
|
166
|
+
if len(modes) > 1:
|
|
167
|
+
print(
|
|
168
|
+
f"{differences} check(s) differ between headless and headful"
|
|
169
|
+
if differences
|
|
170
|
+
else "headless and headful agree on every check"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if annotations and os.environ.get("GITHUB_ACTIONS") == "true":
|
|
174
|
+
for line in annotations:
|
|
175
|
+
print(f"::error title=grewbrowser check::{line}")
|
|
176
|
+
|
|
177
|
+
return 1 if core_failures else 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _clean(args: argparse.Namespace) -> int:
|
|
181
|
+
"""Delete cached builds."""
|
|
182
|
+
if args.version:
|
|
183
|
+
removed = remove_build(args.version)
|
|
184
|
+
print(f"removed {args.version}" if removed else f"no cached build {args.version}")
|
|
185
|
+
return 0 if removed else 1
|
|
186
|
+
count = clear_downloads()
|
|
187
|
+
print(f"removed {count} build(s) from {cache_dir()}")
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: list[str] | None = None) -> int:
|
|
192
|
+
"""Parse arguments and run the chosen command."""
|
|
193
|
+
parser = argparse.ArgumentParser(prog="python -m grewbrowser")
|
|
194
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="suppress progress logs")
|
|
195
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
196
|
+
|
|
197
|
+
install = commands.add_parser("install", help="download grewbrowser's own Chromium")
|
|
198
|
+
install.add_argument("--version", default=None, help="exact version or snapshot revision")
|
|
199
|
+
install.add_argument(
|
|
200
|
+
"--source",
|
|
201
|
+
default="chrome-for-testing",
|
|
202
|
+
choices=("chrome-for-testing", "chromium-snapshots"),
|
|
203
|
+
)
|
|
204
|
+
install.add_argument("--force", action="store_true", help="re-download even if cached")
|
|
205
|
+
install.set_defaults(handler=_install)
|
|
206
|
+
|
|
207
|
+
listing = commands.add_parser("list", help="list cached builds")
|
|
208
|
+
listing.set_defaults(handler=_list)
|
|
209
|
+
|
|
210
|
+
info = commands.add_parser("info", help="show which browser a launch would use")
|
|
211
|
+
info.add_argument("--channel", default="auto")
|
|
212
|
+
info.set_defaults(handler=_info)
|
|
213
|
+
|
|
214
|
+
opener = commands.add_parser("open", help="open a URL in a real window")
|
|
215
|
+
opener.add_argument("url", nargs="?", default="https://example.com")
|
|
216
|
+
opener.add_argument("--channel", default="auto")
|
|
217
|
+
opener.add_argument("--headless", action="store_true")
|
|
218
|
+
opener.set_defaults(handler=_open)
|
|
219
|
+
|
|
220
|
+
check = commands.add_parser("check", help="self-check, headless vs headful")
|
|
221
|
+
check.add_argument("--mode", default="both", choices=("both", "headless", "headful"))
|
|
222
|
+
check.add_argument("--url", default=None, help="run the probes on this page")
|
|
223
|
+
check.add_argument("--channel", default="auto")
|
|
224
|
+
check.add_argument("--seed", type=int, default=None, help="pin the identity")
|
|
225
|
+
check.add_argument("--fast", action="store_true", help="skip the input-behaviour probes")
|
|
226
|
+
check.set_defaults(handler=_check)
|
|
227
|
+
|
|
228
|
+
clean = commands.add_parser("clean", help="delete cached builds")
|
|
229
|
+
clean.add_argument("--version", default=None, help="delete only this version")
|
|
230
|
+
clean.set_defaults(handler=_clean)
|
|
231
|
+
|
|
232
|
+
args = parser.parse_args(argv)
|
|
233
|
+
logging.basicConfig(
|
|
234
|
+
level=logging.WARNING if args.quiet else logging.INFO,
|
|
235
|
+
format="%(message)s",
|
|
236
|
+
)
|
|
237
|
+
try:
|
|
238
|
+
return int(args.handler(args))
|
|
239
|
+
except GrewBrowserError as exc:
|
|
240
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
241
|
+
return 1
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
print("\ninterrupted", file=sys.stderr)
|
|
244
|
+
return 130
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
raise SystemExit(main())
|
grewbrowser/_driver.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared Playwright drivers, reference counted per thread."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
__all__ = ["acquire_sync", "release_sync", "active_sync_drivers"]
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("grewbrowser")
|
|
13
|
+
|
|
14
|
+
_local = threading.local()
|
|
15
|
+
_counter_lock = threading.Lock()
|
|
16
|
+
_live = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class _Driver:
|
|
21
|
+
playwright: Any
|
|
22
|
+
refcount: int = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def acquire_sync() -> Any:
|
|
26
|
+
"""Return this thread's Playwright driver, starting it on first use."""
|
|
27
|
+
global _live
|
|
28
|
+
driver = getattr(_local, "driver", None)
|
|
29
|
+
if driver is None:
|
|
30
|
+
from playwright.sync_api import sync_playwright
|
|
31
|
+
|
|
32
|
+
driver = _Driver(sync_playwright().start())
|
|
33
|
+
_local.driver = driver
|
|
34
|
+
with _counter_lock:
|
|
35
|
+
_live += 1
|
|
36
|
+
driver.refcount += 1
|
|
37
|
+
return driver.playwright
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def release_sync() -> None:
|
|
41
|
+
"""Give back a driver reference, stopping it when the last one goes."""
|
|
42
|
+
global _live
|
|
43
|
+
driver = getattr(_local, "driver", None)
|
|
44
|
+
if driver is None:
|
|
45
|
+
return
|
|
46
|
+
driver.refcount -= 1
|
|
47
|
+
if driver.refcount > 0:
|
|
48
|
+
return
|
|
49
|
+
_local.driver = None
|
|
50
|
+
with _counter_lock:
|
|
51
|
+
_live -= 1
|
|
52
|
+
try:
|
|
53
|
+
driver.playwright.stop()
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
logger.debug("Stopping the Playwright driver failed: %s", exc)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def active_sync_drivers() -> int:
|
|
59
|
+
"""How many threads currently hold a started driver."""
|
|
60
|
+
with _counter_lock:
|
|
61
|
+
return _live
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Human-like interaction primitives that replace Playwright's own."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .config import HumanConfig, Preset, Range, resolve_config
|
|
6
|
+
from .curves import MoveStep, Point, WheelStep, plan_move, plan_stroke, plan_wheel
|
|
7
|
+
from .human import (
|
|
8
|
+
AsyncHumanEngine,
|
|
9
|
+
HumanEngine,
|
|
10
|
+
engine_for,
|
|
11
|
+
humanize_context,
|
|
12
|
+
humanize_context_async,
|
|
13
|
+
humanize_page,
|
|
14
|
+
humanize_page_async,
|
|
15
|
+
install,
|
|
16
|
+
)
|
|
17
|
+
from .keyboard import KeyEvent, plan_typing
|
|
18
|
+
from .world import AsyncIsolatedWorld, IsolatedWorld
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"HumanConfig",
|
|
22
|
+
"Preset",
|
|
23
|
+
"Range",
|
|
24
|
+
"resolve_config",
|
|
25
|
+
"Point",
|
|
26
|
+
"MoveStep",
|
|
27
|
+
"WheelStep",
|
|
28
|
+
"plan_move",
|
|
29
|
+
"plan_stroke",
|
|
30
|
+
"plan_wheel",
|
|
31
|
+
"KeyEvent",
|
|
32
|
+
"plan_typing",
|
|
33
|
+
"HumanEngine",
|
|
34
|
+
"AsyncHumanEngine",
|
|
35
|
+
"humanize_page",
|
|
36
|
+
"humanize_page_async",
|
|
37
|
+
"humanize_context",
|
|
38
|
+
"humanize_context_async",
|
|
39
|
+
"engine_for",
|
|
40
|
+
"install",
|
|
41
|
+
"IsolatedWorld",
|
|
42
|
+
"AsyncIsolatedWorld",
|
|
43
|
+
]
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Tunable parameters for the human-behaviour layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, fields, replace
|
|
9
|
+
from typing import Final, Literal
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Range",
|
|
13
|
+
"HumanConfig",
|
|
14
|
+
"Preset",
|
|
15
|
+
"resolve_config",
|
|
16
|
+
"rand",
|
|
17
|
+
"rand_range",
|
|
18
|
+
"rand_int_range",
|
|
19
|
+
"rand_lognormal",
|
|
20
|
+
"sleep_ms",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
Range = tuple[float, float]
|
|
24
|
+
|
|
25
|
+
Preset = Literal["default", "careful", "brisk"]
|
|
26
|
+
|
|
27
|
+
FRAME_MS: Final[float] = 1000.0 / 60.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class HumanConfig:
|
|
32
|
+
"""Every timing knob of the behaviour layer.
|
|
33
|
+
All durations are milliseconds. ``Range`` values are sampled log-normally
|
|
34
|
+
between roughly the 5th and 95th percentile, so the occasional long pause
|
|
35
|
+
appears without the mean drifting.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
typing_delay: float = 150.0
|
|
39
|
+
typing_delay_spread: float = 70.0
|
|
40
|
+
key_hold: Range = (60.0, 175.0)
|
|
41
|
+
word_pause_chance: float = 0.15
|
|
42
|
+
word_pause: Range = (300.0, 1000.0)
|
|
43
|
+
shift_down_delay: Range = (60.0, 220.0)
|
|
44
|
+
shift_up_delay: Range = (-60.0, 30.0)
|
|
45
|
+
mistype_chance: float = 0.04
|
|
46
|
+
mistype_notice_delay: Range = (100.0, 300.0)
|
|
47
|
+
mistype_correct_delay: Range = (50.0, 150.0)
|
|
48
|
+
field_switch_delay: Range = (800.0, 1500.0)
|
|
49
|
+
|
|
50
|
+
move_time_base: float = 160.0
|
|
51
|
+
move_time_per_bit: float = 75.0
|
|
52
|
+
move_time_spread: float = 0.3
|
|
53
|
+
move_min_steps: int = 2
|
|
54
|
+
move_max_steps: int = 200
|
|
55
|
+
wobble_max: float = 1.5
|
|
56
|
+
overshoot_chance: float = 0.35
|
|
57
|
+
overshoot_px: Range = (2.0, 6.0)
|
|
58
|
+
|
|
59
|
+
aim_delay_input: Range = (60.0, 140.0)
|
|
60
|
+
aim_delay_button: Range = (80.0, 200.0)
|
|
61
|
+
hold_input: Range = (40.0, 100.0)
|
|
62
|
+
hold_button: Range = (60.0, 150.0)
|
|
63
|
+
input_x_fraction: Range = (0.05, 0.30)
|
|
64
|
+
|
|
65
|
+
notch_px: float = 0.0
|
|
66
|
+
notch_pause_fast: Range = (30.0, 80.0)
|
|
67
|
+
notch_pause_slow: Range = (80.0, 200.0)
|
|
68
|
+
accel_notches: Range = (2, 3)
|
|
69
|
+
decel_notches: Range = (2, 3)
|
|
70
|
+
scroll_overshoot_chance: float = 0.10
|
|
71
|
+
scroll_overshoot_px: Range = (50.0, 150.0)
|
|
72
|
+
scroll_settle_delay: Range = (300.0, 600.0)
|
|
73
|
+
|
|
74
|
+
idle_drift_px: float = 3.0
|
|
75
|
+
idle_pause: Range = (300.0, 1000.0)
|
|
76
|
+
idle_between_actions: bool = False
|
|
77
|
+
idle_between_duration: Range = (0.3, 0.8)
|
|
78
|
+
|
|
79
|
+
action_timeout: float = 30_000.0
|
|
80
|
+
poll_interval: float = 50.0
|
|
81
|
+
stability_epsilon: float = 1.0
|
|
82
|
+
|
|
83
|
+
initial_cursor_x: Range = (400.0, 700.0)
|
|
84
|
+
initial_cursor_y: Range = (45.0, 60.0)
|
|
85
|
+
|
|
86
|
+
def merged(self, **overrides: object) -> HumanConfig:
|
|
87
|
+
"""Return a copy with *overrides* applied.
|
|
88
|
+
Raises:
|
|
89
|
+
TypeError: If an override does not name a configuration field.
|
|
90
|
+
"""
|
|
91
|
+
known = {f.name for f in fields(self)}
|
|
92
|
+
unknown = set(overrides) - known
|
|
93
|
+
if unknown:
|
|
94
|
+
raise TypeError(f"Unknown HumanConfig field(s): {', '.join(sorted(unknown))}")
|
|
95
|
+
return replace(self, **overrides)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_CAREFUL = HumanConfig(
|
|
99
|
+
typing_delay=210.0,
|
|
100
|
+
typing_delay_spread=95.0,
|
|
101
|
+
word_pause_chance=0.25,
|
|
102
|
+
word_pause=(400.0, 1600.0),
|
|
103
|
+
mistype_chance=0.06,
|
|
104
|
+
move_time_base=220.0,
|
|
105
|
+
move_time_per_bit=95.0,
|
|
106
|
+
overshoot_chance=0.45,
|
|
107
|
+
aim_delay_input=(90.0, 220.0),
|
|
108
|
+
aim_delay_button=(120.0, 300.0),
|
|
109
|
+
idle_between_actions=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
_BRISK = HumanConfig(
|
|
113
|
+
typing_delay=105.0,
|
|
114
|
+
typing_delay_spread=45.0,
|
|
115
|
+
word_pause_chance=0.08,
|
|
116
|
+
mistype_chance=0.02,
|
|
117
|
+
move_time_base=120.0,
|
|
118
|
+
move_time_per_bit=55.0,
|
|
119
|
+
overshoot_chance=0.25,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
_PRESETS: Final[dict[str, HumanConfig]] = {
|
|
123
|
+
"default": HumanConfig(),
|
|
124
|
+
"careful": _CAREFUL,
|
|
125
|
+
"brisk": _BRISK,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_config(
|
|
130
|
+
humanize: bool | str | HumanConfig | dict[str, object] | None,
|
|
131
|
+
) -> HumanConfig | None:
|
|
132
|
+
"""Normalise the ``humanize=`` argument of the launcher.
|
|
133
|
+
Args:
|
|
134
|
+
humanize: ``True``/``None`` for the default preset, ``False`` to
|
|
135
|
+
disable the layer, a preset name, a ready
|
|
136
|
+
:class:`HumanConfig`, or a mapping of field overrides applied on
|
|
137
|
+
top of the default preset.
|
|
138
|
+
Returns:
|
|
139
|
+
The resolved configuration, or ``None`` when the layer is disabled.
|
|
140
|
+
Raises:
|
|
141
|
+
ValueError: If a preset name is unknown.
|
|
142
|
+
TypeError: If the value is of an unsupported type, or a mapping names
|
|
143
|
+
an unknown field.
|
|
144
|
+
"""
|
|
145
|
+
if humanize is False:
|
|
146
|
+
return None
|
|
147
|
+
if humanize is None or humanize is True:
|
|
148
|
+
return _PRESETS["default"]
|
|
149
|
+
if isinstance(humanize, HumanConfig):
|
|
150
|
+
return humanize
|
|
151
|
+
if isinstance(humanize, str):
|
|
152
|
+
try:
|
|
153
|
+
return _PRESETS[humanize]
|
|
154
|
+
except KeyError:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"Unknown humanize preset {humanize!r}; "
|
|
157
|
+
f"choose from {', '.join(sorted(_PRESETS))}"
|
|
158
|
+
) from None
|
|
159
|
+
if isinstance(humanize, dict):
|
|
160
|
+
return _PRESETS["default"].merged(**humanize)
|
|
161
|
+
raise TypeError(f"Unsupported humanize value of type {type(humanize).__name__}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def rand(low: float, high: float) -> float:
|
|
165
|
+
"""Uniform sample in ``[low, high]``."""
|
|
166
|
+
return random.uniform(low, high)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def rand_range(span: Range) -> float:
|
|
170
|
+
"""Log-normal sample whose 5th/95th percentiles are roughly *span*.
|
|
171
|
+
A uniform sample over a human delay range produces a flat distribution no
|
|
172
|
+
person generates; the log-normal shape gives the characteristic long tail.
|
|
173
|
+
Ranges that cross zero (such as ``shift_up_delay``) fall back to uniform.
|
|
174
|
+
"""
|
|
175
|
+
low, high = span
|
|
176
|
+
if low <= 0 or high <= low:
|
|
177
|
+
return random.uniform(low, high)
|
|
178
|
+
median = math.sqrt(low * high)
|
|
179
|
+
sigma = math.log(high / low) / 3.29
|
|
180
|
+
return min(high * 1.5, median * math.exp(random.gauss(0.0, sigma)))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def rand_int_range(span: Range) -> int:
|
|
184
|
+
"""Integer sample in ``[low, high]``, inclusive."""
|
|
185
|
+
low, high = span
|
|
186
|
+
return random.randint(int(low), int(high))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def rand_lognormal(median: float, sigma: float) -> float:
|
|
190
|
+
"""Log-normal sample around *median*."""
|
|
191
|
+
return median * math.exp(random.gauss(0.0, sigma))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def sleep_ms(milliseconds: float) -> None:
|
|
195
|
+
"""Block for *milliseconds*, ignoring non-positive values."""
|
|
196
|
+
if milliseconds > 0:
|
|
197
|
+
time.sleep(milliseconds / 1000.0)
|