PlaywrightCapture 1.41.2__tar.gz → 1.41.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PlaywrightCapture
3
- Version: 1.41.2
3
+ Version: 1.41.4
4
4
  Summary: A simple library to capture websites using playwright
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE
@@ -25,11 +25,11 @@ Requires-Dist: async-timeout (>=5.0.1) ; python_version < "3.11"
25
25
  Requires-Dist: beautifulsoup4[charset-normalizer,lxml] (>=4.15.0)
26
26
  Requires-Dist: charset-normalizer (>=3.4.6,<4.0.0)
27
27
  Requires-Dist: dnspython (>=2.7.0,<3.0.0)
28
- Requires-Dist: lookyloo-models (>=0.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.1)
29
+ Requires-Dist: orjson (>=3.12,<4.0.0)
30
+ Requires-Dist: playwright (>=1.63.0)
31
31
  Requires-Dist: playwright-stealth (>=2.0.3)
32
- Requires-Dist: pure-magic-rs (>=0.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)
@@ -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,26 @@ class Capture():
199
181
  self.remote_headfull = capture_settings.remote_headfull
200
182
  self._init_script = capture_settings.init_script
201
183
 
184
+ self._proxy: dict[str, str] | None = None
185
+ if capture_settings.proxy:
186
+ if isinstance(capture_settings.proxy, ProxySettings):
187
+ self._proxy = capture_settings.proxy.model_dump(exclude_none=True)
188
+ else:
189
+ # The legacy option to pass 'force_tor', should have been transformed before we get here
190
+ self.logger.error(f'Incorrect value for the proxy: {capture_settings.proxy}')
191
+
202
192
  self.headers = capture_settings.headers
203
- self.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
215
204
 
216
205
  self.should_retry: bool = False
217
206
  self.__network_not_idle: int = 2 # makes sure we do not wait for network idle the max amount of time the capture is allowed to take
@@ -236,8 +225,8 @@ class Capture():
236
225
  if env:
237
226
  self._env.update(env)
238
227
 
239
- def __prepare_proxy_aiohttp(self, proxy: ProxySettings) -> str:
240
- if 'username' in proxy and 'password' in proxy:
228
+ def __prepare_proxy_aiohttp(self, proxy: dict[str, str]) -> str:
229
+ if proxy.get('username') and proxy.get('password'):
241
230
  splitted = urlsplit(proxy['server'])
242
231
  return urlunsplit((splitted.scheme, f'{proxy["username"]}:{proxy["password"]}@{splitted.netloc}', splitted.path, splitted.query, splitted.fragment))
243
232
  return proxy['server']
@@ -283,7 +272,7 @@ class Capture():
283
272
  launch_env = {**os.environ, **self._env}
284
273
 
