PlaywrightCapture 1.40.4__tar.gz → 1.40.6__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.40.4
3
+ Version: 1.40.6
4
4
  Summary: A simple library to capture websites using playwright
5
5
  License-Expression: BSD-3-Clause
6
6
  License-File: LICENSE
@@ -25,7 +25,7 @@ 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.0)
28
+ Requires-Dist: lookyloo-models (>=0.3.1)
29
29
  Requires-Dist: orjson (>=3.11.4,<4.0.0)
30
30
  Requires-Dist: playwright (>=1.62.0)
31
31
  Requires-Dist: playwright-stealth (>=2.0.3)
@@ -238,7 +238,10 @@ class Capture():
238
238
 
239
239
  async def __aenter__(self) -> Capture:
240
240
  """Launch Playwright and the configured browser for this capture."""
241
- self.playwright = await async_playwright().start()
241
+ try:
242
+ self.playwright = await async_playwright().start()
243
+ except Exception as e:
244
+ raise PlaywrightCaptureException('Unable to launch Playwright.') from e
242
245
 
243
246
  if self.device_name:
244
247
  if self.device_name in self.playwright.devices:
@@ -1299,6 +1302,49 @@ class Capture():
1299
1302
  except Exception as e:
1300
1303
  self.logger.warning(f'Unable to get trusted timestamps: {e}')
1301
1304
 
1305
+ def __check_local_url(self, url: str) -> tuple[bool, str]:
1306
+ try:
1307
+ _url = Url(url)
1308
+ except ValueError as e:
1309
+ self.logger.warning(f"Unable to parse URL: {url}: {e}")
1310
+ return False, f"Unable to parse URL '{url}', blocked."
1311
+
1312
+ if not _url.host:
1313
+ self.logger.warning(f"Missing Host: {url}")
1314
+ return False, f"Missing host in URL '{url}', blocked."
1315
+ try:
1316
+ ip = ipaddress.ip_address(_url.host.try_into_ip())
1317
+ if ip.is_global:
1318
+ return True, "Global IP"
1319
+ # Non-global IP
1320
+ self.logger.warning(f"Attempt to open a non-public IP: {url}")
1321
+ return False, f"Attempted to open {url}, blocked."
1322
+ except ValueError:
1323
+ # not an IP, continue to hostname
1324
+ pass
1325
+
1326
+ try:
1327
+ hostname = _url.host.try_into_hostname()
1328
+ if not hostname:
1329
+ self.logger.warning(f"Missing hostname: {url}")
1330
+ return False, f"Missing hostname in URL '{url}', blocked."
1331
+ elif str(hostname) == 'localhost':
1332
+ self.logger.warning(f"Attempt to open localhost: {url}")
1333
+ return False, "Attempted to open localhost, blocked."
1334
+ elif suffix := hostname.suffix:
1335
+ if str(suffix) == 'local':
1336
+ # Got a local domain
1337
+ self.logger.warning(f"Attempt to open a local domain: {url}")
1338
+ return False, f"Attempted to open {url}, blocked."
1339
+ # NOTE: may want to add other things there, but do not want to trigger a domain resolution.
1340
+ return True, "Public hostname"
1341
+ except ValueError:
1342
+ # not an hostname
1343
+ pass
1344
+ # should not happen (should have an IP or a hostname
1345
+ self.logger.warning(f"Opening a weird URL: {url}")
1346
+ return False, f"Attempted to open a weird URL '{url}', blocked."
1347
+
1302
1348
  async def open_page(self, page: Page, url: str, errors: list[str], referer: str | None=None) -> None:
1303
1349
 
1304
1350
  async def catch_file_route(route: Route, request: Request) -> None:
@@ -1316,51 +1362,10 @@ class Capture():
1316
1362
  await route.continue_()
1317
1363
  else:
1318
1364
  # other URLs
