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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PlaywrightCapture
3
- Version: 1.41.3
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.1)
29
- Requires-Dist: orjson (>=3.11.4,<4.0.0)
30
- Requires-Dist: playwright (>=1.62.0)
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.4.3)
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, overload
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: StorageState | None
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: ViewportSize = {'width': 1920, 'height': 1080}
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: 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.cookies = [c.model_dump(exclude_none=True) for c in capture_settings.cookies] if capture_settings.cookies else None
204
- self.storage = capture_settings.storage
205
- self.viewport = capture_settings.viewport.model_dump(exclude_none=True) if capture_settings.viewport else None
206
- self.user_agent = capture_settings.user_agent
207
- self.http_credentials = capture_settings.http_credentials.model_dump(exclude_none=True) if capture_settings.http_credentials else None
208
- self.geolocation = capture_settings.geolocation.model_dump(exclude_none=True) if capture_settings.geolocation else None
209
- self.timezone_id = capture_settings.timezone_id
210
- self.locale = capture_settings.locale
211
- self.color_scheme = capture_settings.color_scheme
212
- self.java_script_enabled = capture_settings.java_script_enabled
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
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: ProxySettings) -> str:
240
- if 'username' in proxy and 'password' in proxy:
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.proxy if self.proxy else None,
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, *, allow_tracking: bool=False) -> Page:
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 locale(self) -> str:
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
- if context_ua := device_context_settings.pop('user_agent', None):
670
- ua = self.user_agent if self.user_agent else context_ua
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.user_agent
676
- vp = self.viewport
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.java_script_enabled,
694
- http_credentials=self.http_credentials if self.http_credentials else None,
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.locale if self.locale else None,
697
- timezone_id=self.timezone_id if self.timezone_id else None,
698
- color_scheme=self.color_scheme if self.color_scheme else None,
699
- viewport=vp,
700
- storage_state=self.storage if self.storage else None,
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.cookies:
609
+ if self._cookies:
757
610
  try:
758
- await self.context.add_cookies([c.model_dump(exclude_none=True) for c in self.cookies]) # type: ignore[misc]
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.cookies}')
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.geolocation:
769
- await self.context.set_geolocation(self.geolocation)
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, allow_tracking: bool, final_wait: int) -> None:
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
- # Collect storage state, including IndexedDB, to capture the full browser state.
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.proxy and self.proxy.get('server')
1307
- and self.proxy['server'].startswith('socks5')):
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.warning(f"Missing Host: {url}")
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, errors: list[str], referer: str | None=None) -> None:
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=0,
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(allow_tracking=allow_tracking)
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
- raise InvalidPlaywrightParameter('The URL to capture is missing.')
1545
- await self.open_page(page, url, errors, referer)
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, allow_tracking, final_wait)
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 > 0 and to_return.get('html') and to_return['html']:
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'], rendered_hostname_only):
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
- rendered_hostname_only=rendered_hostname_only,
1634
- max_depth_capture_time=max_capture_time,
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.proxy and self.proxy.get('server'):
1757
- connector = ProxyConnector.from_url(self.proxy['server'])
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
- mime, mime_params, content = parsed
1878
- charset = 'utf-8'
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, rendered_hostname_only: bool) -> list[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.proxy and self.proxy.get('server'):
1988
- connector = ProxyConnector.from_url(self.proxy['server'])
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.proxy:
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.proxy))
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.user_agent
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
- resolver = Socks5Resolver(logger=self.logger, socks5_proxy=self.proxy['server'],
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"
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.62.0)",
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.11.4,<4.0.0)",
27
- "pure-magic-rs (>=0.4.3)",
28
- "lookyloo-models (>=0.3.1)",
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
  ]