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.
Files changed (51) hide show
  1. hexium_browser/__init__.py +50 -0
  2. hexium_browser/__main__.py +289 -0
  3. hexium_browser/_version.py +1 -0
  4. hexium_browser/artifacts.py +97 -0
  5. hexium_browser/browser.py +1780 -0
  6. hexium_browser/config.py +459 -0
  7. hexium_browser/download.py +280 -0
  8. hexium_browser/geoip.py +418 -0
  9. hexium_browser/human/__init__.py +3231 -0
  10. hexium_browser/human/actionability.py +433 -0
  11. hexium_browser/human/actionability_async.py +316 -0
  12. hexium_browser/human/config.py +273 -0
  13. hexium_browser/human/cursor_overlay.py +188 -0
  14. hexium_browser/human/keyboard.py +189 -0
  15. hexium_browser/human/keyboard_async.py +150 -0
  16. hexium_browser/human/mouse.py +331 -0
  17. hexium_browser/human/mouse_async.py +59 -0
  18. hexium_browser/human/scroll.py +205 -0
  19. hexium_browser/human/scroll_async.py +202 -0
  20. hexium_browser/human/stealth_dom.py +219 -0
  21. hexium_browser/persona/__init__.py +40 -0
  22. hexium_browser/persona/coerce.py +258 -0
  23. hexium_browser/persona/data/browser-helper-file.json +1 -0
  24. hexium_browser/persona/data/fingerprint-network-definition.zip +0 -0
  25. hexium_browser/persona/data/header-network-definition.zip +0 -0
  26. hexium_browser/persona/data/headers-order.json +164 -0
  27. hexium_browser/persona/data/input-network-definition.zip +0 -0
  28. hexium_browser/persona/data/unpacked/README.md +15 -0
  29. hexium_browser/persona/data/windows-fonts.conf +254 -0
  30. hexium_browser/persona/datapack.py +254 -0
  31. hexium_browser/persona/fonts.py +116 -0
  32. hexium_browser/persona/geo_overlay.py +47 -0
  33. hexium_browser/persona/rewrite_ua.py +68 -0
  34. hexium_browser/persona/sample.py +165 -0
  35. hexium_browser/persona/sampler/NOTICE +13 -0
  36. hexium_browser/persona/sampler/__init__.py +7 -0
  37. hexium_browser/persona/sampler/bayes.py +298 -0
  38. hexium_browser/persona/sampler/datapoints.py +27 -0
  39. hexium_browser/persona/sampler/header_utils.py +48 -0
  40. hexium_browser/persona/sampler/headers.py +554 -0
  41. hexium_browser/persona/sampler/rows.py +369 -0
  42. hexium_browser/persona/schema.py +201 -0
  43. hexium_browser/persona/validate.py +152 -0
  44. hexium_browser/profiles.py +178 -0
  45. hexium_browser/widevine.py +128 -0
  46. hexium_browser-0.1.0.dist-info/METADATA +448 -0
  47. hexium_browser-0.1.0.dist-info/RECORD +51 -0
  48. hexium_browser-0.1.0.dist-info/WHEEL +4 -0
  49. hexium_browser-0.1.0.dist-info/entry_points.txt +2 -0
  50. hexium_browser-0.1.0.dist-info/licenses/BINARY-LICENSE.md +52 -0
  51. 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