PlaywrightCapture 1.41.3__tar.gz → 1.41.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PlaywrightCapture
3
- Version: 1.41.3
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,24 +1109,7 @@ class Capture():
1236
1109
  errors.append(f'[Generic Exception] Unable to get the cookies: {e}')
1237
1110
  self.should_retry = True
1238
1111
 
1239
- # 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
1112
+ to_return['storage'] = await self._safe_get_storage_state(errors)
1257
1113
 
1258
1114
  try:
1259
1115
  if page.is_closed():
@@ -1303,8 +1159,8 @@ class Capture():
1303
1159
  # When using a socks5 proxy, post-process the HAR to resolve IPs via
1304
1160
  # the proxy so the stored HAR contains addresses consistent with what
1305
1161
  # the proxy saw.
1306
- if (to_return.get('har') and self.proxy and self.proxy.get('server')
1307
- 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')):
1308
1164
  if har := to_return['har']: # Could be None
1309
1165
  try:
1310
1166
  async with timeout(120):
@@ -1346,7 +1202,7 @@ class Capture():
1346
1202
  return False, f"Unable to parse URL '{url}', blocked."
1347
1203
 
1348
1204
  if not _url.host:
1349
- self.logger.warning(f"Missing Host: {url}")
1205
+ self.logger.info(f"Missing Host: {url}")
1350
1206
  return False, f"Missing host in URL '{url}', blocked."
1351
1207
  try:
1352
1208
  ip = ipaddress.ip_address(_url.host.try_into_ip())
@@ -1753,8 +1609,8 @@ class Capture():
1753
1609
  trusted_timestamps: dict[str, bytes] = {}
1754
1610
 
1755
1611
  connector = None
1756
- if self.proxy and self.proxy.get('server'):
1757
- connector = ProxyConnector.from_url(self.proxy['server'])
1612
+ if self._proxy and self._proxy.get('server'):
1613
+ connector = ProxyConnector.from_url(self._proxy['server'])
1758
1614
 
1759
1615
  timeout = aiohttp.ClientTimeout(total=10)
1760
1616
  async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
@@ -1840,6 +1696,21 @@ class Capture():
1840
1696
  self.__network_not_idle += 1
1841
1697
  self.logger.debug(f'Timed out waiting for network idle, max wait: {max_wait}s')
1842
1698
 
1699
+ def __decode_datauri(self, parsed: tuple[str, str, bytes]) -> str | None:
1700
+ mime, mime_params, content = parsed
1701
+ charset = 'utf-8'
1702
+ if mime_params:
1703
+ # try to get charset
1704
+ if qs := parse_qs(mime_params):
1705
+ if charsets := qs.get('charset'):
1706
+ try:
1707
+ charset = codecs.lookup(charsets[0]).name
1708
+ except LookupError:
1709
+ charset = 'utf-8'
1710
+ if content:
1711
+ return unquote(content, encoding=charset)
1712
+ return None
1713
+
1843
1714
  async def _failsafe_get_content(self, page: Frame) -> str | None:
1844
1715
  ''' The page might be changing for all kind of reason (generally a JS timeout).
1845
1716
  In that case, we try a few times to get the HTML.'''
@@ -1874,18 +1745,8 @@ class Capture():
1874
1745
  self.logger.debug(f'Data URL in frame: {page.url}')
1875
1746
  # 2026-02-10: if the URL starts with data, we have a data URI, and possibly some content
1876
1747
  if parsed := self.__parse_data_uri(page.url):
1877
- 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)
1748
+ if content := self.__decode_datauri(parsed):
1749
+ return content
1889
1750
  else:
1890
1751
  self.logger.warning('No content: {page.url}')
1891
1752
  else:
@@ -1984,8 +1845,8 @@ class Capture():
1984
1845
  await main_frame.get_by_role("button", name="Get an audio challenge").click()
1985
1846
 
1986
1847
  connector = None
1987
- if self.proxy and self.proxy.get('server'):
1988
- connector = ProxyConnector.from_url(self.proxy['server'])
1848
+ if self._proxy and self._proxy.get('server'):
1849
+ connector = ProxyConnector.from_url(self._proxy['server'])
1989
1850
 
1990
1851
  timeout = aiohttp.ClientTimeout(total=10)
1991
1852
  async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
@@ -2179,6 +2040,11 @@ class Capture():
2179
2040
  to_return: FramesResponse = {'name': frame.name, 'url': frame.url, 'content': ''}
2180
2041
  if frame.is_detached():
2181
2042
  self.logger.debug(f'{frame_id} is detached.')
2043
+ # can be a data url
2044
+ if frame.url and frame.url.strip() and frame.url.strip().startswith('data'):
2045
+ if parsed := self.__parse_data_uri(frame.url):
2046
+ if content := self.__decode_datauri(parsed):
2047
+ to_return['content'] = content
2182
2048
  else:
2183
2049
  to_return['content'] = await self._failsafe_get_content(frame)
2184
2050
  if frame.child_frames:
@@ -2305,9 +2171,9 @@ class Capture():
2305
2171
  return await handler(req)
2306
2172
 
2307
2173
  connector = None
2308
- if self.proxy:
2174
+ if self._proxy:
2309
2175
  # NOTE 2024-05-17: switch to async to fetch, the lib uses socks5h by default
2310
- connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self.proxy))
2176
+ connector = ProxyConnector.from_url(self.__prepare_proxy_aiohttp(self._proxy))
2311
2177
 
2312
2178
  extracted_favicons = self.__extract_favicons(rendered_content)
2313
2179
  if not extracted_favicons:
@@ -2316,7 +2182,7 @@ class Capture():
2316
2182
  to_fetch.add('/favicon.ico')
2317
2183
  timeout = aiohttp.ClientTimeout(total=10)
2318
2184
  async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
2319
- session.headers['user-agent'] = self.user_agent
2185
+ session.headers['user-agent'] = self._user_agent
2320
2186
  for u in to_fetch:
2321
2187
  try:
2322
2188
  self.logger.debug(f'Attempting to fetch favicon from {u}.')
@@ -2329,6 +2195,9 @@ class Capture():
2329
2195
  continue
2330
2196
 
2331
2197
  if self.only_global_lookup:
2198
+ if url_to_fetch == "file:///favicon.ico":
2199
+ # skip, no need to log that.
2200
+ continue
2332
2201
  public, message = self.__check_local_url(url_to_fetch)
2333
2202
  if public is False:
2334
2203
  # got a local URL
@@ -2342,7 +2211,7 @@ class Capture():
2342
2211
  favicon_response.raise_for_status()
2343
2212
  favicon = await favicon_response.read()
2344
2213
  if favicon:
2345
- m = self.magicdb.best_magic_buffer(favicon)
2214
+ m = self.magicdb.best_magic_buffer(favicon, None)
2346
2215
  if not m.mime_type:
2347
2216
  # empty, ignore
2348
2217
  pass
@@ -2367,7 +2236,11 @@ class Capture():
2367
2236
  # We get the HAR file, iterate over the entries an update the IPs
2368
2237
 
2369
2238
  async def socks5_resolver(self, harfile: dict[str, Any]) -> None:
2370
- 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'],
2371
2244
  dns_resolver=self.socks5_dns_resolver)
2372
2245
  # get all the hostnames from the HAR file
2373
2246
  hostnames = set()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "PlaywrightCapture"
3
- version = "1.41.3"
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
  ]