invisible-playwright 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invisible_playwright/__init__.py +68 -0
- invisible_playwright/__main__.py +4 -0
- invisible_playwright/_engine.py +90 -0
- invisible_playwright/_fpforge/__init__.py +17 -0
- invisible_playwright/_geo.py +4 -0
- invisible_playwright/_headless.py +4 -0
- invisible_playwright/_pin.py +143 -0
- invisible_playwright/_proxy.py +4 -0
- invisible_playwright/_recaptcha_seed.py +340 -0
- invisible_playwright/_webgl_personas.py +4 -0
- invisible_playwright/async_api.py +271 -0
- invisible_playwright/cli.py +127 -0
- invisible_playwright/config.py +4 -0
- invisible_playwright/constants.py +4 -0
- invisible_playwright/download.py +4 -0
- invisible_playwright/launcher.py +436 -0
- invisible_playwright/prefs.py +4 -0
- invisible_playwright/py.typed +0 -0
- invisible_playwright/sync_api.py +4 -0
- invisible_playwright-0.3.5.dist-info/METADATA +234 -0
- invisible_playwright-0.3.5.dist-info/RECORD +24 -0
- invisible_playwright-0.3.5.dist-info/WHEEL +4 -0
- invisible_playwright-0.3.5.dist-info/entry_points.txt +2 -0
- invisible_playwright-0.3.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""invisible_playwright - Playwright wrapper for a patched Firefox with stealth profile.
|
|
2
|
+
|
|
3
|
+
Quickstart:
|
|
4
|
+
|
|
5
|
+
from invisible_playwright import InvisiblePlaywright
|
|
6
|
+
|
|
7
|
+
with InvisiblePlaywright() as browser: # random seed
|
|
8
|
+
page = browser.new_page()
|
|
9
|
+
page.goto("https://example.com")
|
|
10
|
+
|
|
11
|
+
with InvisiblePlaywright(seed=42) as browser: # deterministic
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
with InvisiblePlaywright(humanize=True) as browser: # human-like cursor motion
|
|
15
|
+
page = browser.new_page()
|
|
16
|
+
page.click("#submit") # expanded into a Bezier trajectory
|
|
17
|
+
"""
|
|
18
|
+
# ── Import-time core assertion, and repair ───────────────────────────────────
|
|
19
|
+
# Runs BEFORE every other import. Two questions, one check: is the installed
|
|
20
|
+
# invisible-core new enough to carry a release seal at all, and is it the exact
|
|
21
|
+
# version this distribution declares in pyproject? The expected version is read
|
|
22
|
+
# back out of our own metadata, so it is written in exactly one place.
|
|
23
|
+
#
|
|
24
|
+
# A mismatch is REPAIRED, not just reported: the declared core is installed and
|
|
25
|
+
# picked up in this process, so the script that just imported us keeps going.
|
|
26
|
+
# That is only sound here, on the first line, while invisible_core has not been
|
|
27
|
+
# imported by anyone yet; _pin.py captures exactly that fact before it imports
|
|
28
|
+
# the core, and the repair refuses itself if the snapshot says otherwise. Every
|
|
29
|
+
# failure of the repair falls back to the message it replaced.
|
|
30
|
+
#
|
|
31
|
+
# The comparison and the repair both live in invisible_core._pin, shared with the
|
|
32
|
+
# profile manager so both products diagnose and fix an environment the same way.
|
|
33
|
+
# Our _pin.py is the floor: it owns the three states a module inside the core
|
|
34
|
+
# cannot report on (core absent, core present but unimportable, core present but
|
|
35
|
+
# too damaged to derive its own version) and delegates everything else.
|
|
36
|
+
from ._pin import enforce_core_pin as _enforce_core_pin
|
|
37
|
+
_enforce_core_pin()
|
|
38
|
+
|
|
39
|
+
from ._engine import assert_playwright_range as _assert_pw_range
|
|
40
|
+
_assert_pw_range()
|
|
41
|
+
|
|
42
|
+
from .config import get_default_args, get_default_stealth_prefs
|
|
43
|
+
from .constants import BINARY_VERSION, FIREFOX_UPSTREAM_VERSION
|
|
44
|
+
from ._geo import GeoTimezoneError, resolve_session_timezone
|
|
45
|
+
from .download import ensure_binary, ensure_geoip_mmdb
|
|
46
|
+
from .launcher import InvisiblePlaywright
|
|
47
|
+
|
|
48
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
__version__ = _pkg_version("invisible-playwright")
|
|
52
|
+
except PackageNotFoundError:
|
|
53
|
+
# Editable / source checkout without an install record: fall back to a
|
|
54
|
+
# marker rather than risk shipping a stale hardcoded string.
|
|
55
|
+
__version__ = "0.0.0+unknown"
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"InvisiblePlaywright",
|
|
59
|
+
"ensure_binary",
|
|
60
|
+
"ensure_geoip_mmdb",
|
|
61
|
+
"get_default_stealth_prefs",
|
|
62
|
+
"get_default_args",
|
|
63
|
+
"resolve_session_timezone",
|
|
64
|
+
"GeoTimezoneError",
|
|
65
|
+
"BINARY_VERSION",
|
|
66
|
+
"FIREFOX_UPSTREAM_VERSION",
|
|
67
|
+
"__version__",
|
|
68
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Every route from a chosen path to a spawned Firefox goes through here.
|
|
2
|
+
|
|
3
|
+
``binary_path=`` (and ``INVPW_BINARY_PATH``, which the test suite and the e2e
|
|
4
|
+
scripts turn into ``binary_path=``) never reaches ``ensure_binary()``, so none
|
|
5
|
+
of the download-side checks run on that route. The guard therefore sits on the
|
|
6
|
+
resolved executable, not inside the fetcher.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import warnings
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, Union
|
|
15
|
+
|
|
16
|
+
from invisible_core.download import ensure_binary
|
|
17
|
+
from invisible_core.seal import EngineMismatch, active_seal, verify_engine
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_executable(binary_path: Optional[Union[str, Path]]) -> Path:
|
|
21
|
+
"""binary_path= and INVPW_BINARY_PATH skip the download path entirely, so
|
|
22
|
+
the guard sits on the resolved executable, not inside the fetcher."""
|
|
23
|
+
seal = active_seal()
|
|
24
|
+
if binary_path:
|
|
25
|
+
return verify_engine(Path(binary_path), seal, source=f"binary_path={binary_path}")
|
|
26
|
+
return ensure_binary(seal=seal)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def assert_wire_version(browser) -> None:
|
|
30
|
+
"""Compare what the engine reports over the protocol with the seal.
|
|
31
|
+
|
|
32
|
+
browser.version is a cached property from the connection initializer
|
|
33
|
+
(Juggler Browser.getInfo -> MOZ_APP_VERSION_DISPLAY), so this costs zero
|
|
34
|
+
round trips and no pref can spoof it. It is the only check that also
|
|
35
|
+
catches a hand-edited application.ini. Not available on the persistent
|
|
36
|
+
context path, where Playwright exposes no Browser.
|
|
37
|
+
"""
|
|
38
|
+
if browser is None:
|
|
39
|
+
return
|
|
40
|
+
seal = active_seal()
|
|
41
|
+
raw = getattr(browser, "version", "")
|
|
42
|
+
# Playwright types Browser.version as str. Anything else (a stub, a mock,
|
|
43
|
+
# a driver that did not populate the initializer) carries no evidence
|
|
44
|
+
# either way, and inventing a mismatch out of it would be a false alarm.
|
|
45
|
+
if not isinstance(raw, str):
|
|
46
|
+
return
|
|
47
|
+
reported = raw.split("/")[-1].strip()
|
|
48
|
+
if reported and reported != seal.upstream_version:
|
|
49
|
+
raise EngineMismatch(
|
|
50
|
+
"engine/seal mismatch after launch - the running browser is not the sealed build\n"
|
|
51
|
+
f" protocol says: Firefox {reported}\n"
|
|
52
|
+
f" seal says : Firefox {seal.upstream_version} (tag {seal.tag}, "
|
|
53
|
+
f"build {seal.build_id})\n"
|
|
54
|
+
" why : application.ini can be edited; this value comes from the "
|
|
55
|
+
"running engine itself.\n"
|
|
56
|
+
" fix : python -m invisible_playwright fetch --force")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def assert_playwright_range() -> None:
|
|
60
|
+
"""The Juggler protocol schema is closed-world: a client outside the range
|
|
61
|
+
the binary was tested with dies at context creation, or silently loses a
|
|
62
|
+
feature. The range travels in the seal, so widening it does not need a
|
|
63
|
+
wrapper release."""
|
|
64
|
+
seal = active_seal()
|
|
65
|
+
if not seal.playwright_min or not seal.playwright_max:
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
have = _pkg_version("playwright")
|
|
69
|
+
except PackageNotFoundError:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
def t(v: str):
|
|
73
|
+
out = []
|
|
74
|
+
for part in v.split(".")[:3]:
|
|
75
|
+
digits = "".join(c for c in part if c.isdigit())
|
|
76
|
+
out.append(int(digits or 0))
|
|
77
|
+
return tuple(out + [0] * (3 - len(out)))
|
|
78
|
+
|
|
79
|
+
if t(seal.playwright_min) <= t(have) <= t(seal.playwright_max):
|
|
80
|
+
return
|
|
81
|
+
msg = (f"playwright {have} is outside the range this engine was tested with "
|
|
82
|
+
f"({seal.playwright_min} .. {seal.playwright_max}, from the seal for {seal.tag}).\n"
|
|
83
|
+
f"The Juggler protocol schema is closed-world: an untested client can kill every "
|
|
84
|
+
f"session at context creation.\n"
|
|
85
|
+
f'Run: pip install "playwright=={seal.playwright_max}" '
|
|
86
|
+
f"(or set INVISIBLE_PLAYWRIGHT_SKEW=allow to proceed anyway)")
|
|
87
|
+
if os.environ.get("INVISIBLE_PLAYWRIGHT_SKEW") == "allow":
|
|
88
|
+
warnings.warn(msg, RuntimeWarning, stacklevel=2)
|
|
89
|
+
else:
|
|
90
|
+
raise RuntimeError(msg)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Backward-compat shim - spostato in invisible_core._fpforge (alias completo).
|
|
2
|
+
|
|
3
|
+
Aliasa il package E i suoi submodule agli stessi oggetti del core, cosi'
|
|
4
|
+
``from invisible_playwright._fpforge.profile import Profile`` e
|
|
5
|
+
``from invisible_core._fpforge.profile import Profile`` sono la STESSA classe
|
|
6
|
+
(isinstance funziona) e i nomi privati restano accessibili.
|
|
7
|
+
"""
|
|
8
|
+
import sys as _sys
|
|
9
|
+
import invisible_core._fpforge as _pkg
|
|
10
|
+
from invisible_core._fpforge import profile as _profile
|
|
11
|
+
from invisible_core._fpforge import _sampler as _sampler_mod
|
|
12
|
+
from invisible_core._fpforge import _network as _network_mod
|
|
13
|
+
|
|
14
|
+
_sys.modules[__name__] = _pkg
|
|
15
|
+
_sys.modules[__name__ + ".profile"] = _profile
|
|
16
|
+
_sys.modules[__name__ + "._sampler"] = _sampler_mod
|
|
17
|
+
_sys.modules[__name__ + "._network"] = _network_mod
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Prelude for the core-pin assertion, then delegation to the core.
|
|
2
|
+
|
|
3
|
+
THE COMPARISON IS NOT HERE. It lives in invisible_core._pin, because the other
|
|
4
|
+
consumer of the core (the profile manager) asks the same question and two copies
|
|
5
|
+
of the answer drift: the day the two products disagree about why an environment
|
|
6
|
+
is broken, the check has stopped being a diagnosis. This module is the small
|
|
7
|
+
part that genuinely cannot be delegated, plus name-bound wrappers so the rest of
|
|
8
|
+
this package can keep calling the functions without repeating our own
|
|
9
|
+
distribution name.
|
|
10
|
+
|
|
11
|
+
WHAT CANNOT BE DELEGATED. A module inside invisible_core cannot report that
|
|
12
|
+
invisible_core is absent, unimportable, or so damaged that it cannot derive its
|
|
13
|
+
own version: by the time any line of it runs, it imported. So the import below
|
|
14
|
+
is the whole floor, and its except branches classify the failure:
|
|
15
|
+
|
|
16
|
+
* ImportError naming a module under invisible_core - either the core is not
|
|
17
|
+
installed at all, or it is older than this check and ships no
|
|
18
|
+
invisible_core._pin (or no invisible_core._pin.enforce_core_pin). Those
|
|
19
|
+
cannot be told apart from here and do not need to be: one remedy fixes all
|
|
20
|
+
of them, `pip install --force-reinstall invisible-playwright`, which makes
|
|
21
|
+
pip resolve the core version from our own Requires-Dist. No version literal
|
|
22
|
+
in the message, so nothing here can drift out of step with pyproject.
|
|
23
|
+
* ImportError naming any other module - the core is installed and one of ITS
|
|
24
|
+
dependencies is missing. That is what `pip install --no-deps` leaves behind,
|
|
25
|
+
and reporting it as "invisible-core is not installed" sends the user to the
|
|
26
|
+
wrong package.
|
|
27
|
+
* anything else - the core is on disk and its import raised something that is
|
|
28
|
+
not an ImportError at all. Measured: delete invisible_core/seal.json and the
|
|
29
|
+
import dies with a raw FileNotFoundError naming an absolute path, because
|
|
30
|
+
invisible_core.__init__ reaches _version.py, which reads the seal. That is a
|
|
31
|
+
partial install, and without this branch it is the one broken state the user
|
|
32
|
+
sees as a traceback instead of a remedy.
|
|
33
|
+
|
|
34
|
+
Both message sets are deliberately short and carry no version number: everything
|
|
35
|
+
that needs one is on the other side of this import.
|
|
36
|
+
|
|
37
|
+
WHY THE REPAIR IS NOT ATTEMPTED IN THIS FILE. enforce_core_pin() repairs a
|
|
38
|
+
version mismatch by installing the declared version and picking it up in
|
|
39
|
+
process. It can only do that because it knows WHICH version is declared, and
|
|
40
|
+
that comes from the one requirement parser, which lives in the core. When the
|
|
41
|
+
core cannot be imported at all there is no parser to ask, and the only remedy
|
|
42
|
+
that needs no version literal is a force-reinstall of THIS distribution, i.e. of
|
|
43
|
+
the package that is halfway through importing right now. Replacing your own
|
|
44
|
+
files mid-import is not a repair, so this floor reports and stops.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import sys as _sys
|
|
49
|
+
|
|
50
|
+
DIST_NAME = "invisible-playwright"
|
|
51
|
+
|
|
52
|
+
# Captured BEFORE the core is imported, and it is the whole soundness argument
|
|
53
|
+
# for the in-process repair: if nothing has imported invisible_core by the time
|
|
54
|
+
# this package's __init__ starts, then nobody is holding an object from the
|
|
55
|
+
# version the repair is about to replace. Read here, at the top of the first
|
|
56
|
+
# module the package imports, because one line later it is always True.
|
|
57
|
+
_CORE_PREIMPORTED = any(
|
|
58
|
+
name == "invisible_core" or name.startswith("invisible_core.")
|
|
59
|
+
for name in list(_sys.modules)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
try: # the floor - see the module docstring for why this is the one local part
|
|
63
|
+
from invisible_core._pin import ( # noqa: F401
|
|
64
|
+
CORE_NAME,
|
|
65
|
+
AUTOFIX_ENV,
|
|
66
|
+
SKIP_ENV,
|
|
67
|
+
PinDeclaration,
|
|
68
|
+
Requirement,
|
|
69
|
+
assert_core_pin as _assert_core_pin,
|
|
70
|
+
canonical_requirement,
|
|
71
|
+
declared_core_pin as _declared_core_pin,
|
|
72
|
+
editable_core_path,
|
|
73
|
+
enforce_core_pin as _enforce_core_pin,
|
|
74
|
+
installed_core_version,
|
|
75
|
+
normalise_name,
|
|
76
|
+
parse_requirement,
|
|
77
|
+
pin_declaration as _pin_declaration,
|
|
78
|
+
pin_from_requirements,
|
|
79
|
+
pin_problem,
|
|
80
|
+
pin_report as _pin_report,
|
|
81
|
+
recorded_core_version,
|
|
82
|
+
repair_core,
|
|
83
|
+
)
|
|
84
|
+
except ImportError as _e:
|
|
85
|
+
_missing = getattr(_e, "name", "") or ""
|
|
86
|
+
if _missing and not _missing.startswith("invisible_core"):
|
|
87
|
+
raise ImportError(
|
|
88
|
+
f"invisible-core is installed but cannot be imported: no module named "
|
|
89
|
+
f"{_missing!r}.\n"
|
|
90
|
+
f"That is one of its own dependencies, so it was installed without "
|
|
91
|
+
f"dependency resolution (pip install --no-deps, or a lockfile that "
|
|
92
|
+
f"lists the packages separately).\n"
|
|
93
|
+
f"Run: pip install --force-reinstall {DIST_NAME}"
|
|
94
|
+
) from _e
|
|
95
|
+
raise ImportError(
|
|
96
|
+
"invisible-core is missing, or too old for this invisible-playwright: it "
|
|
97
|
+
"does not carry the release-pin check (invisible_core._pin), so the engine "
|
|
98
|
+
"it downloads cannot be matched against the configuration this package "
|
|
99
|
+
"ships.\n"
|
|
100
|
+
f"Run: pip install --force-reinstall {DIST_NAME}"
|
|
101
|
+
) from _e
|
|
102
|
+
except Exception as _e:
|
|
103
|
+
raise ImportError(
|
|
104
|
+
f"invisible-core is installed but it cannot even determine its own version: "
|
|
105
|
+
f"importing it raised {type(_e).__name__}: {_e}\n"
|
|
106
|
+
f"That is a partial or damaged installation, not an old one. A missing or "
|
|
107
|
+
f"unreadable invisible_core/seal.json is the usual cause, and it is read "
|
|
108
|
+
f"while invisible_core is still importing, so nothing inside the core can "
|
|
109
|
+
f"report it.\n"
|
|
110
|
+
f"Run: pip install --force-reinstall {DIST_NAME}"
|
|
111
|
+
) from _e
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def declared_core_pin(dist_name: str = DIST_NAME) -> "str | None":
|
|
115
|
+
"""The `invisible-core==X` version THIS distribution declares, or None."""
|
|
116
|
+
return _declared_core_pin(dist_name)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def pin_declaration(dist_name: str = DIST_NAME) -> PinDeclaration:
|
|
120
|
+
"""The declaration and why it is or is not checkable."""
|
|
121
|
+
return _pin_declaration(dist_name)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def pin_report(dist_name: str = DIST_NAME) -> dict:
|
|
125
|
+
"""Non-raising form. Read ["verdict"], not ["ok"], to report health."""
|
|
126
|
+
return _pin_report(dist_name)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def assert_core_pin(dist_name: str = DIST_NAME) -> None:
|
|
130
|
+
"""Report-only form: raise ImportError when the core that will run is not the
|
|
131
|
+
one declared. Installs nothing. Used by the CLI and the tests."""
|
|
132
|
+
_assert_core_pin(dist_name)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def enforce_core_pin(dist_name: str = DIST_NAME, *, stream=None) -> None:
|
|
136
|
+
"""Import-time form: check, and repair the environment instead of only
|
|
137
|
+
reporting it. Raises ImportError only when it is still wrong afterwards.
|
|
138
|
+
|
|
139
|
+
The sys.modules snapshot taken at the top of this module is passed through:
|
|
140
|
+
it is the only evidence that no caller is already holding a core object, and
|
|
141
|
+
it stops being available the moment anything imports the core.
|
|
142
|
+
"""
|
|
143
|
+
_enforce_core_pin(dist_name, core_preimported=_CORE_PREIMPORTED, stream=stream)
|