PlaywrightCapture 1.41.3__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.3 → playwrightcapture-1.41.4}/PKG-INFO +5 -5
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.4}/playwrightcapture/capture.py +130 -257
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.4}/pyproject.toml +5 -5
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.4}/LICENSE +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.4}/README.md +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.4}/playwrightcapture/__init__.py +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.4}/playwrightcapture/exceptions.py +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.4}/playwrightcapture/helpers.py +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.4}/playwrightcapture/py.typed +0 -0
- {playwrightcapture-1.41.3 → 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,24 +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
|
-
# 2026-09-08: add WebAuth credentials
|
|
1241
|
-
try:
|
|
1242
|
-
async with timeout(15):
|
|
1243
|
-
to_return['storage'] = await self.context.storage_state(indexed_db=True, credentials=True)
|
|
1244
|
-
except (TimeoutError, asyncio.TimeoutError):
|
|
1245
|
-
self.logger.warning("Unable to get storage (timeout).")
|
|
1246
|
-
errors.append("Unable to get the storage (timeout).")
|
|
1247
|
-
self.should_retry = True
|
|
1248
|
-
except Error as e:
|
|
1249
|
-
self.logger.warning(f"Unable to get the storage: {e}")
|
|
1250
|
-
errors.append(f'Unable to get the storage: {e}')
|
|
1251
|
-
self.should_retry = True
|
|
1252
|
-
except Exception as e:
|
|
1253
|
-
# When the driver explodes for no clear reason.
|
|
1254
|
-
self.logger.warning(f"[Generic Exception] Unable to get the storage: {e}")
|
|
1255
|
-
errors.append(f'[Generic Exception] Unable to get the storage: {e}')
|
|
1256
|
-
self.should_retry = True
|
|
1112
|
+
to_return['storage'] = await self._safe_get_storage_state(errors)
|
|
1257
1113
|
|
|
1258
1114
|
try:
|
|
1259
1115
|
if page.is_closed():
|
|
@@ -1303,8 +1159,8 @@ class Capture():
|
|
|
1303
1159
|
# When using a socks5 proxy, post-process the HAR to resolve IPs via
|
|
1304
1160
|
# the proxy so the stored HAR contains addresses consistent with what
|
|
1305
1161
|
# the proxy saw.
|
|
1306
|
-
if (to_return.get('har') and self.
|
|
1307
|
-
and self.
|
|
1162
|
+
if (to_return.get('har') and self._proxy and self._proxy.get('server')
|
|
1163
|
+
and self._proxy['server'].startswith('socks5')):
|
|
1308
1164
|
if har := to_return['har']: # Could be None
|
|
1309
1165
|
try:
|
|
1310
1166
|
async with timeout(120):
|
|
@@ -1346,7 +1202,7 @@ class Capture():
|
|
|
1346
1202
|
return False, f"Unable to parse URL '{url}', blocked."
|
|
1347
1203
|
|
|
1348
1204
|
if not _url.host:
|
|
1349
|
-
self.logger.
|
|
1205
|
+
self.logger.info(f"Missing Host: {url}")
|
|
1350
1206
|
return False, f"Missing host in URL '{url}', blocked."
|
|
1351
1207
|
try:
|
|
1352
1208
|
ip = ipaddress.ip_address(_url.host.try_into_ip())
|
|
@@ -1753,8 +1609,8 @@ class Capture():
|
|
|
1753
1609
|
trusted_timestamps: dict[str, bytes] = {}
|
|
1754
1610
|
|
|
1755
1611
|
connector = None
|
|
1756
|
-
if self.
|
|
1757
|
-
connector = ProxyConnector.from_url(self.
|
|
1612
|
+
if self._proxy and self._proxy.get('server'):
|
|
1613
|
+
connector = ProxyConnector.from_url(self._proxy['server'])
|
|
1758
1614
|
|
|
1759
1615
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
1760
1616
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
@@ -1840,6 +1696,21 @@ class Capture():
|
|
|
1840
1696
|
self.__network_not_idle += 1
|
|
1841
1697
|
self.logger.debug(f'Timed out waiting for network idle, max wait: {max_wait}s')
|
|
1842
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
|
+
|
|
1843
1714
|
async def _failsafe_get_content(self, page: Frame) -> str | None:
|
|
1844
1715
|
''' The page might be changing for all kind of reason (generally a JS timeout).
|
|
1845
1716
|
In that case, we try a few times to get the HTML.'''
|
|
@@ -1874,18 +1745,8 @@ class Capture():
|
|
|
1874
1745
|
self.logger.debug(f'Data URL in frame: {page.url}')
|
|
1875
1746
|
# 2026-02-10: if the URL starts with data, we have a data URI, and possibly some content
|
|
1876
1747
|
if parsed := self.__parse_data_uri(page.url):
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
if mime_params:
|
|
1880
|
-
# try to get charset
|
|
1881
|
-
if qs := parse_qs(mime_params):
|
|
1882
|
-
if charsets := qs.get('charset'):
|
|
1883
|
-
try:
|
|
1884
|
-
charset = codecs.lookup(charsets[0]).name
|
|
1885
|
-
except LookupError:
|
|
1886
|
-
charset = 'utf-8'
|
|
1887
|
-
if content:
|
|
1888
|
-
return unquote(content, encoding=charset)
|
|
1748
|
+
if content := self.__decode_datauri(parsed):
|
|
1749
|
+
return content
|
|
1889
1750
|
else:
|
|
1890
1751
|
self.logger.warning('No content: {page.url}')
|
|
1891
1752
|
else:
|
|
@@ -1984,8 +1845,8 @@ class Capture():
|
|
|
1984
1845
|
await main_frame.get_by_role("button", name="Get an audio challenge").click()
|
|
1985
1846
|
|
|
1986
1847
|
connector = None
|
|
1987
|
-
if self.
|
|
1988
|
-
connector = ProxyConnector.from_url(self.
|
|
1848
|
+
if self._proxy and self._proxy.get('server'):
|
|
1849
|
+
connector = ProxyConnector.from_url(self._proxy['server'])
|
|
1989
1850
|
|
|
1990
1851
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
1991
1852
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
@@ -2179,6 +2040,11 @@ class Capture():
|
|
|
2179
2040
|
to_return: FramesResponse = {'name': frame.name, 'url': frame.url, 'content': ''}
|
|
2180
2041
|
if frame.is_detached():
|
|
2181
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
|
|
2182
2048
|
else:
|
|
2183
2049
|
to_return['content'] = await self._failsafe_get_content(frame)
|
|
2184
2050
|
if frame.child_frames:
|
|
@@ -2305,9 +2171,9 @@ class Capture():
|
|
|
2305
2171
|
return await handler(req)
|
|
2306
2172
|
|
|
2307
2173
|
connector = None
|
|
2308
|
-
if self.
|
|
2174
|
+
if self._proxy:
|
|
2309
2175
|
# NOTE 2024-05-17: switch to async to fetch, the lib uses socks5h by default
|
|
2310
|
-
connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self.
|
|
2176
|
+
connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self._proxy))
|
|
2311
2177
|
|
|
2312
2178
|
extracted_favicons = self.__extract_favicons(rendered_content)
|
|
2313
2179
|
if not extracted_favicons:
|
|
@@ -2316,7 +2182,7 @@ class Capture():
|
|
|
2316
2182
|
to_fetch.add('/favicon.ico')
|
|
2317
2183
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
2318
2184
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
2319
|
-
session.headers['user-agent'] = self.
|
|
2185
|
+
session.headers['user-agent'] = self._user_agent
|
|
2320
2186
|
for u in to_fetch:
|
|
2321
2187
|
try:
|
|
2322
2188
|
self.logger.debug(f'Attempting to fetch favicon from {u}.')
|
|
@@ -2329,6 +2195,9 @@ class Capture():
|
|
|
2329
2195
|
continue
|
|
2330
2196
|
|
|
2331
2197
|
if self.only_global_lookup:
|
|
2198
|
+
if url_to_fetch == "file:///favicon.ico":
|
|
2199
|
+
# skip, no need to log that.
|
|
2200
|
+
continue
|
|
2332
2201
|
public, message = self.__check_local_url(url_to_fetch)
|
|
2333
2202
|
if public is False:
|
|
2334
2203
|
# got a local URL
|
|
@@ -2342,7 +2211,7 @@ class Capture():
|
|
|
2342
2211
|
favicon_response.raise_for_status()
|
|
2343
2212
|
favicon = await favicon_response.read()
|
|
2344
2213
|
if favicon:
|
|
2345
|
-
m = self.magicdb.best_magic_buffer(favicon)
|
|
2214
|
+
m = self.magicdb.best_magic_buffer(favicon, None)
|
|
2346
2215
|
if not m.mime_type:
|
|
2347
2216
|
# empty, ignore
|
|
2348
2217
|
pass
|
|
@@ -2367,7 +2236,11 @@ class Capture():
|
|
|
2367
2236
|
# We get the HAR file, iterate over the entries an update the IPs
|
|
2368
2237
|
|
|
2369
2238
|
async def socks5_resolver(self, harfile: dict[str, Any]) -> None:
|
|
2370
|
-
|
|
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'],
|
|
2371
2244
|
dns_resolver=self.socks5_dns_resolver)
|
|
2372
2245
|
# get all the hostnames from the HAR file
|
|
2373
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
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|