ranobelib-python-sdk 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.
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/PKG-INFO +2 -1
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/pyproject.toml +2 -1
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/pyproject.toml.orig +2 -1
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/__init__.py +3 -0
- ranobelib_python_sdk-0.4.0/src/ranobelib/console.py +72 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/__init__.py +13 -1
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/epub.py +13 -1
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/fb2.py +14 -3
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/pdf.py +18 -4
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/txt.py +12 -1
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/sdk.py +111 -5
- ranobelib_python_sdk-0.4.0/src/ranobelib/sizing.py +62 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/README.md +0 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/client.py +0 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exceptions.py +0 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/models.py +0 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ranobelib-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -18,6 +18,7 @@ Classifier: Typing :: Typed
|
|
|
18
18
|
Requires-Dist: ebooklib>=0.20
|
|
19
19
|
Requires-Dist: httpx>=0.27
|
|
20
20
|
Requires-Dist: pydantic>=2.7
|
|
21
|
+
Requires-Dist: rich>=15.0.0
|
|
21
22
|
Requires-Dist: weasyprint>=69.0
|
|
22
23
|
Requires-Python: >=3.11
|
|
23
24
|
Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ranobelib-python-sdk"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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"
|
|
@@ -29,6 +29,7 @@ dependencies = [
|
|
|
29
29
|
"ebooklib>=0.20",
|
|
30
30
|
"httpx>=0.27",
|
|
31
31
|
"pydantic>=2.7",
|
|
32
|
+
"rich>=15.0.0",
|
|
32
33
|
"weasyprint>=69.0",
|
|
33
34
|
]
|
|
34
35
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ranobelib-python-sdk"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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"
|
|
@@ -24,6 +24,7 @@ dependencies = [
|
|
|
24
24
|
"ebooklib>=0.20",
|
|
25
25
|
"httpx>=0.27",
|
|
26
26
|
"pydantic>=2.7",
|
|
27
|
+
"rich>=15.0.0",
|
|
27
28
|
"weasyprint>=69.0",
|
|
28
29
|
]
|
|
29
30
|
|
|
@@ -13,6 +13,7 @@ from ranobelib.exceptions import (
|
|
|
13
13
|
)
|
|
14
14
|
from ranobelib.models import Chapter, ChapterBranch, Title, Volume
|
|
15
15
|
from ranobelib.sdk import RanobeLib
|
|
16
|
+
from ranobelib.sizing import chapter_size, volume_size
|
|
16
17
|
|
|
17
18
|
__all__ = [
|
|
18
19
|
"AmbiguousChapter",
|
|
@@ -29,4 +30,6 @@ __all__ = [
|
|
|
29
30
|
"TitleNotFoundError",
|
|
30
31
|
"Volume",
|
|
31
32
|
"VolumeNotFoundError",
|
|
33
|
+
"chapter_size",
|
|
34
|
+
"volume_size",
|
|
32
35
|
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Console output for RanobeLib: progress bars and full-mode operation logging.
|
|
2
|
+
|
|
3
|
+
Behind ``RanobeLib``'s ``verbosity`` parameter (``False`` by default, ``"progress_only"``,
|
|
4
|
+
or ``"full"`` — see CLAUDE.md's roadmap entry for why these three, and why this prints
|
|
5
|
+
directly via ``rich`` rather than going through ``logging``). ``Reporter`` is the single
|
|
6
|
+
object that knows which of the three modes is active; every call site (``sdk.py``, the
|
|
7
|
+
exporters) just calls ``log()``/``progress()`` unconditionally and lets ``Reporter`` decide
|
|
8
|
+
whether that produces any output.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
|
|
19
|
+
|
|
20
|
+
Verbosity = Literal["full", "progress_only"] | Literal[False]
|
|
21
|
+
"""``False``: silent. ``"progress_only"``: progress bars only. ``"full"``: progress bars
|
|
22
|
+
plus a line logged for every title/chapters-list/chapter-content fetch (cache hit or API)."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Reporter:
|
|
26
|
+
"""Console output gated by a ``Verbosity`` level, backed by a single ``rich.Console``."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, verbosity: Verbosity, *, console: Console | None = None) -> None:
|
|
29
|
+
"""Initialize the reporter.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
verbosity: The active output level.
|
|
33
|
+
console: Custom ``rich.console.Console`` to print through, e.g. one writing to
|
|
34
|
+
an in-memory stream for tests. Defaults to a new ``Console()`` (stdout) —
|
|
35
|
+
only constructed at all when ``verbosity`` isn't ``False``, so a silent
|
|
36
|
+
``RanobeLib`` never touches the terminal.
|
|
37
|
+
"""
|
|
38
|
+
self._verbosity = verbosity
|
|
39
|
+
if not verbosity:
|
|
40
|
+
self._console = None
|
|
41
|
+
else:
|
|
42
|
+
self._console = console if console is not None else Console()
|
|
43
|
+
|
|
44
|
+
def log(self, message: str) -> None:
|
|
45
|
+
"""Print ``message``, only when verbosity is ``"full"``.
|
|
46
|
+
|
|
47
|
+
``markup=False``: messages embed literal ``[cache]``/``[api]`` prefixes (see
|
|
48
|
+
``sdk.py``'s choke points), which rich's default markup parsing would otherwise try
|
|
49
|
+
to interpret as style tags.
|
|
50
|
+
"""
|
|
51
|
+
if self._verbosity == "full" and self._console is not None:
|
|
52
|
+
self._console.log(message, markup=False)
|
|
53
|
+
|
|
54
|
+
@contextmanager
|
|
55
|
+
def progress(self, description: str, total: int) -> Iterator[Callable[[], None]]:
|
|
56
|
+
"""A progress bar shown for ``"progress_only"``/``"full"``, a no-op otherwise.
|
|
57
|
+
|
|
58
|
+
Yields a zero-argument callable: call it once per completed step.
|
|
59
|
+
"""
|
|
60
|
+
if not self._verbosity or total == 0:
|
|
61
|
+
yield lambda: None
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
with Progress(
|
|
65
|
+
TextColumn("[progress.description]{task.description}"),
|
|
66
|
+
BarColumn(),
|
|
67
|
+
MofNCompleteColumn(),
|
|
68
|
+
TimeElapsedColumn(),
|
|
69
|
+
console=self._console,
|
|
70
|
+
) as progress:
|
|
71
|
+
task_id = progress.add_task(description, total=total)
|
|
72
|
+
yield lambda: progress.advance(task_id)
|
{ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import importlib
|
|
11
11
|
import pkgutil
|
|
12
|
+
from collections.abc import Callable
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import ClassVar, Protocol, TypeVar, runtime_checkable
|
|
14
15
|
|
|
@@ -22,7 +23,14 @@ class Exporter(Protocol):
|
|
|
22
23
|
format: ClassVar[str]
|
|
23
24
|
"""The registry key this exporter is selected by, e.g. ``"txt"``."""
|
|
24
25
|
|
|
25
|
-
async def export(
|
|
26
|
+
async def export(
|
|
27
|
+
self,
|
|
28
|
+
title: Title,
|
|
29
|
+
chapters: list[Chapter],
|
|
30
|
+
output_path: Path,
|
|
31
|
+
*,
|
|
32
|
+
on_chapter: Callable[[], None] | None = None,
|
|
33
|
+
) -> Path:
|
|
26
34
|
"""Write ``chapters`` (in the given order) to ``output_path``.
|
|
27
35
|
|
|
28
36
|
``async`` since embedding illustrations (epub, pdf) requires downloading them —
|
|
@@ -33,6 +41,10 @@ class Exporter(Protocol):
|
|
|
33
41
|
title: The chapters' parent title, for metadata (name, authors, ...).
|
|
34
42
|
chapters: The chapters to include, in the order they should appear.
|
|
35
43
|
output_path: Where to write the exported file.
|
|
44
|
+
on_chapter: Called once per chapter processed, if given — drives
|
|
45
|
+
``RanobeLib.export()``'s progress bar (see CLAUDE.md's roadmap step 23).
|
|
46
|
+
For epub/pdf this covers the per-chapter embedding step, not the earlier
|
|
47
|
+
illustration-download step, which isn't itself progress-reported.
|
|
36
48
|
|
|
37
49
|
Returns:
|
|
38
50
|
``output_path``, once the file has been written.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import html
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import ClassVar
|
|
8
9
|
|
|
@@ -39,7 +40,14 @@ class EpubExporter:
|
|
|
39
40
|
"""
|
|
40
41
|
self._transport = transport
|
|
41
42
|
|
|
42
|
-
async def export(
|
|
43
|
+
async def export(
|
|
44
|
+
self,
|
|
45
|
+
title: Title,
|
|
46
|
+
chapters: list[Chapter],
|
|
47
|
+
output_path: Path,
|
|
48
|
+
*,
|
|
49
|
+
on_chapter: Callable[[], None] | None = None,
|
|
50
|
+
) -> Path:
|
|
43
51
|
"""Write ``chapters`` to ``output_path`` as an EPUB file.
|
|
44
52
|
|
|
45
53
|
Downloads the title's cover and every image referenced in the chapters' content and
|
|
@@ -50,6 +58,8 @@ class EpubExporter:
|
|
|
50
58
|
title: The chapters' parent title; supplies metadata, cover, and authors.
|
|
51
59
|
chapters: The chapters to include, in the order they should appear.
|
|
52
60
|
output_path: Where to write the ``.epub`` file.
|
|
61
|
+
on_chapter: Called once per chapter embedded, if given — after illustrations
|
|
62
|
+
have already been downloaded, see the ``Exporter`` protocol's docstring.
|
|
53
63
|
|
|
54
64
|
Returns:
|
|
55
65
|
``output_path``.
|
|
@@ -107,6 +117,8 @@ class EpubExporter:
|
|
|
107
117
|
book.add_item(doc)
|
|
108
118
|
spine.append(doc)
|
|
109
119
|
toc.append(doc)
|
|
120
|
+
if on_chapter is not None:
|
|
121
|
+
on_chapter()
|
|
110
122
|
|
|
111
123
|
book.toc = toc
|
|
112
124
|
book.add_item(epub.EpubNcx())
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import datetime
|
|
6
6
|
import xml.etree.ElementTree as ET
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
from html.parser import HTMLParser
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import ClassVar
|
|
@@ -126,7 +127,7 @@ def _build_description(title: Title) -> ET.Element:
|
|
|
126
127
|
return description
|
|
127
128
|
|
|
128
129
|
|
|
129
|
-
def _build_body(chapters: list[Chapter]) -> ET.Element:
|
|
130
|
+
def _build_body(chapters: list[Chapter], on_chapter: Callable[[], None] | None) -> ET.Element:
|
|
130
131
|
body = ET.Element(_tag("body"))
|
|
131
132
|
for chapter in chapters:
|
|
132
133
|
section = ET.SubElement(body, _tag("section"))
|
|
@@ -134,6 +135,8 @@ def _build_body(chapters: list[Chapter]) -> ET.Element:
|
|
|
134
135
|
ET.SubElement(section_title, _tag("p")).text = chapter_heading(chapter)
|
|
135
136
|
for paragraph in html_to_fb2_paragraphs(chapter.content or ""):
|
|
136
137
|
section.append(paragraph)
|
|
138
|
+
if on_chapter is not None:
|
|
139
|
+
on_chapter()
|
|
137
140
|
return body
|
|
138
141
|
|
|
139
142
|
|
|
@@ -143,20 +146,28 @@ class Fb2Exporter:
|
|
|
143
146
|
|
|
144
147
|
format: ClassVar[str] = "fb2"
|
|
145
148
|
|
|
146
|
-
async def export(
|
|
149
|
+
async def export(
|
|
150
|
+
self,
|
|
151
|
+
title: Title,
|
|
152
|
+
chapters: list[Chapter],
|
|
153
|
+
output_path: Path,
|
|
154
|
+
*,
|
|
155
|
+
on_chapter: Callable[[], None] | None = None,
|
|
156
|
+
) -> Path:
|
|
147
157
|
"""Write ``chapters`` to ``output_path`` as FB2 XML.
|
|
148
158
|
|
|
149
159
|
Args:
|
|
150
160
|
title: The chapters' parent title; supplies ``description`` metadata.
|
|
151
161
|
chapters: The chapters to include, in the order they should appear.
|
|
152
162
|
output_path: Where to write the ``.fb2`` file.
|
|
163
|
+
on_chapter: Called once per chapter written, if given.
|
|
153
164
|
|
|
154
165
|
Returns:
|
|
155
166
|
``output_path``.
|
|
156
167
|
"""
|
|
157
168
|
root = ET.Element(_tag("FictionBook"))
|
|
158
169
|
root.append(_build_description(title))
|
|
159
|
-
root.append(_build_body(chapters))
|
|
170
|
+
root.append(_build_body(chapters, on_chapter))
|
|
160
171
|
|
|
161
172
|
tree = ET.ElementTree(root)
|
|
162
173
|
ET.indent(tree, space=" ")
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import base64
|
|
7
7
|
import html
|
|
8
|
+
from collections.abc import Callable
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import ClassVar
|
|
10
11
|
|
|
@@ -98,13 +99,23 @@ if weasyprint is not None:
|
|
|
98
99
|
"""
|
|
99
100
|
self._transport = transport
|
|
100
101
|
|
|
101
|
-
async def export(
|
|
102
|
+
async def export(
|
|
103
|
+
self,
|
|
104
|
+
title: Title,
|
|
105
|
+
chapters: list[Chapter],
|
|
106
|
+
output_path: Path,
|
|
107
|
+
*,
|
|
108
|
+
on_chapter: Callable[[], None] | None = None,
|
|
109
|
+
) -> Path:
|
|
102
110
|
"""Write ``chapters`` to ``output_path`` as a PDF file.
|
|
103
111
|
|
|
104
112
|
Args:
|
|
105
113
|
title: The chapters' parent title; supplies metadata, cover, and authors.
|
|
106
114
|
chapters: The chapters to include, in the order they should appear.
|
|
107
115
|
output_path: Where to write the ``.pdf`` file.
|
|
116
|
+
on_chapter: Called once per chapter rendered to HTML, if given — after
|
|
117
|
+
illustrations have already been downloaded, see the ``Exporter``
|
|
118
|
+
protocol's docstring.
|
|
108
119
|
|
|
109
120
|
Returns:
|
|
110
121
|
``output_path``.
|
|
@@ -135,9 +146,12 @@ if weasyprint is not None:
|
|
|
135
146
|
for url, data in images.items()
|
|
136
147
|
}
|
|
137
148
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
chapter_html_parts = []
|
|
150
|
+
for chapter in chapters:
|
|
151
|
+
chapter_html_parts.append(_chapter_html(chapter, url_to_data_uri))
|
|
152
|
+
if on_chapter is not None:
|
|
153
|
+
on_chapter()
|
|
154
|
+
body = _title_page_html(title, cover_data_uri) + "".join(chapter_html_parts)
|
|
141
155
|
document = (
|
|
142
156
|
f"<html><head><meta charset='utf-8'><style>{_PAGE_CSS}</style></head>"
|
|
143
157
|
f"<body>{body}</body></html>"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from html.parser import HTMLParser
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import ClassVar
|
|
@@ -69,13 +70,21 @@ class TxtExporter:
|
|
|
69
70
|
|
|
70
71
|
format: ClassVar[str] = "txt"
|
|
71
72
|
|
|
72
|
-
async def export(
|
|
73
|
+
async def export(
|
|
74
|
+
self,
|
|
75
|
+
title: Title,
|
|
76
|
+
chapters: list[Chapter],
|
|
77
|
+
output_path: Path,
|
|
78
|
+
*,
|
|
79
|
+
on_chapter: Callable[[], None] | None = None,
|
|
80
|
+
) -> Path:
|
|
73
81
|
"""Write ``chapters`` to ``output_path`` as plain text.
|
|
74
82
|
|
|
75
83
|
Args:
|
|
76
84
|
title: The chapters' parent title; only its name is used.
|
|
77
85
|
chapters: The chapters to include, in the order they should appear.
|
|
78
86
|
output_path: Where to write the ``.txt`` file.
|
|
87
|
+
on_chapter: Called once per chapter written, if given.
|
|
79
88
|
|
|
80
89
|
Returns:
|
|
81
90
|
``output_path``.
|
|
@@ -84,6 +93,8 @@ class TxtExporter:
|
|
|
84
93
|
for chapter in chapters:
|
|
85
94
|
body = html_to_text(chapter.content or "")
|
|
86
95
|
sections.append(f"{chapter_heading(chapter)}\n\n{body}")
|
|
96
|
+
if on_chapter is not None:
|
|
97
|
+
on_chapter()
|
|
87
98
|
|
|
88
99
|
output_path.write_text("\n\n\n".join(sections) + "\n", encoding="utf-8")
|
|
89
100
|
return output_path
|
|
@@ -9,6 +9,7 @@ from typing import Any, Self
|
|
|
9
9
|
|
|
10
10
|
from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
|
|
11
11
|
from ranobelib.client import ApiClient
|
|
12
|
+
from ranobelib.console import Reporter, Verbosity
|
|
12
13
|
from ranobelib.exceptions import (
|
|
13
14
|
AmbiguousChapter,
|
|
14
15
|
ChapterNotFoundError,
|
|
@@ -19,6 +20,7 @@ from ranobelib.exceptions import (
|
|
|
19
20
|
from ranobelib.exporters import EXPORTERS
|
|
20
21
|
from ranobelib.models import Chapter, ChapterBranch, Title, Volume
|
|
21
22
|
from ranobelib.numbering import parse_slug_url
|
|
23
|
+
from ranobelib.sizing import DEFAULT_AVERAGE_IMAGE_SIZE, DEFAULT_SAMPLE_SIZE, chapter_size
|
|
22
24
|
|
|
23
25
|
_INFO_FIELDS = [
|
|
24
26
|
"background",
|
|
@@ -51,6 +53,7 @@ class RanobeLib:
|
|
|
51
53
|
*,
|
|
52
54
|
cache_dir: str | Path | None = None,
|
|
53
55
|
cache_ttl: float | None = None,
|
|
56
|
+
verbosity: Verbosity = False,
|
|
54
57
|
) -> None:
|
|
55
58
|
"""Initialize the SDK for a title.
|
|
56
59
|
|
|
@@ -63,12 +66,18 @@ class RanobeLib:
|
|
|
63
66
|
cache_ttl: Seconds after which a cached response is treated as stale and
|
|
64
67
|
re-fetched. ``None`` (the default) means cached responses never expire on
|
|
65
68
|
their own — see ``refresh=True`` on individual methods to force one anyway.
|
|
69
|
+
verbosity: Console output level. ``False`` (default): silent. ``"progress_only"``:
|
|
70
|
+
progress bars during ``download_title()``/``export()``/``estimate_title_size()``.
|
|
71
|
+
``"full"``: the same progress bars, plus a line logged for every title/
|
|
72
|
+
chapter-list/chapter-content fetch, noting whether it was served from the
|
|
73
|
+
disk cache or the API.
|
|
66
74
|
"""
|
|
67
75
|
self._slug_url = parse_slug_url(url)
|
|
68
76
|
self._client = ApiClient()
|
|
69
77
|
self._cache = DiskCache(
|
|
70
78
|
cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
|
|
71
79
|
)
|
|
80
|
+
self._reporter = Reporter(verbosity)
|
|
72
81
|
|
|
73
82
|
async def __aenter__(self) -> Self:
|
|
74
83
|
return self
|
|
@@ -288,12 +297,102 @@ class RanobeLib:
|
|
|
288
297
|
raise MultipleTitleTranslationsError(self._slug_url, chapters=unresolved)
|
|
289
298
|
|
|
290
299
|
chapters = []
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
300
|
+
with self._reporter.progress(f"Downloading {self._slug_url}", len(planned)) as advance:
|
|
301
|
+
for index, (volume_str, number, selected_branch_id) in enumerate(planned):
|
|
302
|
+
chapters.append(await self._fetch_chapter(volume_str, number, selected_branch_id))
|
|
303
|
+
advance()
|
|
304
|
+
if chapter_delay and index < len(planned) - 1:
|
|
305
|
+
await asyncio.sleep(chapter_delay)
|
|
295
306
|
return _group_into_volumes(chapters)
|
|
296
307
|
|
|
308
|
+
async def estimate_title_size(
|
|
309
|
+
self,
|
|
310
|
+
*,
|
|
311
|
+
sample_size: int = DEFAULT_SAMPLE_SIZE,
|
|
312
|
+
average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE,
|
|
313
|
+
branch_id: int | None = None,
|
|
314
|
+
translation_index: int | None = None,
|
|
315
|
+
) -> int:
|
|
316
|
+
"""Estimate the whole title's download size in bytes, from a sample of its chapters.
|
|
317
|
+
|
|
318
|
+
Fetches the chapter list (cheap, cached), then the content of up to ``sample_size``
|
|
319
|
+
chapters spread evenly across the title — not just the first ones, since early and
|
|
320
|
+
late chapters can differ in length — and extrapolates the sample's average
|
|
321
|
+
``sizing.chapter_size()`` across the title's total chapter count. This trades
|
|
322
|
+
exactness for cost: an exact total requires fetching every chapter's content, the
|
|
323
|
+
same cost as ``download_title()`` itself, which defeats the purpose of checking
|
|
324
|
+
"is this worth downloading" *before* paying that cost.
|
|
325
|
+
|
|
326
|
+
Sampled chapters go through the same disk cache as any other chapter fetch, so a
|
|
327
|
+
later ``download_title()`` call doesn't refetch them.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
sample_size: How many chapters to sample. Clamped to the number of chapters the
|
|
331
|
+
title actually has (or that resolve to a usable translation, see below) if
|
|
332
|
+
that's fewer.
|
|
333
|
+
average_image_size: Forwarded to ``sizing.chapter_size()`` for each sampled
|
|
334
|
+
chapter.
|
|
335
|
+
branch_id: Same meaning as ``download_title(branch_id=...)`` — which translation
|
|
336
|
+
to sample for chapters that have more than one. Mutually exclusive with
|
|
337
|
+
``translation_index``.
|
|
338
|
+
translation_index: Same meaning as ``download_title(translation_index=...)``.
|
|
339
|
+
Mutually exclusive with ``branch_id``.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
The estimated size in bytes, or ``0`` if the title has no chapters, or none of
|
|
343
|
+
its chapters have a translation ``branch_id``/``translation_index`` resolves.
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
ValueError: If both ``branch_id`` and ``translation_index`` are given, or if
|
|
347
|
+
``sample_size`` is less than 1.
|
|
348
|
+
|
|
349
|
+
Note:
|
|
350
|
+
Unlike ``download_title()``, a chapter with more than one translation that
|
|
351
|
+
``branch_id``/``translation_index`` doesn't resolve is skipped when picking the
|
|
352
|
+
sample rather than raising ``MultipleTitleTranslationsError`` — an estimate
|
|
353
|
+
doesn't need a specific translation to be correct, only *a* chapter's worth of
|
|
354
|
+
this title's content to measure.
|
|
355
|
+
"""
|
|
356
|
+
if branch_id is not None and translation_index is not None:
|
|
357
|
+
raise ValueError("Pass at most one of branch_id or translation_index, not both.")
|
|
358
|
+
if sample_size < 1:
|
|
359
|
+
raise ValueError(f"sample_size must be at least 1, got {sample_size}.")
|
|
360
|
+
|
|
361
|
+
raw_chapters = await self._get_chapters()
|
|
362
|
+
if not raw_chapters:
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
resolvable: list[tuple[str, str, int | None]] = []
|
|
366
|
+
for item in raw_chapters:
|
|
367
|
+
volume_str: str = item["volume"]
|
|
368
|
+
number: str = item["number"]
|
|
369
|
+
branches = item.get("branches") or []
|
|
370
|
+
if len(branches) <= 1:
|
|
371
|
+
resolvable.append((volume_str, number, None))
|
|
372
|
+
continue
|
|
373
|
+
resolved, selected = _resolve_bulk_branch_id(
|
|
374
|
+
branches, branch_id=branch_id, translation_index=translation_index
|
|
375
|
+
)
|
|
376
|
+
if resolved:
|
|
377
|
+
resolvable.append((volume_str, number, selected))
|
|
378
|
+
|
|
379
|
+
if not resolvable:
|
|
380
|
+
return 0
|
|
381
|
+
|
|
382
|
+
size = min(sample_size, len(resolvable))
|
|
383
|
+
step = max(1, len(resolvable) // size)
|
|
384
|
+
sample = [resolvable[index] for index in range(0, len(resolvable), step)][:size]
|
|
385
|
+
|
|
386
|
+
total_sample_bytes = 0
|
|
387
|
+
with self._reporter.progress(f"Sampling {self._slug_url}", len(sample)) as advance:
|
|
388
|
+
for volume_str, number, selected_branch_id in sample:
|
|
389
|
+
chapter = await self._fetch_chapter(volume_str, number, selected_branch_id)
|
|
390
|
+
total_sample_bytes += chapter_size(chapter, average_image_size=average_image_size)
|
|
391
|
+
advance()
|
|
392
|
+
|
|
393
|
+
average_bytes = total_sample_bytes / len(sample)
|
|
394
|
+
return round(average_bytes * len(raw_chapters))
|
|
395
|
+
|
|
297
396
|
async def export(self, chapters: list[Chapter], *, fmt: str, path: str | Path) -> Path:
|
|
298
397
|
"""Export chapters to a file.
|
|
299
398
|
|
|
@@ -311,7 +410,8 @@ class RanobeLib:
|
|
|
311
410
|
available = ", ".join(sorted(EXPORTERS)) or "(none registered)"
|
|
312
411
|
raise ValueError(f"Unknown export format {fmt!r}. Available: {available}")
|
|
313
412
|
title = await self.get_info()
|
|
314
|
-
|
|
413
|
+
with self._reporter.progress(f"Exporting to {fmt}", len(chapters)) as advance:
|
|
414
|
+
return await exporter_cls().export(title, chapters, Path(path), on_chapter=advance)
|
|
315
415
|
|
|
316
416
|
async def _build_volume(self, volume: int, raw_chapters: list[dict[str, Any]]) -> Volume:
|
|
317
417
|
volume_str = str(volume)
|
|
@@ -330,7 +430,9 @@ class RanobeLib:
|
|
|
330
430
|
if not refresh:
|
|
331
431
|
cached = self._cache.get(key)
|
|
332
432
|
if cached is not None:
|
|
433
|
+
self._reporter.log(f"[cache] title metadata for {self._slug_url}")
|
|
333
434
|
return cached # type: ignore[no-any-return]
|
|
435
|
+
self._reporter.log(f"[api] fetching title metadata for {self._slug_url}")
|
|
334
436
|
data = await self._client.get_title(self._slug_url, fields=_INFO_FIELDS)
|
|
335
437
|
self._cache.set(key, data)
|
|
336
438
|
return data
|
|
@@ -340,7 +442,9 @@ class RanobeLib:
|
|
|
340
442
|
if not refresh:
|
|
341
443
|
cached = self._cache.get(key)
|
|
342
444
|
if cached is not None:
|
|
445
|
+
self._reporter.log(f"[cache] chapter list for {self._slug_url}")
|
|
343
446
|
return cached # type: ignore[no-any-return]
|
|
447
|
+
self._reporter.log(f"[api] fetching chapter list for {self._slug_url}")
|
|
344
448
|
data = await self._client.get_chapters(self._slug_url)
|
|
345
449
|
self._cache.set(key, data)
|
|
346
450
|
return data
|
|
@@ -352,7 +456,9 @@ class RanobeLib:
|
|
|
352
456
|
if not refresh:
|
|
353
457
|
cached = self._cache.get(key)
|
|
354
458
|
if cached is not None:
|
|
459
|
+
self._reporter.log(f"[cache] chapter {volume_str}/{number}")
|
|
355
460
|
return Chapter.model_validate(cached)
|
|
461
|
+
self._reporter.log(f"[api] fetching chapter {volume_str}/{number}")
|
|
356
462
|
data = await self._client.get_chapter(
|
|
357
463
|
self._slug_url, number=number, volume=volume_str, branch_id=branch_id
|
|
358
464
|
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Rough size estimation for already-fetched chapters/volumes, and whole-title sampling.
|
|
2
|
+
|
|
3
|
+
The API exposes no content-length or image-size field anywhere (see docs/api-notes.md), so
|
|
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 ``<img>`` is
|
|
6
|
+
assumed to weigh ``average_image_size`` bytes rather than downloaded to measure precisely —
|
|
7
|
+
doing that would cost the same network traffic this module exists to help avoid paying
|
|
8
|
+
upfront. The result approximates the underlying content size, not a specific export
|
|
9
|
+
format's file size (epub's zip compression, pdf's layout overhead, fb2's XML verbosity are
|
|
10
|
+
not modeled) — enough to decide whether a title is worth downloading, not to predict an
|
|
11
|
+
exported file's exact byte count.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from ranobelib.exporters._illustrations import extract_image_urls
|
|
17
|
+
from ranobelib.models import Chapter, Volume
|
|
18
|
+
|
|
19
|
+
DEFAULT_AVERAGE_IMAGE_SIZE = 150_000
|
|
20
|
+
"""Assumed bytes per embedded image when its real size isn't fetched. A rough guess, not
|
|
21
|
+
measured — override with a better estimate for a given title if you have one."""
|
|
22
|
+
|
|
23
|
+
DEFAULT_SAMPLE_SIZE = 5
|
|
24
|
+
"""Default number of chapters ``RanobeLib.estimate_title_size()`` samples."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def chapter_size(chapter: Chapter, *, average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE) -> int:
|
|
28
|
+
"""Estimate a fetched chapter's size in bytes: exact text plus assumed-average images.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
chapter: A chapter with content already fetched (e.g. via ``get_chapter()``).
|
|
32
|
+
average_image_size: Assumed bytes per ``<img>`` referenced in the chapter's content.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If ``chapter.content`` is ``None`` (not yet fetched — see
|
|
36
|
+
``Chapter``'s docstring for when that happens).
|
|
37
|
+
"""
|
|
38
|
+
if chapter.content is None:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"Chapter {chapter.volume}/{chapter.number} has no content to size — "
|
|
41
|
+
"fetch it first, e.g. via get_chapter()."
|
|
42
|
+
)
|
|
43
|
+
text_bytes = len(chapter.content.encode("utf-8"))
|
|
44
|
+
image_count = len(extract_image_urls(chapter.content))
|
|
45
|
+
return text_bytes + image_count * average_image_size
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def volume_size(volume: Volume, *, average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE) -> int:
|
|
49
|
+
"""Estimate a fetched volume's size in bytes: the sum of its chapters' ``chapter_size()``.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
volume: A volume whose chapters have content already fetched (e.g. via
|
|
53
|
+
``get_volume()``).
|
|
54
|
+
average_image_size: Forwarded to ``chapter_size()`` for each chapter.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If any chapter in ``volume.chapters`` has no content — see
|
|
58
|
+
``chapter_size()``.
|
|
59
|
+
"""
|
|
60
|
+
return sum(
|
|
61
|
+
chapter_size(chapter, average_image_size=average_image_size) for chapter in volume.chapters
|
|
62
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/_illustrations.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/_shared.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|