1319
- try:
1320
- _url = Url(request.url)
1321
- except ValueError as e:
1322
- # some URLs will fail here when they have a specific scheme
1323
- allowed_schemes = ('chrome-extension:', )
1324
- if request.url.startswith(allowed_schemes):
1325
- return await route.continue_()
1326
- self.logger.warning(f"Unable to parse URL: {request.url}: {e}")
1327
- return await route.fulfill(status=404, content_type="text/plain", body=f"Unable to parse URL '{request.url}', blocked.")
1328
-
1329
- if not _url.host:
1330
- self.logger.warning(f"Missing Host: {request.url}")
1331
- return await route.fulfill(status=404, content_type="text/plain", body=f"Missing host in URL '{request.url}', blocked.")
1332
- try:
1333
- ip = ipaddress.ip_address(_url.host.try_into_ip())
1334
- if ip.is_global:
1335
- return await route.continue_()
1336
- # Non-global IP
1337
- self.logger.warning(f"Attempt to open a non-public IP: {request.url}")
1338
- return await route.fulfill(status=404, content_type="text/plain", body=f"Attempted to open {request.url}, blocked.")
1339
- except ValueError:
1340
- # not an IP, continue to hostname
1341
- pass
1342
-
1343
- try:
1344
- hostname = _url.host.try_into_hostname()
1345
- if not hostname:
1346
- self.logger.warning(f"Missing hostname: {request.url}")
1347
- return await route.fulfill(status=404, content_type="text/plain", body=f"Missing hostname in URL '{request.url}', blocked.")
1348
- elif str(hostname) == 'localhost':
1349
- self.logger.warning(f"Attempt to open localhost: {request.url}")
1350
- return await route.fulfill(status=404, content_type="text/plain", body="Attempted to open localhost, blocked.")
1351
- elif suffix := hostname.suffix:
1352
- if str(suffix) == 'local':
1353
- # Got a local domain
1354
- self.logger.warning(f"Attempt to open a local domain: {request.url}")
1355
- return await route.fulfill(status=404, content_type="text/plain", body=f"Attempted to open {request.url}, blocked.")
1356
- # NOTE: may want to add other things there, but do not want to trigger a domain resolution.
1357
- return await route.continue_()
1358
- except ValueError:
1359
- # not an hostname
1360
- pass
1361
- # should not happen (should have an IP or a hostname
1362
- self.logger.warning(f"Opening a weird URL: {request.url}")
1363
- return await route.fulfill(status=404, content_type="text/plain", body=f"Attempted to open a weird URL '{request.url}', blocked.")
1365
+ not_local, message = self.__check_local_url(request.url)
1366
+ if not_local:
1367
+ await route.continue_()
1368
+ await route.fulfill(status=404, content_type="text/plain", body=message)
1364
1369
 
1365
1370
  if self.only_global_lookup:
1366
1371
  # routes are processed in reverse order, the latest one in this list is processed first
@@ -1368,12 +1373,20 @@ class Capture():
1368
1373
  await page.route("**/*", handler=catch_local_route) # last one to be checked
1369
1374
  # unless they're the actual URL we're rendering, block.
1370
1375
  await page.route("file:**/*", handler=catch_file_route)
1371
- # blob URLs, they're always fine as their content is created in memory by the browser
1372
- # https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/blob
1373
- await page.route("blob:**/*", lambda route: route.continue_())
1374
- # filesystem URLs, they point to cache or a virtual file system that is browser managed
1375
- # https://developer.mozilla.org/en-US/docs/Web/API/File_System_API
1376
- await page.route("filesystem:**/*", lambda route: route.continue_())
1376
+
1377
+ # these schemes are allowed to continue by default, they do not hit anything private
1378
+ allowed_schemes = [
1379
+ # blob URLs, they're always fine as their content is created in memory by the browser
1380
+ # https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/blob
1381
+ "blob:**/*",
1382
+ # filesystem URLs, they point to cache or a virtual file system that is browser managed
1383
+ # https://developer.mozilla.org/en-US/docs/Web/API/File_System_API
1384
+ "filesystem:**/*",
1385
+ # also purely internal to the browser
1386
+ "chrome-extension:**/*"
1387
+ ]
1388
+ for scheme in allowed_schemes:
1389
+ await page.route(scheme, lambda route: route.continue_())
1377
1390
 
1378
1391
  try:
1379
1392
  await page.goto(url, wait_until='domcontentloaded', referer=referer if referer else '')
@@ -1808,7 +1821,7 @@ class Capture():
1808
1821
  # The frame is loaded, if we get no content there, only retry one more time.
1809
1822
  tries -= 1
1810
1823
  except Exception as e:
1811
- self.logger.warning(f'The Playwright Page is in a broken state: {e}.')
1824
+ self.logger.warning(f'The Playwright Page is in a broken state (unable to get content): {e}.')
1812
1825
  break
1813
1826
  tries -= 1
1814
1827
  if tries > 0:
@@ -1816,7 +1829,7 @@ class Capture():
1816
1829
  await self._wait_for_random_timeout(page, 2)
1817
1830
  await self._safe_wait(page, 2)
1818
1831
  except Exception as e:
1819
- self.logger.warning(f'The Playwright Page is in a broken state: {e}.')
1832
+ self.logger.warning(f'The Playwright Page is in a broken state (unable to wait): {e}.')
1820
1833
  break
1821
1834
 
1822
1835
  # got no content
@@ -2125,17 +2138,25 @@ class Capture():
2125
2138
  else:
2126
2139
  f_name = frame.name
2127
2140
  frame_id = f'{f_name}@{frame.url}'
2141
+ to_return: FramesResponse = {'name': frame.name, 'url': frame.url, 'content': ''}
2128
2142
  if frame.is_detached():
