PlaywrightCapture 1.41.3__tar.gz → 1.41.5__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.5}/PKG-INFO +5 -5
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/playwrightcapture/capture.py +206 -342
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/pyproject.toml +5 -5
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/LICENSE +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/README.md +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/playwrightcapture/__init__.py +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/playwrightcapture/exceptions.py +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/playwrightcapture/helpers.py +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/playwrightcapture/py.typed +0 -0
- {playwrightcapture-1.41.3 → playwrightcapture-1.41.5}/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.5
|
|
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.3
|
|
29
|
-
Requires-Dist: orjson (>=3.
|
|
30
|
-
Requires-Dist: playwright (>=1.
|
|
28
|
+
Requires-Dist: lookyloo-models (>=0.4.3)
|
|
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)
|
|
@@ -18,7 +18,7 @@ from base64 import b64decode, b64encode
|
|
|
18
18
|
from io import BytesIO
|
|
19
19
|
from logging import LoggerAdapter, Logger
|
|
20
20
|
from tempfile import NamedTemporaryFile
|
|
21
|
-
from typing import Any, Literal, TYPE_CHECKING
|
|
21
|
+
from typing import Any, Literal, TYPE_CHECKING
|
|
22
22
|
from collections.abc import Awaitable, Callable, MutableMapping
|
|
23
23
|
from urllib.parse import urlparse, unquote, urljoin, urlsplit, urlunsplit, parse_qs, unquote_plus
|
|
24
24
|
from zipfile import ZipFile
|
|
@@ -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,44 @@ 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.
|
|
204
|
+
self.allow_tracking = capture_settings.allow_tracking
|
|
205
|
+
self.rendered_hostname_only = capture_settings.rendered_hostname_only
|
|
206
|
+
self.with_screenshot = capture_settings.with_screenshot
|
|
207
|
+
self.with_favicon = capture_settings.with_favicon
|
|
208
|
+
self.with_trusted_timestamps = capture_settings.with_trusted_timestamps
|
|
209
|
+
self.capture_depth = capture_settings.depth
|
|
210
|
+
self.final_wait = capture_settings.final_wait
|
|
211
|
+
|
|
212
|
+
self.initial_url: str
|
|
213
|
+
if capture_settings.url:
|
|
214
|
+
# This url could be None in transit when the thing to capture is a file (so the models allows it)
|
|
215
|
+
# But at this stage, the value must have been set to the local path of the file,
|
|
216
|
+
# if it is none, the capture will for sure fail.
|
|
217
|
+
self.initial_url = capture_settings.url
|
|
218
|
+
else:
|
|
219
|
+
raise InvalidPlaywrightParameter('No URL provided, cannot capture.')
|
|
220
|
+
|
|
221
|
+
self.initial_referer = capture_settings.referer
|
|
215
222
|
|
|
216
223
|
self.should_retry: bool = False
|
|
217
224
|
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 +243,8 @@ class Capture():
|
|
|
236
243
|
if env:
|
|
237
244
|
self._env.update(env)
|
|
238
245
|
|
|
239
|
-
def __prepare_proxy_aiohttp(self, proxy:
|
|
240
|
-
if 'username'
|
|
246
|
+
def __prepare_proxy_aiohttp(self, proxy: dict[str, str]) -> str:
|
|
247
|
+
if proxy.get('username') and proxy.get('password'):
|
|
241
248
|
splitted = urlsplit(proxy['server'])
|
|
242
249
|
return urlunsplit((splitted.scheme, f'{proxy["username"]}:{proxy["password"]}@{splitted.netloc}', splitted.path, splitted.query, splitted.fragment))
|
|
243
250
|
return proxy['server']
|
|
@@ -283,7 +290,7 @@ class Capture():
|
|
|
283
290
|
launch_env = {**os.environ, **self._env}
|
|
284
291
|
|
|
285
292
|
self.browser = await self.playwright[self.browser_name].launch(
|
|
286
|
-
proxy=self.
|
|
293
|
+
proxy=self._proxy, # type: ignore[arg-type]
|
|
287
294
|
channel="chromium" if self.browser_name == "chromium" else None,
|
|
288
295
|
args=args,
|
|
289
296
|
headless=self.headless,
|
|
@@ -296,6 +303,9 @@ class Capture():
|
|
|
296
303
|
# Create the temporary file to store the HAR content.
|
|
297
304
|
self._temp_harfile = NamedTemporaryFile(delete=False, prefix="playwright_capture_har", suffix=".json")
|
|
298
305
|
|
|
306
|
+
# all the errors gathered during the capture
|
|
307
|
+
self.errors: list[str] = []
|
|
308
|
+
|
|
299
309
|
return self
|
|
300
310
|
|
|
301
311
|
async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> bool:
|
|
@@ -326,7 +336,7 @@ class Capture():
|
|
|
326
336
|
return False
|
|
327
337
|
return True
|
|
328
338
|
|
|
329
|
-
async def setup_page_capture(self
|
|
339
|
+
async def setup_page_capture(self) -> Page:
|
|
330
340
|
"""Prepare a page for a single-page capture without changing capture semantics.
|
|
331
341
|
|
|
332
342
|
This method preserves the existing per-page setup used by capture_page:
|
|
@@ -375,7 +385,7 @@ class Capture():
|
|
|
375
385
|
if request.resource_type == 'image' and response.ok:
|
|
376
386
|
try:
|
|
377
387
|
if body := await response.body():
|
|
378
|
-
m = self.magicdb.best_magic_buffer(body)
|
|
388
|
+
m = self.magicdb.best_magic_buffer(body, None)
|
|
379
389
|
if m.mime_type.startswith('image'):
|
|
380
390
|
self._requests[request.url] = body
|
|
381
391
|
except Exception:
|
|
@@ -432,7 +442,7 @@ class Capture():
|
|
|
432
442
|
except Error as e:
|
|
433
443
|
self.logger.warning(f'Failed at fetching PDF in headless chromium: {e}')
|
|
434
444
|
|
|
435
|
-
if allow_tracking:
|
|
445
|
+
if self.allow_tracking:
|
|
436
446
|
# Add authorization clickthroughs
|
|
437
447
|
await self.__dialog_didomi_clickthrough(page)
|
|
438
448
|
await self.__dialog_onetrust_clickthrough(page)
|
|
@@ -457,36 +467,13 @@ class Capture():
|
|
|
457
467
|
page.on("console", handle_console_msg)
|
|
458
468
|
return page
|
|
459
469
|
|
|
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
470
|
@property
|
|
485
471
|
def capture_timeout(self) -> int:
|
|
486
472
|
return self._capture_timeout
|
|
487
473
|
|
|
488
474
|
@capture_timeout.setter
|
|
489
475
|
def capture_timeout(self, timeout: int | None) -> None:
|
|
476
|
+
# NOTE: This check is relevant, and needs to happen there
|
|
490
477
|
if not timeout:
|
|
491
478
|
self._capture_timeout = self._default_timeout
|
|
492
479
|
else:
|
|
@@ -497,101 +484,12 @@ class Capture():
|
|
|
497
484
|
self._capture_timeout = timeout
|
|
498
485
|
|
|
499
486
|
@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:
|
|
487
|
+
def headers(self) -> dict[str, str]:
|
|
591
488
|
return self._headers
|
|
592
489
|
|
|
593
490
|
@headers.setter
|
|
594
491
|
def headers(self, headers: dict[str, str] | None) -> None:
|
|
492
|
+
# NOTE: this is probably superseeded by the models, need to check that
|
|
595
493
|
if not headers:
|
|
596
494
|
return
|
|
597
495
|
if isinstance(headers, dict):
|
|
@@ -616,64 +514,18 @@ class Capture():
|
|
|
616
514
|
continue
|
|
617
515
|
self._headers[name] = value
|
|
618
516
|
|
|
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
517
|
async def initialize_context(self) -> None:
|
|
664
518
|
device_context_settings = {}
|
|
519
|
+
vp: dict[str, int] | None = None
|
|
665
520
|
if self.device_name:
|
|
666
521
|
device_context_settings = self.playwright.devices[self.device_name]
|
|
667
522
|
# We need to make sure the device_context_settings dict doesn't contains
|
|
668
523
|
# 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
|
|
524
|
+
ua = device_context_settings.pop('user_agent', self._user_agent)
|
|
525
|
+
vp = device_context_settings.pop('viewport', self._viewport)
|
|
674
526
|
else:
|
|
675
|
-
ua = self.
|
|
676
|
-
vp = self.
|
|
527
|
+
ua = self._user_agent
|
|
528
|
+
vp = self._viewport
|
|
677
529
|
|
|
678
530
|
# NOTE 2026-04-10: Very specific edge case:
|
|
679
531
|
# * capture in remote headfull mode with xpra
|
|
@@ -690,19 +542,20 @@ class Capture():
|
|
|
690
542
|
record_har_path=self._temp_harfile.name,
|
|
691
543
|
ignore_https_errors=True,
|
|
692
544
|
bypass_csp=True,
|
|
693
|
-
java_script_enabled=self.
|
|
694
|
-
http_credentials=self.
|
|
545
|
+
java_script_enabled=self._java_script_enabled,
|
|
546
|
+
http_credentials=self._http_credentials, # type: ignore[arg-type]
|
|
695
547
|
user_agent=ua,
|
|
696
|
-
locale=self.
|
|
697
|
-
timezone_id=self.
|
|
698
|
-
color_scheme=self.
|
|
699
|
-
viewport=vp,
|
|
700
|
-
storage_state=self.
|
|
548
|
+
locale=self._locale,
|
|
549
|
+
timezone_id=self._timezone_id,
|
|
550
|
+
color_scheme=self._color_scheme,
|
|
551
|
+
viewport=vp if vp else self._default_viewport, # type: ignore[arg-type]
|
|
552
|
+
storage_state=self._storage, # type: ignore[arg-type]
|
|
701
553
|
# For debug only
|
|
702
554
|
# record_video_dir='./videos/',
|
|
703
555
|
**device_context_settings
|
|
704
556
|
)
|
|
705
557
|
self.context.set_default_timeout(self._capture_timeout * 1000)
|
|
558
|
+
await self.context.credentials.install()
|
|
706
559
|
|
|
707
560
|
if self._init_script:
|
|
708
561
|
await self.context.add_init_script(script=self._init_script)
|
|
@@ -753,11 +606,11 @@ class Capture():
|
|
|
753
606
|
# 'script_logging': True,
|
|
754
607
|
})
|
|
755
608
|
|
|
756
|
-
if self.
|
|
609
|
+
if self._cookies:
|
|
757
610
|
try:
|
|
758
|
-
await self.context.add_cookies(
|
|
611
|
+
await self.context.add_cookies(self._cookies) # type: ignore[arg-type]
|
|
759
612
|
except Exception:
|
|
760
|
-
self.logger.exception(f'Unable to set cookies: {self.
|
|
613
|
+
self.logger.exception(f'Unable to set cookies: {self._cookies}')
|
|
761
614
|
|
|
762
615
|
if self.headers:
|
|
763
616
|
try:
|
|
@@ -765,8 +618,8 @@ class Capture():
|
|
|
765
618
|
except Exception:
|
|
766
619
|
self.logger.exception(f'Unable to set HTTP Headers: {self.headers}')
|
|
767
620
|
|
|
768
|
-
if self.
|
|
769
|
-
await self.context.set_geolocation(self.
|
|
621
|
+
if self._geolocation:
|
|
622
|
+
await self.context.set_geolocation(self._geolocation) # type: ignore[arg-type]
|
|
770
623
|
|
|
771
624
|
# NOTE: Which perms are supported by which browsers varies
|
|
772
625
|
# See https://github.com/microsoft/playwright/issues/16577
|
|
@@ -1052,7 +905,7 @@ class Capture():
|
|
|
1052
905
|
except Exception as e:
|
|
1053
906
|
self.logger.info(f'Error while moving time forward: {e}')
|
|
1054
907
|
|
|
1055
|
-
async def __instrumentation(self, page: Page, url: str
|
|
908
|
+
async def __instrumentation(self, page: Page, url: str) -> None:
|
|
1056
909
|
try:
|
|
1057
910
|
# NOTE: the clock must be installed after the page is loaded, otherwise it sometimes cause the complete capture to hang.
|
|
1058
911
|
await page.clock.install()
|
|
@@ -1103,7 +956,7 @@ class Capture():
|
|
|
1103
956
|
await self._wait_for_random_timeout(page, 5)
|
|
1104
957
|
self.logger.debug('Keep going after moving mouse.')
|
|
1105
958
|
|
|
1106
|
-
if allow_tracking:
|
|
959
|
+
if self.allow_tracking:
|
|
1107
960
|
await self._wait_for_random_timeout(page, 5)
|
|
1108
961
|
# This event is required trigger the add_locator_handler
|
|
1109
962
|
try:
|
|
@@ -1182,18 +1035,57 @@ class Capture():
|
|
|
1182
1035
|
|
|
1183
1036
|
self.logger.debug('Done with instrumentation.')
|
|
1184
1037
|
# Wait at least 5 sec after instrumentation
|
|
1185
|
-
self.logger.debug(f'Waiting another {max(final_wait, 5)}s.')
|
|
1186
|
-
await self._wait_for_random_timeout(page, max(final_wait, 5))
|
|
1038
|
+
self.logger.debug(f'Waiting another {max(self.final_wait, 5)}s.')
|
|
1039
|
+
await self._wait_for_random_timeout(page, max(self.final_wait, 5))
|
|
1187
1040
|
await self._safe_wait(page)
|
|
1188
1041
|
self.logger.debug('Done with waiting.')
|
|
1189
1042
|
|
|
1043
|
+
async def _safe_get_storage_state(self) -> dict[str, Any]:
|
|
1044
|
+
# Collect storage state, including IndexedDB, to capture the full browser state.
|
|
1045
|
+
# 2026-09-08: add WebAuth credentials
|
|
1046
|
+
# 2026-09-17: Add opfs
|
|
1047
|
+
# Quite a few captures fail either on opfs or on indexed db, so we hav a few fallbacks
|
|
1048
|
+
to_store = {'indexed_db': True, 'opfs': True, 'credentials': True}
|
|
1049
|
+
while True:
|
|
1050
|
+
try:
|
|
1051
|
+
async with timeout(15):
|
|
1052
|
+
return await self.context.storage_state(**to_store) # type: ignore[return-value,arg-type]
|
|
1053
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
1054
|
+
self.logger.warning("Unable to get storage (timeout).")
|
|
1055
|
+
self.errors.append("Unable to get the storage (timeout).")
|
|
1056
|
+
self.should_retry = True
|
|
1057
|
+
break
|
|
1058
|
+
except Error as e:
|
|
1059
|
+
if to_store['indexed_db'] and 'IndexedDB' in str(e):
|
|
1060
|
+
to_store['indexed_db'] = False
|
|
1061
|
+
self.errors.append('Unable to get the IndexedDB')
|
|
1062
|
+
self.logger.warning(f"Unable to get the IndexedDB: {e}")
|
|
1063
|
+
continue
|
|
1064
|
+
if to_store['opfs'] and 'OPFS' in str(e):
|
|
1065
|
+
to_store['opfs'] = False
|
|
1066
|
+
self.errors.append('Unable to get the OPFS')
|
|
1067
|
+
self.logger.warning(f"Unable to get the OPFS: {e}")
|
|
1068
|
+
continue
|
|
1069
|
+
|
|
1070
|
+
if not to_store['indexed_db'] and not to_store['opfs']:
|
|
1071
|
+
# we disabled both options, quit
|
|
1072
|
+
self.should_retry = True
|
|
1073
|
+
self.errors.append(f'Unable to get the storage at all: {e}')
|
|
1074
|
+
self.logger.warning(f"Unable to get the storage at all: {e}")
|
|
1075
|
+
break
|
|
1076
|
+
except Exception as e:
|
|
1077
|
+
# When the driver explodes for no clear reason.
|
|
1078
|
+
self.logger.warning(f"[Generic Exception] Unable to get the storage: {e}")
|
|
1079
|
+
self.errors.append(f'[Generic Exception] Unable to get the storage: {e}')
|
|
1080
|
+
self.should_retry = True
|
|
1081
|
+
break
|
|
1082
|
+
return {}
|
|
1083
|
+
|
|
1190
1084
|
async def _finalize_capture(
|
|
1191
1085
|
self,
|
|
1192
1086
|
*,
|
|
1193
1087
|
page: Page,
|
|
1194
1088
|
to_return: CaptureResponse,
|
|
1195
|
-
errors: list[str],
|
|
1196
|
-
with_trusted_timestamps: bool,
|
|
1197
1089
|
) -> None:
|
|
1198
1090
|
"""Common finalization logic for captures (downloads, cookies, storage, HAR, socks5, timestamps)."""
|
|
1199
1091
|
|
|
@@ -1224,36 +1116,19 @@ class Capture():
|
|
|
1224
1116
|
to_return['cookies'] = [Cookie.model_validate(c).model_dump(exclude_none=True) for c in await self.context.cookies()]
|
|
1225
1117
|
except (TimeoutError, asyncio.TimeoutError):
|
|
1226
1118
|
self.logger.warning("Unable to get cookies (timeout).")
|
|
1227
|
-
errors.append("Unable to get the cookies (timeout).")
|
|
1119
|
+
self.errors.append("Unable to get the cookies (timeout).")
|
|
1228
1120
|
self.should_retry = True
|
|
1229
1121
|
except Error as e:
|
|
1230
1122
|
self.logger.warning(f"Unable to get cookies: {e}")
|
|
1231
|
-
errors.append(f'Unable to get the cookies: {e}')
|
|
1123
|
+
self.errors.append(f'Unable to get the cookies: {e}')
|
|
1232
1124
|
self.should_retry = True
|
|
1233
1125
|
except Exception as e:
|
|
1234
1126
|
# When the driver explodes for no clear reason.
|
|
1235
1127
|
self.logger.warning(f"[Generic Exception] Unable to get cookies: {e}")
|
|
1236
|
-
errors.append(f'[Generic Exception] Unable to get the cookies: {e}')
|
|
1128
|
+
self.errors.append(f'[Generic Exception] Unable to get the cookies: {e}')
|
|
1237
1129
|
self.should_retry = True
|
|
1238
1130
|
|
|
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
|
|
1131
|
+
to_return['storage'] = await self._safe_get_storage_state()
|
|
1257
1132
|
|
|
1258
1133
|
try:
|
|
1259
1134
|
if page.is_closed():
|
|
@@ -1303,22 +1178,22 @@ class Capture():
|
|
|
1303
1178
|
# When using a socks5 proxy, post-process the HAR to resolve IPs via
|
|
1304
1179
|
# the proxy so the stored HAR contains addresses consistent with what
|
|
1305
1180
|
# the proxy saw.
|
|
1306
|
-
if (to_return.get('har') and self.
|
|
1307
|
-
and self.
|
|
1181
|
+
if (to_return.get('har') and self._proxy and self._proxy.get('server')
|
|
1182
|
+
and self._proxy['server'].startswith('socks5')):
|
|
1308
1183
|
if har := to_return['har']: # Could be None
|
|
1309
1184
|
try:
|
|
1310
1185
|
async with timeout(120):
|
|
1311
1186
|
await self.socks5_resolver(har)
|
|
1312
1187
|
except (TimeoutError, asyncio.TimeoutError):
|
|
1313
1188
|
self.logger.warning("Unable to resolve all the IPs via the socks5 proxy.")
|
|
1314
|
-
errors.append("Unable to resolve all the IPs via the socks5 proxy.")
|
|
1189
|
+
self.errors.append("Unable to resolve all the IPs via the socks5 proxy.")
|
|
1315
1190
|
self.should_retry = True
|
|
1316
1191
|
|
|
1317
1192
|
except (TimeoutError, asyncio.TimeoutError):
|
|
1318
1193
|
# If closing the context or generating the HAR takes too long, the
|
|
1319
1194
|
# capture is considered incomplete but we still return what we have.
|
|
1320
1195
|
self.logger.warning("[Timeout] Unable to close context at the end of the capture.")
|
|
1321
|
-
errors.append("[Timeout] Unable to close context at the end of the capture.")
|
|
1196
|
+
self.errors.append("[Timeout] Unable to close context at the end of the capture.")
|
|
1322
1197
|
self.should_retry = True
|
|
1323
1198
|
# In case of timeout, let the exception reach the async calls
|
|
1324
1199
|
await asyncio.sleep(1)
|
|
@@ -1326,11 +1201,11 @@ class Capture():
|
|
|
1326
1201
|
# Any other unexpected failure while finalizing the capture is logged
|
|
1327
1202
|
# and surfaced as a generic HAR-generation error.
|
|
1328
1203
|
self.logger.warning(f"Other exception while finishing up the capture: {e}.")
|
|
1329
|
-
errors.append(f'Unable to generate HAR file: {e}')
|
|
1204
|
+
self.errors.append(f'Unable to generate HAR file: {e}')
|
|
1330
1205
|
|
|
1331
|
-
if errors:
|
|
1332
|
-
to_return['error'] = '\n'.join(errors)
|
|
1333
|
-
if with_trusted_timestamps:
|
|
1206
|
+
if self.errors:
|
|
1207
|
+
to_return['error'] = '\n'.join(self.errors)
|
|
1208
|
+
if self.with_trusted_timestamps:
|
|
1334
1209
|
try:
|
|
1335
1210
|
await self._get_trusted_timestamps(to_return)
|
|
1336
1211
|
except Exception as e:
|
|
@@ -1346,7 +1221,7 @@ class Capture():
|
|
|
1346
1221
|
return False, f"Unable to parse URL '{url}', blocked."
|
|
1347
1222
|
|
|
1348
1223
|
if not _url.host:
|
|
1349
|
-
self.logger.
|
|
1224
|
+
self.logger.info(f"Missing Host: {url}")
|
|
1350
1225
|
return False, f"Missing host in URL '{url}', blocked."
|
|
1351
1226
|
try:
|
|
1352
1227
|
ip = ipaddress.ip_address(_url.host.try_into_ip())
|
|
@@ -1381,7 +1256,11 @@ class Capture():
|
|
|
1381
1256
|
self.logger.warning(f"Opening a weird URL: {url}")
|
|
1382
1257
|
return False, f"Attempted to open a weird URL '{url}', blocked."
|
|
1383
1258
|
|
|
1384
|
-
async def open_page(self, page: Page, url: str
|
|
1259
|
+
async def open_page(self, page: Page, url: str | None = None, referer: str | None=None) -> None:
|
|
1260
|
+
"""This method opens the page but does nothing with it. Use it only if you need a custom instrumentation.
|
|
1261
|
+
The usecase in lookyloo's context is to have a headfull capture in Xpra.
|
|
1262
|
+
Prefer using `capture_page` instead.
|
|
1263
|
+
"""
|
|
1385
1264
|
|
|
1386
1265
|
async def catch_file_route(route: Route, request: Request) -> None:
|
|
1387
1266
|
if unquote(request.url) == url:
|
|
@@ -1424,9 +1303,10 @@ class Capture():
|
|
|
1424
1303
|
]
|
|
1425
1304
|
for scheme in allowed_schemes:
|
|
1426
1305
|
await page.route(scheme, lambda route: route.continue_())
|
|
1427
|
-
|
|
1306
|
+
if not url:
|
|
1307
|
+
url = self.initial_url
|
|
1428
1308
|
try:
|
|
1429
|
-
await page.goto(url, wait_until='domcontentloaded', referer=referer if referer else
|
|
1309
|
+
await page.goto(url, wait_until='domcontentloaded', referer=referer if referer else self.initial_referer)
|
|
1430
1310
|
try:
|
|
1431
1311
|
await page.bring_to_front()
|
|
1432
1312
|
self.logger.debug('Page moved to front.')
|
|
@@ -1442,7 +1322,7 @@ class Capture():
|
|
|
1442
1322
|
try:
|
|
1443
1323
|
async with page.expect_download() as download_info:
|
|
1444
1324
|
try:
|
|
1445
|
-
await page.goto(url, referer=referer if referer else
|
|
1325
|
+
await page.goto(url, referer=referer if referer else self.initial_referer)
|
|
1446
1326
|
except Exception:
|
|
1447
1327
|
pass
|
|
1448
1328
|
with NamedTemporaryFile() as tmp_f:
|
|
@@ -1460,7 +1340,7 @@ class Capture():
|
|
|
1460
1340
|
error_msg = download.failure()
|
|
1461
1341
|
if not error_msg:
|
|
1462
1342
|
raise e
|
|
1463
|
-
errors.append(f"Error while downloading: {error_msg}")
|
|
1343
|
+
self.errors.append(f"Error while downloading: {error_msg}")
|
|
1464
1344
|
self.logger.info(f'Error while downloading: {error_msg}')
|
|
1465
1345
|
self.should_retry = True
|
|
1466
1346
|
except Exception:
|
|
@@ -1470,44 +1350,10 @@ class Capture():
|
|
|
1470
1350
|
else:
|
|
1471
1351
|
await self._wait_for_random_timeout(page, 5) # Wait 5 sec after document loaded
|
|
1472
1352
|
|
|
1473
|
-
@overload
|
|
1474
|
-
async def capture_page(self, url: str, *, max_depth_capture_time: int,
|
|
1475
|
-
referer: str | None=None,
|
|
1476
|
-
page: Page | None=None, depth: int=0,
|
|
1477
|
-
rendered_hostname_only: bool=True,
|
|
1478
|
-
with_screenshot: bool=True,
|
|
1479
|
-
with_favicon: bool=False,
|
|
1480
|
-
allow_tracking: bool=False,
|
|
1481
|
-
with_trusted_timestamps: bool=False,
|
|
1482
|
-
current_page_only: bool=False,
|
|
1483
|
-
final_wait: int=5
|
|
1484
|
-
) -> CaptureResponse:
|
|
1485
|
-
...
|
|
1486
|
-
|
|
1487
|
-
@overload
|
|
1488
|
-
async def capture_page(self, url: None=None, *, max_depth_capture_time: int,
|
|
1489
|
-
referer: str | None=None,
|
|
1490
|
-
page: Page, depth: int=0,
|
|
1491
|
-
rendered_hostname_only: bool=True,
|
|
1492
|
-
with_screenshot: bool=True,
|
|
1493
|
-
with_favicon: bool=False,
|
|
1494
|
-
allow_tracking: bool=False,
|
|
1495
|
-
with_trusted_timestamps: bool=False,
|
|
1496
|
-
current_page_only: bool=False,
|
|
1497
|
-
final_wait: int=5
|
|
1498
|
-
) -> CaptureResponse:
|
|
1499
|
-
...
|
|
1500
|
-
|
|
1501
1353
|
async def capture_page(self, url: str | None=None, *, max_depth_capture_time: int,
|
|
1502
1354
|
referer: str | None=None,
|
|
1503
|
-
page: Page | None=None, depth: int=
|
|
1504
|
-
rendered_hostname_only: bool=True,
|
|
1505
|
-
with_screenshot: bool=True,
|
|
1506
|
-
with_favicon: bool=False,
|
|
1507
|
-
allow_tracking: bool=False,
|
|
1508
|
-
with_trusted_timestamps: bool=False,
|
|
1355
|
+
page: Page | None=None, depth: int | None = None,
|
|
1509
1356
|
current_page_only: bool=False,
|
|
1510
|
-
final_wait: int=5,
|
|
1511
1357
|
) -> CaptureResponse:
|
|
1512
1358
|
"""Capture a URL and optionally recurse into child links.
|
|
1513
1359
|
|
|
@@ -1520,10 +1366,11 @@ class Capture():
|
|
|
1520
1366
|
(no navigation, no recursion) and then finalizes. This is the path
|
|
1521
1367
|
used by remote headfull captures after setup_page_capture has already been
|
|
1522
1368
|
called by the caller.
|
|
1369
|
+
|
|
1370
|
+
The `url` field is only needed when the URL to capture isn't the initial one (depth>0)
|
|
1523
1371
|
"""
|
|
1524
1372
|
|
|
1525
1373
|
to_return: CaptureResponse = {}
|
|
1526
|
-
errors: list[str] = []
|
|
1527
1374
|
capturing_sub = False
|
|
1528
1375
|
|
|
1529
1376
|
if current_page_only:
|
|
@@ -1532,7 +1379,7 @@ class Capture():
|
|
|
1532
1379
|
raise InvalidPlaywrightParameter('current_page_only requires an initialized page')
|
|
1533
1380
|
else:
|
|
1534
1381
|
if page is None:
|
|
1535
|
-
page = await self.setup_page_capture(
|
|
1382
|
+
page = await self.setup_page_capture()
|
|
1536
1383
|
else:
|
|
1537
1384
|
# Automated capture with depth > 0
|
|
1538
1385
|
capturing_sub = True
|
|
@@ -1541,12 +1388,13 @@ class Capture():
|
|
|
1541
1388
|
if not current_page_only:
|
|
1542
1389
|
# Standard navigation + capture path.
|
|
1543
1390
|
if not url:
|
|
1544
|
-
|
|
1545
|
-
await self.open_page(page, url
|
|
1391
|
+
url = self.initial_url
|
|
1392
|
+
await self.open_page(page, url=url if url else self.initial_url,
|
|
1393
|
+
referer=referer if referer else self.initial_referer)
|
|
1546
1394
|
|
|
1547
1395
|
try:
|
|
1548
1396
|
if self.headless:
|
|
1549
|
-
await self.__instrumentation(page, url
|
|
1397
|
+
await self.__instrumentation(page, url)
|
|
1550
1398
|
else:
|
|
1551
1399
|
self.logger.debug('Headed mode, skipping instrumentation.')
|
|
1552
1400
|
await self._wait_for_random_timeout(page, self._capture_timeout - 5)
|
|
@@ -1580,7 +1428,7 @@ class Capture():
|
|
|
1580
1428
|
u = '/!\\ Unknown /!\\'
|
|
1581
1429
|
to_return['last_redirected_url'] = u
|
|
1582
1430
|
|
|
1583
|
-
if 'html' in to_return and to_return['html'] is not None and with_favicon:
|
|
1431
|
+
if 'html' in to_return and to_return['html'] is not None and self.with_favicon:
|
|
1584
1432
|
# We're probably (?) safe only looking for favicons in the main frame.
|
|
1585
1433
|
# TODO: check that?
|
|
1586
1434
|
try:
|
|
@@ -1591,7 +1439,7 @@ class Capture():
|
|
|
1591
1439
|
except Exception as e:
|
|
1592
1440
|
self.logger.warning(f'Unable to get favicons: {e}')
|
|
1593
1441
|
|
|
1594
|
-
if with_screenshot:
|
|
1442
|
+
if self.with_screenshot:
|
|
1595
1443
|
to_return['png'] = await self._failsafe_get_screenshot(page)
|
|
1596
1444
|
|
|
1597
1445
|
# Keep that all the way down there in case the capture failed.
|
|
@@ -1600,11 +1448,15 @@ class Capture():
|
|
|
1600
1448
|
else:
|
|
1601
1449
|
self._already_captured.add(page.url)
|
|
1602
1450
|
|
|
1603
|
-
if depth
|
|
1451
|
+
if depth is None:
|
|
1452
|
+
# fallback for the first call
|
|
1453
|
+
depth = self.capture_depth
|
|
1454
|
+
|
|
1455
|
+
if depth is not None and depth > 0 and to_return.get('html') and to_return['html']:
|
|
1604
1456
|
# TODO with children frames:
|
|
1605
1457
|
# 1. if the frame has a URL, use that as base URL/referer for the subsequent captures
|
|
1606
1458
|
# 2. if it doesn't, the base URL is the url of the parent (which may or may not be the main frame)
|
|
1607
|
-
if child_urls := self._get_links_from_rendered_page(page.url, to_return['html']
|
|
1459
|
+
if child_urls := self._get_links_from_rendered_page(page.url, to_return['html']):
|
|
1608
1460
|
to_return['children'] = []
|
|
1609
1461
|
depth -= 1
|
|
1610
1462
|
total_urls = len(child_urls)
|
|
@@ -1630,11 +1482,8 @@ class Capture():
|
|
|
1630
1482
|
child_capture = await self.capture_page(
|
|
1631
1483
|
url=url, referer=page.url,
|
|
1632
1484
|
page=page, depth=depth,
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
with_screenshot=with_screenshot,
|
|
1636
|
-
final_wait=final_wait)
|
|
1637
|
-
if with_trusted_timestamps:
|
|
1485
|
+
max_depth_capture_time=max_capture_time)
|
|
1486
|
+
if self.with_trusted_timestamps:
|
|
1638
1487
|
try:
|
|
1639
1488
|
await self._get_trusted_timestamps(child_capture)
|
|
1640
1489
|
except Exception as e:
|
|
@@ -1654,7 +1503,7 @@ class Capture():
|
|
|
1654
1503
|
if consecutive_errors >= 5:
|
|
1655
1504
|
# if we have more than 5 consecutive errors, the capture is most probably broken, breaking.
|
|
1656
1505
|
self.logger.warning('Got more than 5 consecutive errors while capturing children, breaking.')
|
|
1657
|
-
errors.append("Got more than 5 consecutive errors while capturing children")
|
|
1506
|
+
self.errors.append("Got more than 5 consecutive errors while capturing children")
|
|
1658
1507
|
self.should_retry = True
|
|
1659
1508
|
break
|
|
1660
1509
|
|
|
@@ -1666,19 +1515,19 @@ class Capture():
|
|
|
1666
1515
|
self.logger.info(f'Unable to go back: {e}.')
|
|
1667
1516
|
|
|
1668
1517
|
except PlaywrightTimeoutError as e:
|
|
1669
|
-
errors.append(f"The capture took too long - {e.message}")
|
|
1518
|
+
self.errors.append(f"The capture took too long - {e.message}")
|
|
1670
1519
|
self.should_retry = True
|
|
1671
1520
|
except (asyncio.TimeoutError, TimeoutError):
|
|
1672
|
-
errors.append("Something in the capture took too long")
|
|
1521
|
+
self.errors.append("Something in the capture took too long")
|
|
1673
1522
|
self.should_retry = True
|
|
1674
1523
|
except TargetClosedError as e:
|
|
1675
|
-
errors.append(f"The target was closed - {e}")
|
|
1524
|
+
self.errors.append(f"The target was closed - {e}")
|
|
1676
1525
|
self.should_retry = True
|
|
1677
1526
|
except Error as e:
|
|
1678
1527
|
# NOTE: there are a lot of errors that look like duplicates and they are triggered at different times in the process.
|
|
1679
1528
|
# it is tricky to figure our which one should (and should not) trigger a retry. Below is our best guess and it will change over time.
|
|
1680
1529
|
self._update_exceptions(e)
|
|
1681
|
-
errors.append(e.message)
|
|
1530
|
+
self.errors.append(e.message)
|
|
1682
1531
|
to_return['error_name'] = e.name
|
|
1683
1532
|
# NOTE: e.name is generally (always?) "Error"
|
|
1684
1533
|
if self._fatal_network_error(e) or self._fatal_auth_error(e) or self.fatal_browser_error(e):
|
|
@@ -1686,7 +1535,7 @@ class Capture():
|
|
|
1686
1535
|
elif self._retry_network_error(e) or self._retry_browser_error(e):
|
|
1687
1536
|
# this one sounds like something we can retry...
|
|
1688
1537
|
self.logger.info(f'Issue with {url} (retrying): {e.message}')
|
|
1689
|
-
errors.append(f'Issue with {url}: {e.message}')
|
|
1538
|
+
self.errors.append(f'Issue with {url}: {e.message}')
|
|
1690
1539
|
self.should_retry = True
|
|
1691
1540
|
else:
|
|
1692
1541
|
# Unexpected ones
|
|
@@ -1694,15 +1543,15 @@ class Capture():
|
|
|
1694
1543
|
except PlaywrightCaptureException as e:
|
|
1695
1544
|
# unrecoverable exeptions
|
|
1696
1545
|
self.logger.warning(f'Unable to run capture: {e}')
|
|
1697
|
-
errors.append(f'Unable to run capture: {e}')
|
|
1546
|
+
self.errors.append(f'Unable to run capture: {e}')
|
|
1698
1547
|
raise e
|
|
1699
1548
|
except Exception as e:
|
|
1700
1549
|
# we may get a non-playwright exception to.
|
|
1701
1550
|
# The ones we try to handle here should be treated as if they were.
|
|
1702
|
-
errors.append(str(e))
|
|
1551
|
+
self.errors.append(str(e))
|
|
1703
1552
|
if str(e) in ['Connection closed while reading from the driver']:
|
|
1704
1553
|
self.logger.info(f'Issue with {url} (retrying): {e}')
|
|
1705
|
-
errors.append(f'Issue with {url}: {e}')
|
|
1554
|
+
self.errors.append(f'Issue with {url}: {e}')
|
|
1706
1555
|
self.should_retry = True
|
|
1707
1556
|
else:
|
|
1708
1557
|
raise e
|
|
@@ -1713,8 +1562,6 @@ class Capture():
|
|
|
1713
1562
|
await self._finalize_capture(
|
|
1714
1563
|
page=page,
|
|
1715
1564
|
to_return=to_return,
|
|
1716
|
-
errors=errors,
|
|
1717
|
-
with_trusted_timestamps=with_trusted_timestamps,
|
|
1718
1565
|
)
|
|
1719
1566
|
self.logger.debug('Capture done')
|
|
1720
1567
|
return to_return
|
|
@@ -1753,8 +1600,8 @@ class Capture():
|
|
|
1753
1600
|
trusted_timestamps: dict[str, bytes] = {}
|
|
1754
1601
|
|
|
1755
1602
|
connector = None
|
|
1756
|
-
if self.
|
|
1757
|
-
connector = ProxyConnector.from_url(self.
|
|
1603
|
+
if self._proxy and self._proxy.get('server'):
|
|
1604
|
+
connector = ProxyConnector.from_url(self._proxy['server'])
|
|
1758
1605
|
|
|
1759
1606
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
1760
1607
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
@@ -1840,6 +1687,21 @@ class Capture():
|
|
|
1840
1687
|
self.__network_not_idle += 1
|
|
1841
1688
|
self.logger.debug(f'Timed out waiting for network idle, max wait: {max_wait}s')
|
|
1842
1689
|
|
|
1690
|
+
def __decode_datauri(self, parsed: tuple[str, str, bytes]) -> str | None:
|
|
1691
|
+
mime, mime_params, content = parsed
|
|
1692
|
+
charset = 'utf-8'
|
|
1693
|
+
if mime_params:
|
|
1694
|
+
# try to get charset
|
|
1695
|
+
if qs := parse_qs(mime_params):
|
|
1696
|
+
if charsets := qs.get('charset'):
|
|
1697
|
+
try:
|
|
1698
|
+
charset = codecs.lookup(charsets[0]).name
|
|
1699
|
+
except LookupError:
|
|
1700
|
+
charset = 'utf-8'
|
|
1701
|
+
if content:
|
|
1702
|
+
return unquote(content, encoding=charset)
|
|
1703
|
+
return None
|
|
1704
|
+
|
|
1843
1705
|
async def _failsafe_get_content(self, page: Frame) -> str | None:
|
|
1844
1706
|
''' The page might be changing for all kind of reason (generally a JS timeout).
|
|
1845
1707
|
In that case, we try a few times to get the HTML.'''
|
|
@@ -1874,18 +1736,8 @@ class Capture():
|
|
|
1874
1736
|
self.logger.debug(f'Data URL in frame: {page.url}')
|
|
1875
1737
|
# 2026-02-10: if the URL starts with data, we have a data URI, and possibly some content
|
|
1876
1738
|
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)
|
|
1739
|
+
if content := self.__decode_datauri(parsed):
|
|
1740
|
+
return content
|
|
1889
1741
|
else:
|
|
1890
1742
|
self.logger.warning('No content: {page.url}')
|
|
1891
1743
|
else:
|
|
@@ -1895,7 +1747,7 @@ class Capture():
|
|
|
1895
1747
|
return unquote(page.name)
|
|
1896
1748
|
return None
|
|
1897
1749
|
|
|
1898
|
-
def _get_links_from_rendered_page(self, rendered_url: str, rendered_html: str
|
|
1750
|
+
def _get_links_from_rendered_page(self, rendered_url: str, rendered_html: str) -> list[str]:
|
|
1899
1751
|
def _sanitize(maybe_url: str) -> str | None:
|
|
1900
1752
|
href = strip_html5_whitespace(maybe_url)
|
|
1901
1753
|
href = safe_url_string(href)
|
|
@@ -1923,7 +1775,7 @@ class Capture():
|
|
|
1923
1775
|
continue
|
|
1924
1776
|
try:
|
|
1925
1777
|
if href := _sanitize(href):
|
|
1926
|
-
if not rendered_hostname_only:
|
|
1778
|
+
if not self.rendered_hostname_only:
|
|
1927
1779
|
urls.add(href)
|
|
1928
1780
|
elif rendered_hostname and urlparse(href).hostname == rendered_hostname:
|
|
1929
1781
|
urls.add(href)
|
|
@@ -1984,8 +1836,8 @@ class Capture():
|
|
|
1984
1836
|
await main_frame.get_by_role("button", name="Get an audio challenge").click()
|
|
1985
1837
|
|
|
1986
1838
|
connector = None
|
|
1987
|
-
if self.
|
|
1988
|
-
connector = ProxyConnector.from_url(self.
|
|
1839
|
+
if self._proxy and self._proxy.get('server'):
|
|
1840
|
+
connector = ProxyConnector.from_url(self._proxy['server'])
|
|
1989
1841
|
|
|
1990
1842
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
1991
1843
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
@@ -2179,6 +2031,11 @@ class Capture():
|
|
|
2179
2031
|
to_return: FramesResponse = {'name': frame.name, 'url': frame.url, 'content': ''}
|
|
2180
2032
|
if frame.is_detached():
|
|
2181
2033
|
self.logger.debug(f'{frame_id} is detached.')
|
|
2034
|
+
# can be a data url
|
|
2035
|
+
if frame.url and frame.url.strip() and frame.url.strip().startswith('data'):
|
|
2036
|
+
if parsed := self.__parse_data_uri(frame.url):
|
|
2037
|
+
if content := self.__decode_datauri(parsed):
|
|
2038
|
+
to_return['content'] = content
|
|
2182
2039
|
else:
|
|
2183
2040
|
to_return['content'] = await self._failsafe_get_content(frame)
|
|
2184
2041
|
if frame.child_frames:
|
|
@@ -2305,9 +2162,9 @@ class Capture():
|
|
|
2305
2162
|
return await handler(req)
|
|
2306
2163
|
|
|
2307
2164
|
connector = None
|
|
2308
|
-
if self.
|
|
2165
|
+
if self._proxy:
|
|
2309
2166
|
# 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.
|
|
2167
|
+
connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self._proxy))
|
|
2311
2168
|
|
|
2312
2169
|
extracted_favicons = self.__extract_favicons(rendered_content)
|
|
2313
2170
|
if not extracted_favicons:
|
|
@@ -2316,7 +2173,7 @@ class Capture():
|
|
|
2316
2173
|
to_fetch.add('/favicon.ico')
|
|
2317
2174
|
timeout = aiohttp.ClientTimeout(total=10)
|
|
2318
2175
|
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
|
2319
|
-
session.headers['user-agent'] = self.
|
|
2176
|
+
session.headers['user-agent'] = self._user_agent
|
|
2320
2177
|
for u in to_fetch:
|
|
2321
2178
|
try:
|
|
2322
2179
|
self.logger.debug(f'Attempting to fetch favicon from {u}.')
|
|
@@ -2329,6 +2186,9 @@ class Capture():
|
|
|
2329
2186
|
continue
|
|
2330
2187
|
|
|
2331
2188
|
if self.only_global_lookup:
|
|
2189
|
+
if url_to_fetch == "file:///favicon.ico":
|
|
2190
|
+
# skip, no need to log that.
|
|
2191
|
+
continue
|
|
2332
2192
|
public, message = self.__check_local_url(url_to_fetch)
|
|
2333
2193
|
if public is False:
|
|
2334
2194
|
# got a local URL
|
|
@@ -2342,7 +2202,7 @@ class Capture():
|
|
|
2342
2202
|
favicon_response.raise_for_status()
|
|
2343
2203
|
favicon = await favicon_response.read()
|
|
2344
2204
|
if favicon:
|
|
2345
|
-
m = self.magicdb.best_magic_buffer(favicon)
|
|
2205
|
+
m = self.magicdb.best_magic_buffer(favicon, None)
|
|
2346
2206
|
if not m.mime_type:
|
|
2347
2207
|
# empty, ignore
|
|
2348
2208
|
pass
|
|
@@ -2367,7 +2227,11 @@ class Capture():
|
|
|
2367
2227
|
# We get the HAR file, iterate over the entries an update the IPs
|
|
2368
2228
|
|
|
2369
2229
|
async def socks5_resolver(self, harfile: dict[str, Any]) -> None:
|
|
2370
|
-
|
|
2230
|
+
if not self._proxy:
|
|
2231
|
+
raise InvalidPlaywrightParameter('Proxy required for socks5_resolver.')
|
|
2232
|
+
|
|
2233
|
+
resolver = Socks5Resolver(logger=self.logger,
|
|
2234
|
+
socks5_proxy=self._proxy['server'],
|
|
2371
2235
|
dns_resolver=self.socks5_dns_resolver)
|
|
2372
2236
|
# get all the hostnames from the HAR file
|
|
2373
2237
|
hostnames = set()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "PlaywrightCapture"
|
|
3
|
-
version = "1.41.
|
|
3
|
+
version = "1.41.5"
|
|
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.3
|
|
26
|
+
"orjson (>=3.12,<4.0.0)",
|
|
27
|
+
"pure-magic-rs (>=0.5)",
|
|
28
|
+
"lookyloo-models (>=0.4.3)",
|
|
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
|