285
274
  self.browser = await self.playwright[self.browser_name].launch(
286
- proxy=self.proxy if self.proxy else None,
275
+ proxy=self._proxy, # type: ignore[arg-type]
287
276
  channel="chromium" if self.browser_name == "chromium" else None,
288
277
  args=args,
289
278
  headless=self.headless,
@@ -375,7 +364,7 @@ class Capture():
375
364
  if request.resource_type == 'image' and response.ok:
376
365
  try:
377
366
  if body := await response.body():
378
- m = self.magicdb.best_magic_buffer(body)
367
+ m = self.magicdb.best_magic_buffer(body, None)
379
368
  if m.mime_type.startswith('image'):
380
369
  self._requests[request.url] = body
381
370
  except Exception:
@@ -457,36 +446,13 @@ class Capture():
457
446
  page.on("console", handle_console_msg)
458
447
  return page
459
448
 
460
- def __prepare_proxy_playwright(self, proxy: str) -> ProxySettings:
461
- splitted = urlsplit(proxy)
462
- if splitted.username and splitted.password:
463
- return {'username': splitted.username, 'password': splitted.password,
464
- 'server': urlunsplit((splitted.scheme, f'{splitted.hostname}:{splitted.port}', splitted.path, splitted.query, splitted.fragment))}
465
- return {'server': proxy}
466
-
467
- @property
468
- def proxy(self) -> ProxySettings:
469
- return self._proxy
470
-
471
- @proxy.setter
472
- def proxy(self, proxy: str | dict[str, str] | None) -> None:
473
- if proxy:
474
- if isinstance(proxy, str):
475
- self._proxy = self.__prepare_proxy_playwright(proxy)
476
- elif isinstance(proxy, dict):
477
- self._proxy = {'server': proxy['server'],
478
- 'bypass': proxy.get('bypass', ''),
479
- 'username': proxy.get('username', ''),
480
- 'password': proxy.get('password', '')}
481
- else:
482
- raise InvalidPlaywrightParameter(f'Invalid proxy parameter: "{proxy}" ({type(proxy)})')
483
-
484
449
  @property
485
450
  def capture_timeout(self) -> int:
486
451
  return self._capture_timeout
487
452
 
488
453
  @capture_timeout.setter
489
454
  def capture_timeout(self, timeout: int | None) -> None:
455
+ # NOTE: This check is relevant, and needs to happen there
490
456
  if not timeout:
491
457
  self._capture_timeout = self._default_timeout
492
458
  else:
@@ -497,101 +463,12 @@ class Capture():
497
463
  self._capture_timeout = timeout
498
464
 
499
465
  @property
500
- def 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:
466
+ def headers(self) -> dict[str, str]:
591
467
  return self._headers
592
468
 
593
469
  @headers.setter
594
470
  def headers(self, headers: dict[str, str] | None) -> None:
471
+ # NOTE: this is probably superseeded by the models, need to check that
595
472
  if not headers:
596
473
  return
597
474
  if isinstance(headers, dict):
@@ -616,64 +493,18 @@ class Capture():
616
493
  continue
617
494
  self._headers[name] = value
618
495
 
619
- @property
620
- def viewport(self) -> ViewportSize | None:
621
- return self._viewport
622
-
623
- @viewport.setter
624
- def viewport(self, viewport: dict[str, str | int] | None) -> None:
625
- if not viewport:
626
- return
627
- if 'width' in viewport and 'height' in viewport:
628
- self._viewport = {'width': int(viewport['width']), 'height': int(viewport['height'])}
629
- else:
630
- raise InvalidPlaywrightParameter(f'A viewport must have a height and a width - {viewport}')
631
-
632
- @property
633
- def user_agent(self) -> str:
634
- return self._user_agent
635
-
636
- @user_agent.setter
637
- def user_agent(self, user_agent: str | None) -> None:
638
- if user_agent is not None:
639
- self._user_agent = user_agent
640
-
641
- @property
642
- def color_scheme(self) -> Literal['dark', 'light', 'no-preference', 'null'] | None:
643
- return self._color_scheme
644
-
645
- @color_scheme.setter
646
- def color_scheme(self, color_scheme: Literal['dark', 'light', 'no-preference', 'null'] | None) -> None:
647
- if not color_scheme:
648
- return
649
- schemes = ['light', 'dark', 'no-preference', 'null']
650
- if color_scheme in schemes:
651
- self._color_scheme = color_scheme
652
- else:
653
- raise InvalidPlaywrightParameter(f'Invalid color scheme ({color_scheme}), must be in {", ".join(schemes)}.')
654
-
655
- @property
656
- def java_script_enabled(self) -> bool:
657
- return self._java_script_enabled
658
-
659
- @java_script_enabled.setter
660
- def java_script_enabled(self, enabled: bool) -> None:
661
- self._java_script_enabled = enabled
662
-
663
496
  async def initialize_context(self) -> None:
664
497
  device_context_settings = {}
498
+ vp: dict[str, int] | None = None
665
499
  if self.device_name:
666
500
  device_context_settings = self.playwright.devices[self.device_name]
667
501
  # We need to make sure the device_context_settings dict doesn't contains
668
502
  # keys that are set by default in the context creation
669
- 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
503
+ ua = device_context_settings.pop('user_agent', self._user_agent)
504
+ vp = device_context_settings.pop('viewport', self._viewport)
674
505
  else:
675
- ua = self.user_agent
676
- vp = self.viewport
506
+ ua = self._user_agent
507
+ vp = self._viewport
677
508
 
678
509
  # NOTE 2026-04-10: Very specific edge case:
679
510
  # * capture in remote headfull mode with xpra
@@ -690,19 +521,20 @@ class Capture():
690
521
  record_har_path=self._temp_harfile.name,
691
522
  ignore_https_errors=True,
692
523
  bypass_csp=True,
693
- java_script_enabled=self.java_script_enabled,
694
- http_credentials=self.http_credentials if self.http_credentials else None,
524
+ java_script_enabled=self._java_script_enabled,
525
+ http_credentials=self._http_credentials, # type: ignore[arg-type]
695
526
  user_agent=ua,
696
- locale=self.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,
527
+ locale=self._locale,
528
+ timezone_id=self._timezone_id,
529
+ color_scheme=self._color_scheme,
530
+ viewport=vp if vp else self._default_viewport, # type: ignore[arg-type]
531
+ storage_state=self._storage, # type: ignore[arg-type]
701
532
  # For debug only
702
533
  # record_video_dir='./videos/',
703
534
  **device_context_settings
704
535
  )
