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.
Files changed (48) hide show
  1. {veilrender-0.3.0 → veilrender-0.4.0}/PKG-INFO +2 -3
  2. {veilrender-0.3.0 → veilrender-0.4.0}/pyproject.toml +1 -2
  3. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/__init__.py +1 -1
  4. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/httpserver.py +148 -48
  5. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/readability.py +43 -30
  6. veilrender-0.4.0/src/veilrender/_vendor/s3.py +934 -0
  7. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/app.py +17 -3
  8. veilrender-0.4.0/src/veilrender/auth.py +114 -0
  9. veilrender-0.4.0/src/veilrender/browser.py +680 -0
  10. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/config.py +42 -0
  11. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/filters.py +2 -2
  12. veilrender-0.4.0/src/veilrender/routes/dashboard.py +605 -0
  13. veilrender-0.4.0/src/veilrender/routes/metrics.py +141 -0
  14. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/routes/render.py +8 -0
  15. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/routes/screenshot.py +12 -0
  16. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/stats.py +13 -4
  17. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/storage.py +7 -12
  18. veilrender-0.4.0/src/veilrender/url_validator.py +123 -0
  19. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/PKG-INFO +2 -3
  20. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/SOURCES.txt +3 -9
  21. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/requires.txt +1 -2
  22. veilrender-0.3.0/src/veilrender/_vendor/benchmark_compare.py +0 -323
  23. veilrender-0.3.0/src/veilrender/_vendor/config.py +0 -713
  24. veilrender-0.3.0/src/veilrender/_vendor/dotenv.py +0 -514
  25. veilrender-0.3.0/src/veilrender/_vendor/jsonc.py +0 -352
  26. veilrender-0.3.0/src/veilrender/_vendor/markdown.py +0 -904
  27. veilrender-0.3.0/src/veilrender/_vendor/retry.py +0 -503
  28. veilrender-0.3.0/src/veilrender/_vendor/structlog.py +0 -888
  29. veilrender-0.3.0/src/veilrender/_vendor/useragent.py +0 -475
  30. veilrender-0.3.0/src/veilrender/_vendor/yaml.py +0 -1124
  31. veilrender-0.3.0/src/veilrender/auth.py +0 -39
  32. veilrender-0.3.0/src/veilrender/browser.py +0 -193
  33. veilrender-0.3.0/src/veilrender/routes/dashboard.py +0 -237
  34. {veilrender-0.3.0 → veilrender-0.4.0}/README.md +0 -0
  35. {veilrender-0.3.0 → veilrender-0.4.0}/setup.cfg +0 -0
  36. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/__main__.py +0 -0
  37. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/__init__.py +0 -0
  38. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/cache.py +0 -0
  39. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/_vendor/soup.py +0 -0
  40. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/cdp_proxy.py +0 -0
  41. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/data/__init__.py +0 -0
  42. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/data/blocklist.txt +0 -0
  43. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/models.py +0 -0
  44. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/routes/__init__.py +0 -0
  45. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender/routes/health.py +0 -0
  46. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/dependency_links.txt +0 -0
  47. {veilrender-0.3.0 → veilrender-0.4.0}/src/veilrender.egg-info/entry_points.txt +0 -0
  48. {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.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: cloakbrowser>=0.3.0
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"
@@ -17,8 +17,7 @@ classifiers = [
17
17
  ]
18
18
 
19
19
  dependencies = [
20
- "cloakbrowser>=0.3.0",
21
- "minio>=7.2.0",
20
+ "patchright>=1.0.0",
22
21
  ]
23
22
 
24
23
  [project.optional-dependencies]
@@ -1,3 +1,3 @@
1
1
  """VeilRender — headless browser rendering API."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -1,9 +1,9 @@
1
1
  # /// zerodep
2
- # version = "0.1.0"
2
+ # version = "0.2.1"
3
3
  # deps = []
4
4
  # tier = "subsystem"
5
5
  # category = "network"
6
- # note = "Install/update via `zerodep add httpserver`"
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
- Writes chunks using ``Transfer-Encoding: chunked`` unless
303
- ``content_type`` is ``text/event-stream`` (SSE), in which case raw
304
- bytes are flushed directly for maximum compatibility with SSE clients.
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
- if not is_sse:
334
- self.headers.setdefault("Transfer-Encoding", "chunked")
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
- if is_sse:
353
- writer.write(chunk)
354
- else:
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
- response = Response(
897
- body=str(exc),
898
- status_code=400,
899
- content_type="text/plain; charset=utf-8",
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
- response = JSONResponse({"error": exc.message}, status_code=exc.status_code)
909
- try:
910
- await response._write(writer)
911
- except (BrokenPipeError, ConnectionResetError):
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 (asyncio.TimeoutError, asyncio.IncompleteReadError):
916
- writer.close()
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
- writer.close()
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
- server = await asyncio.start_server(
971
- self._handle_connection,
972
- host,
973
- port,
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.1.0"
2
+ # version = "0.2.0"
3
3
  # deps = ["soup"]
4
4
  # tier = "medium"
5
5
  # category = "text"
6
- # note = "Install/update via `zerodep add readability`"
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)`` → ``{"tag": tag, "score": float}``.
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
- parent = tag.parent
705
- grandparent = parent.parent if parent is not None else None
706
-
707
- # Ensure parent is initialised.
708
- if parent is not None and id(parent) not in candidates:
709
- candidates[id(parent)] = {
710
- "tag": parent,
711
- "score": self._init_score(parent),
712
- }
713
- # Ensure grandparent is initialised.
714
- if grandparent is not None and id(grandparent) not in candidates:
715
- candidates[id(grandparent)] = {
716
- "tag": grandparent,
717
- "score": self._init_score(grandparent),
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 parent (full) and grandparent (half).
727
- if parent is not None and id(parent) in candidates:
728
- candidates[id(parent)]["score"] += content_score
729
- if grandparent is not None and id(grandparent) in candidates:
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.2)
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: