feat: public config helpers for third-party integrations (#25)
Adds invisible_playwright.config module with: - get_default_stealth_prefs(seed, *, pin, locale, timezone, extra_prefs, humanize, virtual_display) -> dict - get_default_args() -> list Both also re-exported at the package root alongside the existing InvisiblePlaywright. ensure_binary is also re-exported there for parity with the cloakbrowser.download.ensure_binary integration pattern that downstream projects (Skyvern PR #5340, crawlee-python PR #1794, agno PR #8129) already expect. These helpers let third-party fetchers (changedetection.io plugins, Crawlee BrowserPool subclasses, agno toolkits) drive playwright.firefox.launch(executable_path=..., firefox_user_prefs=...) themselves without depending on the InvisiblePlaywright context manager owning the lifecycle. Same seed semantics, same humanize toggle, same extra_prefs overlay as the existing wrapper. Tests: tests/unit/test_config_public.py adds 14 unit tests covering deterministic seed, locale/timezone/pin/extra_prefs/humanize variations, and round-trip via the public namespace. Full unit suite (392 tests) stays green. Backwards compatible: InvisiblePlaywright surface is unchanged. BINARY_VERSION stays at firefox-7. Python-only release.
This commit is contained in:
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Public config helpers in `invisible_playwright.config`: `get_default_stealth_prefs(seed, *, pin, locale, timezone, extra_prefs, humanize, virtual_display)` returns a complete `firefox_user_prefs` dict; `get_default_args()` returns the baseline CLI args list (currently empty). Both also re-exported at the package root.
|
||||||
|
- `invisible_playwright.ensure_binary` re-exported at the package root for parity with the `cloakbrowser.download.ensure_binary` integration pattern that downstream projects (Skyvern, Crawlee, agno) already expect.
|
||||||
|
- These helpers let third-party fetchers (changedetection.io plugins, Crawlee `BrowserPool` subclasses, agno toolkits) drive `playwright.firefox.launch(executable_path=..., firefox_user_prefs=...)` themselves without depending on the `InvisiblePlaywright` context manager owning the lifecycle.
|
||||||
|
- `tests/unit/test_config_public.py`: 14 unit tests covering deterministic seed, locale / timezone / pin / extra_prefs / humanize variations, and round-trip via the public namespace.
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
- `InvisiblePlaywright` context manager surface is identical (backwards compatible).
|
||||||
|
- `BINARY_VERSION` stays at `firefox-7`. Python-only release; no new Firefox build.
|
||||||
|
|
||||||
## [0.1.8] - 2026-05-23
|
## [0.1.8] - 2026-05-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -172,6 +172,25 @@ invisible_playwright version # wrapper and binary versions
|
|||||||
invisible_playwright clear-cache # remove all cached binaries
|
invisible_playwright clear-cache # remove all cached binaries
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Public API for downstream integrations
|
||||||
|
|
||||||
|
When you're building a third-party fetcher (a Crawlee `BrowserPool` subclass, a changedetection.io plugin, an agno toolkit, a Skyvern backend) and need to own the browser lifecycle yourself, use the public helpers instead of `InvisiblePlaywright`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from invisible_playwright import ensure_binary, get_default_stealth_prefs
|
||||||
|
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.firefox.launch(
|
||||||
|
executable_path=str(ensure_binary()),
|
||||||
|
firefox_user_prefs=get_default_stealth_prefs(seed=42),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`get_default_stealth_prefs(seed, *, pin, locale, timezone, extra_prefs, humanize, virtual_display)` returns the same dict that `InvisiblePlaywright(seed=..., locale=..., ...)` would inject. Same deterministic seed semantics, same humanize toggle, same `extra_prefs` overlay. `ensure_binary()` downloads the patched Firefox on first call and returns its absolute path.
|
||||||
|
|
||||||
|
For everyday Python usage the `InvisiblePlaywright` context manager is still the recommended entry point.
|
||||||
|
|
||||||
## Related projects
|
## Related projects
|
||||||
|
|
||||||
invisible_playwright takes a different angle than the major Firefox-hardening projects but stands on their shoulders:
|
invisible_playwright takes a different angle than the major Firefox-hardening projects but stands on their shoulders:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "invisible-playwright"
|
name = "invisible-playwright"
|
||||||
version = "0.1.9"
|
version = "0.2.0"
|
||||||
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
|
description = "Playwright wrapper for a patched Firefox with deterministic stealth profile."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ Quickstart:
|
|||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
page.click("#submit") # expanded into a Bezier trajectory
|
page.click("#submit") # expanded into a Bezier trajectory
|
||||||
"""
|
"""
|
||||||
from .launcher import InvisiblePlaywright
|
from .config import get_default_args, get_default_stealth_prefs
|
||||||
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
|
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
|
||||||
|
from .download import ensure_binary
|
||||||
|
from .launcher import InvisiblePlaywright
|
||||||
|
|
||||||
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
||||||
|
|
||||||
@@ -27,4 +29,12 @@ except PackageNotFoundError:
|
|||||||
# marker rather than risk shipping a stale hardcoded string.
|
# marker rather than risk shipping a stale hardcoded string.
|
||||||
__version__ = "0.0.0+unknown"
|
__version__ = "0.0.0+unknown"
|
||||||
|
|
||||||
__all__ = ["InvisiblePlaywright", "BINARY_VERSION", "FIREFOX_UPSTREAM_VERSION", "__version__"]
|
__all__ = [
|
||||||
|
"InvisiblePlaywright",
|
||||||
|
"ensure_binary",
|
||||||
|
"get_default_stealth_prefs",
|
||||||
|
"get_default_args",
|
||||||
|
"BINARY_VERSION",
|
||||||
|
"FIREFOX_UPSTREAM_VERSION",
|
||||||
|
"__version__",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Public helpers for building Firefox launch config without using ``InvisiblePlaywright``.
|
||||||
|
|
||||||
|
Use these when you need to call ``playwright.firefox.launch()`` (or
|
||||||
|
``firefox.launch_persistent_context()``) directly with our patched binary
|
||||||
|
and stealth prefs, instead of using the ``InvisiblePlaywright`` context
|
||||||
|
manager.
|
||||||
|
|
||||||
|
Typical caller is an external integration that owns its own browser
|
||||||
|
lifecycle (a Crawlee/Skyvern/changedetection-style fetcher, a Playwright
|
||||||
|
Server wrapper, a multi-language harness) and just wants the building
|
||||||
|
blocks::
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from invisible_playwright import ensure_binary, get_default_stealth_prefs
|
||||||
|
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.firefox.launch(
|
||||||
|
executable_path=str(ensure_binary()),
|
||||||
|
firefox_user_prefs=get_default_stealth_prefs(seed=42),
|
||||||
|
)
|
||||||
|
|
||||||
|
For everyday Python usage the ``InvisiblePlaywright`` context manager is
|
||||||
|
still the recommended entry point; these helpers expose the same internals
|
||||||
|
without the lifecycle ownership.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from ._fpforge import generate_profile
|
||||||
|
from .prefs import translate_profile_to_prefs
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_stealth_prefs(
|
||||||
|
seed: Optional[int] = None,
|
||||||
|
*,
|
||||||
|
pin: Optional[Dict[str, Any]] = None,
|
||||||
|
locale: str = "en-US",
|
||||||
|
timezone: str = "",
|
||||||
|
extra_prefs: Optional[Dict[str, Any]] = None,
|
||||||
|
humanize: Union[bool, float] = True,
|
||||||
|
virtual_display: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Build a complete ``firefox_user_prefs`` dict for ``firefox.launch()``.
|
||||||
|
|
||||||
|
Same prefs that ``InvisiblePlaywright(seed=..., locale=..., timezone=...,
|
||||||
|
extra_prefs=..., humanize=...)`` would inject. Use this when you need to
|
||||||
|
drive ``playwright.firefox.launch()`` yourself.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seed: Integer seed for the Bayesian fingerprint sampler. Same seed
|
||||||
|
produces the same fingerprint. ``None`` generates a fresh
|
||||||
|
random int31 (matches ``InvisiblePlaywright`` default).
|
||||||
|
pin: Optional dict forcing specific fingerprint fields while the
|
||||||
|
rest stays seed-derived. See ``docs/pinning.md``.
|
||||||
|
locale: BCP-47 tag (e.g. ``"en-US"``). Drives ``Accept-Language``
|
||||||
|
and ``navigator.language``.
|
||||||
|
timezone: IANA timezone (e.g. ``"America/New_York"``). Empty means
|
||||||
|
use the host TZ.
|
||||||
|
extra_prefs: Optional dict overlaid LAST onto the generated prefs.
|
||||||
|
humanize: When True (default), every mouse move is expanded into
|
||||||
|
a Bezier trajectory by the patched Juggler. A float caps the
|
||||||
|
motion in seconds. False disables the behavior.
|
||||||
|
virtual_display: When True on Windows, apply GPU-disabling prefs
|
||||||
|
to prevent GPU process crashes on virtual desktops without
|
||||||
|
D3D11 backend.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict ready to pass as ``firefox_user_prefs=`` to
|
||||||
|
``playwright.firefox.launch()`` or ``launch_persistent_context()``.
|
||||||
|
"""
|
||||||
|
resolved_seed = int(seed) if seed is not None else secrets.randbits(31)
|
||||||
|
profile = generate_profile(resolved_seed, pin=pin)
|
||||||
|
prefs = translate_profile_to_prefs(
|
||||||
|
profile,
|
||||||
|
locale=locale,
|
||||||
|
timezone=timezone,
|
||||||
|
extra_prefs=extra_prefs,
|
||||||
|
virtual_display=virtual_display,
|
||||||
|
)
|
||||||
|
prefs["invisible_playwright.humanize"] = bool(humanize)
|
||||||
|
if humanize:
|
||||||
|
max_seconds = float(humanize) if not isinstance(humanize, bool) else 1.5
|
||||||
|
prefs["invisible_playwright.humanize.maxTime"] = str(max_seconds)
|
||||||
|
return prefs
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_args() -> List[str]:
|
||||||
|
"""Return the default Firefox CLI args to pass via ``args=``.
|
||||||
|
|
||||||
|
Currently empty list, since all our stealth configuration is delivered
|
||||||
|
via ``firefox_user_prefs`` rather than CLI flags. Exposed for parity
|
||||||
|
with the ``cloakbrowser.config.get_default_stealth_args`` pattern and
|
||||||
|
to future-proof integrations that already wire ``args=[*existing,
|
||||||
|
*get_default_args()]``.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Unit tests for the public ``config`` helpers."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from invisible_playwright import (
|
||||||
|
ensure_binary,
|
||||||
|
get_default_args,
|
||||||
|
get_default_stealth_prefs,
|
||||||
|
)
|
||||||
|
from invisible_playwright.config import get_default_stealth_prefs as _direct
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_args_is_empty_list():
|
||||||
|
"""Currently no baseline CLI args, but must return a list (mutable, fresh each call)."""
|
||||||
|
args = get_default_args()
|
||||||
|
assert args == []
|
||||||
|
assert isinstance(args, list)
|
||||||
|
args.append("--foo")
|
||||||
|
# next call must return a fresh empty list, not the mutated one
|
||||||
|
assert get_default_args() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_stealth_prefs_random_seed_returns_dict():
|
||||||
|
"""No seed -> fresh random fingerprint, dict has expected stealth keys."""
|
||||||
|
prefs = get_default_stealth_prefs()
|
||||||
|
assert isinstance(prefs, dict)
|
||||||
|
assert len(prefs) > 0
|
||||||
|
# humanize toggle is always set explicitly
|
||||||
|
assert "invisible_playwright.humanize" in prefs
|
||||||
|
assert prefs["invisible_playwright.humanize"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_stealth_prefs_seed_is_deterministic():
|
||||||
|
"""Same seed -> byte-identical prefs across calls."""
|
||||||
|
a = get_default_stealth_prefs(seed=42)
|
||||||
|
b = get_default_stealth_prefs(seed=42)
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_stealth_prefs_different_seeds_differ():
|
||||||
|
"""Different seeds -> different prefs."""
|
||||||
|
a = get_default_stealth_prefs(seed=1)
|
||||||
|
b = get_default_stealth_prefs(seed=2)
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_humanize_false_disables_prefs():
|
||||||
|
"""humanize=False removes the maxTime knob and flips the toggle to False."""
|
||||||
|
prefs = get_default_stealth_prefs(seed=42, humanize=False)
|
||||||
|
assert prefs["invisible_playwright.humanize"] is False
|
||||||
|
assert "invisible_playwright.humanize.maxTime" not in prefs
|
||||||
|
|
||||||
|
|
||||||
|
def test_humanize_default_sets_max_time_1_5():
|
||||||
|
"""humanize=True -> default maxTime is 1.5s, stored as string."""
|
||||||
|
prefs = get_default_stealth_prefs(seed=42, humanize=True)
|
||||||
|
assert prefs["invisible_playwright.humanize"] is True
|
||||||
|
assert prefs["invisible_playwright.humanize.maxTime"] == "1.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_humanize_float_overrides_max_time():
|
||||||
|
"""Float for humanize is the explicit cap in seconds."""
|
||||||
|
prefs = get_default_stealth_prefs(seed=42, humanize=3.0)
|
||||||
|
assert prefs["invisible_playwright.humanize"] is True
|
||||||
|
assert prefs["invisible_playwright.humanize.maxTime"] == "3.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_prefs_overlay_takes_precedence():
|
||||||
|
"""extra_prefs overlay LAST overrides any baseline value."""
|
||||||
|
prefs = get_default_stealth_prefs(
|
||||||
|
seed=42, extra_prefs={"some.custom.pref": 999}
|
||||||
|
)
|
||||||
|
assert prefs["some.custom.pref"] == 999
|
||||||
|
|
||||||
|
|
||||||
|
def test_extra_prefs_can_override_baseline():
|
||||||
|
"""A key in extra_prefs that also exists in baseline gets overridden."""
|
||||||
|
baseline = get_default_stealth_prefs(seed=42)
|
||||||
|
a_baseline_key = next(iter(baseline.keys()))
|
||||||
|
overridden = get_default_stealth_prefs(
|
||||||
|
seed=42, extra_prefs={a_baseline_key: "OVERRIDDEN_SENTINEL"}
|
||||||
|
)
|
||||||
|
assert overridden[a_baseline_key] == "OVERRIDDEN_SENTINEL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_argument_changes_prefs():
|
||||||
|
"""Different locales produce different prefs (Accept-Language affected)."""
|
||||||
|
en = get_default_stealth_prefs(seed=42, locale="en-US")
|
||||||
|
it = get_default_stealth_prefs(seed=42, locale="it-IT")
|
||||||
|
assert en != it
|
||||||
|
|
||||||
|
|
||||||
|
def test_timezone_argument_changes_prefs():
|
||||||
|
"""Different timezones produce different prefs."""
|
||||||
|
ny = get_default_stealth_prefs(seed=42, timezone="America/New_York")
|
||||||
|
rome = get_default_stealth_prefs(seed=42, timezone="Europe/Rome")
|
||||||
|
assert ny != rome
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_argument_forces_specific_fields():
|
||||||
|
"""Pin forces a specific field while the rest stays seed-derived."""
|
||||||
|
plain = get_default_stealth_prefs(seed=42)
|
||||||
|
pinned = get_default_stealth_prefs(
|
||||||
|
seed=42, pin={"hardware.concurrency": 999}
|
||||||
|
)
|
||||||
|
# something in the dict must differ vs the plain seed=42 build
|
||||||
|
assert plain != pinned
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_import_matches_direct_import():
|
||||||
|
"""Top-level re-export and direct module import return identical output."""
|
||||||
|
a = get_default_stealth_prefs(seed=42)
|
||||||
|
b = _direct(seed=42)
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_binary_is_callable_via_public_namespace():
|
||||||
|
"""ensure_binary is re-exported and stays callable from the package root."""
|
||||||
|
# We don't invoke it (would trigger a network download in CI) — just
|
||||||
|
# verify the public attribute is the same callable as the underlying.
|
||||||
|
from invisible_playwright.download import ensure_binary as _direct_eb
|
||||||
|
assert ensure_binary is _direct_eb
|
||||||
Reference in New Issue
Block a user