veilrender 0.3.0__tar.gz → 0.4.0__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.
- {veilrender-0.3.0 → veilrender-0.4.0}/PKG-INFO +2 -3
- {veilrender-0.3.0 → veilrender-0.4.0}/pyproject.toml +1 -2
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/__init__.py +1 -1
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/httpserver.py +148 -48
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/readability.py +43 -30
- veilrender-0.4.0/src/veilrender/_vendor/s3.py +934 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/app.py +17 -3
- veilrender-0.4.0/src/veilrender/auth.py +114 -0
- veilrender-0.4.0/src/veilrender/browser.py +680 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/config.py +42 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/filters.py +2 -2
- veilrender-0.4.0/src/veilrender/routes/dashboard.py +605 -0
- veilrender-0.4.0/src/veilrender/routes/metrics.py +141 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/routes/render.py +8 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/routes/screenshot.py +12 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/stats.py +13 -4
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/storage.py +7 -12
- veilrender-0.4.0/src/veilrender/url_validator.py +123 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/PKG-INFO +2 -3
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/SOURCES.txt +3 -9
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/requires.txt +1 -2
- veilrender-0.3.0/src/veilrender/_vendor/benchmark_compare.py +0 -323
- veilrender-0.3.0/src/veilrender/_vendor/config.py +0 -713
- veilrender-0.3.0/src/veilrender/_vendor/dotenv.py +0 -514
- veilrender-0.3.0/src/veilrender/_vendor/jsonc.py +0 -352
- veilrender-0.3.0/src/veilrender/_vendor/markdown.py +0 -904
- veilrender-0.3.0/src/veilrender/_vendor/retry.py +0 -503
- veilrender-0.3.0/src/veilrender/_vendor/structlog.py +0 -888
- veilrender-0.3.0/src/veilrender/_vendor/useragent.py +0 -475
- veilrender-0.3.0/src/veilrender/_vendor/yaml.py +0 -1124
- veilrender-0.3.0/src/veilrender/auth.py +0 -39
- veilrender-0.3.0/src/veilrender/browser.py +0 -193
- veilrender-0.3.0/src/veilrender/routes/dashboard.py +0 -237
- {veilrender-0.3.0 → veilrender-0.4.0}/README.md +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/setup.cfg +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/__main__.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/__init__.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/cache.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/soup.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/cdp_proxy.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/data/__init__.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/data/blocklist.txt +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/models.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/routes/__init__.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/routes/health.py +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/dependency_links.txt +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/entry_points.txt +0 -0
- {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: veilrender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal
|
|
5
5
|
Author: Peng Ding
|
|
6
6
|
License-Expression: MIT
|
|
@@ -11,8 +11,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
12
12
|
Requires-Python: >=3.10
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
|
-
Requires-Dist:
|
|
15
|
-
Requires-Dist: minio>=7.2.0
|
|
14
|
+
Requires-Dist: patchright>=1.0.0
|
|
16
15
|
Provides-Extra: dev
|
|
17
16
|
Requires-Dist: ruff==0.15.20; extra == "dev"
|
|
18
17
|
Requires-Dist: ty==0.0.54; extra == "dev"
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# /// zerodep
|
|
2
|
-
# version = "0.1
|
|
2
|
+
# version = "0.2.1"
|
|
3
3
|
# deps = []
|
|
4
4
|
# tier = "subsystem"
|
|
5
5
|
# category = "network"
|
|
6
|
-
# note = "Install/update via
|
|
6
|
+
# note = "Install/update via: https://zerodep.readthedocs.io/en/latest/guide/cli/"
|
|
7
7
|
# ///
|
|
8
8
|
|
|
9
9
|
"""Zero-dependency async HTTP server with decorator-based routing.
|
|
@@ -299,9 +299,10 @@ class JSONResponse(Response):
|
|
|
299
299
|
class StreamingResponse:
|
|
300
300
|
"""HTTP response streamed from an async generator.
|
|
301
301
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
302
|
+
All streaming responses use ``Transfer-Encoding: chunked`` for
|
|
303
|
+
maximum compatibility with reverse proxies and intermediaries.
|
|
304
|
+
SSE (``text/event-stream``) responses additionally set
|
|
305
|
+
``Cache-Control: no-cache``.
|
|
305
306
|
|
|
306
307
|
Args:
|
|
307
308
|
generator: Async iterator yielding ``bytes`` or ``str`` chunks.
|
|
@@ -330,9 +331,8 @@ class StreamingResponse:
|
|
|
330
331
|
is_sse = self.content_type.startswith("text/event-stream")
|
|
331
332
|
|
|
332
333
|
self.headers["Content-Type"] = self.content_type
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
else:
|
|
334
|
+
self.headers.setdefault("Transfer-Encoding", "chunked")
|
|
335
|
+
if is_sse:
|
|
336
336
|
self.headers.setdefault("Cache-Control", "no-cache")
|
|
337
337
|
self.headers.setdefault("Date", _http_date())
|
|
338
338
|
self.headers.setdefault("Connection", "close")
|
|
@@ -349,16 +349,12 @@ class StreamingResponse:
|
|
|
349
349
|
async for chunk in self._generator:
|
|
350
350
|
if isinstance(chunk, str):
|
|
351
351
|
chunk = chunk.encode("utf-8")
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
writer.write(f"{len(chunk):x}\r\n".encode("latin-1"))
|
|
356
|
-
writer.write(chunk)
|
|
357
|
-
writer.write(b"\r\n")
|
|
358
|
-
await writer.drain()
|
|
359
|
-
if not is_sse:
|
|
360
|
-
writer.write(b"0\r\n\r\n")
|
|
352
|
+
writer.write(f"{len(chunk):x}\r\n".encode("latin-1"))
|
|
353
|
+
writer.write(chunk)
|
|
354
|
+
writer.write(b"\r\n")
|
|
361
355
|
await writer.drain()
|
|
356
|
+
writer.write(b"0\r\n\r\n")
|
|
357
|
+
await writer.drain()
|
|
362
358
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
363
359
|
logger.debug("Client disconnected during streaming")
|
|
364
360
|
finally:
|
|
@@ -878,6 +874,22 @@ class App:
|
|
|
878
874
|
|
|
879
875
|
# ── Connection Handling ───────────────────────────────────────────────
|
|
880
876
|
|
|
877
|
+
@staticmethod
|
|
878
|
+
async def _send_error_and_close(
|
|
879
|
+
writer: asyncio.StreamWriter,
|
|
880
|
+
response: Response | JSONResponse,
|
|
881
|
+
) -> None:
|
|
882
|
+
"""Write an error response to the client and close the connection.
|
|
883
|
+
|
|
884
|
+
Swallows broken-pipe / reset errors that occur when the client has
|
|
885
|
+
already disconnected.
|
|
886
|
+
"""
|
|
887
|
+
try:
|
|
888
|
+
await response._write(writer)
|
|
889
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
890
|
+
pass
|
|
891
|
+
writer.close()
|
|
892
|
+
|
|
881
893
|
async def _handle_connection(
|
|
882
894
|
self,
|
|
883
895
|
reader: asyncio.StreamReader,
|
|
@@ -893,30 +905,43 @@ class App:
|
|
|
893
905
|
)
|
|
894
906
|
except _BadRequest as exc:
|
|
895
907
|
logger.debug("Bad request from %s: %s", client_addr, exc)
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
908
|
+
await self._send_error_and_close(
|
|
909
|
+
writer,
|
|
910
|
+
Response(
|
|
911
|
+
body=str(exc),
|
|
912
|
+
status_code=400,
|
|
913
|
+
content_type="text/plain; charset=utf-8",
|
|
914
|
+
),
|
|
900
915
|
)
|
|
901
|
-
try:
|
|
902
|
-
await response._write(writer)
|
|
903
|
-
except (BrokenPipeError, ConnectionResetError):
|
|
904
|
-
pass
|
|
905
|
-
writer.close()
|
|
906
916
|
return
|
|
907
917
|
except HTTPException as exc:
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
pass
|
|
913
|
-
writer.close()
|
|
918
|
+
await self._send_error_and_close(
|
|
919
|
+
writer,
|
|
920
|
+
JSONResponse({"error": exc.message}, status_code=exc.status_code),
|
|
921
|
+
)
|
|
914
922
|
return
|
|
915
|
-
except
|
|
916
|
-
|
|
923
|
+
except asyncio.TimeoutError:
|
|
924
|
+
logger.debug("Request read timed out from %s", client_addr)
|
|
925
|
+
await self._send_error_and_close(
|
|
926
|
+
writer,
|
|
927
|
+
JSONResponse({"error": "Request Timeout"}, status_code=408),
|
|
928
|
+
)
|
|
929
|
+
return
|
|
930
|
+
except asyncio.IncompleteReadError:
|
|
931
|
+
logger.debug("Incomplete request body from %s", client_addr)
|
|
932
|
+
await self._send_error_and_close(
|
|
933
|
+
writer,
|
|
934
|
+
JSONResponse(
|
|
935
|
+
{"error": "Bad Request: incomplete body"}, status_code=400
|
|
936
|
+
),
|
|
937
|
+
)
|
|
917
938
|
return
|
|
918
939
|
except Exception:
|
|
919
|
-
|
|
940
|
+
logger.debug("Failed to read request from %s", client_addr, exc_info=True)
|
|
941
|
+
await self._send_error_and_close(
|
|
942
|
+
writer,
|
|
943
|
+
JSONResponse({"error": "Internal Server Error"}, status_code=500),
|
|
944
|
+
)
|
|
920
945
|
return
|
|
921
946
|
|
|
922
947
|
request = Request(
|
|
@@ -951,33 +976,44 @@ class App:
|
|
|
951
976
|
self,
|
|
952
977
|
host: str = DEFAULT_HOST,
|
|
953
978
|
port: int = DEFAULT_PORT,
|
|
979
|
+
*,
|
|
980
|
+
socket: str | None = None,
|
|
954
981
|
) -> None:
|
|
955
982
|
"""Start the server (blocking).
|
|
956
983
|
|
|
957
984
|
Args:
|
|
958
|
-
host: Bind address.
|
|
959
|
-
port: Bind port. Use ``0`` for OS-assigned port
|
|
985
|
+
host: Bind address (ignored when *socket* is set).
|
|
986
|
+
port: Bind port. Use ``0`` for OS-assigned port (ignored when
|
|
987
|
+
*socket* is set).
|
|
988
|
+
socket: Unix domain socket path. When set, the server listens
|
|
989
|
+
on a Unix socket instead of TCP. The socket file permissions
|
|
990
|
+
are restricted to owner-only (``0o600``) after creation.
|
|
991
|
+
Only available on Unix-like systems.
|
|
960
992
|
"""
|
|
961
993
|
try:
|
|
962
|
-
asyncio.run(self._serve(host, port))
|
|
994
|
+
asyncio.run(self._serve(host, port, socket=socket))
|
|
963
995
|
except KeyboardInterrupt:
|
|
964
996
|
pass
|
|
965
997
|
|
|
966
|
-
async def _serve(self, host: str, port: int) -> None:
|
|
998
|
+
async def _serve(self, host: str, port: int, *, socket: str | None = None) -> None:
|
|
967
999
|
"""Internal async server loop."""
|
|
968
1000
|
self._shutdown_event = asyncio.Event()
|
|
1001
|
+
self._socket_path: str | None = None
|
|
969
1002
|
|
|
970
|
-
|
|
971
|
-
self.
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1003
|
+
if socket:
|
|
1004
|
+
server = await self._start_unix_socket(socket)
|
|
1005
|
+
else:
|
|
1006
|
+
server = await asyncio.start_server(
|
|
1007
|
+
self._handle_connection,
|
|
1008
|
+
host,
|
|
1009
|
+
port,
|
|
1010
|
+
)
|
|
1011
|
+
addrs = server.sockets[0].getsockname() if server.sockets else (host, port)
|
|
1012
|
+
self.host = addrs[0]
|
|
1013
|
+
self.port = addrs[1]
|
|
1014
|
+
logger.info("Serving on %s:%d", self.host, self.port)
|
|
975
1015
|
|
|
976
1016
|
self._server = server
|
|
977
|
-
addrs = server.sockets[0].getsockname() if server.sockets else (host, port)
|
|
978
|
-
self.host = addrs[0]
|
|
979
|
-
self.port = addrs[1]
|
|
980
|
-
logger.info("Serving on %s:%d", self.host, self.port)
|
|
981
1017
|
|
|
982
1018
|
loop = asyncio.get_running_loop()
|
|
983
1019
|
if sys.platform != "win32":
|
|
@@ -987,6 +1023,70 @@ class App:
|
|
|
987
1023
|
async with server:
|
|
988
1024
|
await self._shutdown_event.wait()
|
|
989
1025
|
logger.info("Shutting down server")
|
|
1026
|
+
if self._socket_path:
|
|
1027
|
+
self._cleanup_socket()
|
|
1028
|
+
|
|
1029
|
+
async def _start_unix_socket(self, socket_path: str) -> asyncio.Server:
|
|
1030
|
+
"""Start listening on a Unix domain socket.
|
|
1031
|
+
|
|
1032
|
+
Handles stale socket cleanup, permission hardening (``0o600``),
|
|
1033
|
+
and registers the path for shutdown cleanup.
|
|
1034
|
+
|
|
1035
|
+
Args:
|
|
1036
|
+
socket_path: Path for the Unix domain socket file.
|
|
1037
|
+
|
|
1038
|
+
Returns:
|
|
1039
|
+
The ``asyncio.Server`` instance.
|
|
1040
|
+
|
|
1041
|
+
Raises:
|
|
1042
|
+
SystemExit: If the path exists but is not a socket, or the
|
|
1043
|
+
parent directory does not exist.
|
|
1044
|
+
"""
|
|
1045
|
+
import stat as stat_mod
|
|
1046
|
+
|
|
1047
|
+
path = os.path.realpath(socket_path)
|
|
1048
|
+
|
|
1049
|
+
# Remove stale socket if present
|
|
1050
|
+
if os.path.exists(path):
|
|
1051
|
+
try:
|
|
1052
|
+
st = os.stat(path)
|
|
1053
|
+
if stat_mod.S_ISSOCK(st.st_mode):
|
|
1054
|
+
os.unlink(path)
|
|
1055
|
+
logger.info("Removed stale socket: %s", path)
|
|
1056
|
+
else:
|
|
1057
|
+
logger.error("Socket path exists and is not a socket: %s", path)
|
|
1058
|
+
sys.exit(1)
|
|
1059
|
+
except OSError as exc:
|
|
1060
|
+
logger.error("Cannot remove stale socket %s: %s", path, exc)
|
|
1061
|
+
sys.exit(1)
|
|
1062
|
+
|
|
1063
|
+
# Ensure parent directory exists
|
|
1064
|
+
parent = os.path.dirname(path)
|
|
1065
|
+
if not os.path.isdir(parent):
|
|
1066
|
+
logger.error("Socket parent directory does not exist: %s", parent)
|
|
1067
|
+
sys.exit(1)
|
|
1068
|
+
|
|
1069
|
+
server = await asyncio.start_unix_server(
|
|
1070
|
+
self._handle_connection,
|
|
1071
|
+
path=path,
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
# Restrict permissions to owner-only
|
|
1075
|
+
if os.path.exists(path):
|
|
1076
|
+
os.chmod(path, 0o600)
|
|
1077
|
+
|
|
1078
|
+
self._socket_path = path
|
|
1079
|
+
logger.info("Serving on unix:%s (mode 0600)", path)
|
|
1080
|
+
return server
|
|
1081
|
+
|
|
1082
|
+
def _cleanup_socket(self) -> None:
|
|
1083
|
+
"""Remove the Unix socket file on shutdown."""
|
|
1084
|
+
if self._socket_path and os.path.exists(self._socket_path):
|
|
1085
|
+
try:
|
|
1086
|
+
os.unlink(self._socket_path)
|
|
1087
|
+
logger.info("Removed socket: %s", self._socket_path)
|
|
1088
|
+
except OSError:
|
|
1089
|
+
pass
|
|
990
1090
|
|
|
991
1091
|
def shutdown(self) -> None:
|
|
992
1092
|
"""Request a graceful server shutdown.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# /// zerodep
|
|
2
|
-
# version = "0.
|
|
2
|
+
# version = "0.2.0"
|
|
3
3
|
# deps = ["soup"]
|
|
4
4
|
# tier = "medium"
|
|
5
5
|
# category = "text"
|
|
6
|
-
# note = "Install/update via
|
|
6
|
+
# note = "Install/update via: https://zerodep.readthedocs.io/en/latest/guide/cli/"
|
|
7
7
|
# ///
|
|
8
8
|
|
|
9
9
|
"""HTML readability content extractor — zero-dep, stdlib only, Python 3.10+.
|
|
@@ -231,6 +231,10 @@ class ReadabilityResult:
|
|
|
231
231
|
lang: Language code from ``<html lang="...">``, or ``None``.
|
|
232
232
|
dir: Text direction (``"ltr"`` / ``"rtl"``), or ``None``.
|
|
233
233
|
length: Character count of *text*.
|
|
234
|
+
score: Readability score of the best candidate container. Higher
|
|
235
|
+
values indicate stronger confidence that the extracted content
|
|
236
|
+
is a real article rather than navigation / boilerplate. Zero
|
|
237
|
+
when no scored candidate was found (body fallback).
|
|
234
238
|
"""
|
|
235
239
|
|
|
236
240
|
title: str
|
|
@@ -243,6 +247,7 @@ class ReadabilityResult:
|
|
|
243
247
|
lang: str | None = None
|
|
244
248
|
dir: str | None = None
|
|
245
249
|
length: int = 0
|
|
250
|
+
score: float = 0.0
|
|
246
251
|
|
|
247
252
|
|
|
248
253
|
# ── Public API ───────────────────────────────────────────────────────────────
|
|
@@ -360,7 +365,7 @@ class _Readability:
|
|
|
360
365
|
# Grab article content. The first iteration reuses self._soup
|
|
361
366
|
# (removing non-content tags in-place); retries use a faster
|
|
362
367
|
# parse that skips those tags during tree construction.
|
|
363
|
-
article_html, article_text = self._grab_article()
|
|
368
|
+
article_html, article_text, article_score = self._grab_article()
|
|
364
369
|
|
|
365
370
|
# If metadata title is empty, try to derive from article headings.
|
|
366
371
|
title = metadata.get("title", "")
|
|
@@ -379,17 +384,19 @@ class _Readability:
|
|
|
379
384
|
lang=lang,
|
|
380
385
|
dir=direction,
|
|
381
386
|
length=len(text),
|
|
387
|
+
score=article_score,
|
|
382
388
|
)
|
|
383
389
|
|
|
384
390
|
# ── Article grabbing (with retry) ────────────────────────────────────
|
|
385
391
|
|
|
386
392
|
_PRE_CLEAN_TAGS = ["script", "style", "link", "noscript"]
|
|
387
393
|
|
|
388
|
-
def _grab_article(self) -> tuple[str, str]:
|
|
394
|
+
def _grab_article(self) -> tuple[str, str, float]:
|
|
389
395
|
"""Extract article content, retrying with relaxed rules if needed.
|
|
390
396
|
|
|
391
397
|
Returns:
|
|
392
|
-
``(article_html, article_text)`` tuple.
|
|
398
|
+
``(article_html, article_text, score)`` tuple. *score* is the
|
|
399
|
+
readability score of the best candidate (0.0 for body fallback).
|
|
393
400
|
"""
|
|
394
401
|
ruthless = True
|
|
395
402
|
|
|
@@ -417,7 +424,7 @@ class _Readability:
|
|
|
417
424
|
article_text = article_tag.get_text(separator=" ", strip=True)
|
|
418
425
|
|
|
419
426
|
if len(article_text) >= RETRY_LENGTH or not ruthless:
|
|
420
|
-
return article_html, article_text
|
|
427
|
+
return article_html, article_text, best["score"]
|
|
421
428
|
|
|
422
429
|
# Too short — retry without ruthless filtering.
|
|
423
430
|
log.debug(
|
|
@@ -438,8 +445,8 @@ class _Readability:
|
|
|
438
445
|
# Final fallback: return body content as-is.
|
|
439
446
|
body = self._soup.find("body")
|
|
440
447
|
if body is not None:
|
|
441
|
-
return body.to_html(), body.get_text(separator=" ", strip=True)
|
|
442
|
-
return "", ""
|
|
448
|
+
return body.to_html(), body.get_text(separator=" ", strip=True), 0.0
|
|
449
|
+
return "", "", 0.0
|
|
443
450
|
|
|
444
451
|
# ── Pre-cleaning ─────────────────────────────────────────────────────
|
|
445
452
|
|
|
@@ -688,11 +695,17 @@ class _Readability:
|
|
|
688
695
|
|
|
689
696
|
# ── Scoring ──────────────────────────────────────────────────────────
|
|
690
697
|
|
|
698
|
+
# Maximum ancestor levels for score propagation. The original
|
|
699
|
+
# algorithm only propagated to parent (1x) and grandparent (0.5x).
|
|
700
|
+
# Modern SPA-rendered pages nest content 3-5 levels deep in wrapper
|
|
701
|
+
# divs, so we extend propagation with diminishing weights.
|
|
702
|
+
_ANCESTOR_WEIGHTS = [1.0, 0.5, 0.333, 0.25]
|
|
703
|
+
|
|
691
704
|
def _score_paragraphs(self) -> dict[int, dict[str, Any]]:
|
|
692
705
|
"""Score paragraph-like nodes and propagate to ancestors.
|
|
693
706
|
|
|
694
707
|
Returns:
|
|
695
|
-
Dict mapping ``id(tag)``
|
|
708
|
+
Dict mapping ``id(tag)`` -> ``{"tag": tag, "score": float}``.
|
|
696
709
|
"""
|
|
697
710
|
candidates: dict[int, dict[str, Any]] = {}
|
|
698
711
|
|
|
@@ -701,21 +714,22 @@ class _Readability:
|
|
|
701
714
|
if len(inner_text) < MIN_PARAGRAPH_LENGTH:
|
|
702
715
|
continue
|
|
703
716
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
# Ensure
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
717
|
+
# Collect ancestors up to the propagation depth.
|
|
718
|
+
ancestors: list[Any] = []
|
|
719
|
+
cur = tag.parent
|
|
720
|
+
for _ in range(len(self._ANCESTOR_WEIGHTS)):
|
|
721
|
+
if cur is None or cur.name in ("html", "body", "[document]"):
|
|
722
|
+
break
|
|
723
|
+
ancestors.append(cur)
|
|
724
|
+
cur = cur.parent
|
|
725
|
+
|
|
726
|
+
# Ensure each ancestor is initialised in the candidate map.
|
|
727
|
+
for anc in ancestors:
|
|
728
|
+
if id(anc) not in candidates:
|
|
729
|
+
candidates[id(anc)] = {
|
|
730
|
+
"tag": anc,
|
|
731
|
+
"score": self._init_score(anc),
|
|
732
|
+
}
|
|
719
733
|
|
|
720
734
|
# Content score for this paragraph.
|
|
721
735
|
inner_len = len(inner_text)
|
|
@@ -723,11 +737,10 @@ class _Readability:
|
|
|
723
737
|
content_score += len(COMMAS_RE.findall(inner_text))
|
|
724
738
|
content_score += min(inner_len / 100.0, 3.0)
|
|
725
739
|
|
|
726
|
-
# Propagate to
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
candidates[id(grandparent)]["score"] += content_score / 2.0
|
|
740
|
+
# Propagate to ancestors with diminishing weights.
|
|
741
|
+
for i, anc in enumerate(ancestors):
|
|
742
|
+
weight = self._ANCESTOR_WEIGHTS[i]
|
|
743
|
+
candidates[id(anc)]["score"] += content_score * weight
|
|
731
744
|
|
|
732
745
|
# Scale scores by link density.
|
|
733
746
|
for entry in candidates.values():
|
|
@@ -797,7 +810,7 @@ class _Readability:
|
|
|
797
810
|
|
|
798
811
|
# Create an article wrapper.
|
|
799
812
|
article = self._Tag("div")
|
|
800
|
-
sibling_threshold = max(10.0, best["score"] * 0.
|
|
813
|
+
sibling_threshold = max(10.0, best["score"] * 0.1)
|
|
801
814
|
|
|
802
815
|
# If there's no parent, use the candidate itself.
|
|
803
816
|
if parent is None:
|