scrapely-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scrapely/__init__.py +246 -0
- scrapely/_browser_locations.py +5470 -0
- scrapely/actor.py +1836 -0
- scrapely/browser_configuration.py +146 -0
- scrapely/charging.py +1143 -0
- scrapely/charging_client.py +107 -0
- scrapely/configuration.py +375 -0
- scrapely/crypto/__init__.py +21 -0
- scrapely/crypto/_secrets.py +210 -0
- scrapely/errors.py +153 -0
- scrapely/events/__init__.py +57 -0
- scrapely/events/_types.py +194 -0
- scrapely/events/event_manager.py +292 -0
- scrapely/log.py +39 -0
- scrapely/patch_browser_pool.py +861 -0
- scrapely/pricing_types.py +172 -0
- scrapely/proxy_configuration.py +741 -0
- scrapely/py.typed +2 -0
- scrapely/request_loaders/__init__.py +5 -0
- scrapely/request_loaders/_scrapely_request_list.py +169 -0
- scrapely/storage/__init__.py +48 -0
- scrapely/storage/_file_system/__init__.py +9 -0
- scrapely/storage/_file_system/_key_value_store_client.py +155 -0
- scrapely/storage/_file_system/_storage_client.py +50 -0
- scrapely/storage/_metadata.py +138 -0
- scrapely/storage/api_client.py +350 -0
- scrapely/storage/client.py +618 -0
- scrapely/storage/patch_storage.py +257 -0
- scrapely/storage/scrapely_dataset_client.py +305 -0
- scrapely/storage/scrapely_kvs_client.py +220 -0
- scrapely/storage/scrapely_rq_client.py +496 -0
- scrapely/storage/scrapely_storage_client.py +192 -0
- scrapely/storage/smart_scrapely_storage_client.py +109 -0
- scrapely/storage/types.py +601 -0
- scrapely/storage/utils.py +38 -0
- scrapely/storage_clients/__init__.py +25 -0
- scrapely/storages/__init__.py +5 -0
- scrapely/types.py +269 -0
- scrapely/webhook.py +82 -0
- scrapely_python-0.1.0.dist-info/METADATA +83 -0
- scrapely_python-0.1.0.dist-info/RECORD +43 -0
- scrapely_python-0.1.0.dist-info/WHEEL +5 -0
- scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
"""Patch Crawlee's Playwright browser pool to use Scrapely remote CDP.
|
|
2
|
+
|
|
3
|
+
Mirrors the Node SDK ``patch-browser-pool.ts``:
|
|
4
|
+
|
|
5
|
+
- On import, monkey-patch ``PlaywrightBrowserPlugin.new_browser`` when Crawlee
|
|
6
|
+
browsers + Playwright are available.
|
|
7
|
+
- Also monkey-patch ``camoufox.AsyncNewBrowser`` when Camoufox is installed,
|
|
8
|
+
so CamoufoxPlugin-based actors also connect via CDP instead of launching a
|
|
9
|
+
local Camoufox browser.
|
|
10
|
+
- Raw Playwright usage (no Crawlee BrowserPool) is covered too: the
|
|
11
|
+
``sync_playwright()`` / ``async_playwright()`` entry points are wrapped so
|
|
12
|
+
every ``BrowserType.launch()`` / ``launch_persistent_context()`` on the
|
|
13
|
+
returned object connects via CDP instead of launching a local browser. This
|
|
14
|
+
is what makes SDK-less actors (plain ``p.chromium.launch()`` scripts) use
|
|
15
|
+
the platform browser — the patch reaches them through the base image's
|
|
16
|
+
sitecustomize import at interpreter start.
|
|
17
|
+
- Browser launch goes through ``chromium.connect_over_cdp(CDP_URL)`` when a
|
|
18
|
+
CDP URL is configured (platform runs, or local runs that opt into a remote
|
|
19
|
+
browser). Requested firefox/webkit/camoufox are not denied — they are
|
|
20
|
+
redirected to the remote Chromium CDP browser (antibot-patched platform
|
|
21
|
+
browser).
|
|
22
|
+
- Local development (no ``CDP_URL`` and not on the platform) falls back to a
|
|
23
|
+
normal local browser launch, exactly like Crawlee without the patch. Local
|
|
24
|
+
launch is disabled on the platform unless the build is unpatched
|
|
25
|
+
(``SCRAPELY_BROWSER_PATCH=0``); otherwise a worker container without
|
|
26
|
+
``CDP_URL`` is a misconfiguration and must fail loudly.
|
|
27
|
+
- Unpatched builds (``SCRAPELY_BROWSER_PATCH=0``, set by the worker when the
|
|
28
|
+
actor's browser patch was off at build time) keep their browser binaries:
|
|
29
|
+
every patched entry point falls through to the original local launch, with
|
|
30
|
+
gVisor-safe Chromium args under ``SCRAPELY_CONTAINER_RUNTIME=runsc``.
|
|
31
|
+
- Soft no-op when Playwright/Crawlee browsers are not installed (e.g. HTTP-only
|
|
32
|
+
actors).
|
|
33
|
+
|
|
34
|
+
Location and proxy handling (the remote browser never takes a proxy):
|
|
35
|
+
|
|
36
|
+
- The Scrapely remote browser routes through its own integrated proxy,
|
|
37
|
+
selected by location params on the CDP URL (``proxy_country``,
|
|
38
|
+
``proxy_state``, ``proxy_city``) plus fingerprint params (``os``,
|
|
39
|
+
``timezone``, ``language``, ``adblock``, ``solve_cloudflare``).
|
|
40
|
+
``apply_proxy_info_to_cdp_url`` is the single chokepoint that builds it:
|
|
41
|
+
``proxy_url`` is always stripped (the worker-injected one's ``country-XX``
|
|
42
|
+
is kept as the run default), a platform-gateway ProxyInfo contributes its
|
|
43
|
+
country / subdivision, a custom (BYOP) proxy is warned about once and
|
|
44
|
+
ignored, and ``BrowserConfiguration`` params always win.
|
|
45
|
+
- ``PlaywrightCrawler(browser_configuration=...)`` is always accepted; the
|
|
46
|
+
configuration is stored on the crawler's BrowserPool. Raw
|
|
47
|
+
``launch(browser_configuration=...)`` is accepted too and never forwarded to
|
|
48
|
+
a real local launch — locally it is simply ignored.
|
|
49
|
+
- Crawlee passes the crawler's ``ProxyConfiguration`` per *page* as
|
|
50
|
+
``proxy_info`` (``BrowserPool.new_page``), but the remote browser's location
|
|
51
|
+
is fixed at *connect* time. The patch stashes ``proxy_info`` and the pool's
|
|
52
|
+
browser configuration (per asyncio task, via ``ContextVar``) when a page
|
|
53
|
+
requests a browser and consumes them in ``new_browser``: the first launch
|
|
54
|
+
pins the connection. Each browser is tagged with its resolved location, and
|
|
55
|
+
reusing it for a page that resolves to a different location logs a warning.
|
|
56
|
+
- ``PlaywrightBrowserController.new_page`` drops ``proxy_info`` while CDP mode
|
|
57
|
+
is active: Crawlee would otherwise turn it into per-context Playwright
|
|
58
|
+
``ProxySettings`` on top of the remote browser's own proxy.
|
|
59
|
+
- The Camoufox patch (``AsyncNewBrowser``) reads the same relayed values.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import contextvars
|
|
65
|
+
import functools
|
|
66
|
+
import logging
|
|
67
|
+
import os
|
|
68
|
+
import re
|
|
69
|
+
from typing import Any, List, Optional, Tuple
|
|
70
|
+
from urllib.parse import SplitResult, parse_qsl, unquote, urlencode, urlsplit, urlunsplit
|
|
71
|
+
|
|
72
|
+
from ._browser_locations import subdivision_to_state_name
|
|
73
|
+
|
|
74
|
+
logger = logging.getLogger(__name__)
|
|
75
|
+
|
|
76
|
+
_cdp_url: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
# Per-launch proxy info and browser configuration, stashed by the patched
|
|
79
|
+
# ``BrowserPool._get_new_page`` and consumed by the patched
|
|
80
|
+
# ``PlaywrightBrowserPlugin.new_browser`` / Camoufox ``AsyncNewBrowser``.
|
|
81
|
+
# A ContextVar is private to each asyncio task, so concurrent page/browser
|
|
82
|
+
# launches cannot mix their configurations.
|
|
83
|
+
_launch_proxy_info: contextvars.ContextVar = contextvars.ContextVar(
|
|
84
|
+
'scrapely_launch_proxy_info', default=None
|
|
85
|
+
)
|
|
86
|
+
_launch_browser_configuration: contextvars.ContextVar = contextvars.ContextVar(
|
|
87
|
+
'scrapely_launch_browser_configuration', default=None
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_cdp_url() -> Optional[str]:
|
|
92
|
+
"""Lazily get CDP URL from explicit setter or ``CDP_URL`` env var."""
|
|
93
|
+
return _cdp_url or os.environ.get('CDP_URL')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def set_cdp_url(url: str) -> None:
|
|
97
|
+
"""Set CDP URL explicitly (for testing or advanced use cases).
|
|
98
|
+
|
|
99
|
+
Normally the CDP URL is read from the ``CDP_URL`` environment variable
|
|
100
|
+
lazily (injected by scrapely-server for platform runs).
|
|
101
|
+
"""
|
|
102
|
+
global _cdp_url
|
|
103
|
+
_cdp_url = url
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_at_home() -> bool:
|
|
107
|
+
"""Whether the process runs on the Scrapely platform (worker-injected env)."""
|
|
108
|
+
return bool(os.environ.get('SCRAPELY_IS_AT_HOME') or os.environ.get('APIFY_IS_AT_HOME'))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _browser_patch_disabled() -> bool:
|
|
112
|
+
"""Whether this run is from an unpatched build (launch the actor's own browsers)."""
|
|
113
|
+
return os.environ.get('SCRAPELY_BROWSER_PATCH') == '0'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# gVisor lacks crashpad support; ReadonlyRootfs + tmpfs /tmp loses crash dirs.
|
|
117
|
+
_GVISOR_CHROME_ARGS = (
|
|
118
|
+
'--disable-breakpad',
|
|
119
|
+
'--disable-crash-reporter',
|
|
120
|
+
'--disable-dev-shm-usage',
|
|
121
|
+
'--crash-dumps-dir=/tmp',
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _with_gvisor_args(options: Optional[dict]) -> dict:
|
|
126
|
+
"""Copy of launch options with gVisor-safe Chromium args when under runsc."""
|
|
127
|
+
out = dict(options or {})
|
|
128
|
+
if os.environ.get('SCRAPELY_CONTAINER_RUNTIME') != 'runsc':
|
|
129
|
+
return out
|
|
130
|
+
args = list(out.get('args') or [])
|
|
131
|
+
for arg in _GVISOR_CHROME_ARGS:
|
|
132
|
+
if arg not in args:
|
|
133
|
+
args.append(arg)
|
|
134
|
+
out['args'] = args
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _set_query_param(query: List[tuple], key: str, value: str) -> List[tuple]:
|
|
139
|
+
"""Replace ``key`` in a parsed query list in place (URLSearchParams.set semantics)."""
|
|
140
|
+
out: List[tuple] = []
|
|
141
|
+
inserted = False
|
|
142
|
+
for k, v in query:
|
|
143
|
+
if k == key:
|
|
144
|
+
if not inserted:
|
|
145
|
+
out.append((key, value))
|
|
146
|
+
inserted = True
|
|
147
|
+
# drop duplicates
|
|
148
|
+
else:
|
|
149
|
+
out.append((k, v))
|
|
150
|
+
if not inserted:
|
|
151
|
+
out.append((key, value))
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_BYOP_IGNORED_WARNING = (
|
|
156
|
+
'[scrapely] Custom proxies are ignored when using the Scrapely remote browser — it routes traffic '
|
|
157
|
+
'through its own integrated proxy. Set the location with browser_configuration '
|
|
158
|
+
'(Actor.create_browser_configuration(country_code=..., state=..., city=...)) or ProxyConfiguration '
|
|
159
|
+
'country_code.'
|
|
160
|
+
)
|
|
161
|
+
_GROUPS_SESSION_INFO = (
|
|
162
|
+
'[scrapely] Proxy groups and session IDs do not apply to the Scrapely remote browser; routing is '
|
|
163
|
+
'location-based (proxy_country/proxy_state). Each browser connection is a fresh session.'
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Once-per-process messages (reset by tests via _reset_warnings()).
|
|
167
|
+
_warned: dict = {'byop': False, 'groups': False, 'subdivision': False}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _reset_warnings() -> None:
|
|
171
|
+
for key in _warned:
|
|
172
|
+
_warned[key] = False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _parse_proxy_url(proxy_url: str) -> Optional[SplitResult]:
|
|
176
|
+
"""Parse a proxy URL, accepting scheme-less ``host:port``."""
|
|
177
|
+
for candidate in (proxy_url, f'http://{proxy_url}'):
|
|
178
|
+
try:
|
|
179
|
+
parts = urlsplit(candidate)
|
|
180
|
+
if parts.hostname:
|
|
181
|
+
return parts
|
|
182
|
+
except ValueError:
|
|
183
|
+
continue
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _platform_proxy_hostname() -> Optional[str]:
|
|
188
|
+
"""Platform gateway hostname injected by the worker (same vars as ProxyConfiguration)."""
|
|
189
|
+
hostname = (os.environ.get('SCRAPELY_PROXY_HOSTNAME') or os.environ.get('APIFY_PROXY_HOSTNAME') or '').strip()
|
|
190
|
+
return hostname.lower() or None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_platform_proxy_url(proxy_url: str) -> bool:
|
|
194
|
+
platform_host = _platform_proxy_hostname()
|
|
195
|
+
if not platform_host:
|
|
196
|
+
return False
|
|
197
|
+
parts = _parse_proxy_url(proxy_url)
|
|
198
|
+
return bool(parts and parts.hostname and parts.hostname.lower() == platform_host)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _redact_proxy_url(proxy_url: str) -> str:
|
|
202
|
+
"""``scheme://***@host:port`` — never log proxy credentials."""
|
|
203
|
+
parts = _parse_proxy_url(proxy_url)
|
|
204
|
+
if not parts or not parts.hostname:
|
|
205
|
+
return '<unparseable proxy URL>'
|
|
206
|
+
try:
|
|
207
|
+
port = f':{parts.port}' if parts.port else ''
|
|
208
|
+
except ValueError:
|
|
209
|
+
port = ''
|
|
210
|
+
userinfo = '***@' if parts.username or parts.password else ''
|
|
211
|
+
return f'{parts.scheme or "http"}://{userinfo}{parts.hostname}{port}'
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_COUNTRY_FRAGMENT_RE = re.compile(r'^country-([A-Za-z]{2})(?:_([A-Za-z0-9]{1,3}))?$')
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _parse_proxy_url_location(proxy_url: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
|
|
218
|
+
"""``(country, subdivision)`` from the gateway grammar ``sub,groups-X,session-Y,country-US_CA``."""
|
|
219
|
+
if not proxy_url:
|
|
220
|
+
return None, None
|
|
221
|
+
parts = _parse_proxy_url(str(proxy_url))
|
|
222
|
+
if not parts or not parts.username:
|
|
223
|
+
return None, None
|
|
224
|
+
for fragment in unquote(parts.username).split(','):
|
|
225
|
+
match = _COUNTRY_FRAGMENT_RE.match(fragment.strip())
|
|
226
|
+
if match:
|
|
227
|
+
subdivision = match.group(2)
|
|
228
|
+
return match.group(1).upper(), subdivision.upper() if subdivision else None
|
|
229
|
+
return None, None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _resolve_launch_location(proxy_info: Any, *, log: bool = True) -> Tuple[Optional[str], Optional[str]]:
|
|
233
|
+
"""``(country, subdivision)`` requested by a platform-gateway proxy info; ``(None, None)`` for none or BYOP."""
|
|
234
|
+
proxy_url = getattr(proxy_info, 'url', None) if proxy_info is not None else None
|
|
235
|
+
if not proxy_url:
|
|
236
|
+
return None, None
|
|
237
|
+
|
|
238
|
+
if not _is_platform_proxy_url(str(proxy_url)):
|
|
239
|
+
if log and not _warned['byop']:
|
|
240
|
+
_warned['byop'] = True
|
|
241
|
+
logger.warning('%s (ignored: %s)', _BYOP_IGNORED_WARNING, _redact_proxy_url(str(proxy_url)))
|
|
242
|
+
return None, None
|
|
243
|
+
|
|
244
|
+
if log and (getattr(proxy_info, 'groups', None) or getattr(proxy_info, 'session_id', None)):
|
|
245
|
+
if not _warned['groups']:
|
|
246
|
+
_warned['groups'] = True
|
|
247
|
+
logger.info(_GROUPS_SESSION_INFO)
|
|
248
|
+
grammar_country, grammar_subdivision = _parse_proxy_url_location(str(proxy_url))
|
|
249
|
+
return (
|
|
250
|
+
getattr(proxy_info, 'country_code', None) or grammar_country,
|
|
251
|
+
getattr(proxy_info, 'subdivision_code', None) or grammar_subdivision,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _location_signature(proxy_info: Any, cdp_url: Optional[str]) -> str:
|
|
256
|
+
"""Comparable location of a connection, e.g. ``country=US,state=california``."""
|
|
257
|
+
country, subdivision = _resolve_launch_location(proxy_info, log=False)
|
|
258
|
+
if country:
|
|
259
|
+
country = country.upper()
|
|
260
|
+
state = subdivision_to_state_name(country, subdivision) if subdivision else None
|
|
261
|
+
else:
|
|
262
|
+
url_params = dict(parse_qsl(urlsplit(cdp_url).query)) if cdp_url else {}
|
|
263
|
+
country = url_params.get('proxy_country')
|
|
264
|
+
state = url_params.get('proxy_state')
|
|
265
|
+
return f'country={country or ""},state={state or ""}'
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _validate_browser_configuration(browser_configuration: Any) -> None:
|
|
269
|
+
if browser_configuration is not None and not callable(getattr(browser_configuration, 'to_cdp_params', None)):
|
|
270
|
+
raise TypeError(
|
|
271
|
+
'browser_configuration must be created with Actor.create_browser_configuration() '
|
|
272
|
+
'or BrowserConfiguration().'
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def apply_proxy_info_to_cdp_url(cdp_url: str, proxy_info: Any, browser_configuration: Any = None) -> str:
|
|
277
|
+
"""Build the remote browser CDP URL for one browser connection.
|
|
278
|
+
|
|
279
|
+
The Scrapely remote browser never takes a proxy: it routes through its own
|
|
280
|
+
integrated proxy, selected by location params. Semantics (mirrors
|
|
281
|
+
``applyProxyInfoToCdpUrl`` in the Node SDK):
|
|
282
|
+
|
|
283
|
+
- The worker-injected ``proxy_country`` is the run default; ``proxy_url``
|
|
284
|
+
is never sent and is removed if present.
|
|
285
|
+
- A platform-gateway ``proxy_info`` (crawler ProxyConfiguration)
|
|
286
|
+
overrides ``proxy_country`` / ``proxy_state`` (ISO subdivision codes
|
|
287
|
+
mapped to region names). A custom (BYOP) proxy is warned about once and
|
|
288
|
+
ignored.
|
|
289
|
+
- ``browser_configuration`` params always win.
|
|
290
|
+
- When nothing changes, the CDP URL is returned byte-identical.
|
|
291
|
+
"""
|
|
292
|
+
_validate_browser_configuration(browser_configuration)
|
|
293
|
+
|
|
294
|
+
parts = urlsplit(cdp_url)
|
|
295
|
+
query = parse_qsl(parts.query, keep_blank_values=True)
|
|
296
|
+
changed = False
|
|
297
|
+
|
|
298
|
+
def set_param(key: str, value: str) -> None:
|
|
299
|
+
nonlocal query, changed
|
|
300
|
+
if dict(query).get(key) == value:
|
|
301
|
+
return
|
|
302
|
+
query = _set_query_param(query, key, value)
|
|
303
|
+
changed = True
|
|
304
|
+
|
|
305
|
+
if any(key == 'proxy_url' for key, _ in query):
|
|
306
|
+
query = [(k, v) for k, v in query if k != 'proxy_url']
|
|
307
|
+
changed = True
|
|
308
|
+
|
|
309
|
+
country, subdivision = _resolve_launch_location(proxy_info)
|
|
310
|
+
country = country.upper() if country else None
|
|
311
|
+
state = None
|
|
312
|
+
if subdivision:
|
|
313
|
+
state = subdivision_to_state_name(country, subdivision)
|
|
314
|
+
if not state and not _warned['subdivision']:
|
|
315
|
+
_warned['subdivision'] = True
|
|
316
|
+
logger.warning(
|
|
317
|
+
'[scrapely] Subdivision "%s-%s" has no Scrapely browser mapping; routing by country only. '
|
|
318
|
+
"Use browser_configuration state='<name>' to target a region.",
|
|
319
|
+
country or '?',
|
|
320
|
+
subdivision,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if country:
|
|
324
|
+
set_param('proxy_country', country)
|
|
325
|
+
if state:
|
|
326
|
+
set_param('proxy_state', state)
|
|
327
|
+
|
|
328
|
+
if browser_configuration is not None:
|
|
329
|
+
for key, value in browser_configuration.to_cdp_params().items():
|
|
330
|
+
set_param(key, value)
|
|
331
|
+
|
|
332
|
+
if not changed:
|
|
333
|
+
return cdp_url
|
|
334
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _apply_patch() -> None:
|
|
338
|
+
"""Monkey-patch Crawlee PlaywrightBrowserPlugin to connect over CDP."""
|
|
339
|
+
try:
|
|
340
|
+
from crawlee._utils.context import ensure_context
|
|
341
|
+
from crawlee.browsers._browser_pool import BrowserPool
|
|
342
|
+
from crawlee.browsers._playwright_browser_controller import PlaywrightBrowserController
|
|
343
|
+
from crawlee.browsers._playwright_browser_plugin import PlaywrightBrowserPlugin
|
|
344
|
+
except ImportError:
|
|
345
|
+
# crawlee browsers / playwright extra not installed — skip
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
if getattr(PlaywrightBrowserPlugin, '_scrapely_cdp_patched', False):
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
# Stash the per-page proxy_info so the (proxy-less) new_browser() launch
|
|
352
|
+
# can honor the crawler's ProxyConfiguration when building the CDP URL.
|
|
353
|
+
_orig_get_new_page = BrowserPool._get_new_page
|
|
354
|
+
|
|
355
|
+
async def _get_new_page_with_proxy(self: Any, page_id: Any, plugin: Any, proxy_info: Any) -> Any:
|
|
356
|
+
token = _launch_proxy_info.set(proxy_info)
|
|
357
|
+
config_token = _launch_browser_configuration.set(getattr(self, '_scrapely_browser_configuration', None))
|
|
358
|
+
try:
|
|
359
|
+
return await _orig_get_new_page(self, page_id, plugin, proxy_info)
|
|
360
|
+
finally:
|
|
361
|
+
_launch_browser_configuration.reset(config_token)
|
|
362
|
+
_launch_proxy_info.reset(token)
|
|
363
|
+
|
|
364
|
+
BrowserPool._get_new_page = _get_new_page_with_proxy # type: ignore[method-assign]
|
|
365
|
+
|
|
366
|
+
# Crawlee would turn proxy_info into per-context Playwright ProxySettings,
|
|
367
|
+
# double-proxying on top of the service-level proxy selected through the
|
|
368
|
+
# CDP URL — drop it while CDP mode is active. Before dropping, verify the
|
|
369
|
+
# reused browser was launched for the location this page asked for: the
|
|
370
|
+
# browser pool may reuse a connection pinned to a different (first-page)
|
|
371
|
+
# location, which would silently route the page from the wrong region.
|
|
372
|
+
_orig_controller_new_page = PlaywrightBrowserController.new_page
|
|
373
|
+
|
|
374
|
+
async def _new_page_without_context_proxy(self: Any, browser_new_context_options: Any = None, proxy_info: Any = None) -> Any:
|
|
375
|
+
cdp_url = get_cdp_url()
|
|
376
|
+
if cdp_url and not _browser_patch_disabled():
|
|
377
|
+
requested_info = _launch_proxy_info.get()
|
|
378
|
+
launched = getattr(self, '_scrapely_location_signature', None)
|
|
379
|
+
if requested_info is not None and getattr(requested_info, 'url', None) and launched is not None:
|
|
380
|
+
requested = _location_signature(requested_info, cdp_url)
|
|
381
|
+
if requested != launched and not getattr(self, '_scrapely_proxy_mismatch_warned', False):
|
|
382
|
+
self._scrapely_proxy_mismatch_warned = True
|
|
383
|
+
logger.warning(
|
|
384
|
+
'Browser location mismatch: a page requested "%s" but this browser '
|
|
385
|
+
'connection was launched with "%s" (the browser location is fixed at '
|
|
386
|
+
'connect time; the first page that triggered the launch pins it). The page '
|
|
387
|
+
'will use the launch location. Location rotation for browser crawlers works '
|
|
388
|
+
'per browser connection — pair one Session with one browser (as Crawlee '
|
|
389
|
+
'does) or accept the launch location.',
|
|
390
|
+
requested,
|
|
391
|
+
launched,
|
|
392
|
+
)
|
|
393
|
+
proxy_info = None
|
|
394
|
+
return await _orig_controller_new_page(self, browser_new_context_options, proxy_info)
|
|
395
|
+
|
|
396
|
+
PlaywrightBrowserController.new_page = _new_page_without_context_proxy # type: ignore[method-assign]
|
|
397
|
+
|
|
398
|
+
_orig_new_browser = PlaywrightBrowserPlugin.new_browser
|
|
399
|
+
|
|
400
|
+
@ensure_context
|
|
401
|
+
async def new_browser(self: Any) -> PlaywrightBrowserController:
|
|
402
|
+
if not self._playwright:
|
|
403
|
+
raise RuntimeError('Playwright browser plugin is not initialized.')
|
|
404
|
+
|
|
405
|
+
browser_configuration = _launch_browser_configuration.get()
|
|
406
|
+
if _browser_patch_disabled():
|
|
407
|
+
_log_ignored_browser_configuration(browser_configuration)
|
|
408
|
+
return await _orig_new_browser(self)
|
|
409
|
+
|
|
410
|
+
cdp_url = get_cdp_url()
|
|
411
|
+
if not cdp_url:
|
|
412
|
+
if not _is_at_home():
|
|
413
|
+
# Local development without CDP_URL: normal local browser launch.
|
|
414
|
+
_log_ignored_browser_configuration(browser_configuration)
|
|
415
|
+
return await _orig_new_browser(self)
|
|
416
|
+
raise RuntimeError(
|
|
417
|
+
'cdp_url is required. Local browser launch is disabled on the '
|
|
418
|
+
'platform — all runs must use the remote browser.'
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# The first page's proxy info / browser configuration pins the
|
|
422
|
+
# connection's location params. The resolved location is tagged on the
|
|
423
|
+
# controller so later page requests can detect (and loudly warn about)
|
|
424
|
+
# a mismatch instead of silently reusing a connection pinned elsewhere.
|
|
425
|
+
proxy_info = _launch_proxy_info.get()
|
|
426
|
+
launch_signature = _location_signature(proxy_info, cdp_url)
|
|
427
|
+
cdp_url = apply_proxy_info_to_cdp_url(cdp_url, proxy_info, browser_configuration)
|
|
428
|
+
|
|
429
|
+
# Remote browser infrastructure is Chromium CDP (same as Node connectOverCDP).
|
|
430
|
+
browser = await self._playwright.chromium.connect_over_cdp(cdp_url)
|
|
431
|
+
|
|
432
|
+
controller = PlaywrightBrowserController(
|
|
433
|
+
browser,
|
|
434
|
+
use_incognito_pages=self._use_incognito_pages,
|
|
435
|
+
max_open_pages_per_browser=self._max_open_pages_per_browser,
|
|
436
|
+
fingerprint_generator=self._fingerprint_generator,
|
|
437
|
+
)
|
|
438
|
+
controller._scrapely_location_signature = launch_signature # type: ignore[attr-defined]
|
|
439
|
+
return controller
|
|
440
|
+
|
|
441
|
+
PlaywrightBrowserPlugin.new_browser = new_browser # type: ignore[method-assign]
|
|
442
|
+
PlaywrightBrowserPlugin._scrapely_cdp_patched = True # type: ignore[attr-defined]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _log_ignored_browser_configuration(browser_configuration: Any) -> None:
|
|
446
|
+
if browser_configuration is not None:
|
|
447
|
+
logger.debug('browser_configuration ignored: browser launched locally (not the Scrapely remote browser).')
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _apply_crawler_patch() -> None:
|
|
451
|
+
"""Accept ``browser_configuration=`` on ``PlaywrightCrawler``.
|
|
452
|
+
|
|
453
|
+
Always accepted (local runs and unpatched builds too) so actor code does not
|
|
454
|
+
change between environments; it only takes effect on remote CDP launches.
|
|
455
|
+
The configuration is stored on the crawler's BrowserPool and relayed to
|
|
456
|
+
``new_browser`` by the patched ``BrowserPool._get_new_page``.
|
|
457
|
+
"""
|
|
458
|
+
try:
|
|
459
|
+
from crawlee.crawlers import PlaywrightCrawler
|
|
460
|
+
except ImportError:
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
if getattr(PlaywrightCrawler, '_scrapely_browser_config_patched', False):
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
_orig_init = PlaywrightCrawler.__init__
|
|
467
|
+
|
|
468
|
+
@functools.wraps(_orig_init)
|
|
469
|
+
def __init__(self: Any, *args: Any, browser_configuration: Any = None, **kwargs: Any) -> None:
|
|
470
|
+
_validate_browser_configuration(browser_configuration)
|
|
471
|
+
_orig_init(self, *args, **kwargs)
|
|
472
|
+
if browser_configuration is not None:
|
|
473
|
+
pool = getattr(self, '_browser_pool', None)
|
|
474
|
+
if pool is not None:
|
|
475
|
+
pool._scrapely_browser_configuration = browser_configuration
|
|
476
|
+
|
|
477
|
+
PlaywrightCrawler.__init__ = __init__ # type: ignore[method-assign]
|
|
478
|
+
PlaywrightCrawler._scrapely_browser_config_patched = True # type: ignore[attr-defined]
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _apply_camoufox_patch() -> None:
|
|
482
|
+
"""Monkey-patch camoufox.AsyncNewBrowser to connect over CDP.
|
|
483
|
+
|
|
484
|
+
CamoufoxPlugin (from crawlee's camoufox template) overrides
|
|
485
|
+
``PlaywrightBrowserPlugin.new_browser`` and calls
|
|
486
|
+
``AsyncNewBrowser(self._playwright, headless=True)`` directly, bypassing
|
|
487
|
+
the PlaywrightBrowserPlugin patch above. By patching AsyncNewBrowser itself,
|
|
488
|
+
we ensure that any Camoufox-based actor also connects via CDP — local
|
|
489
|
+
launch is disabled, exactly like every other browser path.
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
import camoufox # type: ignore[import-not-found] # optional dependency
|
|
493
|
+
except ImportError:
|
|
494
|
+
# camoufox not installed — skip
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
if getattr(camoufox, '_scrapely_cdp_patched', False):
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
_orig_async_new_browser = camoufox.AsyncNewBrowser
|
|
501
|
+
|
|
502
|
+
async def _patched_async_new_browser(playwright: Any, **kwargs: Any) -> Any:
|
|
503
|
+
"""Redirect Camoufox browser creation to CDP; local launch is disabled."""
|
|
504
|
+
browser_configuration = _launch_browser_configuration.get()
|
|
505
|
+
if _browser_patch_disabled():
|
|
506
|
+
_log_ignored_browser_configuration(browser_configuration)
|
|
507
|
+
return await _orig_async_new_browser(playwright, **kwargs)
|
|
508
|
+
cdp_url = get_cdp_url()
|
|
509
|
+
if not cdp_url:
|
|
510
|
+
if not _is_at_home():
|
|
511
|
+
# Local development without CDP_URL: normal local Camoufox launch.
|
|
512
|
+
_log_ignored_browser_configuration(browser_configuration)
|
|
513
|
+
return await _orig_async_new_browser(playwright, **kwargs)
|
|
514
|
+
raise RuntimeError(
|
|
515
|
+
'cdp_url is required. Local browser launch is disabled on the '
|
|
516
|
+
'platform — all runs must use the remote browser.'
|
|
517
|
+
)
|
|
518
|
+
# Same as the PlaywrightBrowserPlugin patch above: the first page's
|
|
519
|
+
# proxy info / browser configuration pins the connection's location.
|
|
520
|
+
cdp_url = apply_proxy_info_to_cdp_url(cdp_url, _launch_proxy_info.get(), browser_configuration)
|
|
521
|
+
return await playwright.chromium.connect_over_cdp(cdp_url)
|
|
522
|
+
|
|
523
|
+
camoufox.AsyncNewBrowser = _patched_async_new_browser
|
|
524
|
+
camoufox._scrapely_cdp_patched = True
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ─── Raw Playwright API → remote CDP ─────────────────────────────────────────
|
|
528
|
+
#
|
|
529
|
+
# Actors that use Playwright directly (no Crawlee BrowserPool, no Camoufox)
|
|
530
|
+
# would otherwise launch a local browser inside the worker container, silently
|
|
531
|
+
# bypassing the platform's remote browser (antibot patching, proxying, session
|
|
532
|
+
# pairing). The pool patch above cannot see those launches, so the
|
|
533
|
+
# sync_playwright()/async_playwright() entry points are wrapped and every
|
|
534
|
+
# BrowserType on the returned Playwright object gets launch() and
|
|
535
|
+
# launch_persistent_context() redirected to chromium.connect_over_cdp(CDP_URL).
|
|
536
|
+
|
|
537
|
+
class _ProxyUrlInfo:
|
|
538
|
+
"""Minimal ProxyInfo stand-in for apply_proxy_info_to_cdp_url()."""
|
|
539
|
+
|
|
540
|
+
__slots__ = ('url',)
|
|
541
|
+
|
|
542
|
+
def __init__(self, url: str) -> None:
|
|
543
|
+
self.url = url
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _compose_launch_proxy_url(proxy: Any) -> Optional[str]:
|
|
547
|
+
"""Compose a full proxy URL from Playwright's launch ``proxy`` mapping."""
|
|
548
|
+
if isinstance(proxy, dict):
|
|
549
|
+
server = proxy.get('server')
|
|
550
|
+
username = proxy.get('username')
|
|
551
|
+
password = proxy.get('password')
|
|
552
|
+
else:
|
|
553
|
+
server = getattr(proxy, 'server', None)
|
|
554
|
+
username = getattr(proxy, 'username', None)
|
|
555
|
+
password = getattr(proxy, 'password', None)
|
|
556
|
+
if not server:
|
|
557
|
+
return None
|
|
558
|
+
if username:
|
|
559
|
+
parts = urlsplit(str(server))
|
|
560
|
+
host = parts.hostname or ''
|
|
561
|
+
port = f':{parts.port}' if parts.port else ''
|
|
562
|
+
netloc = f'{username}:{password or ""}@{host}{port}'
|
|
563
|
+
return urlunsplit((parts.scheme or 'http', netloc, parts.path, parts.query, parts.fragment))
|
|
564
|
+
return str(server)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _raw_launch_cdp_url(kwargs: dict, browser_configuration: Any = None) -> Optional[str]:
|
|
568
|
+
"""CDP URL for a raw launch (a launch ``proxy`` option is warned about and ignored)."""
|
|
569
|
+
cdp_url = get_cdp_url()
|
|
570
|
+
if not cdp_url:
|
|
571
|
+
return None
|
|
572
|
+
proxy_url = _compose_launch_proxy_url(kwargs.get('proxy'))
|
|
573
|
+
return apply_proxy_info_to_cdp_url(
|
|
574
|
+
cdp_url, _ProxyUrlInfo(proxy_url) if proxy_url else None, browser_configuration
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _take_browser_configuration(kwargs: dict) -> Any:
|
|
579
|
+
"""Pop ``browser_configuration`` so it never reaches a real (local) launch."""
|
|
580
|
+
browser_configuration = kwargs.pop('browser_configuration', None)
|
|
581
|
+
_validate_browser_configuration(browser_configuration)
|
|
582
|
+
if browser_configuration is not None and (_browser_patch_disabled() or not get_cdp_url()):
|
|
583
|
+
_log_ignored_browser_configuration(browser_configuration)
|
|
584
|
+
return browser_configuration
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _raw_connect_kwargs(kwargs: dict) -> dict:
|
|
588
|
+
"""Forward connection-level options playwright's CDP connect understands."""
|
|
589
|
+
out: dict = {}
|
|
590
|
+
if 'timeout' in kwargs:
|
|
591
|
+
out['timeout'] = kwargs['timeout']
|
|
592
|
+
if 'slow_mo' in kwargs:
|
|
593
|
+
out['slow_mo'] = kwargs['slow_mo']
|
|
594
|
+
return out
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _warn_dropped_launch_options(kind: str, browser_name: str, kwargs: dict) -> None:
|
|
598
|
+
dropped = sorted(k for k in kwargs if k not in ('timeout', 'slow_mo'))
|
|
599
|
+
if dropped:
|
|
600
|
+
logger.warning(
|
|
601
|
+
'playwright %s.%s redirected to the remote CDP browser; local launch options '
|
|
602
|
+
'ignored: %s (the remote browser manages headless mode, binary and flags).',
|
|
603
|
+
browser_name,
|
|
604
|
+
kind,
|
|
605
|
+
', '.join(dropped),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# Options that only make sense for a local launch and have no remote-CDP
|
|
610
|
+
# equivalent. Everything else is forwarded to browser.new_context() for
|
|
611
|
+
# launch_persistent_context() calls (viewport, locale, user_agent, ...).
|
|
612
|
+
_RAW_LAUNCH_ONLY_KEYS = frozenset({
|
|
613
|
+
'user_data_dir',
|
|
614
|
+
'headless',
|
|
615
|
+
'channel',
|
|
616
|
+
'args',
|
|
617
|
+
'ignore_args',
|
|
618
|
+
'ignore_default_args',
|
|
619
|
+
'executable_path',
|
|
620
|
+
'devtools',
|
|
621
|
+
'env',
|
|
622
|
+
'chromium_sandbox',
|
|
623
|
+
'firefox_user_prefs',
|
|
624
|
+
'proxy',
|
|
625
|
+
'traces_dir',
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
_RAW_LAUNCH_DISABLED_MSG = (
|
|
629
|
+
'cdp_url is required. Local browser launch is disabled on the '
|
|
630
|
+
'platform — all runs must use the remote browser.'
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _persistent_context_kwargs(kwargs: dict) -> dict:
|
|
635
|
+
"""Context options forwarded to browser.new_context() after a redirect."""
|
|
636
|
+
return {
|
|
637
|
+
k: v
|
|
638
|
+
for k, v in kwargs.items()
|
|
639
|
+
if k not in _RAW_LAUNCH_ONLY_KEYS and k not in ('timeout', 'slow_mo')
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _warn_persistent_context_redirect(name: str, kwargs: dict) -> None:
|
|
644
|
+
if kwargs.get('user_data_dir'):
|
|
645
|
+
logger.warning(
|
|
646
|
+
'playwright %s.launch_persistent_context(user_data_dir=%r) redirected to the '
|
|
647
|
+
'remote CDP browser — persistent profiles have no remote equivalent; a fresh '
|
|
648
|
+
'context is returned and user_data_dir is ignored.',
|
|
649
|
+
name,
|
|
650
|
+
kwargs.get('user_data_dir'),
|
|
651
|
+
)
|
|
652
|
+
_warn_dropped_launch_options('launch_persistent_context', name, kwargs)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _patch_raw_browser_type(browser_type: Any, chromium: Any, name: str, is_async: bool) -> None:
|
|
656
|
+
"""Redirect a BrowserType's launch()/launch_persistent_context() to CDP."""
|
|
657
|
+
if getattr(browser_type, '_scrapely_raw_cdp_patched', False):
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
if is_async:
|
|
661
|
+
orig_launch = browser_type.launch
|
|
662
|
+
orig_lpc = browser_type.launch_persistent_context
|
|
663
|
+
|
|
664
|
+
async def launch(**kwargs: Any) -> Any:
|
|
665
|
+
browser_configuration = _take_browser_configuration(kwargs)
|
|
666
|
+
if _browser_patch_disabled():
|
|
667
|
+
return await orig_launch(**_with_gvisor_args(kwargs))
|
|
668
|
+
cdp_url = _raw_launch_cdp_url(kwargs, browser_configuration)
|
|
669
|
+
if not cdp_url:
|
|
670
|
+
if not _is_at_home():
|
|
671
|
+
return await orig_launch(**kwargs)
|
|
672
|
+
raise RuntimeError(_RAW_LAUNCH_DISABLED_MSG)
|
|
673
|
+
_warn_dropped_launch_options('launch', name, kwargs)
|
|
674
|
+
return await chromium.connect_over_cdp(cdp_url, **_raw_connect_kwargs(kwargs))
|
|
675
|
+
|
|
676
|
+
# Playwright: launch_persistent_context(user_data_dir, **options)
|
|
677
|
+
async def launch_persistent_context(*args: Any, **kwargs: Any) -> Any:
|
|
678
|
+
browser_configuration = _take_browser_configuration(kwargs)
|
|
679
|
+
if _browser_patch_disabled():
|
|
680
|
+
return await orig_lpc(*args, **_with_gvisor_args(kwargs))
|
|
681
|
+
cdp_url = _raw_launch_cdp_url(kwargs, browser_configuration)
|
|
682
|
+
if not cdp_url:
|
|
683
|
+
if not _is_at_home():
|
|
684
|
+
return await orig_lpc(*args, **kwargs)
|
|
685
|
+
raise RuntimeError(_RAW_LAUNCH_DISABLED_MSG)
|
|
686
|
+
_warn_persistent_context_redirect(name, kwargs)
|
|
687
|
+
browser = await chromium.connect_over_cdp(cdp_url, **_raw_connect_kwargs(kwargs))
|
|
688
|
+
return await browser.new_context(**_persistent_context_kwargs(kwargs))
|
|
689
|
+
|
|
690
|
+
browser_type.launch = launch
|
|
691
|
+
browser_type.launch_persistent_context = launch_persistent_context
|
|
692
|
+
else:
|
|
693
|
+
orig_launch = browser_type.launch
|
|
694
|
+
orig_lpc = browser_type.launch_persistent_context
|
|
695
|
+
|
|
696
|
+
def launch(**kwargs: Any) -> Any:
|
|
697
|
+
browser_configuration = _take_browser_configuration(kwargs)
|
|
698
|
+
if _browser_patch_disabled():
|
|
699
|
+
return orig_launch(**_with_gvisor_args(kwargs))
|
|
700
|
+
cdp_url = _raw_launch_cdp_url(kwargs, browser_configuration)
|
|
701
|
+
if not cdp_url:
|
|
702
|
+
if not _is_at_home():
|
|
703
|
+
return orig_launch(**kwargs)
|
|
704
|
+
raise RuntimeError(_RAW_LAUNCH_DISABLED_MSG)
|
|
705
|
+
_warn_dropped_launch_options('launch', name, kwargs)
|
|
706
|
+
return chromium.connect_over_cdp(cdp_url, **_raw_connect_kwargs(kwargs))
|
|
707
|
+
|
|
708
|
+
def launch_persistent_context(*args: Any, **kwargs: Any) -> Any:
|
|
709
|
+
browser_configuration = _take_browser_configuration(kwargs)
|
|
710
|
+
if _browser_patch_disabled():
|
|
711
|
+
return orig_lpc(*args, **_with_gvisor_args(kwargs))
|
|
712
|
+
cdp_url = _raw_launch_cdp_url(kwargs, browser_configuration)
|
|
713
|
+
if not cdp_url:
|
|
714
|
+
if not _is_at_home():
|
|
715
|
+
return orig_lpc(*args, **kwargs)
|
|
716
|
+
raise RuntimeError(_RAW_LAUNCH_DISABLED_MSG)
|
|
717
|
+
_warn_persistent_context_redirect(name, kwargs)
|
|
718
|
+
browser = chromium.connect_over_cdp(cdp_url, **_raw_connect_kwargs(kwargs))
|
|
719
|
+
return browser.new_context(**_persistent_context_kwargs(kwargs))
|
|
720
|
+
|
|
721
|
+
browser_type.launch = launch
|
|
722
|
+
browser_type.launch_persistent_context = launch_persistent_context
|
|
723
|
+
|
|
724
|
+
def _patch_playwright_object(playwright: Any, is_async: bool) -> Any:
|
|
725
|
+
"""Patch every BrowserType on a Playwright driver object for CDP redirect."""
|
|
726
|
+
chromium = getattr(playwright, 'chromium', None)
|
|
727
|
+
if chromium is None or getattr(chromium, 'connect_over_cdp', None) is None:
|
|
728
|
+
return playwright
|
|
729
|
+
for name in ('chromium', 'firefox', 'webkit'):
|
|
730
|
+
browser_type = getattr(playwright, name, None)
|
|
731
|
+
if browser_type is None:
|
|
732
|
+
continue
|
|
733
|
+
_patch_raw_browser_type(browser_type, chromium, name, is_async)
|
|
734
|
+
return playwright
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
class _SyncPlaywrightContextManagerProxy:
|
|
738
|
+
"""Wraps sync_playwright()'s context manager; patches the driver on start/enter."""
|
|
739
|
+
|
|
740
|
+
def __init__(self, cm: Any) -> None:
|
|
741
|
+
self._cm = cm
|
|
742
|
+
|
|
743
|
+
def start(self) -> Any:
|
|
744
|
+
return _patch_playwright_object(self._cm.start(), is_async=False)
|
|
745
|
+
|
|
746
|
+
def __enter__(self) -> Any:
|
|
747
|
+
return _patch_playwright_object(self._cm.__enter__(), is_async=False)
|
|
748
|
+
|
|
749
|
+
def __exit__(self, *exc: Any) -> Any:
|
|
750
|
+
return self._cm.__exit__(*exc)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class _AsyncPlaywrightContextManagerProxy:
|
|
754
|
+
"""Wraps async_playwright()'s context manager; patches the driver on start/enter."""
|
|
755
|
+
|
|
756
|
+
def __init__(self, cm: Any) -> None:
|
|
757
|
+
self._cm = cm
|
|
758
|
+
|
|
759
|
+
async def start(self) -> Any:
|
|
760
|
+
return _patch_playwright_object(await self._cm.start(), is_async=True)
|
|
761
|
+
|
|
762
|
+
async def __aenter__(self) -> Any:
|
|
763
|
+
return _patch_playwright_object(await self._cm.__aenter__(), is_async=True)
|
|
764
|
+
|
|
765
|
+
async def __aexit__(self, *exc: Any) -> Any:
|
|
766
|
+
return await self._cm.__aexit__(*exc)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _apply_raw_playwright_patch() -> None:
|
|
770
|
+
"""Wrap playwright's sync/async entry points to redirect raw launches to CDP."""
|
|
771
|
+
try:
|
|
772
|
+
import playwright.async_api as playwright_async_api
|
|
773
|
+
except ImportError:
|
|
774
|
+
playwright_async_api = None # type: ignore[assignment]
|
|
775
|
+
|
|
776
|
+
if playwright_async_api is not None and not getattr(
|
|
777
|
+
playwright_async_api, '_scrapely_raw_cdp_patched', False
|
|
778
|
+
):
|
|
779
|
+
orig_async = playwright_async_api.async_playwright
|
|
780
|
+
|
|
781
|
+
def async_playwright_patched() -> Any:
|
|
782
|
+
return _AsyncPlaywrightContextManagerProxy(orig_async())
|
|
783
|
+
|
|
784
|
+
playwright_async_api.async_playwright = async_playwright_patched
|
|
785
|
+
playwright_async_api._scrapely_raw_cdp_patched = True # type: ignore[attr-defined]
|
|
786
|
+
|
|
787
|
+
try:
|
|
788
|
+
import playwright.sync_api as playwright_sync_api
|
|
789
|
+
except ImportError:
|
|
790
|
+
playwright_sync_api = None # type: ignore[assignment]
|
|
791
|
+
|
|
792
|
+
if playwright_sync_api is not None and not getattr(
|
|
793
|
+
playwright_sync_api, '_scrapely_raw_cdp_patched', False
|
|
794
|
+
):
|
|
795
|
+
orig_sync = playwright_sync_api.sync_playwright
|
|
796
|
+
|
|
797
|
+
def sync_playwright_patched() -> Any:
|
|
798
|
+
return _SyncPlaywrightContextManagerProxy(orig_sync())
|
|
799
|
+
|
|
800
|
+
playwright_sync_api.sync_playwright = sync_playwright_patched
|
|
801
|
+
playwright_sync_api._scrapely_raw_cdp_patched = True # type: ignore[attr-defined]
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _apply_pyppeteer_patch() -> None:
|
|
805
|
+
"""Redirect raw pyppeteer launches to the remote CDP browser.
|
|
806
|
+
|
|
807
|
+
pyppeteer (the Python puppeteer port) ships both ``launch`` (spawns a local
|
|
808
|
+
Chromium) and ``connect(browserWSEndpoint=...)`` — the same launcher family
|
|
809
|
+
as puppeteer, so it qualifies as CDP-attachable. Both the package-level
|
|
810
|
+
``pyppeteer.launch`` and ``pyppeteer.launcher.launch`` bindings are patched
|
|
811
|
+
(the package re-exports the function, so patching one module attribute does
|
|
812
|
+
not fix the other binding). Semantics match the raw playwright patch.
|
|
813
|
+
"""
|
|
814
|
+
try:
|
|
815
|
+
import pyppeteer as pyppeteer_mod
|
|
816
|
+
import pyppeteer.launcher as pyppeteer_launcher
|
|
817
|
+
except ImportError:
|
|
818
|
+
# pyppeteer not installed — skip
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
if getattr(pyppeteer_launcher, '_scrapely_cdp_patched', False):
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
_orig_launch = pyppeteer_launcher.launch
|
|
825
|
+
|
|
826
|
+
async def _patched_launch(options=None, **kwargs):
|
|
827
|
+
browser_configuration = _take_browser_configuration(kwargs)
|
|
828
|
+
if isinstance(options, dict) and 'browser_configuration' in options:
|
|
829
|
+
options = dict(options)
|
|
830
|
+
browser_configuration = _take_browser_configuration(options) or browser_configuration
|
|
831
|
+
if _browser_patch_disabled():
|
|
832
|
+
return await _orig_launch(_with_gvisor_args({**(options or {}), **kwargs}))
|
|
833
|
+
cdp_url = get_cdp_url()
|
|
834
|
+
if not cdp_url:
|
|
835
|
+
if not _is_at_home():
|
|
836
|
+
return await _orig_launch(options, **kwargs)
|
|
837
|
+
raise RuntimeError(_RAW_LAUNCH_DISABLED_MSG)
|
|
838
|
+
cdp_url = apply_proxy_info_to_cdp_url(cdp_url, None, browser_configuration)
|
|
839
|
+
|
|
840
|
+
merged = dict(options or {})
|
|
841
|
+
merged.update(kwargs)
|
|
842
|
+
if merged:
|
|
843
|
+
logger.warning(
|
|
844
|
+
'pyppeteer.launch redirected to the remote CDP browser; local launch options '
|
|
845
|
+
'ignored: %s (the remote browser manages headless mode, binary and flags).',
|
|
846
|
+
', '.join(sorted(merged.keys())),
|
|
847
|
+
)
|
|
848
|
+
return await pyppeteer_launcher.connect(browserWSEndpoint=cdp_url)
|
|
849
|
+
|
|
850
|
+
pyppeteer_launcher.launch = _patched_launch
|
|
851
|
+
if hasattr(pyppeteer_mod, 'launch'):
|
|
852
|
+
pyppeteer_mod.launch = _patched_launch
|
|
853
|
+
pyppeteer_launcher._scrapely_cdp_patched = True
|
|
854
|
+
pyppeteer_mod._scrapely_cdp_patched = True
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
_apply_patch()
|
|
858
|
+
_apply_crawler_patch()
|
|
859
|
+
_apply_camoufox_patch()
|
|
860
|
+
_apply_raw_playwright_patch()
|
|
861
|
+
_apply_pyppeteer_patch()
|