705
536
  self.context.set_default_timeout(self._capture_timeout * 1000)
537
+ await self.context.credentials.install()
706
538
 
707
539
  if self._init_script:
708
540
  await self.context.add_init_script(script=self._init_script)
@@ -753,11 +585,11 @@ class Capture():
753
585
  # 'script_logging': True,
754
586
  })
755
587
 
756
- if self.cookies:
588
+ if self._cookies:
757
589
  try:
758
- await self.context.add_cookies([c.model_dump(exclude_none=True) for c in self.cookies]) # type: ignore[misc]
590
+ await self.context.add_cookies(self._cookies) # type: ignore[arg-type]
759
591
  except Exception:
760
- self.logger.exception(f'Unable to set cookies: {self.cookies}')
592
+ self.logger.exception(f'Unable to set cookies: {self._cookies}')
761
593
 
762
594
  if self.headers:
763
595
  try:
@@ -765,8 +597,8 @@ class Capture():
765
597
  except Exception:
766
598
  self.logger.exception(f'Unable to set HTTP Headers: {self.headers}')
767
599
 
768
- if self.geolocation:
769
- await self.context.set_geolocation(self.geolocation)
600
+ if self._geolocation:
601
+ await self.context.set_geolocation(self._geolocation) # type: ignore[arg-type]
770
602
 
771
603
  # NOTE: Which perms are supported by which browsers varies
772
604
  # See https://github.com/microsoft/playwright/issues/16577
@@ -1187,6 +1019,47 @@ class Capture():
1187
1019
  await self._safe_wait(page)
1188
1020
  self.logger.debug('Done with waiting.')
1189
1021
 
