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.
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/models.py +89 -3
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/sdk.py +13 -2
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/README.md +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/__init__.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/client.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exceptions.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/__init__.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/epub.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/fb2.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/pdf.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/txt.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/py.typed +0 -0
- {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.
|
|
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
|
|
@@ -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)
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/_illustrations.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.4.0 → ranobelib_python_sdk-0.5.0}/src/ranobelib/exporters/_shared.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|