hexium-browser 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.
- hexium_browser/__init__.py +50 -0
- hexium_browser/__main__.py +289 -0
- hexium_browser/_version.py +1 -0
- hexium_browser/artifacts.py +97 -0
- hexium_browser/browser.py +1780 -0
- hexium_browser/config.py +459 -0
- hexium_browser/download.py +280 -0
- hexium_browser/geoip.py +418 -0
- hexium_browser/human/__init__.py +3231 -0
- hexium_browser/human/actionability.py +433 -0
- hexium_browser/human/actionability_async.py +316 -0
- hexium_browser/human/config.py +273 -0
- hexium_browser/human/cursor_overlay.py +188 -0
- hexium_browser/human/keyboard.py +189 -0
- hexium_browser/human/keyboard_async.py +150 -0
- hexium_browser/human/mouse.py +331 -0
- hexium_browser/human/mouse_async.py +59 -0
- hexium_browser/human/scroll.py +205 -0
- hexium_browser/human/scroll_async.py +202 -0
- hexium_browser/human/stealth_dom.py +219 -0
- hexium_browser/persona/__init__.py +40 -0
- hexium_browser/persona/coerce.py +258 -0
- hexium_browser/persona/data/browser-helper-file.json +1 -0
- hexium_browser/persona/data/fingerprint-network-definition.zip +0 -0
- hexium_browser/persona/data/header-network-definition.zip +0 -0
- hexium_browser/persona/data/headers-order.json +164 -0
- hexium_browser/persona/data/input-network-definition.zip +0 -0
- hexium_browser/persona/data/unpacked/README.md +15 -0
- hexium_browser/persona/data/windows-fonts.conf +254 -0
- hexium_browser/persona/datapack.py +254 -0
- hexium_browser/persona/fonts.py +116 -0
- hexium_browser/persona/geo_overlay.py +47 -0
- hexium_browser/persona/rewrite_ua.py +68 -0
- hexium_browser/persona/sample.py +165 -0
- hexium_browser/persona/sampler/NOTICE +13 -0
- hexium_browser/persona/sampler/__init__.py +7 -0
- hexium_browser/persona/sampler/bayes.py +298 -0
- hexium_browser/persona/sampler/datapoints.py +27 -0
- hexium_browser/persona/sampler/header_utils.py +48 -0
- hexium_browser/persona/sampler/headers.py +554 -0
- hexium_browser/persona/sampler/rows.py +369 -0
- hexium_browser/persona/schema.py +201 -0
- hexium_browser/persona/validate.py +152 -0
- hexium_browser/profiles.py +178 -0
- hexium_browser/widevine.py +128 -0
- hexium_browser-0.1.0.dist-info/METADATA +448 -0
- hexium_browser-0.1.0.dist-info/RECORD +51 -0
- hexium_browser-0.1.0.dist-info/WHEEL +4 -0
- hexium_browser-0.1.0.dist-info/entry_points.txt +2 -0
- hexium_browser-0.1.0.dist-info/licenses/BINARY-LICENSE.md +52 -0
- hexium_browser-0.1.0.dist-info/licenses/LICENSE.md +678 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""hexium_browser — Playwright wrappers for the Hexium stealth Chromium engine."""
|
|
2
|
+
|
|
3
|
+
from .browser import (
|
|
4
|
+
build_args,
|
|
5
|
+
launch,
|
|
6
|
+
launch_async,
|
|
7
|
+
launch_context,
|
|
8
|
+
launch_context_async,
|
|
9
|
+
launch_persistent_context,
|
|
10
|
+
launch_persistent_context_async,
|
|
11
|
+
maybe_resolve_geoip,
|
|
12
|
+
ProxySettings,
|
|
13
|
+
)
|
|
14
|
+
from .config import CHROMIUM_VERSION, get_default_stealth_args
|
|
15
|
+
from .download import binary_info, check_for_update, clear_cache, ensure_binary
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def __getattr__(name):
|
|
20
|
+
if name == "HumanConfig":
|
|
21
|
+
from .human.config import HumanConfig
|
|
22
|
+
globals()["HumanConfig"] = HumanConfig
|
|
23
|
+
return HumanConfig
|
|
24
|
+
if name == "resolve_human_config":
|
|
25
|
+
from .human.config import resolve_config
|
|
26
|
+
globals()["resolve_human_config"] = resolve_config
|
|
27
|
+
return resolve_config
|
|
28
|
+
raise AttributeError(f"module 'hexium_browser' has no attribute {name}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"launch",
|
|
33
|
+
"launch_async",
|
|
34
|
+
"launch_context",
|
|
35
|
+
"launch_context_async",
|
|
36
|
+
"launch_persistent_context",
|
|
37
|
+
"launch_persistent_context_async",
|
|
38
|
+
"ensure_binary",
|
|
39
|
+
"clear_cache",
|
|
40
|
+
"binary_info",
|
|
41
|
+
"check_for_update",
|
|
42
|
+
"CHROMIUM_VERSION",
|
|
43
|
+
"get_default_stealth_args",
|
|
44
|
+
"build_args",
|
|
45
|
+
"maybe_resolve_geoip",
|
|
46
|
+
"ProxySettings",
|
|
47
|
+
"HumanConfig",
|
|
48
|
+
"resolve_human_config",
|
|
49
|
+
"__version__",
|
|
50
|
+
]
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""CLI for hexium_browser — fetch and inspect the Hexium Chromium binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib.util
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _setup_logging() -> None:
|
|
15
|
+
logging.basicConfig(
|
|
16
|
+
level=logging.INFO,
|
|
17
|
+
format="%(message)s",
|
|
18
|
+
stream=sys.stderr,
|
|
19
|
+
force=True,
|
|
20
|
+
)
|
|
21
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_fetch(args: argparse.Namespace) -> None:
|
|
25
|
+
from .download import ensure_binary
|
|
26
|
+
|
|
27
|
+
path = ensure_binary(allow_download=True)
|
|
28
|
+
print(path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_install(args: argparse.Namespace) -> None:
|
|
32
|
+
cmd_fetch(args)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _module_available(module: str) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
return importlib.util.find_spec(module) is not None
|
|
38
|
+
except (ImportError, ValueError):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _binary_version(binary_path: str) -> tuple[bool, str, str]:
|
|
43
|
+
argv = [binary_path, "--version"]
|
|
44
|
+
if platform.system() == "Windows":
|
|
45
|
+
argv.append("--no-startup-window")
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
|
48
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
49
|
+
return False, "", str(exc)
|
|
50
|
+
if result.returncode != 0:
|
|
51
|
+
return False, "", (result.stderr or result.stdout).strip()
|
|
52
|
+
return True, result.stdout.strip(), ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _collect_diagnostics(quick: bool, proxy: str | None = None) -> dict:
|
|
56
|
+
from ._version import __version__
|
|
57
|
+
from .config import get_platform_tag
|
|
58
|
+
from .download import binary_info
|
|
59
|
+
|
|
60
|
+
diag: dict = {
|
|
61
|
+
"environment": {
|
|
62
|
+
"wrapper": __version__,
|
|
63
|
+
"python": sys.version.split()[0],
|
|
64
|
+
"os": platform.system(),
|
|
65
|
+
"arch": platform.machine(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try:
|
|
69
|
+
diag["environment"]["platform_tag"] = get_platform_tag()
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
diag["environment"]["platform_tag"] = f"unavailable ({exc})"
|
|
72
|
+
|
|
73
|
+
diag["binary"] = binary_info()
|
|
74
|
+
|
|
75
|
+
binary = diag["binary"].get("binary_path")
|
|
76
|
+
installed = diag["binary"].get("installed")
|
|
77
|
+
if quick:
|
|
78
|
+
diag["launch"] = {"tested": False, "reason": "skipped (--quick)"}
|
|
79
|
+
elif not binary or not installed:
|
|
80
|
+
diag["launch"] = {"tested": False, "reason": "binary not installed"}
|
|
81
|
+
else:
|
|
82
|
+
ok, version, err = _binary_version(binary)
|
|
83
|
+
diag["launch"] = {"tested": True, "ok": ok, "version": version, "error": err}
|
|
84
|
+
|
|
85
|
+
from .geoip import GEOIP_DB_FILENAME, _get_geoip_dir
|
|
86
|
+
|
|
87
|
+
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
|
|
88
|
+
diag["geoip"] = {"db_present": db_path.exists(), "path": str(db_path)}
|
|
89
|
+
|
|
90
|
+
if proxy:
|
|
91
|
+
try:
|
|
92
|
+
from .geoip import resolve_proxy_geo_with_ip
|
|
93
|
+
|
|
94
|
+
tz, locale, exit_ip = resolve_proxy_geo_with_ip(proxy)
|
|
95
|
+
diag["geoip"]["resolved"] = {
|
|
96
|
+
"exit_ip": exit_ip,
|
|
97
|
+
"timezone": tz,
|
|
98
|
+
"locale": locale,
|
|
99
|
+
}
|
|
100
|
+
except ImportError:
|
|
101
|
+
diag["geoip"]["resolved"] = {"error": "geoip2 not installed"}
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
diag["geoip"]["resolved"] = {"error": str(exc)}
|
|
104
|
+
|
|
105
|
+
diag["modules"] = {
|
|
106
|
+
label: _module_available(module)
|
|
107
|
+
for label, module in {
|
|
108
|
+
"playwright": "playwright.sync_api",
|
|
109
|
+
"geoip2": "geoip2.database",
|
|
110
|
+
}.items()
|
|
111
|
+
}
|
|
112
|
+
return diag
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _print_diagnostics(diag: dict) -> None:
|
|
116
|
+
env = diag["environment"]
|
|
117
|
+
print("Hexium browser diagnostics")
|
|
118
|
+
print(f"Wrapper: {env['wrapper']}")
|
|
119
|
+
print(f"Python: {env['python']}")
|
|
120
|
+
print(f"OS: {env['os']} {env['arch']}")
|
|
121
|
+
print(f"Platform: {env.get('platform_tag', 'unknown')}")
|
|
122
|
+
|
|
123
|
+
binary = diag["binary"]
|
|
124
|
+
print(f"Version: {binary.get('version')}")
|
|
125
|
+
print(f"Source: {binary.get('source', 'cache')}")
|
|
126
|
+
print(f"Binary: {binary.get('binary_path')}")
|
|
127
|
+
print(f"Installed: {binary.get('installed')}")
|
|
128
|
+
if binary.get("cache_dir"):
|
|
129
|
+
print(f"Cache: {binary['cache_dir']}")
|
|
130
|
+
print(f"Download: {binary.get('download_url')}")
|
|
131
|
+
|
|
132
|
+
launch = diag["launch"]
|
|
133
|
+
if not launch.get("tested"):
|
|
134
|
+
print(f"Launch: {launch['reason']}")
|
|
135
|
+
elif launch["ok"]:
|
|
136
|
+
print(f"Launch: ok — {launch['version'] or 'runs'}")
|
|
137
|
+
else:
|
|
138
|
+
print(f"Launch: failed — {launch['error']}")
|
|
139
|
+
|
|
140
|
+
geoip = diag["geoip"]
|
|
141
|
+
print(f"GeoIP DB: {'present' if geoip['db_present'] else 'not downloaded'}")
|
|
142
|
+
resolved = geoip.get("resolved")
|
|
143
|
+
if resolved:
|
|
144
|
+
if resolved.get("error"):
|
|
145
|
+
print(f"Exit IP: (could not resolve — {resolved['error']})")
|
|
146
|
+
else:
|
|
147
|
+
print(f"Exit IP: {resolved.get('exit_ip') or '(unknown)'}")
|
|
148
|
+
print(f"Timezone: {resolved.get('timezone') or '(unknown)'}")
|
|
149
|
+
print(f"Locale: {resolved.get('locale') or '(unknown)'}")
|
|
150
|
+
|
|
151
|
+
print("Modules:")
|
|
152
|
+
for label, available in diag["modules"].items():
|
|
153
|
+
print(f" {label}: {'ok' if available else 'missing'}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_info(args: argparse.Namespace) -> None:
|
|
157
|
+
quick = getattr(args, "quick", False)
|
|
158
|
+
diag = _collect_diagnostics(quick=quick, proxy=getattr(args, "proxy", None))
|
|
159
|
+
if getattr(args, "json", False):
|
|
160
|
+
import json
|
|
161
|
+
|
|
162
|
+
print(json.dumps(diag, indent=2))
|
|
163
|
+
else:
|
|
164
|
+
_print_diagnostics(diag)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_clear_cache(args: argparse.Namespace) -> None:
|
|
168
|
+
from .config import get_cache_dir
|
|
169
|
+
from .download import clear_cache
|
|
170
|
+
|
|
171
|
+
if not get_cache_dir().exists():
|
|
172
|
+
print("No cache to clear.")
|
|
173
|
+
return
|
|
174
|
+
clear_cache()
|
|
175
|
+
print("Cache cleared.")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _explicit_last_profile_name() -> str | None:
|
|
179
|
+
from .profiles import load_config
|
|
180
|
+
|
|
181
|
+
value = load_config().get("last_profile")
|
|
182
|
+
if not isinstance(value, str):
|
|
183
|
+
return None
|
|
184
|
+
name = value.strip()
|
|
185
|
+
return name or None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cmd_profiles(args: argparse.Namespace) -> None:
|
|
189
|
+
from .profiles import (
|
|
190
|
+
ensure_profile,
|
|
191
|
+
get_last_profile_name,
|
|
192
|
+
list_profile_names,
|
|
193
|
+
profile_dir,
|
|
194
|
+
set_last_profile_name,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
action = getattr(args, "profiles_cmd", None) or "list"
|
|
198
|
+
if action == "list":
|
|
199
|
+
last = get_last_profile_name()
|
|
200
|
+
explicit_last = _explicit_last_profile_name()
|
|
201
|
+
for name in list_profile_names():
|
|
202
|
+
suffix = " (last)" if explicit_last is not None and name == last else ""
|
|
203
|
+
print(f"{name}{suffix}")
|
|
204
|
+
return
|
|
205
|
+
if action == "new":
|
|
206
|
+
name = getattr(args, "name", None)
|
|
207
|
+
if not name:
|
|
208
|
+
raise ValueError("profiles new requires a name")
|
|
209
|
+
path = ensure_profile(name)
|
|
210
|
+
print(path)
|
|
211
|
+
return
|
|
212
|
+
if action == "use":
|
|
213
|
+
name = getattr(args, "name", None)
|
|
214
|
+
if not name:
|
|
215
|
+
raise ValueError("profiles use requires a name")
|
|
216
|
+
path = profile_dir(name)
|
|
217
|
+
if not path.is_dir():
|
|
218
|
+
raise FileNotFoundError(f"profile {name!r} not found at {path}")
|
|
219
|
+
set_last_profile_name(name)
|
|
220
|
+
print(path)
|
|
221
|
+
return
|
|
222
|
+
if action == "last":
|
|
223
|
+
name = _explicit_last_profile_name()
|
|
224
|
+
if not name:
|
|
225
|
+
raise FileNotFoundError(
|
|
226
|
+
"no last named profile; run: hexium-browser profiles use NAME"
|
|
227
|
+
)
|
|
228
|
+
print(profile_dir(name))
|
|
229
|
+
return
|
|
230
|
+
raise ValueError(f"unknown profiles command {action!r}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main() -> None:
|
|
234
|
+
parser = argparse.ArgumentParser(
|
|
235
|
+
prog="hexium-browser",
|
|
236
|
+
description="Manage the Hexium stealth Chromium binary.",
|
|
237
|
+
)
|
|
238
|
+
sub = parser.add_subparsers(dest="command")
|
|
239
|
+
|
|
240
|
+
sub.add_parser("fetch", help="Ensure the Hexium binary is available")
|
|
241
|
+
sub.add_parser("install", help="Alias for fetch")
|
|
242
|
+
|
|
243
|
+
def _add_info_flags(p: argparse.ArgumentParser) -> None:
|
|
244
|
+
p.add_argument("--quick", action="store_true", help="Skip launch test")
|
|
245
|
+
p.add_argument("--json", action="store_true", help="Emit diagnostics as JSON")
|
|
246
|
+
p.add_argument("--proxy", metavar="URL", help="Resolve geoip for this proxy")
|
|
247
|
+
|
|
248
|
+
_add_info_flags(sub.add_parser("info", help="Environment + binary diagnostics"))
|
|
249
|
+
_add_info_flags(sub.add_parser("doctor", help="Alias for info"))
|
|
250
|
+
sub.add_parser("clear-cache", help="Remove cached binaries")
|
|
251
|
+
|
|
252
|
+
profiles_p = sub.add_parser(
|
|
253
|
+
"profiles", help="List and select persistent Hexium profiles"
|
|
254
|
+
)
|
|
255
|
+
profiles_sub = profiles_p.add_subparsers(dest="profiles_cmd")
|
|
256
|
+
profiles_sub.add_parser("list", help="List profile names")
|
|
257
|
+
new_p = profiles_sub.add_parser("new", help="Create a named profile directory")
|
|
258
|
+
new_p.add_argument("name")
|
|
259
|
+
use_p = profiles_sub.add_parser("use", help="Set last_profile")
|
|
260
|
+
use_p.add_argument("name")
|
|
261
|
+
profiles_sub.add_parser("last", help="Print the last named profile path")
|
|
262
|
+
|
|
263
|
+
args = parser.parse_args()
|
|
264
|
+
if not args.command:
|
|
265
|
+
parser.print_help()
|
|
266
|
+
sys.exit(2)
|
|
267
|
+
|
|
268
|
+
_setup_logging()
|
|
269
|
+
|
|
270
|
+
commands = {
|
|
271
|
+
"fetch": cmd_fetch,
|
|
272
|
+
"install": cmd_install,
|
|
273
|
+
"info": cmd_info,
|
|
274
|
+
"doctor": cmd_info,
|
|
275
|
+
"clear-cache": cmd_clear_cache,
|
|
276
|
+
"profiles": cmd_profiles,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
commands[args.command](args)
|
|
281
|
+
except KeyboardInterrupt:
|
|
282
|
+
sys.exit(130)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Where example/oracle artifacts land (screenshots + Playwright video)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
ARTIFACTS_ROOT = Path(__file__).resolve().parent.parent / "assets"
|
|
13
|
+
SCREENSHOTS_DIR = ARTIFACTS_ROOT / "screenshots"
|
|
14
|
+
RECORDINGS_DIR = ARTIFACTS_ROOT / "recordings"
|
|
15
|
+
|
|
16
|
+
_FALLBACK_VIDEO_SIZE = {"width": 1920, "height": 1080}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ensure_artifact_dirs() -> tuple[Path, Path]:
|
|
20
|
+
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return SCREENSHOTS_DIR, RECORDINGS_DIR
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_screen_size() -> dict[str, int]:
|
|
26
|
+
"""Physical display size so Playwright video matches the window.
|
|
27
|
+
|
|
28
|
+
Headed Hexium uses ``no_viewport``. Playwright then records 800x800 unless
|
|
29
|
+
``record_video_size`` (and a matching ``viewport``) are set explicitly.
|
|
30
|
+
"""
|
|
31
|
+
env_w = os.environ.get("HEXIUM_VIDEO_WIDTH")
|
|
32
|
+
env_h = os.environ.get("HEXIUM_VIDEO_HEIGHT")
|
|
33
|
+
if env_w and env_h:
|
|
34
|
+
return {"width": int(env_w), "height": int(env_h)}
|
|
35
|
+
try:
|
|
36
|
+
out = subprocess.check_output(
|
|
37
|
+
["xrandr", "--current"], text=True, timeout=2, stderr=subprocess.DEVNULL
|
|
38
|
+
)
|
|
39
|
+
for line in out.splitlines():
|
|
40
|
+
if "*" not in line:
|
|
41
|
+
continue
|
|
42
|
+
match = re.search(r"(\d{3,5})x(\d{3,5})", line)
|
|
43
|
+
if match:
|
|
44
|
+
return {"width": int(match.group(1)), "height": int(match.group(2))}
|
|
45
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
46
|
+
pass
|
|
47
|
+
return dict(_FALLBACK_VIDEO_SIZE)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _scratch_dir(name: str) -> Path:
|
|
51
|
+
_, recs = ensure_artifact_dirs()
|
|
52
|
+
scratch = recs / f".tmp-{name}"
|
|
53
|
+
if scratch.exists():
|
|
54
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
55
|
+
scratch.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
return scratch
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def recording_kwargs(name: str) -> dict[str, Any]:
|
|
60
|
+
"""``launch()`` kwargs so recorded video is 1:1 with the page.
|
|
61
|
+
|
|
62
|
+
Playwright writes ``page@<hash>.webm`` into a scratch folder. Call
|
|
63
|
+
:func:`finalize_recording` after ``browser.close()`` to save a stable name
|
|
64
|
+
that overwrites on the next run.
|
|
65
|
+
"""
|
|
66
|
+
size = detect_screen_size()
|
|
67
|
+
return {
|
|
68
|
+
"record_video_dir": str(_scratch_dir(name)),
|
|
69
|
+
"record_video_size": size,
|
|
70
|
+
"viewport": size,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def finalize_recording(video: Any, name: str) -> Path | None:
|
|
75
|
+
"""Copy the Playwright video to ``assets/recordings/<name>.webm`` and drop leftovers."""
|
|
76
|
+
ensure_artifact_dirs()
|
|
77
|
+
dest = RECORDINGS_DIR / f"{name}.webm"
|
|
78
|
+
scratch = RECORDINGS_DIR / f".tmp-{name}"
|
|
79
|
+
if dest.exists():
|
|
80
|
+
dest.unlink()
|
|
81
|
+
saved = False
|
|
82
|
+
if video is not None:
|
|
83
|
+
try:
|
|
84
|
+
video.save_as(str(dest))
|
|
85
|
+
saved = dest.is_file() and dest.stat().st_size > 0
|
|
86
|
+
except Exception:
|
|
87
|
+
saved = False
|
|
88
|
+
if not saved and scratch.is_dir():
|
|
89
|
+
webms = sorted(scratch.glob("*.webm"), key=lambda p: p.stat().st_mtime)
|
|
90
|
+
if webms:
|
|
91
|
+
shutil.move(str(webms[-1]), str(dest))
|
|
92
|
+
saved = dest.is_file()
|
|
93
|
+
if scratch.exists():
|
|
94
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
95
|
+
for leftover in RECORDINGS_DIR.glob("page@*.webm"):
|
|
96
|
+
leftover.unlink(missing_ok=True)
|
|
97
|
+
return dest if saved else None
|