1022
+ async def _safe_get_storage_state(self, errors: list[str]) -> dict[str, Any]:
1023
+ # Collect storage state, including IndexedDB, to capture the full browser state.
1024
+ # 2026-09-08: add WebAuth credentials
1025
+ # 2026-09-17: Add opfs
1026
+ # Quite a few captures fail either on opfs or on indexed db, so we hav a few fallbacks
1027
+ to_store = {'indexed_db': True, 'opfs': True, 'credentials': True}
1028
+ while True:
1029
+ try:
1030
+ async with timeout(15):
1031
+ return await self.context.storage_state(**to_store) # type: ignore[return-value,arg-type]
1032
+ except (TimeoutError, asyncio.TimeoutError):
1033
+ self.logger.warning("Unable to get storage (timeout).")
1034
+ errors.append("Unable to get the storage (timeout).")
1035
+ self.should_retry = True
1036
+ break
1037
+ except Error as e:
1038
+ if to_store['indexed_db'] and 'IndexedDB' in str(e):
1039
+ to_store['indexed_db'] = False
1040
+ errors.append('Unable to get the IndexedDB')
1041
+ self.logger.warning(f"Unable to get the IndexedDB: {e}")
1042
+ continue
1043
+ if to_store['opfs'] and 'OPFS' in str(e):
1044
+ to_store['opfs'] = False
1045
+ errors.append('Unable to get the OPFS')
1046
+ self.logger.warning(f"Unable to get the OPFS: {e}")
1047
+ continue
1048
+
1049
+ if not to_store['indexed_db'] and not to_store['opfs']:
1050
+ # we disabled both options, quit
1051
+ self.should_retry = True
1052
+ errors.append(f'Unable to get the storage at all: {e}')
1053
+ self.logger.warning(f"Unable to get the storage at all: {e}")
1054
+ break
1055
+ except Exception as e:
1056
+ # When the driver explodes for no clear reason.
1057
+ self.logger.warning(f"[Generic Exception] Unable to get the storage: {e}")
1058
+ errors.append(f'[Generic Exception] Unable to get the storage: {e}')
1059
+ self.should_retry = True
1060
+ break
1061
+ return {}
1062
+
1190
1063
  async def _finalize_capture(
1191
1064
  self,
1192
1065
  *,
@@ -1236,23 +1109,7 @@ class Capture():
1236
1109
  errors.append(f'[Generic Exception] Unable to get the cookies: {e}')
1237
1110
  self.should_retry = True
1238
1111
 
1239
- # Collect storage state, including IndexedDB, to capture the full browser state.
1240
- try:
1241
- async with timeout(15):
1242
- to_return['storage'] = await self.context.storage_state(indexed_db=True)
1243
- except (TimeoutError, asyncio.TimeoutError):
1244
- self.logger.warning("Unable to get storage (timeout).")
1245
- errors.append("Unable to get the storage (timeout).")
1246
- self.should_retry = True
1247
- except Error as e:
1248
- self.logger.warning(f"Unable to get the storage: {e}")
1249
- errors.append(f'Unable to get the storage: {e}')
1250
- self.should_retry = True
1251
- except Exception as e:
1252
- # When the driver explodes for no clear reason.
1253
- self.logger.warning(f"[Generic Exception] Unable to get the storage: {e}")
1254
- errors.append(f'[Generic Exception] Unable to get the storage: {e}')
1255
- self.should_retry = True
1112
+ to_return['storage'] = await self._safe_get_storage_state(errors)
1256
1113
 
1257
1114
  try:
1258
1115
  if page.is_closed():
@@ -1302,8 +1159,8 @@ class Capture():
1302
1159
  # When using a socks5 proxy, post-process the HAR to resolve IPs via
1303
1160
  # the proxy so the stored HAR contains addresses consistent with what
1304
1161
  # the proxy saw.
1305
- if (to_return.get('har') and self.proxy and self.proxy.get('server')
1306
- and self.proxy['server'].startswith('socks5')):
1162
+ if (to_return.get('har') and self._proxy and self._proxy.get('server')
1163
+ and self._proxy['server'].startswith('socks5')):
1307
1164
  if har := to_return['har']: # Could be None
1308
1165
  try:
1309
1166
  async with timeout(120):
@@ -1345,7 +1202,7 @@ class Capture():
1345
1202
  return False, f"Unable to parse URL '{url}', blocked."
1346
1203
 
1347
1204
  if not _url.host:
1348
- self.logger.warning(f"Missing Host: {url}")
1205
+ self.logger.info(f"Missing Host: {url}")
1349
1206
  return False, f"Missing host in URL '{url}', blocked."
1350
1207
  try:
1351
1208
  ip = ipaddress.ip_address(_url.host.try_into_ip())
@@ -1752,8 +1609,8 @@ class Capture():
1752
1609
  trusted_timestamps: dict[str, bytes] = {}
1753
1610
 
1754
1611
  connector = None
1755
- if self.proxy and self.proxy.get('server'):
1756
- connector = ProxyConnector.from_url(self.proxy['server'])
1612
+ if self._proxy and self._proxy.get('server'):
1613
+ connector = ProxyConnector.from_url(self._proxy['server'])
1757
1614
 
1758
1615
  timeout = aiohttp.ClientTimeout(total=10)
1759
1616
  async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
@@ -1839,6 +1696,21 @@ class Capture():
1839
1696
  self.__network_not_idle += 1
1840
1697
  self.logger.debug(f'Timed out waiting for network idle, max wait: {max_wait}s')
1841
1698
 
1699
+ def __decode_datauri(self, parsed: tuple[str, str, bytes]) -> str | None:
1700
+ mime, mime_params, content = parsed
1701
+ charset = 'utf-8'
1702
+ if mime_params:
1703
+ # try to get charset
1704
+ if qs := parse_qs(mime_params):
1705
+ if charsets := qs.get('charset'):
1706
+ try:
1707
+ charset = codecs.lookup(charsets[0]).name
1708
+ except LookupError:
1709
+ charset = 'utf-8'
1710
+ if content:
1711
+ return unquote(content, encoding=charset)
1712
+ return None
1713
+
1842
1714
  async def _failsafe_get_content(self, page: Frame) -> str | None:
1843
1715
  ''' The page might be changing for all kind of reason (generally a JS timeout).
1844
1716
  In that case, we try a few times to get the HTML.'''
@@ -1873,18 +1745,8 @@ class Capture():
1873
1745
  self.logger.debug(f'Data URL in frame: {page.url}')
1874
1746
  # 2026-02-10: if the URL starts with data, we have a data URI, and possibly some content
1875
1747
  if parsed := self.__parse_data_uri(page.url):
1876
- mime, mime_params, content = parsed
1877
- charset = 'utf-8'
1878
- if mime_params:
1879
- # try to get charset
1880
- if qs := parse_qs(mime_params):
1881
- if charsets := qs.get('charset'):
1882
- try:
1883
- charset = codecs.lookup(charsets[0]).name
1884
- except LookupError:
1885
- charset = 'utf-8'
1886
- if content:
1887
- return unquote(content, encoding=charset)
1748
+ if content := self.__decode_datauri(parsed):
1749
+ return content
1888
1750
  else:
1889
1751
  self.logger.warning('No content: {page.url}')
1890
1752
  else:
@@ -1983,8 +1845,8 @@ class Capture():
1983
1845
  await main_frame.get_by_role("button", name="Get an audio challenge").click()
1984
1846
 
1985
1847
  connector = None
1986
- if self.proxy and self.proxy.get('server'):
1987
- connector = ProxyConnector.from_url(self.proxy['server'])
1848
+ if self._proxy and self._proxy.get('server'):
1849
+ connector = ProxyConnector.from_url(self._proxy['server'])
1988
1850
 
1989
1851
  timeout = aiohttp.ClientTimeout(total=10)
1990
1852
  async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
@@ -2178,6 +2040,11 @@ class Capture():
2178
2040
  to_return: FramesResponse = {'name': frame.name, 'url': frame.url, 'content': ''}
2179
2041
  if frame.is_detached():
2180
2042
  self.logger.debug(f'{frame_id} is detached.')
2043
+ # can be a data url
2044
+ if frame.url and frame.url.strip() and frame.url.strip().startswith('data'):
2045
+ if parsed := self.__parse_data_uri(frame.url):
2046
+ if content := self.__decode_datauri(parsed):
2047
+ to_return['content'] = content
2181
2048
  else:
2182
2049
  to_return['content'] = await self._failsafe_get_content(frame)
2183
2050
  if frame.child_frames:
@@ -2304,9 +2171,9 @@ class Capture():
2304
2171
  return await handler(req)
2305
2172
 
2306
2173
  connector = None
2307
- if self.proxy:
2174
+ if self._proxy:
2308
2175
  # NOTE 2024-05-17: switch to async to fetch, the lib uses socks5h by default
2309
- connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self.proxy))
2176
+ connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self._proxy))
2310
2177
 
