chromiumfish 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.
- chromiumfish-0.1.0/.gitignore +31 -0
- chromiumfish-0.1.0/LICENSE +30 -0
- chromiumfish-0.1.0/PKG-INFO +90 -0
- chromiumfish-0.1.0/README.md +68 -0
- chromiumfish-0.1.0/pyproject.toml +43 -0
- chromiumfish-0.1.0/src/chromiumfish/__init__.py +30 -0
- chromiumfish-0.1.0/src/chromiumfish/__main__.py +6 -0
- chromiumfish-0.1.0/src/chromiumfish/async_api.py +65 -0
- chromiumfish-0.1.0/src/chromiumfish/cli.py +51 -0
- chromiumfish-0.1.0/src/chromiumfish/fetch.py +201 -0
- chromiumfish-0.1.0/src/chromiumfish/launcher.py +56 -0
- chromiumfish-0.1.0/src/chromiumfish/sync_api.py +65 -0
- chromiumfish-0.1.0/src/chromiumfish/version.py +30 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Node
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
npm-debug.log*
|
|
6
|
+
|
|
7
|
+
# Python
|
|
8
|
+
__pycache__/
|
|
9
|
+
*.py[cod]
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.eggs/
|
|
12
|
+
build/
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
|
|
19
|
+
# Browser cache / downloaded builds
|
|
20
|
+
.cache/
|
|
21
|
+
*.tar.gz
|
|
22
|
+
*.zip
|
|
23
|
+
|
|
24
|
+
# OS / editor
|
|
25
|
+
.DS_Store
|
|
26
|
+
*.swp
|
|
27
|
+
.idea/
|
|
28
|
+
|
|
29
|
+
# Local secrets / env
|
|
30
|
+
.env
|
|
31
|
+
*.local
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arman Hossain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This package distributes builds of the Chromium project, which is licensed
|
|
26
|
+
under the BSD 3-Clause License and includes third-party components under their
|
|
27
|
+
respective licenses. The full Chromium license text and credits are bundled
|
|
28
|
+
with each browser release. "Chromium" and "Google Chrome" are trademarks of
|
|
29
|
+
Google LLC. ChromiumFish is an independent fork and is not affiliated with,
|
|
30
|
+
sponsored by, or endorsed by Google LLC.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chromiumfish
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Stealth Chromium build with a drop-in Playwright harness — fetches and launches the ChromiumFish browser.
|
|
5
|
+
Project-URL: Homepage, https://chromiumfish.com
|
|
6
|
+
Project-URL: Repository, https://github.com/arman-bd/chromiumfish
|
|
7
|
+
Project-URL: Documentation, https://chromiumfish.com
|
|
8
|
+
Project-URL: Releases, https://github.com/arman-bd/chromiumfish/releases
|
|
9
|
+
Author: Arman Hossain
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: anti-detect,automation,chromium,fingerprint,playwright,stealth
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
18
|
+
Classifier: Topic :: Software Development :: Testing
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: playwright>=1.40
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# chromiumfish (Python)
|
|
24
|
+
|
|
25
|
+
Stealth Chromium with a drop-in [Playwright](https://playwright.dev) harness.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install chromiumfish
|
|
29
|
+
chromiumfish fetch # download + cache the browser build
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
**Sync**
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from chromiumfish.sync_api import Chromiumfish
|
|
38
|
+
|
|
39
|
+
with Chromiumfish(persona_seed=27182, headless=True) as browser:
|
|
40
|
+
page = browser.new_page()
|
|
41
|
+
page.goto("https://abrahamjuliot.github.io/creepjs/")
|
|
42
|
+
page.screenshot(path="fp.png")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Async**
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
from chromiumfish.async_api import AsyncChromiumfish
|
|
50
|
+
|
|
51
|
+
async def main():
|
|
52
|
+
async with AsyncChromiumfish(persona_seed=27182) as browser:
|
|
53
|
+
page = await browser.new_page()
|
|
54
|
+
await page.goto("https://example.com")
|
|
55
|
+
print(await page.title())
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The returned object is a standard Playwright `Browser`, so `new_context`,
|
|
61
|
+
`new_page`, routing, tracing, etc. all work as usual.
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
|
|
65
|
+
| Argument | Default | Description |
|
|
66
|
+
|----------|---------|-------------|
|
|
67
|
+
| `persona_seed` | `None` | Integer seed for a stable, internally-consistent fingerprint persona. |
|
|
68
|
+
| `headless` | `True` | Run headless (SwiftShader). |
|
|
69
|
+
| `proxy` | `None` | Playwright proxy dict, e.g. `{"server": "http://host:port", "username": ..., "password": ...}`. |
|
|
70
|
+
| `window_size` | `(1920, 1080)` | Window dimensions. |
|
|
71
|
+
| `version` | pinned | Override the browser build version. |
|
|
72
|
+
| `download` | `True` | Auto-download the build if missing. |
|
|
73
|
+
| `args` | `None` | Extra Chromium flags. |
|
|
74
|
+
| `**launch_kwargs` | — | Forwarded to `chromium.launch()`. |
|
|
75
|
+
|
|
76
|
+
## CLI
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
chromiumfish fetch [--browser-version X] [--force] # download + cache
|
|
80
|
+
chromiumfish path # print binary path
|
|
81
|
+
chromiumfish clear # wipe the cache
|
|
82
|
+
chromiumfish --version
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Builds are cached under `~/.cache/chromiumfish/<version>/` (override with
|
|
86
|
+
`CHROMIUMFISH_CACHE_DIR`). Pin a build with `CHROMIUMFISH_VERSION`.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT © Arman Hossain. See the [repository](https://github.com/arman-bd/chromiumfish).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# chromiumfish (Python)
|
|
2
|
+
|
|
3
|
+
Stealth Chromium with a drop-in [Playwright](https://playwright.dev) harness.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install chromiumfish
|
|
7
|
+
chromiumfish fetch # download + cache the browser build
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
**Sync**
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from chromiumfish.sync_api import Chromiumfish
|
|
16
|
+
|
|
17
|
+
with Chromiumfish(persona_seed=27182, headless=True) as browser:
|
|
18
|
+
page = browser.new_page()
|
|
19
|
+
page.goto("https://abrahamjuliot.github.io/creepjs/")
|
|
20
|
+
page.screenshot(path="fp.png")
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Async**
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import asyncio
|
|
27
|
+
from chromiumfish.async_api import AsyncChromiumfish
|
|
28
|
+
|
|
29
|
+
async def main():
|
|
30
|
+
async with AsyncChromiumfish(persona_seed=27182) as browser:
|
|
31
|
+
page = await browser.new_page()
|
|
32
|
+
await page.goto("https://example.com")
|
|
33
|
+
print(await page.title())
|
|
34
|
+
|
|
35
|
+
asyncio.run(main())
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The returned object is a standard Playwright `Browser`, so `new_context`,
|
|
39
|
+
`new_page`, routing, tracing, etc. all work as usual.
|
|
40
|
+
|
|
41
|
+
## Options
|
|
42
|
+
|
|
43
|
+
| Argument | Default | Description |
|
|
44
|
+
|----------|---------|-------------|
|
|
45
|
+
| `persona_seed` | `None` | Integer seed for a stable, internally-consistent fingerprint persona. |
|
|
46
|
+
| `headless` | `True` | Run headless (SwiftShader). |
|
|
47
|
+
| `proxy` | `None` | Playwright proxy dict, e.g. `{"server": "http://host:port", "username": ..., "password": ...}`. |
|
|
48
|
+
| `window_size` | `(1920, 1080)` | Window dimensions. |
|
|
49
|
+
| `version` | pinned | Override the browser build version. |
|
|
50
|
+
| `download` | `True` | Auto-download the build if missing. |
|
|
51
|
+
| `args` | `None` | Extra Chromium flags. |
|
|
52
|
+
| `**launch_kwargs` | — | Forwarded to `chromium.launch()`. |
|
|
53
|
+
|
|
54
|
+
## CLI
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
chromiumfish fetch [--browser-version X] [--force] # download + cache
|
|
58
|
+
chromiumfish path # print binary path
|
|
59
|
+
chromiumfish clear # wipe the cache
|
|
60
|
+
chromiumfish --version
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Builds are cached under `~/.cache/chromiumfish/<version>/` (override with
|
|
64
|
+
`CHROMIUMFISH_CACHE_DIR`). Pin a build with `CHROMIUMFISH_VERSION`.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT © Arman Hossain. See the [repository](https://github.com/arman-bd/chromiumfish).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chromiumfish"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Stealth Chromium build with a drop-in Playwright harness — fetches and launches the ChromiumFish browser."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Arman Hossain" }]
|
|
14
|
+
keywords = ["playwright", "chromium", "stealth", "fingerprint", "automation", "anti-detect"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
21
|
+
"Topic :: Software Development :: Testing",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"playwright>=1.40",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://chromiumfish.com"
|
|
29
|
+
Repository = "https://github.com/arman-bd/chromiumfish"
|
|
30
|
+
Documentation = "https://chromiumfish.com"
|
|
31
|
+
Releases = "https://github.com/arman-bd/chromiumfish/releases"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
chromiumfish = "chromiumfish.cli:main"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.version]
|
|
37
|
+
path = "src/chromiumfish/version.py"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/chromiumfish"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
include = ["src/chromiumfish", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""ChromiumFish — a stealth Chromium build with a Playwright harness.
|
|
2
|
+
|
|
3
|
+
Quick start (sync)::
|
|
4
|
+
|
|
5
|
+
from chromiumfish.sync_api import Chromiumfish
|
|
6
|
+
|
|
7
|
+
with Chromiumfish(persona_seed=27182) as browser:
|
|
8
|
+
page = browser.new_page()
|
|
9
|
+
page.goto("https://example.com")
|
|
10
|
+
|
|
11
|
+
Quick start (async)::
|
|
12
|
+
|
|
13
|
+
from chromiumfish.async_api import AsyncChromiumfish
|
|
14
|
+
|
|
15
|
+
async with AsyncChromiumfish(persona_seed=27182) as browser:
|
|
16
|
+
page = await browser.new_page()
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .fetch import binary_path, fetch, install_dir
|
|
21
|
+
from .version import DEFAULT_BROWSER_VERSION, __version__, browser_version
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"__version__",
|
|
25
|
+
"DEFAULT_BROWSER_VERSION",
|
|
26
|
+
"browser_version",
|
|
27
|
+
"fetch",
|
|
28
|
+
"binary_path",
|
|
29
|
+
"install_dir",
|
|
30
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Async Playwright wrapper for ChromiumFish.
|
|
2
|
+
|
|
3
|
+
from chromiumfish.async_api import AsyncChromiumfish
|
|
4
|
+
|
|
5
|
+
async with AsyncChromiumfish(persona_seed=27182, headless=True) as browser:
|
|
6
|
+
page = await browser.new_page()
|
|
7
|
+
await page.goto("https://example.com")
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from playwright.async_api import Browser, async_playwright
|
|
14
|
+
|
|
15
|
+
from .fetch import binary_path
|
|
16
|
+
from .launcher import launch_options
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncChromiumfish:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
persona_seed: int | None = None,
|
|
24
|
+
headless: bool = True,
|
|
25
|
+
proxy: dict[str, Any] | None = None,
|
|
26
|
+
window_size: tuple[int, int] | None = (1920, 1080),
|
|
27
|
+
version: str | None = None,
|
|
28
|
+
download: bool = True,
|
|
29
|
+
args: list[str] | None = None,
|
|
30
|
+
**launch_kwargs: Any,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._opts = dict(
|
|
33
|
+
persona_seed=persona_seed,
|
|
34
|
+
headless=headless,
|
|
35
|
+
proxy=proxy,
|
|
36
|
+
window_size=window_size,
|
|
37
|
+
args=args,
|
|
38
|
+
extra=launch_kwargs,
|
|
39
|
+
)
|
|
40
|
+
self._version = version
|
|
41
|
+
self._download = download
|
|
42
|
+
self._pw = None
|
|
43
|
+
self._browser: Browser | None = None
|
|
44
|
+
|
|
45
|
+
async def start(self) -> Browser:
|
|
46
|
+
exe = binary_path(self._version, download=self._download)
|
|
47
|
+
self._pw = await async_playwright().start()
|
|
48
|
+
self._browser = await self._pw.chromium.launch(
|
|
49
|
+
**launch_options(executable_path=exe, **self._opts)
|
|
50
|
+
)
|
|
51
|
+
return self._browser
|
|
52
|
+
|
|
53
|
+
async def close(self) -> None:
|
|
54
|
+
if self._browser:
|
|
55
|
+
await self._browser.close()
|
|
56
|
+
self._browser = None
|
|
57
|
+
if self._pw:
|
|
58
|
+
await self._pw.stop()
|
|
59
|
+
self._pw = None
|
|
60
|
+
|
|
61
|
+
async def __aenter__(self) -> Browser:
|
|
62
|
+
return await self.start()
|
|
63
|
+
|
|
64
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
65
|
+
await self.close()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""`chromiumfish` command-line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .fetch import binary_path, cache_root, fetch, install_dir
|
|
9
|
+
from .version import __version__, browser_version
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="chromiumfish",
|
|
15
|
+
description="Fetch and manage the ChromiumFish browser build.",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("-V", "--version", action="version",
|
|
18
|
+
version=f"chromiumfish {__version__} (browser {browser_version()})")
|
|
19
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
20
|
+
|
|
21
|
+
f = sub.add_parser("fetch", help="download + cache the browser build")
|
|
22
|
+
f.add_argument("--browser-version", default=None, help="override the build version")
|
|
23
|
+
f.add_argument("--force", action="store_true", help="re-download even if cached")
|
|
24
|
+
|
|
25
|
+
sub.add_parser("path", help="print the cached binary path (fetching if missing)")
|
|
26
|
+
sub.add_parser("clear", help="remove all cached browser builds")
|
|
27
|
+
|
|
28
|
+
args = parser.parse_args(argv)
|
|
29
|
+
|
|
30
|
+
if args.cmd == "fetch":
|
|
31
|
+
path = fetch(args.browser_version, force=args.force)
|
|
32
|
+
print(path)
|
|
33
|
+
return 0
|
|
34
|
+
if args.cmd == "path":
|
|
35
|
+
print(binary_path())
|
|
36
|
+
return 0
|
|
37
|
+
if args.cmd == "clear":
|
|
38
|
+
root = cache_root()
|
|
39
|
+
if root.exists():
|
|
40
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
41
|
+
print(f"removed {root}")
|
|
42
|
+
else:
|
|
43
|
+
print("nothing to remove")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
parser.print_help()
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
sys.exit(main())
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Download, verify, and cache the ChromiumFish browser build.
|
|
2
|
+
|
|
3
|
+
Fetch model: resolve ``version × platform`` to a GitHub
|
|
4
|
+
Release asset, verify its SHA-256, extract it to a per-version cache dir, and
|
|
5
|
+
return the path to the launchable binary.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tarfile
|
|
16
|
+
import urllib.request
|
|
17
|
+
import zipfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .version import browser_version, release_base_url
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnsupportedPlatformError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cache_root() -> Path:
|
|
28
|
+
env = os.environ.get("CHROMIUMFISH_CACHE_DIR")
|
|
29
|
+
if env:
|
|
30
|
+
return Path(env).expanduser()
|
|
31
|
+
if sys.platform == "darwin":
|
|
32
|
+
return Path.home() / "Library" / "Caches" / "chromiumfish"
|
|
33
|
+
if os.name == "nt":
|
|
34
|
+
base = os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))
|
|
35
|
+
return Path(base) / "chromiumfish"
|
|
36
|
+
return Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "chromiumfish"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def platform_slug() -> str:
|
|
40
|
+
"""e.g. ``linux-x64``, ``win-x64``, ``mac-arm64``."""
|
|
41
|
+
machine = platform.machine().lower()
|
|
42
|
+
arch = {
|
|
43
|
+
"x86_64": "x64", "amd64": "x64",
|
|
44
|
+
"aarch64": "arm64", "arm64": "arm64",
|
|
45
|
+
}.get(machine)
|
|
46
|
+
if arch is None:
|
|
47
|
+
raise UnsupportedPlatformError(f"unsupported architecture: {machine}")
|
|
48
|
+
if sys.platform.startswith("linux"):
|
|
49
|
+
return f"linux-{arch}"
|
|
50
|
+
if sys.platform == "darwin":
|
|
51
|
+
return f"mac-{arch}"
|
|
52
|
+
if os.name == "nt":
|
|
53
|
+
return f"win-{arch}"
|
|
54
|
+
raise UnsupportedPlatformError(f"unsupported platform: {sys.platform}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _asset_name(version: str) -> str:
|
|
58
|
+
slug = platform_slug()
|
|
59
|
+
ext = "zip" if slug.startswith("win") else "tar.gz"
|
|
60
|
+
return f"chromiumfish-{version}-{slug}.{ext}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _binary_name() -> str:
|
|
64
|
+
if os.name == "nt":
|
|
65
|
+
return "chromiumfish.exe"
|
|
66
|
+
return "chromiumfish" # falls back to "chrome" during discovery
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def install_dir(version: str | None = None) -> Path:
|
|
70
|
+
version = version or browser_version()
|
|
71
|
+
return cache_root() / version / platform_slug()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_binary(root: Path) -> Path | None:
|
|
75
|
+
"""Locate the launchable binary inside an extracted build."""
|
|
76
|
+
candidates = ["chromiumfish", "chrome", "chromiumfish.exe", "chrome.exe", "ChromiumFish"]
|
|
77
|
+
for name in candidates:
|
|
78
|
+
direct = root / name
|
|
79
|
+
if direct.is_file():
|
|
80
|
+
return direct
|
|
81
|
+
for name in candidates:
|
|
82
|
+
for hit in root.rglob(name):
|
|
83
|
+
if hit.is_file():
|
|
84
|
+
return hit
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _download(url: str, dest: Path) -> None:
|
|
89
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
print(f"[chromiumfish] downloading {url}", file=sys.stderr)
|
|
91
|
+
with urllib.request.urlopen(url) as resp, open(dest, "wb") as out: # noqa: S310
|
|
92
|
+
total = int(resp.headers.get("Content-Length", 0))
|
|
93
|
+
read = 0
|
|
94
|
+
while chunk := resp.read(1 << 20):
|
|
95
|
+
out.write(chunk)
|
|
96
|
+
read += len(chunk)
|
|
97
|
+
if total:
|
|
98
|
+
pct = read * 100 // total
|
|
99
|
+
print(f"\r[chromiumfish] {pct:3d}% ({read >> 20} / {total >> 20} MiB)",
|
|
100
|
+
end="", file=sys.stderr)
|
|
101
|
+
print("", file=sys.stderr)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sha256(path: Path) -> str:
|
|
105
|
+
h = hashlib.sha256()
|
|
106
|
+
with open(path, "rb") as f:
|
|
107
|
+
while chunk := f.read(1 << 20):
|
|
108
|
+
h.update(chunk)
|
|
109
|
+
return h.hexdigest()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _verify(archive: Path, base_url: str, asset: str) -> None:
|
|
113
|
+
try:
|
|
114
|
+
with urllib.request.urlopen(f"{base_url}/{asset}.sha256") as r: # noqa: S310
|
|
115
|
+
expected = r.read().decode().split()[0].strip()
|
|
116
|
+
except Exception: # noqa: BLE001
|
|
117
|
+
print("[chromiumfish] warning: no .sha256 published, skipping verification", file=sys.stderr)
|
|
118
|
+
return
|
|
119
|
+
actual = _sha256(archive)
|
|
120
|
+
if actual != expected:
|
|
121
|
+
archive.unlink(missing_ok=True)
|
|
122
|
+
raise RuntimeError(f"checksum mismatch for {asset}: {actual} != {expected}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _extract(archive: Path, dest: Path) -> None:
|
|
126
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
if archive.name.endswith(".zip"):
|
|
128
|
+
with zipfile.ZipFile(archive) as z:
|
|
129
|
+
z.extractall(dest)
|
|
130
|
+
else:
|
|
131
|
+
with tarfile.open(archive) as t:
|
|
132
|
+
t.extractall(dest) # noqa: S202 - trusted first-party asset
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _macos_prepare(target: Path) -> None:
|
|
136
|
+
"""Identity-clean macOS prep.
|
|
137
|
+
|
|
138
|
+
1. Strip the ``com.apple.quarantine`` flag. Programmatic downloads usually
|
|
139
|
+
don't set it, but browsers/tools might — removing it avoids Gatekeeper's
|
|
140
|
+
"unidentified developer" block without any notarization.
|
|
141
|
+
2. Ensure the bundle is ad-hoc signed (``codesign -s -``). Apple Silicon
|
|
142
|
+
refuses to run an unsigned binary; ad-hoc signing fixes that and embeds
|
|
143
|
+
NO certificate, name, or identity. (Release builds ship ad-hoc signed;
|
|
144
|
+
this is a defensive fallback.)
|
|
145
|
+
"""
|
|
146
|
+
if sys.platform != "darwin":
|
|
147
|
+
return
|
|
148
|
+
app = next(target.glob("*.app"), None)
|
|
149
|
+
sign_target = app or find_binary(target)
|
|
150
|
+
subprocess.run(["xattr", "-dr", "com.apple.quarantine", str(target)],
|
|
151
|
+
check=False, capture_output=True)
|
|
152
|
+
if sign_target:
|
|
153
|
+
valid = subprocess.run(["codesign", "--verify", "--quiet", str(sign_target)],
|
|
154
|
+
capture_output=True).returncode == 0
|
|
155
|
+
if not valid:
|
|
156
|
+
subprocess.run(["codesign", "--force", "--deep", "--sign", "-", str(sign_target)],
|
|
157
|
+
check=False, capture_output=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fetch(version: str | None = None, *, force: bool = False) -> Path:
|
|
161
|
+
"""Ensure the browser build is present and return the binary path."""
|
|
162
|
+
version = version or browser_version()
|
|
163
|
+
target = install_dir(version)
|
|
164
|
+
|
|
165
|
+
if force and target.exists():
|
|
166
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
167
|
+
|
|
168
|
+
if target.exists():
|
|
169
|
+
binp = find_binary(target)
|
|
170
|
+
if binp:
|
|
171
|
+
return binp
|
|
172
|
+
|
|
173
|
+
base = release_base_url(version)
|
|
174
|
+
asset = _asset_name(version)
|
|
175
|
+
archive = cache_root() / version / asset
|
|
176
|
+
_download(f"{base}/{asset}", archive)
|
|
177
|
+
_verify(archive, base, asset)
|
|
178
|
+
_extract(archive, target)
|
|
179
|
+
archive.unlink(missing_ok=True)
|
|
180
|
+
_macos_prepare(target)
|
|
181
|
+
|
|
182
|
+
binp = find_binary(target)
|
|
183
|
+
if not binp:
|
|
184
|
+
raise RuntimeError(f"no browser binary found in extracted build at {target}")
|
|
185
|
+
if os.name != "nt":
|
|
186
|
+
binp.chmod(0o755)
|
|
187
|
+
print(f"[chromiumfish] ready: {binp}", file=sys.stderr)
|
|
188
|
+
return binp
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def binary_path(version: str | None = None, *, download: bool = True) -> Path:
|
|
192
|
+
"""Path to the cached binary, fetching it if needed (and allowed)."""
|
|
193
|
+
version = version or browser_version()
|
|
194
|
+
existing = find_binary(install_dir(version))
|
|
195
|
+
if existing:
|
|
196
|
+
return existing
|
|
197
|
+
if not download:
|
|
198
|
+
raise FileNotFoundError(
|
|
199
|
+
f"ChromiumFish {version} not installed. Run `chromiumfish fetch`."
|
|
200
|
+
)
|
|
201
|
+
return fetch(version)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared launch-argument construction for the sync/async wrappers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
# Flags that keep the GPU-less / SwiftShader path working and the persona
|
|
8
|
+
# engine happy. Mirrors the production launch_lean.sh defaults (minus anything
|
|
9
|
+
# that is now baked into the build / bundled addon).
|
|
10
|
+
BASE_ARGS: list[str] = [
|
|
11
|
+
"--no-sandbox",
|
|
12
|
+
"--no-zygote",
|
|
13
|
+
"--disable-dev-shm-usage",
|
|
14
|
+
"--use-gl=angle",
|
|
15
|
+
"--use-angle=swiftshader",
|
|
16
|
+
"--enable-unsafe-swiftshader",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_args(
|
|
21
|
+
*,
|
|
22
|
+
persona_seed: int | None = None,
|
|
23
|
+
window_size: tuple[int, int] | None = (1920, 1080),
|
|
24
|
+
extra_args: list[str] | None = None,
|
|
25
|
+
) -> list[str]:
|
|
26
|
+
args = list(BASE_ARGS)
|
|
27
|
+
if persona_seed is not None:
|
|
28
|
+
args.append(f"--persona-seed={persona_seed}")
|
|
29
|
+
if window_size is not None:
|
|
30
|
+
args.append(f"--window-size={window_size[0]},{window_size[1]}")
|
|
31
|
+
if extra_args:
|
|
32
|
+
args.extend(extra_args)
|
|
33
|
+
return args
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def launch_options(
|
|
37
|
+
*,
|
|
38
|
+
executable_path: Path,
|
|
39
|
+
headless: bool,
|
|
40
|
+
persona_seed: int | None,
|
|
41
|
+
proxy: dict[str, Any] | None,
|
|
42
|
+
window_size: tuple[int, int] | None,
|
|
43
|
+
args: list[str] | None,
|
|
44
|
+
extra: dict[str, Any],
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
opts: dict[str, Any] = {
|
|
47
|
+
"executable_path": str(executable_path),
|
|
48
|
+
"headless": headless,
|
|
49
|
+
"args": build_args(
|
|
50
|
+
persona_seed=persona_seed, window_size=window_size, extra_args=args
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
if proxy is not None:
|
|
54
|
+
opts["proxy"] = proxy
|
|
55
|
+
opts.update(extra)
|
|
56
|
+
return opts
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Sync Playwright wrapper for ChromiumFish.
|
|
2
|
+
|
|
3
|
+
from chromiumfish.sync_api import Chromiumfish
|
|
4
|
+
|
|
5
|
+
with Chromiumfish(persona_seed=27182, headless=True) as browser:
|
|
6
|
+
page = browser.new_page()
|
|
7
|
+
page.goto("https://example.com")
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from playwright.sync_api import Browser, sync_playwright
|
|
14
|
+
|
|
15
|
+
from .fetch import binary_path
|
|
16
|
+
from .launcher import launch_options
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Chromiumfish:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
persona_seed: int | None = None,
|
|
24
|
+
headless: bool = True,
|
|
25
|
+
proxy: dict[str, Any] | None = None,
|
|
26
|
+
window_size: tuple[int, int] | None = (1920, 1080),
|
|
27
|
+
version: str | None = None,
|
|
28
|
+
download: bool = True,
|
|
29
|
+
args: list[str] | None = None,
|
|
30
|
+
**launch_kwargs: Any,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._opts = dict(
|
|
33
|
+
persona_seed=persona_seed,
|
|
34
|
+
headless=headless,
|
|
35
|
+
proxy=proxy,
|
|
36
|
+
window_size=window_size,
|
|
37
|
+
args=args,
|
|
38
|
+
extra=launch_kwargs,
|
|
39
|
+
)
|
|
40
|
+
self._version = version
|
|
41
|
+
self._download = download
|
|
42
|
+
self._pw = None
|
|
43
|
+
self._browser: Browser | None = None
|
|
44
|
+
|
|
45
|
+
def start(self) -> Browser:
|
|
46
|
+
exe = binary_path(self._version, download=self._download)
|
|
47
|
+
self._pw = sync_playwright().start()
|
|
48
|
+
self._browser = self._pw.chromium.launch(
|
|
49
|
+
**launch_options(executable_path=exe, **self._opts)
|
|
50
|
+
)
|
|
51
|
+
return self._browser
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
if self._browser:
|
|
55
|
+
self._browser.close()
|
|
56
|
+
self._browser = None
|
|
57
|
+
if self._pw:
|
|
58
|
+
self._pw.stop()
|
|
59
|
+
self._pw = None
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> Browser:
|
|
62
|
+
return self.start()
|
|
63
|
+
|
|
64
|
+
def __exit__(self, *exc: object) -> None:
|
|
65
|
+
self.close()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Pinned browser build + release coordinates.
|
|
2
|
+
|
|
3
|
+
The browser is built privately and published to this repo's GitHub Releases.
|
|
4
|
+
`BROWSER_VERSION` is the release tag (without the leading ``v``) the SDK
|
|
5
|
+
downloads by default; override it at runtime with ``CHROMIUMFISH_VERSION``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
# SDK package version. Single source of truth: pyproject.toml reads this via
|
|
12
|
+
# [tool.hatch.version] (dynamic = ["version"]).
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
# Default ChromiumFish browser build to fetch. Matches src/chrome/VERSION.
|
|
16
|
+
DEFAULT_BROWSER_VERSION = "150.0.7844"
|
|
17
|
+
|
|
18
|
+
# GitHub repo that hosts the release assets (public; binary built from the
|
|
19
|
+
# private chromiumfish-browser repo).
|
|
20
|
+
RELEASE_REPO = "arman-bd/chromiumfish"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def browser_version() -> str:
|
|
24
|
+
"""Resolved browser version (env override wins)."""
|
|
25
|
+
return os.environ.get("CHROMIUMFISH_VERSION", DEFAULT_BROWSER_VERSION)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def release_base_url(version: str | None = None) -> str:
|
|
29
|
+
version = version or browser_version()
|
|
30
|
+
return f"https://github.com/{RELEASE_REPO}/releases/download/v{version}"
|