mirror of
https://forge.fsky.io/oneflux/omegafox.git
synced 2026-02-10 04:52:03 -08:00
Camoufox Python interface
Port of the launcher to a Python library.
This commit is contained in:
parent
9dfb15d371
commit
5e1fb78cfc
16 changed files with 1439 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,6 +6,7 @@
|
|||
_old/
|
||||
dist/
|
||||
bin/
|
||||
venv/
|
||||
/bundle/fonts/extra
|
||||
launch
|
||||
launch.exe
|
||||
|
|
|
|||
|
|
@ -344,7 +344,8 @@ Miscellaneous (WebGl spoofing, battery status, etc)
|
|||
|
||||
- Custom implementation of Playwright for the latest Firefox
|
||||
- Various config patches to evade bot detection
|
||||
- Removed leaking Playwright patches:
|
||||
- Fixes leaking Playwright patches:
|
||||
- All page agent javascript is sandboxed
|
||||
- Fixes frame execution context leaks
|
||||
- Fixes `navigator.webdriver` detection
|
||||
- Removed potentially leaking anti-zoom/meta viewport handling patches
|
||||
|
|
@ -356,6 +357,7 @@ Miscellaneous (WebGl spoofing, battery status, etc)
|
|||
- Stripped out/disabled _many, many_ Mozilla services. Runs faster than the original Mozilla Firefox, and uses less memory (200mb)
|
||||
- Includes the debloat config from PeskyFox & LibreWolf, and others
|
||||
- Speed optimizations from FastFox, and other network optimizations
|
||||
- Removed all CSS animations
|
||||
- Minimalistic theming
|
||||
- etc.
|
||||
|
||||
|
|
|
|||
114
pythonlib/README.md
Normal file
114
pythonlib/README.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Camoufox Python Interface
|
||||
|
||||
#### This is the Python library for Camoufox.
|
||||
|
||||
> [!WARNING]
|
||||
> This is still experimental and in active development!
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/camoufox
|
||||
cd camoufox/pythonlib
|
||||
pip install .
|
||||
```
|
||||
|
||||
<hr width=50>
|
||||
|
||||
## Usage
|
||||
|
||||
Camoufox is fully compatible with your existing Playwright code. You only have to change your browser initialization:
|
||||
|
||||
#### Sync API
|
||||
|
||||
```python
|
||||
from camoufox.sync_api import Camoufox
|
||||
|
||||
with Camoufox() as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://www.browserscan.net/")
|
||||
```
|
||||
|
||||
#### Async API
|
||||
|
||||
```python
|
||||
from camoufox.async_api import AsyncCamoufox
|
||||
|
||||
async with AsyncCamoufox() as browser:
|
||||
page = await browser.new_page()
|
||||
await page.goto("https://www.browserscan.net/")
|
||||
```
|
||||
<details>
|
||||
<summary>Parameters</summary>
|
||||
|
||||
```
|
||||
Launches a new browser instance for Camoufox.
|
||||
|
||||
Parameters:
|
||||
playwright (Playwright):
|
||||
The playwright instance to use.
|
||||
config (Optional[Dict[str, Any]]):
|
||||
The configuration to use.
|
||||
addons (Optional[List[str]]):
|
||||
The addons to use.
|
||||
fingerprint (Optional[Fingerprint]):
|
||||
The fingerprint to use.
|
||||
exclude_addons (Optional[List[DefaultAddons]]):
|
||||
The default addons to exclude, passed as a list of camoufox.DefaultAddons enums.
|
||||
screen (Optional[browserforge.fingerprints.Screen]):
|
||||
The screen constraints to use.
|
||||
os (Optional[ListOrString]):
|
||||
The operating system to use for the fingerprint. Either a string or a list of strings.
|
||||
user_agent (Optional[ListOrString]):
|
||||
The user agent to use for the fingerprint. Either a string or a list of strings.
|
||||
fonts (Optional[List[str]]):
|
||||
The fonts to load into Camoufox, in addition to the default fonts.
|
||||
args (Optional[List[str]]):
|
||||
The arguments to pass to the browser.
|
||||
executable_path (Optional[str]):
|
||||
The path to the Camoufox browser executable.
|
||||
**launch_options (Dict[str, Any]):
|
||||
Additional Firefox launch options.
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Config
|
||||
|
||||
Camoufox [config data](https://github.com/daijro/camoufox?tab=readme-ov-file#fingerprint-injection) can be passed as a dictionary to the `config` parameter:
|
||||
|
||||
```python
|
||||
from camoufox import Camoufox
|
||||
|
||||
with Camoufox(
|
||||
config={
|
||||
'webrtc:ipv4': '123.45.67.89',
|
||||
'webrtc:ipv6': 'e791:d37a:88f6:48d1:2cad:2667:4582:1d6d',
|
||||
}
|
||||
) as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://www.browserscan.net/")
|
||||
```
|
||||
|
||||
<hr width=50>
|
||||
|
||||
### BrowserForge Integration
|
||||
|
||||
Camoufox is fully compatible with BrowserForge.
|
||||
|
||||
By default, Camoufox will use a random fingerprint. You can also inject your own Firefox Browserforge fingerprint into Camoufox with the following example:
|
||||
|
||||
```python
|
||||
from camoufox.sync_api import Camoufox
|
||||
from browserforge.fingerprints import FingerprintGenerator
|
||||
|
||||
fg = FingerprintGenerator(browser='firefox')
|
||||
|
||||
# Launch Camoufox with a random Firefox fingerprint
|
||||
with Camoufox(fingerprint=fg.generate()) as browser:
|
||||
page = browser.new_page()
|
||||
page.goto("https://www.browserscan.net/")
|
||||
```
|
||||
|
||||
---
|
||||
5
pythonlib/camoufox/__init__.py
Normal file
5
pythonlib/camoufox/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from .addons import DefaultAddons
|
||||
from .async_api import AsyncCamoufox, AsyncNewBrowser
|
||||
from .sync_api import Camoufox, NewBrowser
|
||||
|
||||
__all__ = ["Camoufox", "NewBrowser", "AsyncCamoufox", "AsyncNewBrowser", "DefaultAddons"]
|
||||
112
pythonlib/camoufox/__main__.py
Normal file
112
pythonlib/camoufox/__main__.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""
|
||||
Binary CLI manager for Camoufox.
|
||||
|
||||
Adapted from https://github.com/daijro/hrequests/blob/main/hrequests/__main__.py
|
||||
"""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as pkg_version
|
||||
|
||||
import click
|
||||
|
||||
from .pkgman import CamoufoxFetcher, installed_verstr, rprint
|
||||
|
||||
|
||||
class CamoufoxUpdate(CamoufoxFetcher):
|
||||
"""
|
||||
Checks & updates Camoufox
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initializes the CamoufoxUpdate class
|
||||
"""
|
||||
super().__init__()
|
||||
try:
|
||||
self.current_verstr = installed_verstr()
|
||||
except FileNotFoundError:
|
||||
self.current_verstr = None
|
||||
|
||||
def is_updated_needed(self) -> None:
|
||||
if self.current_verstr is None:
|
||||
return True
|
||||
# If the installed version is not the latest version
|
||||
if self.current_verstr != self.verstr:
|
||||
return True
|
||||
return False
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Updates the library if needed
|
||||
"""
|
||||
# Check if the version is the same as the latest available version
|
||||
if not self.is_updated_needed():
|
||||
rprint("Camoufox binaries up to date!", fg="green")
|
||||
rprint(f"Current version: v{self.current_verstr}", fg="green")
|
||||
return
|
||||
|
||||
# Download updated file
|
||||
if self.current_verstr is not None:
|
||||
# Display an updating message
|
||||
rprint(
|
||||
f"Updating Camoufox binaries from v{self.current_verstr} => v{self.verstr}",
|
||||
fg="yellow",
|
||||
)
|
||||
else:
|
||||
rprint(f"Fetching Camoufox binaries v{self.verstr}...", fg="yellow")
|
||||
# Install the new version
|
||||
self.install()
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli() -> None:
|
||||
pass
|
||||
|
||||
|
||||
@cli.command(name='fetch')
|
||||
def fetch():
|
||||
"""
|
||||
Fetch the latest version of Camoufox
|
||||
"""
|
||||
CamoufoxUpdate().update()
|
||||
|
||||
|
||||
@cli.command(name='remove')
|
||||
def remove() -> None:
|
||||
"""
|
||||
Remove all library files
|
||||
"""
|
||||
if not CamoufoxUpdate().cleanup():
|
||||
rprint("Camoufox binaries not found!", fg="red")
|
||||
|
||||
|
||||
@cli.command(name='version')
|
||||
def version() -> None:
|
||||
"""
|
||||
Display the current version
|
||||
"""
|
||||
# python package version
|
||||
try:
|
||||
rprint(f"Pip package:\tv{pkg_version('camoufox')}", fg="green")
|
||||
except PackageNotFoundError:
|
||||
rprint("Pip package:\tNot installed!", fg="red")
|
||||
|
||||
updater = CamoufoxUpdate()
|
||||
bin_ver = updater.current_verstr
|
||||
|
||||
# If binaries are not downloaded
|
||||
if not bin_ver:
|
||||
rprint("Camoufox:\tNot downloaded!", fg="red")
|
||||
return
|
||||
# Print the base version
|
||||
rprint(f"Camoufox:\tv{bin_ver} ", fg="green", nl=False)
|
||||
|
||||
# Check for library updates
|
||||
if updater.is_updated_needed():
|
||||
rprint(f"(Latest: v{updater.verstr})", fg="red")
|
||||
else:
|
||||
rprint("(Up to date!)", fg="yellow")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
||||
184
pythonlib/camoufox/addons.py
Normal file
184
pythonlib/camoufox/addons.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
|
||||
import orjson
|
||||
|
||||
from .exceptions import InvalidAddonPath, InvalidDebugPort, MissingDebugPort
|
||||
|
||||
|
||||
class DefaultAddons(Enum):
|
||||
"""
|
||||
Default addons to be downloaded
|
||||
"""
|
||||
|
||||
uBO = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi"
|
||||
BPC = "https://gitflic.ru/project/magnolia1234/bpc_uploads/blob/raw?file=bypass_paywalls_clean-latest.xpi"
|
||||
|
||||
|
||||
def get_debug_port(args: List[str]) -> int:
|
||||
"""
|
||||
Gets the debug port from the args, or creates a new one if not provided
|
||||
"""
|
||||
for i, arg in enumerate(args):
|
||||
# Search for debugger server port
|
||||
if arg == "-start-debugger-server":
|
||||
# If arg is found but no port is provided, raise an error
|
||||
if i + 1 >= len(args):
|
||||
raise MissingDebugPort(f"No debug port provided: {args}")
|
||||
debug_port = args[i + 1]
|
||||
# Try to parse the debug port as an integer
|
||||
try:
|
||||
return int(debug_port)
|
||||
except ValueError as e:
|
||||
raise InvalidDebugPort(
|
||||
f"Error parsing debug port. Must be an integer: {debug_port}"
|
||||
) from e
|
||||
|
||||
# Create new debugger server port
|
||||
debug_port_int = get_open_port()
|
||||
# Add -start-debugger-server {debug_port} to args
|
||||
args.extend(["-start-debugger-server", str(debug_port_int)])
|
||||
|
||||
return debug_port_int
|
||||
|
||||
|
||||
def confirm_paths(paths: List[str]) -> None:
|
||||
"""
|
||||
Confirms that the addon paths are valid
|
||||
"""
|
||||
for path in paths:
|
||||
if not os.path.exists(path):
|
||||
raise InvalidAddonPath(path)
|
||||
|
||||
|
||||
def get_open_port() -> int:
|
||||
"""
|
||||
Gets an open port
|
||||
"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('localhost', 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def threaded_try_load_addons(debug_port_int: int, addons_list: List[str]) -> None:
|
||||
"""
|
||||
Tries to load addons (in a separate thread)
|
||||
"""
|
||||
thread = threading.Thread(
|
||||
target=try_load_addons, args=(debug_port_int, addons_list), daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
def try_load_addons(debug_port_int: int, addons_list: List[str]) -> None:
|
||||
"""
|
||||
Tries to load addons
|
||||
"""
|
||||
# Wait for the server to be open
|
||||
while True:
|
||||
try:
|
||||
with socket.create_connection(("localhost", debug_port_int)):
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
time.sleep(0.05)
|
||||
|
||||
# Load addons
|
||||
asyncio.run(load_all_addons(debug_port_int, addons_list))
|
||||
|
||||
|
||||
async def load_all_addons(debug_port_int: int, addons_list: List[str]) -> None:
|
||||
"""
|
||||
Loads all addons
|
||||
"""
|
||||
addon_loaders = [LoadFirefoxAddon(debug_port_int, addon) for addon in addons_list]
|
||||
await asyncio.gather(*[addon_loader.load() for addon_loader in addon_loaders])
|
||||
|
||||
|
||||
class LoadFirefoxAddon:
|
||||
'''
|
||||
Firefox addon loader
|
||||
https://github.com/daijro/hrequests/blob/main/hrequests/extensions.py#L95
|
||||
'''
|
||||
|
||||
def __init__(self, port, addon_path):
|
||||
self.port: int = port
|
||||
self.addon_path: str = addon_path
|
||||
self.success: bool = False
|
||||
self.buffers: list = []
|
||||
self.remaining_bytes: int = 0
|
||||
|
||||
async def load(self):
|
||||
reader, writer = await asyncio.open_connection('localhost', self.port)
|
||||
writer.write(self._format_message({"to": "root", "type": "getRoot"}))
|
||||
await writer.drain()
|
||||
|
||||
while True:
|
||||
data = await reader.read(100) # Adjust buffer size as needed
|
||||
if not data:
|
||||
break
|
||||
await self._process_data(writer, data)
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return self.success
|
||||
|
||||
async def _process_data(self, writer, data):
|
||||
while data:
|
||||
if self.remaining_bytes == 0:
|
||||
index = data.find(b':')
|
||||
if index == -1:
|
||||
self.buffers.append(data)
|
||||
return
|
||||
|
||||
total_data = b''.join(self.buffers) + data
|
||||
size, _, remainder = total_data.partition(b':')
|
||||
|
||||
try:
|
||||
self.remaining_bytes = int(size)
|
||||
except ValueError as e:
|
||||
raise ValueError("Invalid state") from e
|
||||
|
||||
data = remainder
|
||||
|
||||
if len(data) < self.remaining_bytes:
|
||||
self.remaining_bytes -= len(data)
|
||||
self.buffers.append(data)
|
||||
return
|
||||
else:
|
||||
self.buffers.append(data[: self.remaining_bytes])
|
||||
message = orjson.loads(b''.join(self.buffers))
|
||||
self.buffers.clear()
|
||||
|
||||
await self._on_message(writer, message)
|
||||
|
||||
data = data[self.remaining_bytes :]
|
||||
self.remaining_bytes = 0
|
||||
|
||||
async def _on_message(self, writer, message):
|
||||
if "addonsActor" in message:
|
||||
writer.write(
|
||||
self._format_message(
|
||||
{
|
||||
"to": message["addonsActor"],
|
||||
"type": "installTemporaryAddon",
|
||||
"addonPath": self.addon_path,
|
||||
}
|
||||
)
|
||||
)
|
||||
await writer.drain()
|
||||
|
||||
if "addon" in message:
|
||||
self.success = True
|
||||
writer.write_eof()
|
||||
|
||||
if "error" in message:
|
||||
writer.write_eof()
|
||||
|
||||
def _format_message(self, data):
|
||||
raw = orjson.dumps(data)
|
||||
return f"{len(raw)}:".encode() + raw
|
||||
88
pythonlib/camoufox/async_api.py
Normal file
88
pythonlib/camoufox/async_api.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from browserforge.fingerprints import Fingerprint, Screen
|
||||
from playwright.async_api import Browser, Playwright, PlaywrightContextManager
|
||||
|
||||
from .addons import DefaultAddons
|
||||
from .utils import ListOrString, get_launch_options
|
||||
|
||||
|
||||
class AsyncCamoufox(PlaywrightContextManager):
|
||||
"""
|
||||
Wrapper around playwright.async_api.PlaywrightContextManager that automatically
|
||||
launches a browser and closes it when the context manager is exited.
|
||||
"""
|
||||
|
||||
def __init__(self, **launch_options):
|
||||
super().__init__()
|
||||
self.launch_options = launch_options
|
||||
self.browser: Optional[Browser] = None
|
||||
|
||||
async def __aenter__(self) -> Browser:
|
||||
await super().__aenter__()
|
||||
self.browser = await AsyncNewBrowser(self._playwright, **self.launch_options)
|
||||
return self.browser
|
||||
|
||||
async def __aexit__(self, *args: Any):
|
||||
if self.browser:
|
||||
await self.browser.close()
|
||||
await super().__aexit__(*args)
|
||||
|
||||
|
||||
async def AsyncNewBrowser(
|
||||
playwright: Playwright,
|
||||
*,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
addons: Optional[List[str]] = None,
|
||||
fingerprint: Optional[Fingerprint] = None,
|
||||
exclude_addons: Optional[List[DefaultAddons]] = None,
|
||||
screen: Optional[Screen] = None,
|
||||
os: Optional[ListOrString] = None,
|
||||
user_agent: Optional[ListOrString] = None,
|
||||
fonts: Optional[List[str]] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
executable_path: Optional[str] = None,
|
||||
**launch_options: Dict[str, Any]
|
||||
) -> Browser:
|
||||
"""
|
||||
Launches a new browser instance for Camoufox.
|
||||
|
||||
Parameters:
|
||||
playwright (Playwright):
|
||||
The playwright instance to use.
|
||||
config (Optional[Dict[str, Any]]):
|
||||
The configuration to use.
|
||||
addons (Optional[List[str]]):
|
||||
The addons to use.
|
||||
fingerprint (Optional[Fingerprint]):
|
||||
The fingerprint to use.
|
||||
exclude_addons (Optional[List[DefaultAddons]]):
|
||||
The default addons to exclude, passed as a list of camoufox.DefaultAddons enums.
|
||||
screen (Optional[browserforge.fingerprints.Screen]):
|
||||
The screen constraints to use.
|
||||
os (Optional[ListOrString]):
|
||||
The operating system to use for the fingerprint. Either a string or a list of strings.
|
||||
user_agent (Optional[ListOrString]):
|
||||
The user agent to use for the fingerprint. Either a string or a list of strings.
|
||||
fonts (Optional[List[str]]):
|
||||
The fonts to load into Camoufox, in addition to the default fonts.
|
||||
args (Optional[List[str]]):
|
||||
The arguments to pass to the browser.
|
||||
executable_path (Optional[str]):
|
||||
The path to the Camoufox browser executable.
|
||||
**launch_options (Dict[str, Any]):
|
||||
Additional Firefox launch options.
|
||||
"""
|
||||
opt = get_launch_options(
|
||||
config=config,
|
||||
addons=addons,
|
||||
fingerprint=fingerprint,
|
||||
exclude_addons=exclude_addons,
|
||||
screen=screen,
|
||||
os=os,
|
||||
user_agent=user_agent,
|
||||
fonts=fonts,
|
||||
args=args,
|
||||
executable_path=executable_path,
|
||||
)
|
||||
return await playwright.firefox.launch(**opt, **launch_options)
|
||||
65
pythonlib/camoufox/browserforge.yaml
Normal file
65
pythonlib/camoufox/browserforge.yaml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Mappings of Browserforge fingerprints to Camoufox config properties.
|
||||
|
||||
navigator:
|
||||
# Disabled for now, since Browserforge tends to have outdated UAs
|
||||
# userAgent: navigator.userAgent
|
||||
# userAgentData not in Firefox
|
||||
doNotTrack: navigator.doNotTrack
|
||||
appCodeName: navigator.appCodeName
|
||||
appName: navigator.appName
|
||||
appVersion: navigator.appVersion
|
||||
oscpu: navigator.oscpu
|
||||
# webdriver is always True
|
||||
language: navigator.language
|
||||
languages: navigator.languages
|
||||
platform: navigator.platform
|
||||
# deviceMemory not in Firefox
|
||||
hardwareConcurrency: navigator.hardwareConcurrency
|
||||
product: navigator.product
|
||||
productSub: navigator.productSub
|
||||
# vendor is not necessary
|
||||
# vendorSub is not necessary
|
||||
maxTouchPoints: navigator.maxTouchPoints
|
||||
extraProperties:
|
||||
# Note: Changing pdfViewerEnabled is not recommended. This will be kept to True.
|
||||
globalPrivacyControl: navigator.globalPrivacyControl
|
||||
|
||||
screen:
|
||||
# hasHDR is not implemented in Camoufox
|
||||
availHeight: screen.availHeight
|
||||
availWidth: screen.availWidth
|
||||
availTop: screen.availTop
|
||||
availLeft: screen.availLeft
|
||||
height: screen.height
|
||||
width: screen.width
|
||||
colorDepth: screen.colorDepth
|
||||
pixelDepth: screen.pixelDepth
|
||||
# devicePixelRatio is not recommended. Any value other than 1.0 is suspicious.
|
||||
pageXOffset: screen.pageXOffset
|
||||
pageYOffset: screen.pageYOffset
|
||||
# Disable viewport hijacking temporarily.
|
||||
# outerHeight: window.outerHeight
|
||||
# outerWidth: window.outerWidth
|
||||
# innerHeight: window.innerHeight
|
||||
# innerWidth: window.innerWidth
|
||||
screenX: window.screenX
|
||||
# These seem to not be generating properly in Browserforge:
|
||||
# clientWidth: document.body.clientWidth
|
||||
# clientHeight: document.body.clientHeight
|
||||
|
||||
videoCard:
|
||||
renderer: webGl:renderer
|
||||
vendor: webGl:vendor
|
||||
|
||||
headers:
|
||||
# headers.User-Agent is redundant with navigator.userAgent
|
||||
Accept-Language: headers.Accept-Language
|
||||
Accept-Encoding: headers.Accept-Encoding
|
||||
|
||||
battery:
|
||||
charging: battery:charging
|
||||
chargingTime: battery:chargingTime
|
||||
dischargingTime: battery:dischargingTime
|
||||
|
||||
# Unsupported: videoCodecs, audioCodecs, pluginsData, multimediaDevices
|
||||
# Fonts are listed through the launcher.
|
||||
54
pythonlib/camoufox/exceptions.py
Normal file
54
pythonlib/camoufox/exceptions.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
class UnsupportedArchitecture(Exception):
|
||||
"""
|
||||
Raised when the architecture is not supported.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class UnsupportedOS(Exception):
|
||||
"""
|
||||
Raised when the OS is not supported.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class UnknownProperty(Exception):
|
||||
"""
|
||||
Raised when the property is unknown.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class InvalidPropertyType(Exception):
|
||||
"""
|
||||
Raised when the property type is invalid.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class InvalidAddonPath(FileNotFoundError):
|
||||
"""
|
||||
Raised when the addon path is invalid.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class InvalidDebugPort(ValueError):
|
||||
"""
|
||||
Raised when the debug port is invalid.
|
||||
"""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class MissingDebugPort(ValueError):
|
||||
"""
|
||||
Raised when the debug port is missing.
|
||||
"""
|
||||
|
||||
...
|
||||
50
pythonlib/camoufox/fingerprints.py
Normal file
50
pythonlib/camoufox/fingerprints.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import os.path
|
||||
from dataclasses import asdict
|
||||
|
||||
from browserforge.fingerprints import Fingerprint, FingerprintGenerator
|
||||
from yaml import CLoader, load
|
||||
|
||||
# Load the browserforge.yaml file
|
||||
with open(os.path.join(os.path.dirname(__file__), 'browserforge.yaml'), 'r') as f:
|
||||
BROWSERFORGE_DATA = load(f, Loader=CLoader)
|
||||
|
||||
FP_GENERATOR = FingerprintGenerator(browser='firefox', os=('linux', 'macos', 'windows'))
|
||||
|
||||
|
||||
def _cast_to_properties(camoufox_data, cast_enum: dict, bf_dict: dict):
|
||||
"""
|
||||
Casts Browserforge fingerprints to Camoufox config properties.
|
||||
"""
|
||||
for key, data in bf_dict.items():
|
||||
# Ignore non-truthy values
|
||||
if not data:
|
||||
continue
|
||||
# Get the associated Camoufox property
|
||||
type_key = cast_enum.get(key)
|
||||
if not type_key:
|
||||
continue
|
||||
# If the value is a dictionary, recursively recall
|
||||
if isinstance(data, dict):
|
||||
_cast_to_properties(camoufox_data, type_key, data)
|
||||
else:
|
||||
camoufox_data[type_key] = data
|
||||
|
||||
|
||||
def from_browserforge(fingerprint: Fingerprint) -> dict:
|
||||
camoufox_data = {}
|
||||
_cast_to_properties(camoufox_data, cast_enum=BROWSERFORGE_DATA, bf_dict=asdict(fingerprint))
|
||||
return camoufox_data
|
||||
|
||||
|
||||
def generate(**config) -> dict:
|
||||
"""
|
||||
Generates a Firefox fingerprint.
|
||||
"""
|
||||
data = FP_GENERATOR.generate(**config)
|
||||
return from_browserforge(data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pprint import pprint
|
||||
|
||||
pprint(generate())
|
||||
11
pythonlib/camoufox/fonts.json
Normal file
11
pythonlib/camoufox/fonts.json
Normal file
File diff suppressed because one or more lines are too long
339
pythonlib/camoufox/pkgman.py
Normal file
339
pythonlib/camoufox/pkgman.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
from zipfile import ZipFile
|
||||
|
||||
import click
|
||||
import orjson
|
||||
import requests
|
||||
from platformdirs import user_cache_dir
|
||||
from tqdm import tqdm
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
from .exceptions import UnsupportedArchitecture, UnsupportedOS
|
||||
|
||||
DownloadBuffer: TypeAlias = Union[BytesIO, tempfile._TemporaryFileWrapper]
|
||||
|
||||
# Map machine architecture to Camoufox binary name
|
||||
ARCH_MAP: dict[str, str] = {
|
||||
'amd64': 'x86_64',
|
||||
'x86_64': 'x86_64',
|
||||
'x86': 'x86_64',
|
||||
'i686': 'i686',
|
||||
'i386': 'i686',
|
||||
'arm64': 'arm64',
|
||||
'aarch64': 'arm64',
|
||||
'armv5l': 'arm64',
|
||||
'armv6l': 'arm64',
|
||||
'armv7l': 'arm64',
|
||||
}
|
||||
OS_MAP: dict[str, str] = {'darwin': 'mac', 'linux': 'lin', 'win32': 'win'}
|
||||
|
||||
if sys.platform not in OS_MAP:
|
||||
raise UnsupportedOS(f"OS {sys.platform} is not supported")
|
||||
|
||||
OS_NAME: str = OS_MAP[sys.platform]
|
||||
|
||||
INSTALL_DIR: Path = Path(user_cache_dir("camoufox"))
|
||||
|
||||
# The supported architectures for each OS
|
||||
OS_ARCH_MATRIX: dict[str, List[str]] = {
|
||||
'mac': ['x86_64', 'arm64'],
|
||||
'win': ['x86_64', 'i686'],
|
||||
'lin': ['x86_64', 'arm64', 'i686'],
|
||||
}
|
||||
|
||||
|
||||
def rprint(*a, **k):
|
||||
click.secho(*a, **k, bold=True)
|
||||
|
||||
|
||||
class CamoufoxFetcher:
|
||||
def __init__(self) -> None:
|
||||
self.arch = self.get_platform_arch()
|
||||
self._version: str | None = None
|
||||
self._release: str | None = None
|
||||
self.pattern: re.Pattern = re.compile(rf'camoufox-(.+)-(.+)-{OS_NAME}\.{self.arch}\.zip')
|
||||
|
||||
self.fetch_latest()
|
||||
|
||||
@staticmethod
|
||||
def get_platform_arch() -> str:
|
||||
"""
|
||||
Get the current platform and architecture information.
|
||||
|
||||
Returns:
|
||||
|
||||
|
||||
Raises:
|
||||
UnsupportedOS: If the current OS is not supported
|
||||
UnsupportedArchitecture: If the current architecture is not supported
|
||||
"""
|
||||
|
||||
# Check if the architecture is supported for the OS
|
||||
plat_arch = platform.machine().lower()
|
||||
if plat_arch not in ARCH_MAP:
|
||||
raise UnsupportedArchitecture(f"Architecture {plat_arch} is not supported")
|
||||
|
||||
arch = ARCH_MAP[plat_arch]
|
||||
|
||||
# Check if the architecture is supported for the OS
|
||||
if arch not in OS_ARCH_MATRIX[OS_NAME]:
|
||||
raise UnsupportedArchitecture(f"Architecture {arch} is not supported for {OS_NAME}")
|
||||
|
||||
return arch
|
||||
|
||||
def fetch_latest(self) -> None:
|
||||
"""
|
||||
Fetch the URL of the latest camoufox release for the current platform.
|
||||
Sets the version, release, and url properties.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If there's an error fetching release data
|
||||
ValueError: If no matching release is found for the current platform
|
||||
"""
|
||||
api_url = "https://api.github.com/repos/daijro/camoufox/releases/latest"
|
||||
response = requests.get(api_url, timeout=20)
|
||||
response.raise_for_status()
|
||||
|
||||
release_data = response.json()
|
||||
assets = release_data['assets']
|
||||
|
||||
for asset in assets:
|
||||
if match := self.pattern.match(asset['name']):
|
||||
# Set the version and release
|
||||
self._version = match.group(1)
|
||||
self._release = match.group(2)
|
||||
# Return the download URL
|
||||
self._url = asset['browser_download_url']
|
||||
return
|
||||
|
||||
raise ValueError(f"No matching release found for {OS_NAME}-{self.arch}")
|
||||
|
||||
@staticmethod
|
||||
def download_file(file: DownloadBuffer, url: str) -> DownloadBuffer:
|
||||
"""
|
||||
Download a file from the given URL and return it as BytesIO.
|
||||
|
||||
Args:
|
||||
url (str): The URL to download the file from
|
||||
|
||||
Returns:
|
||||
BytesIO: The downloaded file content as a BytesIO object
|
||||
"""
|
||||
rprint(f'Downloading package: {url}')
|
||||
return webdl(url, buffer=file)
|
||||
|
||||
def extract_zip(self, zip_file: DownloadBuffer) -> None:
|
||||
"""
|
||||
Extract the contents of a zip file to the installation directory.
|
||||
|
||||
Args:
|
||||
zip_file (BytesIO): The zip file content as a BytesIO object
|
||||
"""
|
||||
rprint(f'Extracting Camoufox: {INSTALL_DIR}')
|
||||
unzip(zip_file, str(INSTALL_DIR))
|
||||
|
||||
@staticmethod
|
||||
def cleanup() -> bool:
|
||||
"""
|
||||
Clean up the old installation.
|
||||
"""
|
||||
if INSTALL_DIR.exists():
|
||||
rprint(f'Cleaning up cache: {INSTALL_DIR}')
|
||||
shutil.rmtree(INSTALL_DIR)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_version(self) -> None:
|
||||
"""
|
||||
Set the version in the INSTALL_DIR/version.json file
|
||||
"""
|
||||
with open(INSTALL_DIR / 'version.json', 'wb') as f:
|
||||
f.write(orjson.dumps({'version': self.version, 'release': self.release}))
|
||||
|
||||
def install(self) -> None:
|
||||
"""
|
||||
Download and install the latest version of camoufox.
|
||||
|
||||
Raises:
|
||||
Exception: If any error occurs during the installation process
|
||||
"""
|
||||
# Clean up old installation
|
||||
self.cleanup()
|
||||
try:
|
||||
# Install to directory
|
||||
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Fetch the latest zip
|
||||
with tempfile.NamedTemporaryFile() as temp_file:
|
||||
self.download_file(temp_file, self.url)
|
||||
self.extract_zip(temp_file)
|
||||
self.set_version()
|
||||
|
||||
# Run chmod -R 755 on INSTALL_DIR
|
||||
if OS_NAME != 'win':
|
||||
shutil.chmod(INSTALL_DIR, 0o755)
|
||||
|
||||
# Clean up old installation
|
||||
rprint('\nCamoufox successfully installed.', fg="yellow")
|
||||
except Exception as e:
|
||||
rprint(f"Error installing Camoufox: {str(e)}")
|
||||
self.cleanup()
|
||||
raise
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""
|
||||
Url of the fetched latest version of camoufox.
|
||||
|
||||
Returns:
|
||||
str: The version of the installed camoufox
|
||||
|
||||
Raises:
|
||||
ValueError: If the version is not available (fetch_latest not ran)
|
||||
"""
|
||||
if self._url is None:
|
||||
raise ValueError("Url is not available. Make sure to run fetch_latest first.")
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""
|
||||
Version of the fetched latest version of camoufox.
|
||||
|
||||
Returns:
|
||||
str: The version of the installed camoufox
|
||||
|
||||
Raises:
|
||||
ValueError: If the version is not available (fetch_latest not ran)
|
||||
"""
|
||||
if self._version is None:
|
||||
raise ValueError("Version is not available. Make sure to run the fetch_latest first.")
|
||||
return self._version
|
||||
|
||||
@property
|
||||
def release(self) -> str:
|
||||
"""
|
||||
Release of the fetched latest version of camoufox.
|
||||
|
||||
Returns:
|
||||
str: The release of the installed camoufox
|
||||
|
||||
Raises:
|
||||
ValueError: If the release information is not available (fetch_latest not ran)
|
||||
"""
|
||||
if self._release is None:
|
||||
raise ValueError(
|
||||
"Release information is not available. Make sure to run the installation first."
|
||||
)
|
||||
return self._release
|
||||
|
||||
@property
|
||||
def verstr(self) -> str:
|
||||
"""
|
||||
Fetches the version and release in version-release format
|
||||
|
||||
Returns:
|
||||
str: The version of the installed camoufox
|
||||
"""
|
||||
return f"{self.version}-{self.release}"
|
||||
|
||||
|
||||
def installed_verstr() -> str:
|
||||
"""
|
||||
Get the full version string of the installed camoufox.
|
||||
"""
|
||||
version_path = INSTALL_DIR / 'version.json'
|
||||
if not os.path.exists(version_path):
|
||||
raise FileNotFoundError(f"Version information not found at {version_path}")
|
||||
|
||||
with open(version_path, 'rb') as f:
|
||||
version_data = orjson.loads(f.read())
|
||||
return f"{version_data['version']}-{version_data['release']}"
|
||||
|
||||
|
||||
def camoufox_path(download_if_missing: bool = True) -> Path:
|
||||
"""
|
||||
Full path to the camoufox folder.
|
||||
"""
|
||||
if not os.path.exists(INSTALL_DIR):
|
||||
if not download_if_missing:
|
||||
raise FileNotFoundError(f"Camoufox executable not found at {INSTALL_DIR}")
|
||||
|
||||
installer = CamoufoxFetcher()
|
||||
installer.install()
|
||||
# Rerun and ensure it's installed
|
||||
return camoufox_path()
|
||||
|
||||
return INSTALL_DIR
|
||||
|
||||
|
||||
def get_path(file: str) -> str:
|
||||
"""
|
||||
Get the path to the camoufox executable.
|
||||
"""
|
||||
if OS_NAME == 'mac':
|
||||
return str(camoufox_path() / 'Camoufox.app' / 'Contents' / 'Resources' / file)
|
||||
return str(camoufox_path() / file)
|
||||
|
||||
|
||||
def webdl(
|
||||
url: str,
|
||||
desc: Optional[str] = None,
|
||||
buffer: Optional[DownloadBuffer] = None,
|
||||
) -> DownloadBuffer:
|
||||
"""
|
||||
Download a file from the given URL and return it as BytesIO.
|
||||
|
||||
Args:
|
||||
url (str): The URL to download the file from
|
||||
buffer (Optional[BytesIO]): A BytesIO object to store the downloaded file
|
||||
|
||||
Returns:
|
||||
DownloadBuffer: The downloaded file content as a BytesIO object
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If there's an error downloading the file
|
||||
"""
|
||||
response = requests.get(url, stream=True, timeout=20)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
block_size = 8192
|
||||
if buffer is None:
|
||||
buffer = BytesIO()
|
||||
|
||||
with tqdm(total=total_size, unit='iB', unit_scale=True, desc=desc) as progress_bar:
|
||||
for data in response.iter_content(block_size):
|
||||
size = buffer.write(data)
|
||||
progress_bar.update(size)
|
||||
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
|
||||
def unzip(
|
||||
zip_file: DownloadBuffer,
|
||||
extract_path: str,
|
||||
desc: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Extract the contents of a zip file to the installation directory.
|
||||
|
||||
Args:
|
||||
zip_file (BytesIO): The zip file content as a BytesIO object
|
||||
|
||||
Raises:
|
||||
zipfile.BadZipFile: If the zip file is invalid or corrupted
|
||||
OSError: If there's an error creating directories or writing files
|
||||
"""
|
||||
with ZipFile(zip_file) as zf:
|
||||
for member in tqdm(zf.infolist(), desc=desc):
|
||||
zf.extract(member, extract_path)
|
||||
88
pythonlib/camoufox/sync_api.py
Normal file
88
pythonlib/camoufox/sync_api.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from browserforge.fingerprints import Fingerprint, Screen
|
||||
from playwright.sync_api import Browser, Playwright, PlaywrightContextManager
|
||||
|
||||
from .addons import DefaultAddons
|
||||
from .utils import ListOrString, get_launch_options
|
||||
|
||||
|
||||
class Camoufox(PlaywrightContextManager):
|
||||
"""
|
||||
Wrapper around playwright.sync_api.PlaywrightContextManager that automatically
|
||||
launches a browser and closes it when the context manager is exited.
|
||||
"""
|
||||
|
||||
def __init__(self, **launch_options):
|
||||
super().__init__()
|
||||
self.launch_options = launch_options
|
||||
self.browser: Optional[Browser] = None
|
||||
|
||||
def __enter__(self) -> Browser:
|
||||
super().__enter__()
|
||||
self.browser = NewBrowser(self._playwright, **self.launch_options)
|
||||
return self.browser
|
||||
|
||||
def __exit__(self, *args: Any):
|
||||
if self.browser:
|
||||
self.browser.close()
|
||||
super().__exit__(*args)
|
||||
|
||||
|
||||
def NewBrowser(
|
||||
playwright: Playwright,
|
||||
*,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
addons: Optional[List[str]] = None,
|
||||
fingerprint: Optional[Fingerprint] = None,
|
||||
exclude_addons: Optional[List[DefaultAddons]] = None,
|
||||
screen: Optional[Screen] = None,
|
||||
os: Optional[ListOrString] = None,
|
||||
user_agent: Optional[ListOrString] = None,
|
||||
fonts: Optional[List[str]] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
executable_path: Optional[str] = None,
|
||||
**launch_options: Dict[str, Any]
|
||||
) -> Browser:
|
||||
"""
|
||||
Launches a new browser instance for Camoufox.
|
||||
|
||||
Parameters:
|
||||
playwright (Playwright):
|
||||
The playwright instance to use.
|
||||
config (Optional[Dict[str, Any]]):
|
||||
The configuration to use.
|
||||
addons (Optional[List[str]]):
|
||||
The addons to use.
|
||||
fingerprint (Optional[Fingerprint]):
|
||||
The fingerprint to use.
|
||||
exclude_addons (Optional[List[DefaultAddons]]):
|
||||
The default addons to exclude, passed as a list of camoufox.DefaultAddons enums.
|
||||
screen (Optional[browserforge.fingerprints.Screen]):
|
||||
The screen constraints to use.
|
||||
os (Optional[ListOrString]):
|
||||
The operating system to use for the fingerprint. Either a string or a list of strings.
|
||||
user_agent (Optional[ListOrString]):
|
||||
The user agent to use for the fingerprint. Either a string or a list of strings.
|
||||
fonts (Optional[List[str]]):
|
||||
The fonts to load into Camoufox, in addition to the default fonts.
|
||||
args (Optional[List[str]]):
|
||||
The arguments to pass to the browser.
|
||||
executable_path (Optional[str]):
|
||||
The path to the Camoufox browser executable.
|
||||
**launch_options (Dict[str, Any]):
|
||||
Additional Firefox launch options.
|
||||
"""
|
||||
opt = get_launch_options(
|
||||
config=config,
|
||||
addons=addons,
|
||||
fingerprint=fingerprint,
|
||||
exclude_addons=exclude_addons,
|
||||
screen=screen,
|
||||
os=os,
|
||||
user_agent=user_agent,
|
||||
fonts=fonts,
|
||||
args=args,
|
||||
executable_path=executable_path,
|
||||
)
|
||||
return playwright.firefox.launch(**opt, **launch_options)
|
||||
221
pythonlib/camoufox/utils.py
Normal file
221
pythonlib/camoufox/utils.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Set to environment variables
|
||||
import os
|
||||
import sys
|
||||
from os import environ
|
||||
from random import randrange
|
||||
from typing import Any, Dict, List, Optional, Tuple, TypeAlias, Union
|
||||
|
||||
import numpy as np
|
||||
import orjson
|
||||
from browserforge.fingerprints import Fingerprint, Screen
|
||||
from ua_parser import user_agent_parser
|
||||
|
||||
from .addons import (
|
||||
DefaultAddons,
|
||||
confirm_paths,
|
||||
get_debug_port,
|
||||
threaded_try_load_addons,
|
||||
)
|
||||
from .exceptions import InvalidPropertyType, UnknownProperty
|
||||
from .fingerprints import from_browserforge, generate
|
||||
from .pkgman import OS_NAME, get_path
|
||||
from .xpi_dl import add_default_addons
|
||||
|
||||
LAUNCH_FILE = {
|
||||
'win': 'camoufox.exe',
|
||||
'lin': 'camoufox-bin',
|
||||
'mac': 'camoufox',
|
||||
}
|
||||
|
||||
ListOrString: TypeAlias = Union[Tuple[str, ...], List[str], str]
|
||||
|
||||
|
||||
def get_env_vars(
|
||||
config_map: Dict[str, str], user_agent_os: str
|
||||
) -> Dict[str, Union[str, float, bool]]:
|
||||
"""
|
||||
Gets a dictionary of environment variables for Camoufox.
|
||||
"""
|
||||
env_vars = {}
|
||||
try:
|
||||
updated_config_data = orjson.dumps(config_map)
|
||||
except orjson.JSONEncodeError as e:
|
||||
print(f"Error updating config: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Split the config into chunks
|
||||
chunk_size = 2047 if OS_NAME == 'windows' else 32767
|
||||
config_str = updated_config_data.decode('utf-8')
|
||||
|
||||
for i in range(0, len(config_str), chunk_size):
|
||||
chunk = config_str[i : i + chunk_size]
|
||||
env_name = f"CAMOU_CONFIG_{(i // chunk_size) + 1}"
|
||||
try:
|
||||
env_vars[env_name] = chunk
|
||||
except Exception as e:
|
||||
print(f"Error setting {env_name}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if OS_NAME == 'linux':
|
||||
fontconfig_path = get_path(os.path.join("fontconfig", user_agent_os))
|
||||
env_vars['FONTCONFIG_PATH'] = fontconfig_path
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
def _load_properties() -> Dict[str, str]:
|
||||
"""
|
||||
Loads the properties.json file.
|
||||
"""
|
||||
prop_file = get_path("properties.json")
|
||||
with open(prop_file, "rb") as f:
|
||||
prop_dict = orjson.loads(f.read())
|
||||
|
||||
return {prop['property']: prop['type'] for prop in prop_dict}
|
||||
|
||||
|
||||
def validate_config(config_map: Dict[str, str]) -> None:
|
||||
"""
|
||||
Validates the config map.
|
||||
"""
|
||||
property_types = _load_properties()
|
||||
|
||||
for key, value in config_map.items():
|
||||
expected_type = property_types.get(key)
|
||||
if not expected_type:
|
||||
raise UnknownProperty(f"Unknown property {key} in config")
|
||||
|
||||
if not validate_type(value, expected_type):
|
||||
raise InvalidPropertyType(
|
||||
f"Invalid type for property {key}. Expected {expected_type}, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def validate_type(value: Any, expected_type: str) -> bool:
|
||||
"""
|
||||
Validates the type of the value.
|
||||
"""
|
||||
if expected_type == "str":
|
||||
return isinstance(value, str)
|
||||
elif expected_type == "int":
|
||||
return isinstance(value, int) or (isinstance(value, float) and value.is_integer())
|
||||
elif expected_type == "uint":
|
||||
return (
|
||||
isinstance(value, int) or (isinstance(value, float) and value.is_integer())
|
||||
) and value >= 0
|
||||
elif expected_type == "double":
|
||||
return isinstance(value, (float, int))
|
||||
elif expected_type == "bool":
|
||||
return isinstance(value, bool)
|
||||
elif expected_type == "array":
|
||||
return isinstance(value, list)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def get_target_os(config: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Gets the OS from the config if the user agent is set,
|
||||
otherwise returns the OS of the current system.
|
||||
"""
|
||||
if config.get("navigator.userAgent"):
|
||||
return determine_ua_os(config["navigator.userAgent"])
|
||||
return OS_NAME
|
||||
|
||||
|
||||
def determine_ua_os(user_agent: str) -> str:
|
||||
"""
|
||||
Determines the OS from the user agent string.
|
||||
"""
|
||||
parsed_ua = user_agent_parser.ParseOS(user_agent).get('family')
|
||||
if not parsed_ua:
|
||||
raise ValueError("Could not determine OS from user agent")
|
||||
if parsed_ua.startswith("Mac"):
|
||||
return "mac"
|
||||
if parsed_ua.startswith("Windows"):
|
||||
return "win"
|
||||
return "lin"
|
||||
|
||||
|
||||
def update_fonts(config: Dict[str, Any], target_os: str) -> None:
|
||||
"""
|
||||
Updates the fonts for the target OS.
|
||||
"""
|
||||
with open(os.path.join(os.path.dirname(__file__), "fonts.json"), "rb") as f:
|
||||
fonts = orjson.loads(f.read())[target_os]
|
||||
|
||||
# Merge with existing fonts
|
||||
if 'fonts' in config:
|
||||
config['fonts'] = np.unique(fonts + config['fonts']).tolist()
|
||||
else:
|
||||
config['fonts'] = fonts
|
||||
|
||||
|
||||
def get_launch_options(
|
||||
*,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
addons: Optional[List[str]] = None,
|
||||
fingerprint: Optional[Fingerprint] = None,
|
||||
exclude_addons: Optional[List[DefaultAddons]] = None,
|
||||
screen: Optional[Screen] = None,
|
||||
os: Optional[ListOrString] = None,
|
||||
user_agent: Optional[ListOrString] = None,
|
||||
fonts: Optional[List[str]] = None,
|
||||
args: Optional[List[str]] = None,
|
||||
executable_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Builds the launch options for the Camoufox browser.
|
||||
"""
|
||||
# Validate the config
|
||||
if config is None:
|
||||
config = {}
|
||||
|
||||
if addons is None:
|
||||
addons = []
|
||||
if args is None:
|
||||
args = []
|
||||
|
||||
# Add the default addons
|
||||
add_default_addons(addons, exclude_addons)
|
||||
|
||||
# Confirm all addon paths are valid
|
||||
if addons:
|
||||
confirm_paths(addons)
|
||||
|
||||
# Generate new fingerprint
|
||||
if fingerprint is None:
|
||||
config = {
|
||||
**generate(
|
||||
screen=screen,
|
||||
os=os,
|
||||
user_agent=user_agent,
|
||||
),
|
||||
**config,
|
||||
}
|
||||
else:
|
||||
config = {
|
||||
**from_browserforge(fingerprint),
|
||||
**config,
|
||||
}
|
||||
|
||||
# Set a random window.history.length
|
||||
config['window.history.length'] = randrange(1, 6)
|
||||
|
||||
if fonts:
|
||||
config['fonts'] = fonts
|
||||
|
||||
validate_config(config)
|
||||
|
||||
# Update fonts list
|
||||
target_os = get_target_os(config)
|
||||
update_fonts(config, target_os)
|
||||
|
||||
# Launch
|
||||
threaded_try_load_addons(get_debug_port(args), addons)
|
||||
env_vars = {**get_env_vars(config, target_os), **environ}
|
||||
return {
|
||||
"executable_path": executable_path or get_path(LAUNCH_FILE[OS_NAME]),
|
||||
"args": args,
|
||||
"env": env_vars,
|
||||
}
|
||||
61
pythonlib/camoufox/xpi_dl.py
Normal file
61
pythonlib/camoufox/xpi_dl.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from .addons import DefaultAddons
|
||||
from .pkgman import get_path, unzip, webdl
|
||||
|
||||
|
||||
def add_default_addons(
|
||||
addons_list: List[str], exclude_list: Optional[List[DefaultAddons]] = None
|
||||
) -> None:
|
||||
"""
|
||||
Adds default addons, minus any specified in exclude_list, to addons_list
|
||||
"""
|
||||
# Build a dictionary from DefaultAddons, excluding keys found in exclude_list
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
addons = [addon for addon in DefaultAddons if addon not in exclude_list]
|
||||
|
||||
maybe_download_addons(addons, addons_list)
|
||||
|
||||
|
||||
def download_and_extract(url: str, extract_path: str) -> None:
|
||||
"""
|
||||
Downloads and extracts an addon from a given URL to a specified path
|
||||
"""
|
||||
# Create a temporary file to store the downloaded zip
|
||||
buffer = webdl(url, desc="Downloading addon")
|
||||
unzip(buffer, extract_path)
|
||||
|
||||
|
||||
def get_addon_path(addon_name: str) -> str:
|
||||
"""
|
||||
Returns a path to the addon
|
||||
"""
|
||||
return get_path(os.path.join("addons", addon_name))
|
||||
|
||||
|
||||
def maybe_download_addons(addons: List[DefaultAddons], addons_list: List[str]) -> None:
|
||||
"""
|
||||
Downloads and extracts addons from a given dictionary to a specified list
|
||||
Skips downloading if the addon is already downloaded
|
||||
"""
|
||||
for addon in addons:
|
||||
# Get the addon path
|
||||
addon_path = get_addon_path(addon.name)
|
||||
|
||||
# Check if the addon is already extracted
|
||||
if os.path.exists(addon_path):
|
||||
# Add the existing addon path to addons_list
|
||||
addons_list.append(addon_path)
|
||||
continue
|
||||
|
||||
# Addon doesn't exist, create directory and download
|
||||
try:
|
||||
os.makedirs(addon_path, exist_ok=True)
|
||||
download_and_extract(addon.value, addon_path)
|
||||
# Add the new addon directory path to addons_list
|
||||
addons_list.append(addon_path)
|
||||
except Exception as e:
|
||||
print(f"Failed to download and extract {addon.name}: {e}")
|
||||
43
pythonlib/pyproject.toml
Normal file
43
pythonlib/pyproject.toml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "camoufox"
|
||||
version = "1.0.0"
|
||||
description = "Wrapper around Playwright to help launch Camoufox"
|
||||
authors = ["daijro <daijro.dev@gmail.com>"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/daijro/camoufox"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"client",
|
||||
"fingerprint",
|
||||
"browser",
|
||||
"scraping",
|
||||
"injector",
|
||||
"firefox",
|
||||
"playwright",
|
||||
]
|
||||
classifiers = [
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
click = "*"
|
||||
requests = "*"
|
||||
orjson = "*"
|
||||
browserforge = "*"
|
||||
playwright = "*"
|
||||
pyyaml = "*"
|
||||
platformdirs = "*"
|
||||
tqdm = "*"
|
||||
numpy = "*"
|
||||
ua_parser = "*"
|
||||
typing_extensions = "*"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
camoufox = "camoufox.__main__:cli"
|
||||
Loading…
Add table
Reference in a new issue