2311
2178
  extracted_favicons = self.__extract_favicons(rendered_content)
2312
2179
  if not extracted_favicons:
@@ -2315,7 +2182,7 @@ class Capture():
2315
2182
  to_fetch.add('/favicon.ico')
2316
2183
  timeout = aiohttp.ClientTimeout(total=10)
2317
2184
  async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
2318
- session.headers['user-agent'] = self.user_agent
2185
+ session.headers['user-agent'] = self._user_agent
2319
2186
  for u in to_fetch:
2320
2187
  try:
2321
2188
  self.logger.debug(f'Attempting to fetch favicon from {u}.')
@@ -2328,6 +2195,9 @@ class Capture():
2328
2195
  continue
2329
2196
 
2330
2197
  if self.only_global_lookup:
2198
+ if url_to_fetch == "file:///favicon.ico":
2199
+ # skip, no need to log that.
2200
+ continue
2331
2201
  public, message = self.__check_local_url(url_to_fetch)
2332
2202
  if public is False:
2333
2203
  # got a local URL
@@ -2341,7 +2211,7 @@ class Capture():
2341
2211
  favicon_response.raise_for_status()
2342
2212
  favicon = await favicon_response.read()
2343
2213
  if favicon:
2344
- m = self.magicdb.best_magic_buffer(favicon)
2214
+ m = self.magicdb.best_magic_buffer(favicon, None)
2345
2215
  if not m.mime_type:
