ranobelib-python-sdk 0.4.0__tar.gz → 0.5.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 (21) hide show
  1. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/models.py +89 -3
  5. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/sdk.py +13 -2
  6. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/README.md +0 -0
  7. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/__init__.py +0 -0
  8. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/cache.py +0 -0
  9. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/client.py +0 -0
  10. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/console.py +0 -0
  11. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exceptions.py +0 -0
  12. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/__init__.py +0 -0
  13. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  14. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/_shared.py +0 -0
  15. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/epub.py +0 -0
  16. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/fb2.py +0 -0
  17. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/pdf.py +0 -0
  18. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/txt.py +0 -0
  19. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/numbering.py +0 -0
  20. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/py.typed +0 -0
  21. {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/sizing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ranobelib-python-sdk
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf
5
5
  Keywords: ranobelib,ranobe,novel,webnovel,epub,fb2,sdk
6
6
  Author: LLIJIYAPNIK
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import html
6
6
  from datetime import datetime
7
+ from html.parser import HTMLParser
7
8
  from typing import Any
9
+ from urllib.parse import urlsplit
8
10
 
9
11
  from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
10
12
 
@@ -92,6 +94,86 @@ def _prosemirror_to_html(doc: dict[str, Any], attachments: list[dict[str, Any]])
92
94
  return "".join(_render_prosemirror_block(block, attachments) for block in _child_nodes(doc))
93
95
 
94
96
 
97
+ _ALLOWED_CONTENT_TAGS = frozenset({"p", "img", "strong", "em", "br", "hr"})
98
+ _CONTENT_TAG_ALIASES = {"b": "strong", "i": "em"}
99
+ _VOID_CONTENT_TAGS = frozenset({"br", "hr", "img"})
100
+ _DROPPED_CONTENT_TEXT_TAGS = frozenset({"script", "style"})
101
+ _SAFE_IMG_SCHEMES = frozenset({"http", "https"})
102
+
103
+
104
+ class _ContentSanitizer(HTMLParser):
105
+ """Rebuilds an HTML-string-format chapter body using only the same restricted tag
106
+ vocabulary ``_prosemirror_to_html`` already produces for the other content format
107
+ (``p``/``img``/``strong``/``em``/``br``/``hr``) — see ``_normalize_content``.
108
+
109
+ The HTML-string format passes the site's own markup straight from its editor (see
110
+ docs/api-notes.md), so unlike the prosemirror path it isn't SDK-generated and can't be
111
+ trusted as-is: any tag/attribute outside that vocabulary is dropped rather than kept,
112
+ including all attributes on the tags that are kept (``img`` is the one exception, and
113
+ only its ``src`` survives, and only when it resolves to an http(s) URL). ``b``/``i`` are
114
+ folded into ``strong``/``em`` (observed as interchangeable in samples, see
115
+ docs/api-notes.md) rather than dropped, since they're semantically equivalent and not a
116
+ safety concern. Text inside a dropped ``script``/``style`` tag is discarded rather than
117
+ kept as escaped text — anywhere else, a dropped tag's text content is kept (e.g. a
118
+ stripped ``<a href="...">text</a>`` still leaves ``text`` behind).
119
+ """
120
+
121
+ def __init__(self) -> None:
122
+ super().__init__(convert_charrefs=True)
123
+ self._output: list[str] = []
124
+ self._skip_text_depth = 0
125
+
126
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
127
+ self._emit(tag, attrs, self_closing=False)
128
+
129
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
130
+ self._emit(tag, attrs, self_closing=True)
131
+
132
+ def _emit(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
133
+ resolved = _CONTENT_TAG_ALIASES.get(tag, tag)
134
+ if resolved not in _ALLOWED_CONTENT_TAGS:
135
+ if tag in _DROPPED_CONTENT_TEXT_TAGS:
136
+ self._skip_text_depth += 1
137
+ return
138
+ if resolved == "img":
139
+ src = dict(attrs).get("src")
140
+ if src and urlsplit(src).scheme in _SAFE_IMG_SCHEMES:
141
+ self._output.append(f'<img loading="lazy" src="{html.escape(src, quote=True)}" />')
142
+ return
143
+ if resolved in _VOID_CONTENT_TAGS:
144
+ self._output.append(f"<{resolved} />")
145
+ else:
146
+ self._output.append(f"<{resolved}>")
147
+
148
+ def handle_endtag(self, tag: str) -> None:
149
+ resolved = _CONTENT_TAG_ALIASES.get(tag, tag)
150
+ if resolved not in _ALLOWED_CONTENT_TAGS:
151
+ if tag in _DROPPED_CONTENT_TEXT_TAGS and self._skip_text_depth > 0:
152
+ self._skip_text_depth -= 1
153
+ return
154
+ if resolved not in _VOID_CONTENT_TAGS:
155
+ self._output.append(f"</{resolved}>")
156
+
157
+ def handle_data(self, data: str) -> None:
158
+ if self._skip_text_depth == 0:
159
+ self._output.append(html.escape(data))
160
+
161
+ def get_html(self) -> str:
162
+ return "".join(self._output)
163
+
164
+
165
+ def _sanitize_content_html(content: str) -> str:
166
+ """Sanitize an HTML-string-format chapter body down to the restricted tag vocabulary.
167
+
168
+ Counterpart to ``_prosemirror_to_html`` for the other content format the API returns
169
+ (see docs/api-notes.md) — without this, ``Chapter.content`` would only actually be safe
170
+ to render as raw HTML for prosemirror-sourced chapters, not HTML-string-sourced ones.
171
+ """
172
+ sanitizer = _ContentSanitizer()
173
+ sanitizer.feed(content)
174
+ return sanitizer.get_html()
175
+
176
+
95
177
  class Cover(BaseModel):
96
178
  """A set of cover image URLs at different sizes."""
97
179
 
@@ -232,9 +314,13 @@ class Chapter(BaseModel):
232
314
  @model_validator(mode="before")
233
315
  @classmethod
234
316
  def _normalize_content(cls, data: Any) -> Any:
235
- if isinstance(data, dict) and isinstance(data.get("content"), dict):
236
- attachments = data.get("attachments") or []
237
- data = {**data, "content": _prosemirror_to_html(data["content"], attachments)}
317
+ if isinstance(data, dict):
318
+ content = data.get("content")
319
+ if isinstance(content, dict):
320
+ attachments = data.get("attachments") or []
321
+ data = {**data, "content": _prosemirror_to_html(content, attachments)}
322
+ elif isinstance(content, str):
323
+ data = {**data, "content": _sanitize_content_html(content)}
238
324
  return data
239
325
 
240
326
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ from collections.abc import Callable
6
7
  from pathlib import Path
7
8
  from types import TracebackType
8
9
  from typing import Any, Self
@@ -233,6 +234,7 @@ class RanobeLib:
233
234
  branch_id: int | None = None,
234
235
  translation_index: int | None = None,
235
236
  chapter_delay: float = 0.0,
237
+ on_chapter: Callable[[int, int], None] | None = None,
236
238
  ) -> list[Volume]:
237
239
  """Download every chapter of the title, across all its volumes.
238
240
 
@@ -258,6 +260,12 @@ class RanobeLib:
258
260
  chapter_delay: Extra delay, in seconds, after fetching each chapter, on top of
259
261
  ``ApiClient``'s own per-request pacing — to be gentler on the API during a
260
262
  large bulk download. Defaults to no extra delay.
263
+ on_chapter: Called with ``(completed, total)`` after each chapter is fetched, if
264
+ given — a programmatic progress hook for callers that aren't printing to a
265
+ console (e.g. a web app polling/streaming download progress to a browser),
266
+ who would otherwise have no way to observe anything before this whole
267
+ ``await`` returns. Independent of ``verbosity``: both can be set at once,
268
+ each drives its own output.
261
269
 
262
270
  Raises:
263
271
  ValueError: If both ``branch_id`` and ``translation_index`` are given.
@@ -297,11 +305,14 @@ class RanobeLib:
297
305
  raise MultipleTitleTranslationsError(self._slug_url, chapters=unresolved)
298
306
 
299
307
  chapters = []
300
- with self._reporter.progress(f"Downloading {self._slug_url}", len(planned)) as advance:
308
+ total = len(planned)
309
+ with self._reporter.progress(f"Downloading {self._slug_url}", total) as advance:
301
310
  for index, (volume_str, number, selected_branch_id) in enumerate(planned):
302
311
  chapters.append(await self._fetch_chapter(volume_str, number, selected_branch_id))
303
312
  advance()
304
- if chapter_delay and index < len(planned) - 1:
313
+ if on_chapter is not None:
314
+ on_chapter(index + 1, total)
315
+ if chapter_delay and index < total - 1:
305
316
  await asyncio.sleep(chapter_delay)
306
317
  return _group_into_volumes(chapters)
307
318