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.
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/PKG-INFO +2 -2
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/playwrightcapture/capture.py +85 -58
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/playwrightcapture/helpers.py +10 -3
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/pyproject.toml +3 -3
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/LICENSE +0 -0
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/README.md +0 -0
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/playwrightcapture/__init__.py +0 -0
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/playwrightcapture/exceptions.py +0 -0
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/playwrightcapture/py.typed +0 -0
- {playwrightcapture-1.40.4 → playwrightcapture-1.40.6}/playwrightcapture/socks5dnslookup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PlaywrightCapture
|
|
3
|
-
Version: 1.40.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1372
|
-
#
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
+
types-dateparser = "^1.4.1.20260806"
|
|
57
57
|
types-pytz = "^2026.3.1.20260727"
|
|
58
58
|
|
|
59
59
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|