2129
2143
  self.logger.debug(f'{frame_id} is detached.')
2130
- to_return: FramesResponse = {'name': frame.name, 'url': frame.url, 'content': await self._failsafe_get_content(frame)}
2144
+ else:
2145
+ to_return['content'] = await self._failsafe_get_content(frame)
2146
+ if frame.child_frames:
2147
+ all_children = await asyncio.gather(*[self.make_frame_tree(child) for child in frame.child_frames], return_exceptions=True)
2148
+ # separate exceptions vs. actual responses
2149
+ for e in [be for be in all_children if isinstance(be, BaseException)]:
2150
+ self.logger.warning(f'Exception in {f_name}: {e}')
2151
+
2152
+ to_return['children'] = [fc for fc in all_children if not isinstance(fc, BaseException)]
2153
+
2131
2154
  if not to_return.get('content'):
2132
2155
  if frame.url in ['about:blank', 'about:srcdoc', '', None, 'chrome-error://chromewebdata/']:
2133
2156
  # too noisy in the warnings
2134
2157
  self.logger.debug(f'Got no content for {frame_id}.')
2135
2158
  else:
2136
2159
  self.logger.warning(f'Got no content for {frame_id}.')
2137
- if frame.child_frames:
2138
- to_return['children'] = await asyncio.gather(*[self.make_frame_tree(child) for child in frame.child_frames])
2139
2160
  return to_return
2140
2161
 
2141
2162
  # Method copied from HAR2Tree
@@ -2253,6 +2274,12 @@ class Capture():
2253
2274
  try:
2254
2275
  self.logger.debug(f'Attempting to fetch favicon from {u}.')
2255
2276
  url_to_fetch = urljoin(rendered_url, u)
2277
+ if self.only_global_lookup:
2278
+ not_local, message = self.__check_local_url(url_to_fetch)
2279
+ if not_local is False:
2280
+ # got a local URL
2281
+ self.logger.warning(f'Attempted to get a local favicon: {message}')
2282
+ continue
2256
2283
  favicon = b''
2257
2284
  if url_to_fetch in self._requests:
2258
2285
  favicon = self._requests[url_to_fetch]
@@ -2300,7 +2327,7 @@ class Capture():
2300
2327
  # use the same technique as in lookyloo to resolve many domains in parallel
2301
2328
  semaphore = asyncio.Semaphore(30)
2302
2329
  all_requests = [resolver.resolve(hostname, semaphore) for hostname in hostnames]
2303
- await asyncio.gather(*all_requests)
2330
+ await asyncio.gather(*all_requests, return_exceptions=True)
2304
2331
  self.logger.debug('Resolved all domains through the proxy.')
2305
2332
  for entry in harfile['log']['entries']:
2306
2333
  if entry['request']['url']:
@@ -2,11 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
6
+ from functools import cache
5
7
  import sys
6
8
 
7
9
  from collections import defaultdict
8
10
 
9
- from playwright.sync_api import sync_playwright
11
+ from playwright.async_api import async_playwright
10
12
 
11
13
  from .exceptions import UnknownPlaywrightDeviceType
12
14
 
@@ -26,10 +28,15 @@ class PlaywrightDevice(TypedDict):
26
28
  default_browser_type: str
27
29
 
28
30
 
31
+ @cache
29
32
  def get_devices(in_testsuite: bool=False) -> dict[str, dict[str, dict[str, PlaywrightDevice]]]:
33
+
34
+ async def get_devices() -> dict[str, PlaywrightDevice]:
35
+ async with async_playwright() as playwright:
36
+ return playwright.devices
37
+
30
38
  to_return: dict[str, dict[str, dict[str, PlaywrightDevice]]] = {'desktop': defaultdict(dict), 'mobile': defaultdict(dict)}
31
- with sync_playwright() as playwright:
32
- devices: dict[str, PlaywrightDevice] = playwright.devices
39
+ devices: dict[str, PlaywrightDevice] = asyncio.run(get_devices())
33
40
  for device_name, settings in devices.items():
34
41
  splitted_name = device_name.split(' ')
35
42
  if splitted_name[0] == 'Desktop':
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "PlaywrightCapture"
3
- version = "1.40.4"
3
+ version = "1.40.6"
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"}
@@ -25,7 +25,7 @@ dependencies = [
25
25
  "rfc3161-client (>=1.0.4,<2.0.0)",
26
26
  "orjson (>=3.11.4,<4.0.0)",
27
27
  "pure-magic-rs (>=0.4.3)",
28
- "lookyloo-models (>=0.3.0)",
28
+ "lookyloo-models (>=0.3.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.0"
56
- types-dateparser = "^1.4.1.20260617"
56
+ types-dateparser = "^1.4.1.20260806"
57
57
  types-pytz = "^2026.3.1.20260727"
58
58
 
59
59