ranobelib-python-sdk 0.8.0__tar.gz → 0.10.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.8.0 → ranobelib_python_sdk-0.10.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/__init__.py +11 -1
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/catalog.py +15 -11
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/client.py +8 -6
- ranobelib_python_sdk-0.10.0/src/ranobelib/exporters/_shared.py +29 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exporters/epub.py +5 -3
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exporters/fb2.py +2 -2
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exporters/pdf.py +3 -3
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exporters/txt.py +2 -2
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/models.py +247 -21
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/sizing.py +9 -6
- ranobelib_python_sdk-0.8.0/src/ranobelib/exporters/_shared.py +0 -11
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/README.md +0 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exceptions.py +0 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exporters/__init__.py +0 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/py.typed +0 -0
- {ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/sdk.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.10.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
|
|
@@ -13,7 +13,16 @@ from ranobelib.exceptions import (
|
|
|
13
13
|
TitleNotFoundError,
|
|
14
14
|
VolumeNotFoundError,
|
|
15
15
|
)
|
|
16
|
-
from ranobelib.models import
|
|
16
|
+
from ranobelib.models import (
|
|
17
|
+
CatalogPage,
|
|
18
|
+
Chapter,
|
|
19
|
+
ChapterBranch,
|
|
20
|
+
Country,
|
|
21
|
+
Footnote,
|
|
22
|
+
Genre,
|
|
23
|
+
Title,
|
|
24
|
+
Volume,
|
|
25
|
+
)
|
|
17
26
|
from ranobelib.sdk import RanobeLib
|
|
18
27
|
from ranobelib.sizing import chapter_size, volume_size
|
|
19
28
|
|
|
@@ -27,6 +36,7 @@ __all__ = [
|
|
|
27
36
|
"ChapterNotFoundError",
|
|
28
37
|
"Country",
|
|
29
38
|
"DownloadTitleInterruptedError",
|
|
39
|
+
"Footnote",
|
|
30
40
|
"Genre",
|
|
31
41
|
"MultipleTitleTranslationsError",
|
|
32
42
|
"MultipleTranslationsError",
|
|
@@ -81,7 +81,7 @@ class Catalog:
|
|
|
81
81
|
genres: list[int] | None = None,
|
|
82
82
|
tags: list[int] | None = None,
|
|
83
83
|
status: int | None = None,
|
|
84
|
-
|
|
84
|
+
countries: list[int] | None = None,
|
|
85
85
|
sort: str = DEFAULT_SORT,
|
|
86
86
|
refresh: bool = False,
|
|
87
87
|
) -> CatalogPage:
|
|
@@ -104,11 +104,14 @@ class Catalog:
|
|
|
104
104
|
from tags being more numerous/specific than genres). Also not validated
|
|
105
105
|
before sending, same as ``genres``.
|
|
106
106
|
status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
108
|
+
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
109
|
+
country of origin, so requiring all of them could never match past the first,
|
|
110
|
+
see docs/api-notes.md). ``None`` or an empty list applies no country filter.
|
|
111
|
+
Ids come from ``list_countries()``. Sent on the wire as the API's ``types[]``
|
|
112
|
+
parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
|
|
113
|
+
why (``Country`` mirrors the API's own "type" concept, which isn't strictly
|
|
114
|
+
limited to literal countries).
|
|
112
115
|
sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
|
|
113
116
|
parameter — an actual ``sort`` parameter exists but is silently ignored by
|
|
114
117
|
the API (see docs/api-notes.md). Known accepted values: ``"name"``,
|
|
@@ -140,7 +143,7 @@ class Catalog:
|
|
|
140
143
|
genres=genres,
|
|
141
144
|
tags=tags,
|
|
142
145
|
status=status,
|
|
143
|
-
|
|
146
|
+
countries=countries,
|
|
144
147
|
sort=sort,
|
|
145
148
|
)
|
|
146
149
|
if not refresh:
|
|
@@ -155,7 +158,7 @@ class Catalog:
|
|
|
155
158
|
genres=genres,
|
|
156
159
|
tags=tags,
|
|
157
160
|
status=status,
|
|
158
|
-
|
|
161
|
+
countries=countries,
|
|
159
162
|
sort=sort,
|
|
160
163
|
)
|
|
161
164
|
self._cache.set(key, data)
|
|
@@ -191,7 +194,7 @@ class Catalog:
|
|
|
191
194
|
|
|
192
195
|
async def list_countries(self, *, refresh: bool = False) -> list[Country]:
|
|
193
196
|
"""Fetch the full list of catalog countries (id → name), for use as filter options
|
|
194
|
-
with ``list_titles(
|
|
197
|
+
with ``list_titles(countries=...)``.
|
|
195
198
|
|
|
196
199
|
Mirrors ``list_genres()`` exactly, down to the network-wide, not-site-scoped shape
|
|
197
200
|
of the underlying endpoint (``GET /api/constants?fields[]=types`` — the API's own
|
|
@@ -225,14 +228,15 @@ def _cache_key(
|
|
|
225
228
|
genres: list[int] | None,
|
|
226
229
|
tags: list[int] | None,
|
|
227
230
|
status: int | None,
|
|
228
|
-
|
|
231
|
+
countries: list[int] | None,
|
|
229
232
|
sort: str,
|
|
230
233
|
) -> str:
|
|
231
234
|
genres_part = ",".join(str(genre_id) for genre_id in genres or [])
|
|
232
235
|
tags_part = ",".join(str(tag_id) for tag_id in tags or [])
|
|
236
|
+
countries_part = ",".join(str(country_id) for country_id in countries or [])
|
|
233
237
|
return (
|
|
234
238
|
f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
|
|
235
|
-
f"{status}:{
|
|
239
|
+
f"{status}:{countries_part}:{sort}"
|
|
236
240
|
)
|
|
237
241
|
|
|
238
242
|
|
|
@@ -172,7 +172,7 @@ class ApiClient:
|
|
|
172
172
|
genres: list[int] | None,
|
|
173
173
|
tags: list[int] | None,
|
|
174
174
|
status: int | None,
|
|
175
|
-
|
|
175
|
+
countries: list[int] | None,
|
|
176
176
|
sort: str,
|
|
177
177
|
) -> dict[str, Any]:
|
|
178
178
|
"""Fetch one page of the catalog listing/search results.
|
|
@@ -192,9 +192,11 @@ class ApiClient:
|
|
|
192
192
|
tags: Tag ids to filter by. Same AND semantics as ``genres`` — a title must have
|
|
193
193
|
*all* of them, not just any one (see docs/api-notes.md).
|
|
194
194
|
status: A single ``Title.status.id`` to filter by.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
196
|
+
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
197
|
+
country, confirmed against the live API, see docs/api-notes.md). Sent on the
|
|
198
|
+
wire as repeated ``types[]`` parameters — the API's own name for this concept
|
|
199
|
+
is "type", not "country" (see docs/api-notes.md).
|
|
198
200
|
sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
|
|
199
201
|
silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
|
|
200
202
|
mismatch between the SDK's public keyword and the wire parameter name is
|
|
@@ -217,8 +219,8 @@ class ApiClient:
|
|
|
217
219
|
params.append(("tags[]", str(tag_id)))
|
|
218
220
|
if status is not None:
|
|
219
221
|
params.append(("status[]", str(status)))
|
|
220
|
-
|
|
221
|
-
params.append(("types[]", str(
|
|
222
|
+
for country_id in countries or []:
|
|
223
|
+
params.append(("types[]", str(country_id)))
|
|
222
224
|
|
|
223
225
|
response = await self._get("/manga", params=httpx.QueryParams(params))
|
|
224
226
|
self._raise_for_status(
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Helpers shared between exporter implementations (not part of the public Exporter protocol)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ranobelib.models import Chapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def chapter_heading(chapter: Chapter) -> str:
|
|
9
|
+
"""A human-readable "Volume X, Chapter Y[: Name]" heading for ``chapter``."""
|
|
10
|
+
heading = f"Volume {chapter.volume}, Chapter {chapter.number}"
|
|
11
|
+
return f"{heading}: {chapter.name}" if chapter.name else heading
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
FOOTNOTES_HEADING = "Notes"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def chapter_body_html(chapter: Chapter) -> str:
|
|
18
|
+
"""``chapter.content`` with its footnotes appended as a trailing "Notes" block.
|
|
19
|
+
|
|
20
|
+
Footnotes aren't part of ``Chapter.content`` (see ``Chapter.footnotes``), so exporters
|
|
21
|
+
render a chapter's body through this rather than reading ``content`` directly — otherwise
|
|
22
|
+
an export would silently drop them. There's no in-text link to point them back to (see
|
|
23
|
+
``Footnote``), so they're listed after the chapter, in source order.
|
|
24
|
+
"""
|
|
25
|
+
body = chapter.content or ""
|
|
26
|
+
if not chapter.footnotes:
|
|
27
|
+
return body
|
|
28
|
+
notes = "".join(f"<p>{footnote.content}</p>" for footnote in chapter.footnotes)
|
|
29
|
+
return f"{body}<hr /><p><strong>{FOOTNOTES_HEADING}</strong></p>{notes}"
|
|
@@ -19,7 +19,7 @@ from ranobelib.exporters._illustrations import (
|
|
|
19
19
|
pick_cover_url,
|
|
20
20
|
rewrite_image_srcs,
|
|
21
21
|
)
|
|
22
|
-
from ranobelib.exporters._shared import chapter_heading
|
|
22
|
+
from ranobelib.exporters._shared import chapter_body_html, chapter_heading
|
|
23
23
|
from ranobelib.models import Chapter, Title
|
|
24
24
|
|
|
25
25
|
|
|
@@ -83,7 +83,9 @@ class EpubExporter:
|
|
|
83
83
|
|
|
84
84
|
image_urls = list(
|
|
85
85
|
dict.fromkeys(
|
|
86
|
-
url
|
|
86
|
+
url
|
|
87
|
+
for chapter in chapters
|
|
88
|
+
for url in extract_image_urls(chapter_body_html(chapter))
|
|
87
89
|
)
|
|
88
90
|
)
|
|
89
91
|
images = await download_images(client, image_urls)
|
|
@@ -106,7 +108,7 @@ class EpubExporter:
|
|
|
106
108
|
toc: list[object] = []
|
|
107
109
|
for index, chapter in enumerate(chapters, start=1):
|
|
108
110
|
heading = chapter_heading(chapter)
|
|
109
|
-
body = rewrite_image_srcs(chapter
|
|
111
|
+
body = rewrite_image_srcs(chapter_body_html(chapter), url_to_local)
|
|
110
112
|
doc = epub.EpubHtml(
|
|
111
113
|
uid=f"chapter_{index}",
|
|
112
114
|
file_name=f"chapter_{index}.xhtml",
|
|
@@ -10,7 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from typing import ClassVar
|
|
11
11
|
|
|
12
12
|
from ranobelib.exporters import register
|
|
13
|
-
from ranobelib.exporters._shared import chapter_heading
|
|
13
|
+
from ranobelib.exporters._shared import chapter_body_html, chapter_heading
|
|
14
14
|
from ranobelib.models import Chapter, Title
|
|
15
15
|
|
|
16
16
|
FB2_NS = "http://www.gribuser.ru/xml/fictionbook/2.0"
|
|
@@ -133,7 +133,7 @@ def _build_body(chapters: list[Chapter], on_chapter: Callable[[], None] | None)
|
|
|
133
133
|
section = ET.SubElement(body, _tag("section"))
|
|
134
134
|
section_title = ET.SubElement(section, _tag("title"))
|
|
135
135
|
ET.SubElement(section_title, _tag("p")).text = chapter_heading(chapter)
|
|
136
|
-
for paragraph in html_to_fb2_paragraphs(chapter
|
|
136
|
+
for paragraph in html_to_fb2_paragraphs(chapter_body_html(chapter)):
|
|
137
137
|
section.append(paragraph)
|
|
138
138
|
if on_chapter is not None:
|
|
139
139
|
on_chapter()
|
|
@@ -20,7 +20,7 @@ from ranobelib.exporters._illustrations import (
|
|
|
20
20
|
pick_cover_url,
|
|
21
21
|
rewrite_image_srcs,
|
|
22
22
|
)
|
|
23
|
-
from ranobelib.exporters._shared import chapter_heading
|
|
23
|
+
from ranobelib.exporters._shared import chapter_body_html, chapter_heading
|
|
24
24
|
from ranobelib.models import Chapter, Title
|
|
25
25
|
|
|
26
26
|
try:
|
|
@@ -63,7 +63,7 @@ def _title_page_html(title: Title, cover_data_uri: str | None) -> str:
|
|
|
63
63
|
|
|
64
64
|
def _chapter_html(chapter: Chapter, url_to_data_uri: dict[str, str]) -> str:
|
|
65
65
|
heading = html.escape(chapter_heading(chapter))
|
|
66
|
-
body = rewrite_image_srcs(chapter
|
|
66
|
+
body = rewrite_image_srcs(chapter_body_html(chapter), url_to_data_uri)
|
|
67
67
|
return f'<section class="chapter"><h1>{heading}</h1>{body}</section>'
|
|
68
68
|
|
|
69
69
|
|
|
@@ -136,7 +136,7 @@ if weasyprint is not None:
|
|
|
136
136
|
dict.fromkeys(
|
|
137
137
|
url
|
|
138
138
|
for chapter in chapters
|
|
139
|
-
for url in extract_image_urls(chapter
|
|
139
|
+
for url in extract_image_urls(chapter_body_html(chapter))
|
|
140
140
|
)
|
|
141
141
|
)
|
|
142
142
|
images = await download_images(client, image_urls)
|
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from typing import ClassVar
|
|
9
9
|
|
|
10
10
|
from ranobelib.exporters import register
|
|
11
|
-
from ranobelib.exporters._shared import chapter_heading
|
|
11
|
+
from ranobelib.exporters._shared import chapter_body_html, chapter_heading
|
|
12
12
|
from ranobelib.models import Chapter, Title
|
|
13
13
|
|
|
14
14
|
_BLOCK_TAGS = frozenset({"p", "div"})
|
|
@@ -91,7 +91,7 @@ class TxtExporter:
|
|
|
91
91
|
"""
|
|
92
92
|
sections = [title.name]
|
|
93
93
|
for chapter in chapters:
|
|
94
|
-
body = html_to_text(chapter
|
|
94
|
+
body = html_to_text(chapter_body_html(chapter))
|
|
95
95
|
sections.append(f"{chapter_heading(chapter)}\n\n{body}")
|
|
96
96
|
if on_chapter is not None:
|
|
97
97
|
on_chapter()
|
|
@@ -67,31 +67,73 @@ def _render_prosemirror_inline(node: dict[str, Any], attachments: list[dict[str,
|
|
|
67
67
|
return "".join(_render_prosemirror_inline(child, attachments) for child in _child_nodes(node))
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
_FOOTNOTE_ARROW = "↑"
|
|
71
|
+
"""The "↑" a translator footnote paragraph starts with — see ``Chapter.footnotes``."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _strip_footnote_arrow(paragraph: dict[str, Any]) -> dict[str, Any] | None:
|
|
75
|
+
"""Return ``paragraph`` without its leading ``_FOOTNOTE_ARROW``, or ``None`` if its first
|
|
76
|
+
non-blank text doesn't start with one (i.e. it's a regular paragraph, not a footnote).
|
|
77
|
+
|
|
78
|
+
Non-text children (an image, a hard break) before that text are kept and skipped over,
|
|
79
|
+
same as ``_ContentSanitizer`` does for the HTML-string format.
|
|
80
|
+
"""
|
|
81
|
+
children = _child_nodes(paragraph)
|
|
82
|
+
for index, child in enumerate(children):
|
|
83
|
+
if child.get("type") != "text":
|
|
84
|
+
continue
|
|
85
|
+
text = str(child.get("text", "")).lstrip()
|
|
86
|
+
if not text:
|
|
87
|
+
continue
|
|
88
|
+
if not text.startswith(_FOOTNOTE_ARROW):
|
|
89
|
+
return None
|
|
90
|
+
stripped = {**child, "text": text[len(_FOOTNOTE_ARROW) :].lstrip()}
|
|
91
|
+
return {**paragraph, "content": [*children[:index], stripped, *children[index + 1 :]]}
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _render_prosemirror_block(
|
|
96
|
+
node: dict[str, Any], attachments: list[dict[str, Any]], footnotes: list[str]
|
|
97
|
+
) -> str:
|
|
71
98
|
node_type = node.get("type")
|
|
72
99
|
if node_type == "paragraph":
|
|
100
|
+
footnote = _strip_footnote_arrow(node)
|
|
101
|
+
paragraph = footnote if footnote is not None else node
|
|
73
102
|
inner = "".join(
|
|
74
|
-
_render_prosemirror_inline(child, attachments) for child in _child_nodes(
|
|
103
|
+
_render_prosemirror_inline(child, attachments) for child in _child_nodes(paragraph)
|
|
75
104
|
)
|
|
76
|
-
|
|
105
|
+
if footnote is None:
|
|
106
|
+
return f"<p>{inner}</p>"
|
|
107
|
+
if inner.strip():
|
|
108
|
+
footnotes.append(inner.strip())
|
|
109
|
+
return ""
|
|
77
110
|
if node_type == "image":
|
|
78
111
|
return _render_prosemirror_inline(node, attachments)
|
|
79
112
|
if node_type == "horizontalRule":
|
|
80
113
|
return "<hr />"
|
|
81
|
-
return "".join(
|
|
114
|
+
return "".join(
|
|
115
|
+
_render_prosemirror_block(child, attachments, footnotes) for child in _child_nodes(node)
|
|
116
|
+
)
|
|
82
117
|
|
|
83
118
|
|
|
84
|
-
def _prosemirror_to_html(
|
|
85
|
-
|
|
119
|
+
def _prosemirror_to_html(
|
|
120
|
+
doc: dict[str, Any], attachments: list[dict[str, Any]]
|
|
121
|
+
) -> tuple[str, list[str]]:
|
|
122
|
+
"""Render a prosemirror-doc JSON structure as an HTML fragment, plus its footnotes.
|
|
86
123
|
|
|
87
124
|
Chapter content comes from the API in one of two formats — an HTML string, or
|
|
88
125
|
prosemirror-doc JSON (see docs/api-notes.md) — this makes the latter match the tag
|
|
89
126
|
vocabulary (``p``/``img``/``strong``/``em``) the former already uses natively, so
|
|
90
127
|
downstream consumers (exporters) only ever handle one shape. Image nodes reference
|
|
91
128
|
attachments by an opaque id, resolved against the chapter response's ``attachments``
|
|
92
|
-
array.
|
|
129
|
+
array. Footnote paragraphs (see ``Chapter.footnotes``) are left out of the returned HTML
|
|
130
|
+
and returned separately instead, as inline HTML fragments without the leading arrow.
|
|
93
131
|
"""
|
|
94
|
-
|
|
132
|
+
footnotes: list[str] = []
|
|
133
|
+
rendered = "".join(
|
|
134
|
+
_render_prosemirror_block(block, attachments, footnotes) for block in _child_nodes(doc)
|
|
135
|
+
)
|
|
136
|
+
return rendered, footnotes
|
|
95
137
|
|
|
96
138
|
|
|
97
139
|
_ALLOWED_CONTENT_TAGS = frozenset({"p", "img", "strong", "em", "br", "hr"})
|
|
@@ -101,6 +143,70 @@ _DROPPED_CONTENT_TEXT_TAGS = frozenset({"script", "style"})
|
|
|
101
143
|
_SAFE_IMG_SCHEMES = frozenset({"http", "https"})
|
|
102
144
|
|
|
103
145
|
|
|
146
|
+
_DROPPED_BLOCK_TAGS = frozenset(
|
|
147
|
+
{
|
|
148
|
+
"address",
|
|
149
|
+
"article",
|
|
150
|
+
"aside",
|
|
151
|
+
"blockquote",
|
|
152
|
+
"caption",
|
|
153
|
+
"center",
|
|
154
|
+
"dd",
|
|
155
|
+
"details",
|
|
156
|
+
"div",
|
|
157
|
+
"dl",
|
|
158
|
+
"dt",
|
|
159
|
+
"fieldset",
|
|
160
|
+
"figcaption",
|
|
161
|
+
"figure",
|
|
162
|
+
"footer",
|
|
163
|
+
"form",
|
|
164
|
+
"h1",
|
|
165
|
+
"h2",
|
|
166
|
+
"h3",
|
|
167
|
+
"h4",
|
|
168
|
+
"h5",
|
|
169
|
+
"h6",
|
|
170
|
+
"header",
|
|
171
|
+
"hgroup",
|
|
172
|
+
"li",
|
|
173
|
+
"main",
|
|
174
|
+
"nav",
|
|
175
|
+
"ol",
|
|
176
|
+
"pre",
|
|
177
|
+
"section",
|
|
178
|
+
"summary",
|
|
179
|
+
"table",
|
|
180
|
+
"tbody",
|
|
181
|
+
"td",
|
|
182
|
+
"tfoot",
|
|
183
|
+
"th",
|
|
184
|
+
"thead",
|
|
185
|
+
"tr",
|
|
186
|
+
"ul",
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
_BLOCK_TAGS = _DROPPED_BLOCK_TAGS | {"p"}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _Block:
|
|
193
|
+
"""A ``p`` or dropped block-level tag still being parsed.
|
|
194
|
+
|
|
195
|
+
``run`` is the inline content collected since the block opened or since its last child
|
|
196
|
+
block — for a ``p`` that's its whole content; for a dropped block it's flushed into its
|
|
197
|
+
own ``<p>`` in ``segments`` whenever a child block (or ``hr``) starts, and when the block
|
|
198
|
+
closes. Each such paragraph is checked for a footnote on its own, so buffering is needed:
|
|
199
|
+
whether it is one is only known once its first non-blank text arrives.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, tag: str) -> None:
|
|
203
|
+
self.tag = tag
|
|
204
|
+
self.segments: list[str] = []
|
|
205
|
+
self.run: list[str] = []
|
|
206
|
+
self.arrow_index: int | None = None
|
|
207
|
+
self.seen_text = False
|
|
208
|
+
|
|
209
|
+
|
|
104
210
|
class _ContentSanitizer(HTMLParser):
|
|
105
211
|
"""Rebuilds an HTML-string-format chapter body using only the same restricted tag
|
|
106
212
|
vocabulary ``_prosemirror_to_html`` already produces for the other content format
|
|
@@ -116,20 +222,87 @@ class _ContentSanitizer(HTMLParser):
|
|
|
116
222
|
safety concern. Text inside a dropped ``script``/``style`` tag is discarded rather than
|
|
117
223
|
kept as escaped text — anywhere else, a dropped tag's text content is kept (e.g. a
|
|
118
224
|
stripped ``<a href="...">text</a>`` still leaves ``text`` behind).
|
|
225
|
+
|
|
226
|
+
A dropped *block-level* tag (``li``, ``div``, ``h3``, ...) keeps the paragraph boundary
|
|
227
|
+
it gave its text: its inline content is wrapped in ``<p>`` rather than inlined into the
|
|
228
|
+
parent, one ``<p>`` per run of inline content between nested blocks, so the output stays
|
|
229
|
+
a flat sequence of paragraphs whatever the nesting was. Opening a block inside an open
|
|
230
|
+
``p`` closes that ``p`` first, as HTML itself does (a ``p`` can't contain blocks).
|
|
231
|
+
|
|
232
|
+
A paragraph — an explicit ``p`` or one made from a dropped block's text — whose first
|
|
233
|
+
non-blank text starts with ``_FOOTNOTE_ARROW`` is a translator footnote: it's collected
|
|
234
|
+
into ``footnotes`` (sanitized inner HTML, arrow stripped) instead of the output.
|
|
119
235
|
"""
|
|
120
236
|
|
|
121
237
|
def __init__(self) -> None:
|
|
122
238
|
super().__init__(convert_charrefs=True)
|
|
123
239
|
self._output: list[str] = []
|
|
240
|
+
self._blocks: list[_Block] = []
|
|
124
241
|
self._skip_text_depth = 0
|
|
242
|
+
self.footnotes: list[str] = []
|
|
243
|
+
|
|
244
|
+
def _out(self) -> list[str]:
|
|
245
|
+
return self._blocks[-1].run if self._blocks else self._output
|
|
125
246
|
|
|
126
247
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
127
|
-
|
|
248
|
+
if tag in _BLOCK_TAGS:
|
|
249
|
+
self._open_block(tag)
|
|
250
|
+
else:
|
|
251
|
+
self._emit(tag, attrs)
|
|
128
252
|
|
|
129
253
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
130
|
-
|
|
254
|
+
if tag in _BLOCK_TAGS:
|
|
255
|
+
self._open_block(tag)
|
|
256
|
+
self._close_block()
|
|
257
|
+
else:
|
|
258
|
+
self._emit(tag, attrs)
|
|
259
|
+
|
|
260
|
+
def _open_block(self, tag: str) -> None:
|
|
261
|
+
# Same implicit closes HTML itself does: any block ends an open <p>, and a new <li>
|
|
262
|
+
# ends an open <li>.
|
|
263
|
+
if self._blocks and (self._blocks[-1].tag == "p" or self._blocks[-1].tag == tag == "li"):
|
|
264
|
+
self._close_block()
|
|
265
|
+
self._blocks.append(_Block(tag))
|
|
266
|
+
|
|
267
|
+
def _close_block(self) -> None:
|
|
268
|
+
block = self._blocks.pop()
|
|
269
|
+
if block.tag == "p":
|
|
270
|
+
paragraph = self._take_paragraph(block, keep_empty=True)
|
|
271
|
+
else:
|
|
272
|
+
self._flush_run(block)
|
|
273
|
+
paragraph = "".join(block.segments)
|
|
274
|
+
if paragraph:
|
|
275
|
+
self._append_block_html(paragraph)
|
|
276
|
+
|
|
277
|
+
def _take_paragraph(self, block: _Block, *, keep_empty: bool) -> str:
|
|
278
|
+
"""Turn ``block.run`` into a ``<p>`` (or a footnote, returning ``""``) and reset it."""
|
|
279
|
+
run, arrow_index = block.run, block.arrow_index
|
|
280
|
+
block.run, block.arrow_index, block.seen_text = [], None, False
|
|
281
|
+
if arrow_index is not None:
|
|
282
|
+
run[arrow_index] = run[arrow_index].lstrip()[len(_FOOTNOTE_ARROW) :].lstrip()
|
|
283
|
+
inner = "".join(run).strip()
|
|
284
|
+
if inner:
|
|
285
|
+
self.footnotes.append(inner)
|
|
286
|
+
return ""
|
|
287
|
+
inner = "".join(run)
|
|
288
|
+
if keep_empty:
|
|
289
|
+
return f"<p>{inner}</p>"
|
|
290
|
+
return f"<p>{inner.strip()}</p>" if inner.strip() else ""
|
|
291
|
+
|
|
292
|
+
def _flush_run(self, block: _Block) -> None:
|
|
293
|
+
paragraph = self._take_paragraph(block, keep_empty=False)
|
|
294
|
+
if paragraph:
|
|
295
|
+
block.segments.append(paragraph)
|
|
296
|
+
|
|
297
|
+
def _append_block_html(self, block_html: str) -> None:
|
|
298
|
+
if not self._blocks:
|
|
299
|
+
self._output.append(block_html)
|
|
300
|
+
return
|
|
301
|
+
parent = self._blocks[-1]
|
|
302
|
+
self._flush_run(parent)
|
|
303
|
+
parent.segments.append(block_html)
|
|
131
304
|
|
|
132
|
-
def _emit(self, tag: str, attrs: list[tuple[str, str | None]]
|
|
305
|
+
def _emit(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
133
306
|
resolved = _CONTENT_TAG_ALIASES.get(tag, tag)
|
|
134
307
|
if resolved not in _ALLOWED_CONTENT_TAGS:
|
|
135
308
|
if tag in _DROPPED_CONTENT_TEXT_TAGS:
|
|
@@ -138,40 +311,61 @@ class _ContentSanitizer(HTMLParser):
|
|
|
138
311
|
if resolved == "img":
|
|
139
312
|
src = dict(attrs).get("src")
|
|
140
313
|
if src and urlsplit(src).scheme in _SAFE_IMG_SCHEMES:
|
|
141
|
-
self.
|
|
314
|
+
self._out().append(f'<img loading="lazy" src="{html.escape(src, quote=True)}" />')
|
|
315
|
+
return
|
|
316
|
+
if resolved == "hr" and self._blocks and self._blocks[-1].tag != "p":
|
|
317
|
+
# Between a dropped block's paragraphs, not inside one.
|
|
318
|
+
self._append_block_html("<hr />")
|
|
142
319
|
return
|
|
143
320
|
if resolved in _VOID_CONTENT_TAGS:
|
|
144
|
-
self.
|
|
321
|
+
self._out().append(f"<{resolved} />")
|
|
145
322
|
else:
|
|
146
|
-
self.
|
|
323
|
+
self._out().append(f"<{resolved}>")
|
|
147
324
|
|
|
148
325
|
def handle_endtag(self, tag: str) -> None:
|
|
326
|
+
if tag in _BLOCK_TAGS:
|
|
327
|
+
# Close up to the matching open block; a stray end tag with none open is dropped.
|
|
328
|
+
if any(block.tag == tag for block in self._blocks):
|
|
329
|
+
while self._blocks[-1].tag != tag:
|
|
330
|
+
self._close_block()
|
|
331
|
+
self._close_block()
|
|
332
|
+
return
|
|
149
333
|
resolved = _CONTENT_TAG_ALIASES.get(tag, tag)
|
|
150
334
|
if resolved not in _ALLOWED_CONTENT_TAGS:
|
|
151
335
|
if tag in _DROPPED_CONTENT_TEXT_TAGS and self._skip_text_depth > 0:
|
|
152
336
|
self._skip_text_depth -= 1
|
|
153
337
|
return
|
|
154
338
|
if resolved not in _VOID_CONTENT_TAGS:
|
|
155
|
-
self.
|
|
339
|
+
self._out().append(f"</{resolved}>")
|
|
156
340
|
|
|
157
341
|
def handle_data(self, data: str) -> None:
|
|
158
|
-
if self._skip_text_depth
|
|
159
|
-
|
|
342
|
+
if self._skip_text_depth > 0:
|
|
343
|
+
return
|
|
344
|
+
if self._blocks and not self._blocks[-1].seen_text and data.strip():
|
|
345
|
+
block = self._blocks[-1]
|
|
346
|
+
block.seen_text = True
|
|
347
|
+
if data.lstrip().startswith(_FOOTNOTE_ARROW):
|
|
348
|
+
block.arrow_index = len(block.run)
|
|
349
|
+
self._out().append(html.escape(data))
|
|
160
350
|
|
|
161
351
|
def get_html(self) -> str:
|
|
352
|
+
while self._blocks:
|
|
353
|
+
self._close_block()
|
|
162
354
|
return "".join(self._output)
|
|
163
355
|
|
|
164
356
|
|
|
165
|
-
def _sanitize_content_html(content: str) -> str:
|
|
357
|
+
def _sanitize_content_html(content: str) -> tuple[str, list[str]]:
|
|
166
358
|
"""Sanitize an HTML-string-format chapter body down to the restricted tag vocabulary.
|
|
167
359
|
|
|
168
360
|
Counterpart to ``_prosemirror_to_html`` for the other content format the API returns
|
|
169
361
|
(see docs/api-notes.md) — without this, ``Chapter.content`` would only actually be safe
|
|
170
362
|
to render as raw HTML for prosemirror-sourced chapters, not HTML-string-sourced ones.
|
|
363
|
+
Returns the sanitized HTML and, separately, the footnotes taken out of it (see
|
|
364
|
+
``Chapter.footnotes``).
|
|
171
365
|
"""
|
|
172
366
|
sanitizer = _ContentSanitizer()
|
|
173
367
|
sanitizer.feed(content)
|
|
174
|
-
return sanitizer.get_html()
|
|
368
|
+
return sanitizer.get_html(), sanitizer.footnotes
|
|
175
369
|
|
|
176
370
|
|
|
177
371
|
class Cover(BaseModel):
|
|
@@ -310,6 +504,22 @@ class ChapterBranch(BaseModel):
|
|
|
310
504
|
user: ChapterUser
|
|
311
505
|
|
|
312
506
|
|
|
507
|
+
class Footnote(BaseModel):
|
|
508
|
+
"""A translator footnote (term explanation, translation note) taken out of a chapter.
|
|
509
|
+
|
|
510
|
+
The API has no structured footnotes (see docs/api-notes.md): they're ordinary
|
|
511
|
+
paragraphs/list items whose text starts with ``↑``, so that arrow is what the SDK detects
|
|
512
|
+
them by — and strips from ``content``. There's no ``marker`` linking a footnote back to a
|
|
513
|
+
point in the text: translators mark the reference with an unnumbered ``*`` at best, which
|
|
514
|
+
the text also uses for scene breaks and censored words, so ``Chapter.footnotes``'s order
|
|
515
|
+
(the order they appear in the source) is the only link there is.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
content: str
|
|
519
|
+
"""Sanitized inline HTML (same tag vocabulary as ``Chapter.content``), without the arrow
|
|
520
|
+
and without a wrapping ``<p>``."""
|
|
521
|
+
|
|
522
|
+
|
|
313
523
|
class Chapter(BaseModel):
|
|
314
524
|
"""A chapter: volume, number, name, available translations, and optionally its content.
|
|
315
525
|
|
|
@@ -318,6 +528,10 @@ class Chapter(BaseModel):
|
|
|
318
528
|
``Chapter`` instead comes from fetching a single chapter's content, which has a
|
|
319
529
|
different response shape (see docs/api-notes.md). ``content`` is the reverse: only
|
|
320
530
|
populated by the single-chapter endpoint.
|
|
531
|
+
|
|
532
|
+
``footnotes`` holds the chapter's translator footnotes (see ``Footnote``), which are
|
|
533
|
+
removed from ``content`` rather than kept in both places — empty when the chapter has
|
|
534
|
+
none, or when ``content`` isn't fetched.
|
|
321
535
|
"""
|
|
322
536
|
|
|
323
537
|
id: int
|
|
@@ -330,6 +544,7 @@ class Chapter(BaseModel):
|
|
|
330
544
|
branches: list[ChapterBranch] = Field(default_factory=list)
|
|
331
545
|
bundle_id: int | None = None
|
|
332
546
|
content: str | None = None
|
|
547
|
+
footnotes: list[Footnote] = Field(default_factory=list)
|
|
333
548
|
|
|
334
549
|
@model_validator(mode="before")
|
|
335
550
|
@classmethod
|
|
@@ -338,9 +553,20 @@ class Chapter(BaseModel):
|
|
|
338
553
|
content = data.get("content")
|
|
339
554
|
if isinstance(content, dict):
|
|
340
555
|
attachments = data.get("attachments") or []
|
|
341
|
-
|
|
556
|
+
rendered, footnotes = _prosemirror_to_html(content, attachments)
|
|
342
557
|
elif isinstance(content, str):
|
|
343
|
-
|
|
558
|
+
rendered, footnotes = _sanitize_content_html(content)
|
|
559
|
+
else:
|
|
560
|
+
return data
|
|
561
|
+
# A Chapter being re-validated from its own dump (model_validate(model_dump()))
|
|
562
|
+
# already has its footnotes out of ``content`` — keep them rather than overwrite
|
|
563
|
+
# them with the now-empty re-extraction.
|
|
564
|
+
data = {
|
|
565
|
+
**data,
|
|
566
|
+
"content": rendered,
|
|
567
|
+
"footnotes": data.get("footnotes")
|
|
568
|
+
or [{"content": footnote} for footnote in footnotes],
|
|
569
|
+
}
|
|
344
570
|
return data
|
|
345
571
|
|
|
346
572
|
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
The API exposes no content-length or image-size field anywhere (see docs/api-notes.md), so
|
|
4
4
|
any size here is necessarily approximate for the image portion: chapter text is measured
|
|
5
|
-
exactly (the UTF-8 byte length of ``Chapter.content``), but each embedded
|
|
6
|
-
assumed to weigh ``average_image_size`` bytes rather than downloaded to measure
|
|
7
|
-
doing that would cost the same network traffic this module exists to help avoid
|
|
8
|
-
upfront. The result approximates the underlying content size, not a specific export
|
|
5
|
+
exactly (the UTF-8 byte length of ``Chapter.content`` plus its footnotes), but each embedded
|
|
6
|
+
``<img>`` is assumed to weigh ``average_image_size`` bytes rather than downloaded to measure
|
|
7
|
+
precisely — doing that would cost the same network traffic this module exists to help avoid
|
|
8
|
+
paying upfront. The result approximates the underlying content size, not a specific export
|
|
9
9
|
format's file size (epub's zip compression, pdf's layout overhead, fb2's XML verbosity are
|
|
10
10
|
not modeled) — enough to decide whether a title is worth downloading, not to predict an
|
|
11
11
|
exported file's exact byte count.
|
|
@@ -40,8 +40,11 @@ def chapter_size(chapter: Chapter, *, average_image_size: int = DEFAULT_AVERAGE_
|
|
|
40
40
|
f"Chapter {chapter.volume}/{chapter.number} has no content to size — "
|
|
41
41
|
"fetch it first, e.g. via get_chapter()."
|
|
42
42
|
)
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# Footnotes are split out of ``content`` (see ``Chapter.footnotes``) but are still part
|
|
44
|
+
# of what was downloaded — and of what every exporter writes out.
|
|
45
|
+
text = chapter.content + "".join(footnote.content for footnote in chapter.footnotes)
|
|
46
|
+
text_bytes = len(text.encode("utf-8"))
|
|
47
|
+
image_count = len(extract_image_urls(text))
|
|
45
48
|
return text_bytes + image_count * average_image_size
|
|
46
49
|
|
|
47
50
|
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
"""Helpers shared between exporter implementations (not part of the public Exporter protocol)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from ranobelib.models import Chapter
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def chapter_heading(chapter: Chapter) -> str:
|
|
9
|
-
"""A human-readable "Volume X, Chapter Y[: Name]" heading for ``chapter``."""
|
|
10
|
-
heading = f"Volume {chapter.volume}, Chapter {chapter.number}"
|
|
11
|
-
return f"{heading}: {chapter.name}" if chapter.name else heading
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.8.0 → ranobelib_python_sdk-0.10.0}/src/ranobelib/exporters/_illustrations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|