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.
Files changed (43) hide show
  1. scrapely/__init__.py +246 -0
  2. scrapely/_browser_locations.py +5470 -0
  3. scrapely/actor.py +1836 -0
  4. scrapely/browser_configuration.py +146 -0
  5. scrapely/charging.py +1143 -0
  6. scrapely/charging_client.py +107 -0
  7. scrapely/configuration.py +375 -0
  8. scrapely/crypto/__init__.py +21 -0
  9. scrapely/crypto/_secrets.py +210 -0
  10. scrapely/errors.py +153 -0
  11. scrapely/events/__init__.py +57 -0
  12. scrapely/events/_types.py +194 -0
  13. scrapely/events/event_manager.py +292 -0
  14. scrapely/log.py +39 -0
  15. scrapely/patch_browser_pool.py +861 -0
  16. scrapely/pricing_types.py +172 -0
  17. scrapely/proxy_configuration.py +741 -0
  18. scrapely/py.typed +2 -0
  19. scrapely/request_loaders/__init__.py +5 -0
  20. scrapely/request_loaders/_scrapely_request_list.py +169 -0
  21. scrapely/storage/__init__.py +48 -0
  22. scrapely/storage/_file_system/__init__.py +9 -0
  23. scrapely/storage/_file_system/_key_value_store_client.py +155 -0
  24. scrapely/storage/_file_system/_storage_client.py +50 -0
  25. scrapely/storage/_metadata.py +138 -0
  26. scrapely/storage/api_client.py +350 -0
  27. scrapely/storage/client.py +618 -0
  28. scrapely/storage/patch_storage.py +257 -0
  29. scrapely/storage/scrapely_dataset_client.py +305 -0
  30. scrapely/storage/scrapely_kvs_client.py +220 -0
  31. scrapely/storage/scrapely_rq_client.py +496 -0
  32. scrapely/storage/scrapely_storage_client.py +192 -0
  33. scrapely/storage/smart_scrapely_storage_client.py +109 -0
  34. scrapely/storage/types.py +601 -0
  35. scrapely/storage/utils.py +38 -0
  36. scrapely/storage_clients/__init__.py +25 -0
  37. scrapely/storages/__init__.py +5 -0
  38. scrapely/types.py +269 -0
  39. scrapely/webhook.py +82 -0
  40. scrapely_python-0.1.0.dist-info/METADATA +83 -0
  41. scrapely_python-0.1.0.dist-info/RECORD +43 -0
  42. scrapely_python-0.1.0.dist-info/WHEEL +5 -0
  43. 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()