2346
2216
  # empty, ignore
2347
2217
  pass
@@ -2366,7 +2236,11 @@ class Capture():
2366
2236
  # We get the HAR file, iterate over the entries an update the IPs
2367
2237
 
2368
2238
  async def socks5_resolver(self, harfile: dict[str, Any]) -> None:
2369
- resolver = Socks5Resolver(logger=self.logger, socks5_proxy=self.proxy['server'],
2239
+ if not self._proxy:
2240
+ raise InvalidPlaywrightParameter('Proxy required for socks5_resolver.')
2241
+
2242
+ resolver = Socks5Resolver(logger=self.logger,
2243
+ socks5_proxy=self._proxy['server'],
2370
2244
  dns_resolver=self.socks5_dns_resolver)
2371
2245
  # get all the hostnames from the HAR file
2372
2246
  hostnames = set()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "PlaywrightCapture"
3
- version = "1.41.2"
3
+ version = "1.41.4"
4
4
  description = "A simple library to capture websites using playwright"
5
5
  authors = [
6
6
  {name="Raphaël Vinot", email= "raphael.vinot@circl.lu"}
@@ -12,7 +12,7 @@ requires-python = ">=3.10,<3.15"
12
12
  dynamic = [ "classifiers" ]
13
13
 
14
14
  dependencies = [
15
- "playwright (>=1.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.1)",
29
29
  "charset-normalizer (>=3.4.6,<4.0.0)",
30
30
  "pyfaup-rs (>=0.4.6,<0.5.0)"
31
31
  ]
@@ -53,7 +53,7 @@ recaptcha = [
53
53
  types-beautifulsoup4 = "^4.12.0.20250516"
54
54
  pytest = "^9.1.1"
55
55
  mypy = "^2.3.1"
56
- types-dateparser = "^1.4.2.20260813"
56
+ types-dateparser = "^1.4.3.20260907"
57
57
  types-pytz = "^2026.3.1.20260727"
58
58
 
59
59