PlaywrightCapture 1.41.2__tar.gz → 1.41.4__tar.gz
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.
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/PKG-INFO +5 -5
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/playwrightcapture/capture.py +130 -256
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/pyproject.toml +6 -6
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/LICENSE +0 -0
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/README.md +0 -0
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/playwrightcapture/__init__.py +0 -0
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/playwrightcapture/exceptions.py +0 -0
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/playwrightcapture/helpers.py +0 -0
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/playwrightcapture/py.typed +0 -0
- {playwrightcapture-1.41.2 → playwrightcapture-1.41.4}/playwrightcapture/socks5dnslookup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PlaywrightCapture
|
|
3
|
-
Version: 1.41.
|
|
3
|
+
Version: 1.41.4
|
|
4
4
|
Summary: A simple library to capture websites using playwright
|
|
5
5
|
License-Expression: BSD-3-Clause
|
|
6
6
|
License-File: LICENSE
|
|
@@ -25,11 +25,11 @@ Requires-Dist: async-timeout (>=5.0.1) ; python_version < "3.11"
|
|
|
25
25
|
Requires-Dist: beautifulsoup4[charset-normalizer,lxml] (>=4.15.0)
|
|
26
26
|
Requires-Dist: charset-normalizer (>=3.4.6,<4.0.0)
|
|
27
27
|
Requires-Dist: dnspython (>=2.7.0,<3.0.0)
|
|
28
|
-
Requires-Dist: lookyloo-models (>=0.
|
|
29
|
-
Requires-Dist: orjson (>=3.
|
|
30
|
-
Requires-Dist: playwright (>=1.
|
|
28
|
+
Requires-Dist: lookyloo-models (>=0.4.1)
|
|
29
|
+
Requires-Dist: orjson (>=3.12,<4.0.0)
|
|
30
|
+
Requires-Dist: playwright (>=1.63.0)
|
|
31
31
|
Requires-Dist: playwright-stealth (>=2.0.3)
|
|
32
|
-
Requires-Dist: pure-magic-rs (>=0.
|
|
32
|
+
Requires-Dist: pure-magic-rs (>=0.5)
|
|
33
33
|
Requires-Dist: pydub-ng (>=0.2.0) ; extra == "recaptcha"
|
|
34
34
|
Requires-Dist: pyfaup-rs (>=0.4.6,<0.5.0)
|
|
35
35
|
Requires-Dist: python-socks (>=3.0.0,<4.0.0)
|
|
@@ -29,7 +29,7 @@ import orjson
|
|
|
29
29
|
from aiohttp_socks import ProxyConnector
|
|
30
30
|
from bs4 import BeautifulSoup
|
|
31
31
|
from charset_normalizer import from_bytes
|
|
32
|
-
from lookyloo_models import Cookie, CaptureSettings
|
|
32
|
+
from lookyloo_models import (Cookie, CaptureSettings, ViewportSettings, ProxySettings)
|
|
33
33
|
from playwright._impl._errors import TargetClosedError
|
|
34
34
|
from playwright.async_api import async_playwright, Frame, Error, Page, Download, Request, Route, ConsoleMessage
|
|
35
35
|
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
|
@@ -43,9 +43,6 @@ from w3lib.url import canonicalize_url, safe_url_string
|
|
|
43
43
|
from .exceptions import UnknownPlaywrightBrowser, UnknownPlaywrightDevice, InvalidPlaywrightParameter, PlaywrightCaptureException
|
|
44
44
|
from .socks5dnslookup import Socks5Resolver
|
|
45
45
|
|
|
46
|
-
from zoneinfo import available_timezones
|
|
47
|
-
all_timezones_set = available_timezones()
|
|
48
|
-
|
|
49
46
|
if sys.version_info < (3, 11):
|
|
50
47
|
from async_timeout import timeout
|
|
51
48
|
else:
|
|
@@ -67,10 +64,6 @@ else:
|
|
|
67
64
|
|
|
68
65
|
|
|
69
66
|
if TYPE_CHECKING:
|
|
70
|
-
from playwright._impl._api_structures import (Geolocation,
|
|
71
|
-
HttpCredentials, Headers,
|
|
72
|
-
ViewportSize,
|
|
73
|
-
ProxySettings, StorageState)
|
|
74
67
|
BROWSER = Literal['chromium', 'firefox', 'webkit']
|
|
75
68
|
|
|
76
69
|
|
|
@@ -87,7 +80,7 @@ class CaptureResponse(TypedDict, total=False):
|
|
|
87
80
|
last_redirected_url: str
|
|
88
81
|
har: dict[str, Any] | None
|
|
89
82
|
cookies: list[dict[str, Any]] | None
|
|
90
|
-
storage:
|
|
83
|
+
storage: dict[str, Any] | None
|
|
91
84
|
error: str | None
|
|
92
85
|
error_name: str | None
|
|
93
86
|
html: str | None
|
|
@@ -137,7 +130,7 @@ class TrustedTimestampSettings(TypedDict, total=False):
|
|
|
137
130
|
class Capture():
|
|
138
131
|
|
|
139
132
|
_browsers: list[BROWSER] = ['chromium', 'firefox', 'webkit']
|
|
140
|
-
_default_viewport:
|
|
133
|
+
_default_viewport: dict[str, int] = ViewportSettings.model_validate({'width': 1920, 'height': 1080}).model_dump(exclude_none=True)
|
|
141
134
|
_default_timeout: int = 90 # set to 90s by default
|
|
142
135
|
_minimal_timeout: int = 15 # set to 15s - It makes little sense to attempt a capture below that limit.
|
|
143
136
|
|
|
@@ -166,19 +159,8 @@ class Capture():
|
|
|
166
159
|
self._requests: dict[str, bytes] = {}
|
|
167
160
|
|
|
168
161
|
# Initialize the values with getter/setter
|
|
169
|
-
self._headers:
|
|
170
|
-
self._cookies: list[Cookie] = []
|
|
171
|
-
self._storage: StorageState = {}
|
|
172
|
-
self._viewport: ViewportSize | None = None
|
|
173
|
-
self._user_agent: str = ''
|
|
174
|
-
self._http_credentials: HttpCredentials = {}
|
|
175
|
-
self._geolocation: Geolocation = {}
|
|
176
|
-
self._timezone_id: str = ''
|
|
177
|
-
self._locale: str = 'en-US'
|
|
178
|
-
self._color_scheme: Literal['dark', 'light', 'no-preference', 'null'] | None = None
|
|
179
|
-
self._java_script_enabled: bool = True
|
|
162
|
+
self._headers: dict[str, str] = {}
|
|
180
163
|
self._capture_timeout: int = self._default_timeout
|
|
181
|
-
self._proxy: ProxySettings = {}
|
|
182
164
|
|
|
183
165
|
# ###
|
|
184
166
|
|
|
@@ -199,19 +181,26 @@ class Capture():
|
|
|
199
181
|
self.remote_headfull = capture_settings.remote_headfull
|
|
200
182
|
self._init_script = capture_settings.init_script
|
|
201
183
|
|
|
184
|
+
self._proxy: dict[str, str] | None = None
|
|
185
|
+
if capture_settings.proxy:
|
|
186
|
+
if isinstance(capture_settings.proxy, ProxySettings):
|
|
187
|
+
self._proxy = capture_settings.proxy.model_dump(exclude_none=True)
|
|
188
|
+
else:
|
|
189
|
+
# The legacy option to pass 'force_tor', should have been transformed before we get here
|
|
190
|
+
self.logger.error(f'Incorrect value for the proxy: {capture_settings.proxy}')
|
|
191
|
+
|
|
202
192
|
self.headers = capture_settings.headers
|
|
203
|
-
self.
|
|
204
|
-
self.
|
|
205
|
-
self.
|
|
206
|
-
self.
|
|
207
|
-
self.
|
|
208
|
-
self.
|
|
209
|
-
self.
|
|
210
|
-
self.
|
|
211
|
-
self.
|
|
212
|
-
self.
|
|
193
|
+
self._cookies: list[dict[str, Any]] = [cookie.model_dump(exclude_none=True) for cookie in capture_settings.cookies] if capture_settings.cookies else []
|
|
194
|
+
self._storage: dict[str, Any] | None = capture_settings.storage.model_dump(exclude_none=True) if capture_settings.storage else None
|
|
195
|
+
self._viewport: dict[str, int] | None = capture_settings.viewport.model_dump(exclude_none=True) if capture_settings.viewport else None
|
|
196
|
+
self._user_agent: str = capture_settings.user_agent if capture_settings.user_agent else ''
|
|
197
|
+
self._http_credentials: dict[str, str] | None = capture_settings.http_credentials.model_dump(exclude_none=True) if capture_settings.http_credentials else None
|
|
198
|
+
self._geolocation: dict[str, float] | None = capture_settings.geolocation.model_dump(exclude_none=True) if capture_settings.geolocation else None
|
|
199
|
+
self._timezone_id = str(capture_settings.timezone_id) if capture_settings.timezone_id else None
|
|
200
|
+
self._locale: str = capture_settings.locale if capture_settings.locale else 'en-US'
|
|
201
|
+
self._color_scheme: Literal['dark', 'light', 'no-preference', 'null'] | None = capture_settings.color_scheme if capture_settings.color_scheme else None
|
|
202
|
+
self._java_script_enabled: bool = capture_settings.java_script_enabled
|
|
213
203
|
self.capture_timeout = capture_settings.general_timeout_in_sec
|
|
214
|
-
self.proxy = capture_settings.proxy
|
|
215
204
|
|
|
216
205
|
self.should_retry: bool = False
|
|
217
206
|
self.__network_not_idle: int = 2 # makes sure we do not wait for network idle the max amount of time the capture is allowed to take
|
|
@@ -236,8 +225,8 @@ class Capture():
|
|
|
236
225
|
if env:
|
|
237
226
|
self._env.update(env)
|
|
238
227
|
|
|
239
|
-
def __prepare_proxy_aiohttp(self, proxy:
|
|
240
|
-
if 'username'
|
|
228
|
+
def __prepare_proxy_aiohttp(self, proxy: dict[str, str]) -> str:
|
|
229
|
+
if proxy.get('username') and proxy.get('password'):
|
|
241
230
|
splitted = urlsplit(proxy['server'])
|
|
242
231
|
return urlunsplit((splitted.scheme, f'{proxy["username"]}:{proxy["password"]}@{splitted.netloc}', splitted.path, splitted.query, splitted.fragment))
|
|
243
232
|
return proxy['server']
|
|
@@ -283,7 +272,7 @@ class Capture():
|
|
|
283
272
|
launch_env = {**os.environ, **self._env}
|
|
284
273
|
|
|
285
274
|
self.browser = await self.playwright[self.browser_name].launch(
|
|
286
|
-
proxy=self.
|
|
275
|
+
proxy=self._proxy, # type: ignore[arg-type]
|
|
287
276
|
channel="chromium" if self.browser_name == "chromium" else None,
|
|
288
277
|
args=args,
|
|
289
278
|
headless=self.headless,
|
|
@@ -375,7 +364,7 @@ class Capture():
|
|
|
375
364
|
if request.resource_type == 'image' and response.ok:
|
|
376
365
|
try:
|
|
377
366
|
if body := await response.body():
|
|
378
|
-
m = self.magicdb.best_magic_buffer(body)
|
|
367
|
+
m = self.magicdb.best_magic_buffer(body, None)
|
|
379
368
|
if m.mime_type.startswith('image'):
|
|
380
369
|
self._requests[request.url] = body
|
|
381
370
|
except Exception:
|
|
@@ -457,36 +446,13 @@ class Capture():
|
|
|
457
446
|
page.on("console", handle_console_msg)
|
|
458
447
|
return page
|
|
459
448
|
|
|
460
|
-
def __prepare_proxy_playwright(self, proxy: str) -> ProxySettings:
|
|
461
|
-
splitted = urlsplit(proxy)
|
|
462
|
-
if splitted.username and splitted.password:
|
|
463
|
-
return {'username': splitted.username, 'password': splitted.password,
|
|
464
|
-
'server': urlunsplit((splitted.scheme, f'{splitted.hostname}:{splitted.port}', splitted.path, splitted.query, splitted.fragment))}
|
|
465
|
-
return {'server': proxy}
|
|
466
|
-
|
|
467
|
-
@property
|
|
468
|
-
def proxy(self) -> ProxySettings:
|
|
469
|
-
return self._proxy
|
|
470
|
-
|
|
471
|
-
@proxy.setter
|
|
472
|
-
def proxy(self, proxy: str | dict[str, str] | None) -> None:
|
|
473
|
-
if proxy:
|
|
474
|
-
if isinstance(proxy, str):
|
|
475
|
-
self._proxy = self.__prepare_proxy_playwright(proxy)
|
|
476
|
-
elif isinstance(proxy, dict):
|
|
477
|
-
self._proxy = {'server': proxy['server'],
|
|
478
|
-
'bypass': proxy.get('bypass', ''),
|
|
479
|
-
'username': proxy.get('username', ''),
|
|
480
|
-
'password': proxy.get('password', '')}
|
|
481
|
-
else:
|
|
482
|
-
raise InvalidPlaywrightParameter(f'Invalid proxy parameter: "{proxy}" ({type(proxy)})')
|
|
483
|
-
|
|
484
449
|
@property
|
|
485
450
|
def capture_timeout(self) -> int:
|
|
486
451
|
return self._capture_timeout
|
|
487
452
|
|
|
488
453
|
@capture_timeout.setter
|
|
489
454
|
def capture_timeout(self, timeout: int | None) -> None:
|
|
455
|
+
# NOTE: This check is relevant, and needs to happen there
|
|
490
456
|
if not timeout:
|
|
491
457
|
self._capture_timeout = self._default_timeout
|
|
492
458
|
else:
|
|
@@ -497,101 +463,12 @@ class Capture():
|
|
|
497
463
|
self._capture_timeout = timeout
|
|
498
464
|
|
|
499
465
|
@property
|
|
500
|
-
def
|
|
501
|
-
return self._locale
|
|
502
|
-
|
|
503
|
-
@locale.setter
|
|
504
|
-
def locale(self, locale: str | None) -> None:
|
|
505
|
-
if locale:
|
|
506
|
-
self._locale = locale
|
|
507
|
-
|
|
508
|
-
@property
|
|
509
|
-
def timezone_id(self) -> str:
|
|
510
|
-
return self._timezone_id
|
|
511
|
-
|
|
512
|
-
@timezone_id.setter
|
|
513
|
-
def timezone_id(self, timezone_id: str | None) -> None:
|
|
514
|
-
if not timezone_id:
|
|
515
|
-
return
|
|
516
|
-
if timezone_id in all_timezones_set:
|
|
517
|
-
self._timezone_id = timezone_id
|
|
518
|
-
else:
|
|
519
|
-
raise InvalidPlaywrightParameter(f'The Timezone ID provided ({timezone_id}) is invalid.')
|
|
520
|
-
|
|
521
|
-
@property
|
|
522
|
-
def http_credentials(self) -> HttpCredentials:
|
|
523
|
-
return self._http_credentials
|
|
524
|
-
|
|
525
|
-
@http_credentials.setter
|
|
526
|
-
def http_credentials(self, credentials: dict[str, str] | None) -> None:
|
|
527
|
-
if not credentials:
|
|
528
|
-
return
|
|
529
|
-
if 'username' in credentials and 'password' in credentials:
|
|
530
|
-
self._http_credentials = {'username': credentials['username'],
|
|
531
|
-
'password': credentials['password']}
|
|
532
|
-
if 'origin' in credentials:
|
|
533
|
-
self._http_credentials['origin'] = credentials['origin']
|
|
534
|
-
else:
|
|
535
|
-
raise InvalidPlaywrightParameter(f'At least a username and a password are required in the credentials: {credentials}')
|
|
536
|
-
|
|
537
|
-
def set_http_credentials(self, username: str, password: str, origin: str | None=None) -> None:
|
|
538
|
-
self._http_credentials = {'username': username, 'password': password, 'origin': origin}
|
|
539
|
-
|
|
540
|
-
@property
|
|
541
|
-
def geolocation(self) -> Geolocation:
|
|
542
|
-
return self._geolocation
|
|
543
|
-
|
|
544
|
-
@geolocation.setter
|
|
545
|
-
def geolocation(self, geolocation: dict[str, str | int | float] | None) -> None:
|
|
546
|
-
if not geolocation:
|
|
547
|
-
return
|
|
548
|
-
if 'latitude' in geolocation and 'longitude' in geolocation:
|
|
549
|
-
self._geolocation = {'latitude': float(geolocation['latitude']),
|
|
550
|
-
'longitude': float(geolocation['longitude'])}
|
|
551
|
-
if 'accuracy' in geolocation:
|
|
552
|
-
self._geolocation['accuracy'] = float(geolocation['accuracy'])
|
|
553
|
-
else:
|
|
554
|
-
raise InvalidPlaywrightParameter(f'At least a latitude and a longitude are required in the geolocation: {geolocation}')
|
|
555
|
-
|
|
556
|
-
@property
|
|
557
|
-
def cookies(self) -> list[Cookie]:
|
|
558
|
-
return self._cookies
|
|
559
|
-
|
|
560
|
-
@cookies.setter
|
|
561
|
-
def cookies(self, cookies: list[Cookie | dict[str, Any]] | None) -> None:
|
|
562
|
-
'''Cookies to send along to the initial request.
|
|
563
|
-
|
|
564
|
-
:param cookies: The cookies, in this format: https://playwright.dev/python/docs/api/class-browsercontext#browser-context-add-cookies
|
|
565
|
-
'''
|
|
566
|
-
if not cookies:
|
|
567
|
-
return
|
|
568
|
-
for raw_cookie in cookies:
|
|
569
|
-
if not raw_cookie:
|
|
570
|
-
continue
|
|
571
|
-
if isinstance(raw_cookie, Cookie):
|
|
572
|
-
self._cookies.append(raw_cookie)
|
|
573
|
-
continue
|
|
574
|
-
try:
|
|
575
|
-
self._cookies.append(Cookie.model_validate(raw_cookie))
|
|
576
|
-
except Exception as e:
|
|
577
|
-
self.logger.warning(f'Invalid cookie: {e}')
|
|
578
|
-
|
|
579
|
-
@property
|
|
580
|
-
def storage(self) -> StorageState:
|
|
581
|
-
return self._storage
|
|
582
|
-
|
|
583
|
-
@storage.setter
|
|
584
|
-
def storage(self, storage: dict[str, Any] | None) -> None:
|
|
585
|
-
if storage and 'cookies' in storage and 'origins' in storage:
|
|
586
|
-
self._storage['cookies'] = storage['cookies']
|
|
587
|
-
self._storage['origins'] = storage['origins']
|
|
588
|
-
|
|
589
|
-
@property
|
|
590
|
-
def headers(self) -> Headers:
|
|
466
|
+
def headers(self) -> dict[str, str]:
|
|
591
467
|
return self._headers
|
|
592
468
|
|
|
593
469
|
@headers.setter
|
|
594
470
|
def headers(self, headers: dict[str, str] | None) -> None:
|
|
471
|
+
# NOTE: this is probably superseeded by the models, need to check that
|
|
595
472
|
if not headers:
|
|
596
473
|
return
|
|
597
474
|
if isinstance(headers, dict):
|
|
@@ -616,64 +493,18 @@ class Capture():
|
|
|
616
493
|
continue
|
|
617
494
|
self._headers[name] = value
|
|
618
495
|
|
|
619
|
-
@property
|
|
620
|
-
def viewport(self) -> ViewportSize | None:
|
|
621
|
-
return self._viewport
|
|
622
|
-
|
|
623
|
-
@viewport.setter
|
|
624
|
-
def viewport(self, viewport: dict[str, str | int] | None) -> None:
|
|
625
|
-
if not viewport:
|
|
626
|
-
return
|
|
627
|
-
if 'width' in viewport and 'height' in viewport:
|
|
628
|
-
self._viewport = {'width': int(viewport['width']), 'height': int(viewport['height'])}
|
|
629
|
-
else:
|
|
630
|
-
raise InvalidPlaywrightParameter(f'A viewport must have a height and a width - {viewport}')
|
|
631
|
-
|
|
632
|
-
@property
|
|
633
|
-
def user_agent(self) -> str:
|
|
634
|
-
return self._user_agent
|
|
635
|
-
|
|
636
|
-
@user_agent.setter
|
|
637
|
-
def user_agent(self, user_agent: str | None) -> None:
|
|
638
|
-
if user_agent is not None:
|
|
639
|
-
self._user_agent = user_agent
|
|
640
|
-
|
|
641
|
-
@property
|
|
642
|
-
def color_scheme(self) -> Literal['dark', 'light', 'no-preference', 'null'] | None:
|
|
643
|
-
return self._color_scheme
|
|
644
|
-
|
|
645
|
-
@color_scheme.setter
|
|
646
|
-
def color_scheme(self, color_scheme: Literal['dark', 'light', 'no-preference', 'null'] | None) -> None:
|
|
647
|
-
if not color_scheme:
|
|
648
|
-
return
|
|
649
|
-
schemes = ['light', 'dark', 'no-preference', 'null']
|
|
650
|
-
if color_scheme in schemes:
|
|
651
|
-
self._color_scheme = color_scheme
|
|
652
|
-
else:
|
|
653
|
-
raise InvalidPlaywrightParameter(f'Invalid color scheme ({color_scheme}), must be in {", ".join(schemes)}.')
|
|
654
|
-
|
|
655
|
-
@property
|
|
656
|
-
def java_script_enabled(self) -> bool:
|
|
657
|
-
return self._java_script_enabled
|
|
658
|
-
|
|
659
|
-
@java_script_enabled.setter
|
|
660
|
-
def java_script_enabled(self, enabled: bool) -> None:
|
|
661
|
-
self._java_script_enabled = enabled
|
|
662
|
-
|
|
663
496
|
async def initialize_context(self) -> None:
|
|
664
497
|
device_context_settings = {}
|
|
498
|
+
vp: dict[str, int] | None = None
|
|
665
499
|
if self.device_name:
|
|
666
500
|
device_context_settings = self.playwright.devices[self.device_name]
|
|
667
501
|
# We need to make sure the device_context_settings dict doesn't contains
|
|
668
502
|
# keys that are set by default in the context creation
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
if context_vp := device_context_settings.pop('viewport', self._default_viewport):
|
|
672
|
-
# Always true, but we also always want to pop it.
|
|
673
|
-
vp = self.viewport if self.viewport else context_vp
|
|
503
|
+
ua = device_context_settings.pop('user_agent', self._user_agent)
|
|
504
|
+
vp = device_context_settings.pop('viewport', self._viewport)
|
|
674
505
|
else:
|
|
675
|
-
ua = self.
|
|
676
|
-
vp = self.
|
|
506
|
+
ua = self._user_agent
|
|
507
|
+
vp = self._viewport
|
|
677
508
|
|
|
678
509
|
# NOTE 2026-04-10: Very specific edge case:
|
|
679
510
|
# * capture in remote headfull mode with xpra
|
|
@@ -690,19 +521,20 @@ class Capture():
|
|
|
690
521
|
record_har_path=self._temp_harfile.name,
|
|
691
522
|
ignore_https_errors=True,
|
|
692
523
|
bypass_csp=True,
|
|
693
|
-
java_script_enabled=self.
|
|
694
|
-
http_credentials=self.
|
|
524
|
+
java_script_enabled=self._java_script_enabled,
|
|
525
|
+
http_credentials=self._http_credentials, # type: ignore[arg-type]
|
|
695
526
|
user_agent=ua,
|
|
696
|
-
locale=self.
|
|
697
|
-
timezone_id=self.
|
|
698
|
-
color_scheme=self.
|
|
699
|
-
viewport=vp,
|
|
700
|
-
storage_state=self.
|
|
527
|
+
locale=self._locale,
|
|
528
|
+
timezone_id=self._timezone_id,
|
|
529
|
+
color_scheme=self._color_scheme,
|
|
530
|
+
viewport=vp if vp else self._default_viewport, # type: ignore[arg-type]
|
|
531
|
+
storage_state=self._storage, # type: ignore[arg-type]
|
|
701
532
|
# For debug only
|
|
702
533
|
# record_video_dir='./videos/',
|
|
703
534
|
**device_context_settings
|
|
704
535
|
)
|
|
705
536
|
self.context.set_default_timeout(self._capture_timeout * 1000)
|
|
537
|
+
await self.context.credentials.install()
|
|
706
538
|
|
|
707
539
|
if self._init_script:
|
|
708
540
|
await self.context.add_init_script(script=self._init_script)
|
|
@@ -753,11 +585,11 @@ class Capture():
|
|
|
753
585
|
# 'script_logging': True,
|
|
754
586
|
})
|
|
755
587
|
|
|
756
|
-
if self.
|
|
588
|
+
if self._cookies:
|
|
757
589
|
try:
|
|
758
|
-
await self.context.add_cookies(
|
|
590
|
+
await self.context.add_cookies(self._cookies) # type: ignore[arg-type]
|
|
759
591
|
except Exception:
|
|
760
|
-
self.logger.exception(f'Unable to set cookies: {self.
|
|
592
|
+
self.logger.exception(f'Unable to set cookies: {self._cookies}')
|
|
761
593
|
|
|
762
594
|
if self.headers:
|
|
763
595
|
try:
|
|
@@ -765,8 +597,8 @@ class Capture():
|
|
|
765
597
|
except Exception:
|
|
766
598
|
self.logger.exception(f'Unable to set HTTP Headers: {self.headers}')
|
|
767
599
|
|
|
768
|
-
if self.
|
|
769
|
-
await self.context.set_geolocation(self.
|
|
600
|
+
if self._geolocation:
|
|
601
|
+
await self.context.set_geolocation(self._geolocation) # type: ignore[arg-type]
|
|
770
602
|
|
|
771
603
|
# NOTE: Which perms are supported by which browsers varies
|
|
772
604
|
# See https://github.com/microsoft/playwright/issues/16577
|
|
@@ -1187,6 +1019,47 @@ class Capture():
|
|
|
1187
1019
|
await self._safe_wait(page)
|
|
1188
1020
|
self.logger.debug('Done with waiting.')
|
|
1189
1021
|
|
|
1022
|
+
async def _safe_get_storage_state(self, errors: list[str]) -> dict[str, Any]:
|
|
1023
|
+
# Collect storage state, including IndexedDB, to capture the full browser state.
|
|
1024
|
+
# 2026-09-08: add WebAuth credentials
|
|
1025
|
+
# 2026-09-17: Add opfs
|
|
1026
|
+
# Quite a few captures fail either on opfs or on indexed db, so we hav a few fallbacks
|
|
1027
|
+
to_store = {'indexed_db': True, 'opfs': True, 'credentials': True}
|
|
1028
|
+
while True:
|
|
1029
|
+
try:
|
|
1030
|
+
async with timeout(15):
|
|
1031
|
+
return await self.context.storage_state(**to_store) # type: ignore[return-value,arg-type]
|
|
1032
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
1033
|
+
self.logger.warning("Unable to get storage (timeout).")
|
|
1034
|
+
errors.append("Unable to get the storage (timeout).")
|
|
1035
|
+
self.should_retry = True
|
|
1036
|
+
break
|
|
1037
|
+
except Error as e:
|
|
1038
|
+
if to_store['indexed_db'] and 'IndexedDB' in str(e):
|
|
1039
|
+
to_store['indexed_db'] = False
|
|
1040
|
+
errors.append('Unable to get the IndexedDB')
|
|
1041
|
+
self.logger.warning(f"Unable to get the IndexedDB: {e}")
|
|
1042
|
+
continue
|
|
1043
|
+
if to_store['opfs'] and 'OPFS' in str(e):
|
|
1044
|
+
to_store['opfs'] = False
|
|
1045
|
+
errors.append('Unable to get the OPFS')
|
|
1046
|
+
self.logger.warning(f"Unable to get the OPFS: {e}")
|
|
1047
|
+
continue
|
|
1048
|
+
|
|
1049
|
+
if not to_store['indexed_db'] and not to_store['opfs']:
|
|
1050
|
+
# we disabled both options, quit
|
|
1051
|
+
self.should_retry = True
|
|
1052
|
+
errors.append(f'Unable to get the storage at all: {e}')
|
|
1053
|
+
self.logger.warning(f"Unable to get the storage at all: {e}")
|
|
1054
|
+
break
|
|
1055
|
+
except Exception as e:
|
|
1056
|
+
# When the driver explodes for no clear reason.
|
|
1057
|
+
self.logger.warning(f"[Generic Exception] Unable to get the storage: {e}")
|
|
1058
|
+
errors.append(f'[Generic Exception] Unable to get the storage: {e}')
|
|
1059
|
+
self.should_retry = True
|
|
1060
|
+
break
|
|
1061
|
+
return {}
|
|
1062
|
+
|
|
1190
1063
|
async def _finalize_capture(
|
|
1191
1064
|
self,
|
|
1192
1065
|
*,
|
|
@@ -1236,23 +1109,7 @@ class Capture():
|
|
|
1236
1109
|
errors.append(f'[Generic Exception] Unable to get the cookies: {e}')
|
|
1237
1110
|
self.should_retry = True
|
|
1238
1111
|
|
|
1239
|
-
|
|
1240
|
-
try:
|
|
1241
|
-
async with timeout(15):
|
|
1242
|
-
to_return['storage'] = await self.context.storage_state(indexed_db=True)
|
|
1243
|
-
except (TimeoutError, asyncio.TimeoutError):
|
|
1244
|
-
self.logger.warning("Unable to get storage (timeout).")
|
|
1245
|
-
errors.append("Unable to get the storage (timeout).")
|
|
1246
|
-
self.should_retry = True
|
|
1247
|
-
except Error as e:
|
|
1248
|
-
self.logger.warning(f"Unable to get the storage: {e}")
|
|
1249
|
-
errors.append(f'Unable to get the storage: {e}')
|
|
1250
|
-
self.should_retry = True
|
|
1251
|
-
except Exception as e:
|
|
1252
|
-
# When the driver explodes for no clear reason.
|
|
1253
|
-
self.logger.warning(f"[Generic Exception] Unable to get the storage: {e}")
|
|
1254
|
-
errors.append(f'[Generic Exception] Unable to get the storage: {e}')
|
|
1255
|
-
self.should_retry = True
|
|
1112
|
+
to_return['storage'] = await self._safe_get_storage_state(errors)
|
|
1256
1113
|
|
|
1257
1114
|
try:
|
|
1258
1115
|
if page.is_closed():
|
|
@@ -1302,8 +1159,8 @@ class Capture():
|
|
|
1302
1159
|
# When using a socks5 proxy, post-process the HAR to resolve IPs via
|
|
1303
1160
|
# the proxy so the stored HAR contains addresses consistent with what
|
|
1304
1161
|
# the proxy saw.
|
|
1305
|
-
if (to_return.get('har') and self.
|
|
1306
|
-
and self.
|
|
1162
|
+
if (to_return.get('har') and self._proxy and self._proxy.get('server')
|
|
1163
|
+
and self._proxy['server'].startswith('socks5')):
|
|
1307
1164
|
if har := to_return['har']: # Could be None
|
|
1308
1165
|
try:
|
|
1309
1166
|
async with timeout(120):
|
|
@@ -1345,7 +1202,7 @@ class Capture():
|
|
|
1345
1202
|
return False, f"Unable to parse URL '{url}', blocked."
|
|
1346
1203
|
|
|
1347
1204
|
if not _url.host:
|
|
1348
|
-
self.logger.
|
|
1205
|
+
self.logger.info(f"Missing Host: {url}")
|
|
1349
1206
|
return False, f"Missing host in URL '{url}', blocked."
|
|
1350
1207
|
try:
|
|
1351
1208
|
ip = ipaddress.ip_address(_url.host.try_into_ip())
|
|
@@ -1752,8 +1609,8 @@ class Capture():
|
|
|
1752
1609
|
trusted_timestamps: dict[str, bytes] = {}
|
|
1753
1610
|
|
|
1754
1611
|
connector = None
|
|
1755
|
-
if self.
|
|
1756
|
-
connector = ProxyConnector.from_url(self.
|
|
1612
|
+
if self._proxy and self._proxy.get('server'):
|
|
1613
|
+
connector = ProxyConnector.from_url(self._proxy['server'])
|
|
1757
1614
|
|
|
1758
1615
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
1759
1616
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
@@ -1839,6 +1696,21 @@ class Capture():
|
|
|
1839
1696
|
self.__network_not_idle += 1
|
|
1840
1697
|
self.logger.debug(f'Timed out waiting for network idle, max wait: {max_wait}s')
|
|
1841
1698
|
|
|
1699
|
+
def __decode_datauri(self, parsed: tuple[str, str, bytes]) -> str | None:
|
|
1700
|
+
mime, mime_params, content = parsed
|
|
1701
|
+
charset = 'utf-8'
|
|
1702
|
+
if mime_params:
|
|
1703
|
+
# try to get charset
|
|
1704
|
+
if qs := parse_qs(mime_params):
|
|
1705
|
+
if charsets := qs.get('charset'):
|
|
1706
|
+
try:
|
|
1707
|
+
charset = codecs.lookup(charsets[0]).name
|
|
1708
|
+
except LookupError:
|
|
1709
|
+
charset = 'utf-8'
|
|
1710
|
+
if content:
|
|
1711
|
+
return unquote(content, encoding=charset)
|
|
1712
|
+
return None
|
|
1713
|
+
|
|
1842
1714
|
async def _failsafe_get_content(self, page: Frame) -> str | None:
|
|
1843
1715
|
''' The page might be changing for all kind of reason (generally a JS timeout).
|
|
1844
1716
|
In that case, we try a few times to get the HTML.'''
|
|
@@ -1873,18 +1745,8 @@ class Capture():
|
|
|
1873
1745
|
self.logger.debug(f'Data URL in frame: {page.url}')
|
|
1874
1746
|
# 2026-02-10: if the URL starts with data, we have a data URI, and possibly some content
|
|
1875
1747
|
if parsed := self.__parse_data_uri(page.url):
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
if mime_params:
|
|
1879
|
-
# try to get charset
|
|
1880
|
-
if qs := parse_qs(mime_params):
|
|
1881
|
-
if charsets := qs.get('charset'):
|
|
1882
|
-
try:
|
|
1883
|
-
charset = codecs.lookup(charsets[0]).name
|
|
1884
|
-
except LookupError:
|
|
1885
|
-
charset = 'utf-8'
|
|
1886
|
-
if content:
|
|
1887
|
-
return unquote(content, encoding=charset)
|
|
1748
|
+
if content := self.__decode_datauri(parsed):
|
|
1749
|
+
return content
|
|
1888
1750
|
else:
|
|
1889
1751
|
self.logger.warning('No content: {page.url}')
|
|
1890
1752
|
else:
|
|
@@ -1983,8 +1845,8 @@ class Capture():
|
|
|
1983
1845
|
await main_frame.get_by_role("button", name="Get an audio challenge").click()
|
|
1984
1846
|
|
|
1985
1847
|
connector = None
|
|
1986
|
-
if self.
|
|
1987
|
-
connector = ProxyConnector.from_url(self.
|
|
1848
|
+
if self._proxy and self._proxy.get('server'):
|
|
1849
|
+
connector = ProxyConnector.from_url(self._proxy['server'])
|
|
1988
1850
|
|
|
1989
1851
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
1990
1852
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
@@ -2178,6 +2040,11 @@ class Capture():
|
|
|
2178
2040
|
to_return: FramesResponse = {'name': frame.name, 'url': frame.url, 'content': ''}
|
|
2179
2041
|
if frame.is_detached():
|
|
2180
2042
|
self.logger.debug(f'{frame_id} is detached.')
|
|
2043
|
+
# can be a data url
|
|
2044
|
+
if frame.url and frame.url.strip() and frame.url.strip().startswith('data'):
|
|
2045
|
+
if parsed := self.__parse_data_uri(frame.url):
|
|
2046
|
+
if content := self.__decode_datauri(parsed):
|
|
2047
|
+
to_return['content'] = content
|
|
2181
2048
|
else:
|
|
2182
2049
|
to_return['content'] = await self._failsafe_get_content(frame)
|
|
2183
2050
|
if frame.child_frames:
|
|
@@ -2304,9 +2171,9 @@ class Capture():
|
|
|
2304
2171
|
return await handler(req)
|
|
2305
2172
|
|
|
2306
2173
|
connector = None
|
|
2307
|
-
if self.
|
|
2174
|
+
if self._proxy:
|
|
2308
2175
|
# NOTE 2024-05-17: switch to async to fetch, the lib uses socks5h by default
|
|
2309
|
-
connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self.
|
|
2176
|
+
connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self._proxy))
|
|
2310
2177
|
|
|
2311
2178
|
extracted_favicons = self.__extract_favicons(rendered_content)
|
|
2312
2179
|
if not extracted_favicons:
|
|
@@ -2315,7 +2182,7 @@ class Capture():
|
|
|
2315
2182
|
to_fetch.add('/favicon.ico')
|
|
2316
2183
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
2317
2184
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
2318
|
-
session.headers['user-agent'] = self.
|
|
2185
|
+
session.headers['user-agent'] = self._user_agent
|
|
2319
2186
|
for u in to_fetch:
|
|
2320
2187
|
try:
|
|
2321
2188
|
self.logger.debug(f'Attempting to fetch favicon from {u}.')
|
|
@@ -2328,6 +2195,9 @@ class Capture():
|
|
|
2328
2195
|
continue
|
|
2329
2196
|
|
|
2330
2197
|
if self.only_global_lookup:
|
|
2198
|
+
if url_to_fetch == "file:///favicon.ico":
|
|
2199
|
+
# skip, no need to log that.
|
|
2200
|
+
continue
|
|
2331
2201
|
public, message = self.__check_local_url(url_to_fetch)
|
|
2332
2202
|
if public is False:
|
|
2333
2203
|
# got a local URL
|
|
@@ -2341,7 +2211,7 @@ class Capture():
|
|
|
2341
2211
|
favicon_response.raise_for_status()
|
|
2342
2212
|
favicon = await favicon_response.read()
|
|
2343
2213
|
if favicon:
|
|
2344
|
-
m = self.magicdb.best_magic_buffer(favicon)
|
|
2214
|
+
m = self.magicdb.best_magic_buffer(favicon, None)
|
|
2345
2215
|
if not m.mime_type:
|
|
2346
2216
|
# empty, ignore
|
|
2347
2217
|
pass
|
|
@@ -2366,7 +2236,11 @@ class Capture():
|
|
|
2366
2236
|
# We get the HAR file, iterate over the entries an update the IPs
|
|
2367
2237
|
|
|
2368
2238
|
async def socks5_resolver(self, harfile: dict[str, Any]) -> None:
|
|
2369
|
-
|
|
2239
|
+
if not self._proxy:
|
|
2240
|
+
raise InvalidPlaywrightParameter('Proxy required for socks5_resolver.')
|
|
2241
|
+
|
|
2242
|
+
resolver = Socks5Resolver(logger=self.logger,
|
|
2243
|
+
socks5_proxy=self._proxy['server'],
|
|
2370
2244
|
dns_resolver=self.socks5_dns_resolver)
|
|
2371
2245
|
# get all the hostnames from the HAR file
|
|
2372
2246
|
hostnames = set()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "PlaywrightCapture"
|
|
3
|
-
version = "1.41.
|
|
3
|
+
version = "1.41.4"
|
|
4
4
|
description = "A simple library to capture websites using playwright"
|
|
5
5
|
authors = [
|
|
6
6
|
{name="Raphaël Vinot", email= "raphael.vinot@circl.lu"}
|
|
@@ -12,7 +12,7 @@ requires-python = ">=3.10,<3.15"
|
|
|
12
12
|
dynamic = [ "classifiers" ]
|
|
13
13
|
|
|
14
14
|
dependencies = [
|
|
15
|
-
"playwright (>=1.
|
|
15
|
+
"playwright (>=1.63.0)",
|
|
16
16
|
"beautifulsoup4[charset-normalizer,lxml] (>=4.15.0)",
|
|
17
17
|
"w3lib (>=2.4.1)",
|
|
18
18
|
"playwright-stealth (>=2.0.3)",
|
|
@@ -23,9 +23,9 @@ dependencies = [
|
|
|
23
23
|
"dnspython (>=2.7.0,<3.0.0)",
|
|
24
24
|
"python-socks (>=3.0.0,<4.0.0)",
|
|
25
25
|
"rfc3161-client (>=1.0.4,<2.0.0)",
|
|
26
|
-
"orjson (>=3.
|
|
27
|
-
"pure-magic-rs (>=0.
|
|
28
|
-
"lookyloo-models (>=0.
|
|
26
|
+
"orjson (>=3.12,<4.0.0)",
|
|
27
|
+
"pure-magic-rs (>=0.5)",
|
|
28
|
+
"lookyloo-models (>=0.4.1)",
|
|
29
29
|
"charset-normalizer (>=3.4.6,<4.0.0)",
|
|
30
30
|
"pyfaup-rs (>=0.4.6,<0.5.0)"
|
|
31
31
|
]
|
|
@@ -53,7 +53,7 @@ recaptcha = [
|
|
|
53
53
|
types-beautifulsoup4 = "^4.12.0.20250516"
|
|
54
54
|
pytest = "^9.1.1"
|
|
55
55
|
mypy = "^2.3.1"
|
|
56
|
-
types-dateparser = "^1.4.
|
|
56
|
+
types-dateparser = "^1.4.3.20260907"
|
|
57
57
|
types-pytz = "^2026.3.1.20260727"
|
|
58
58
|
|
|
59
59
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|