ranobelib-python-sdk 0.1.0__py3-none-any.whl
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/__init__.py +28 -0
- ranobelib/cache.py +64 -0
- ranobelib/client.py +210 -0
- ranobelib/exceptions.py +118 -0
- ranobelib/exporters/__init__.py +56 -0
- ranobelib/exporters/_illustrations.py +170 -0
- ranobelib/exporters/_shared.py +11 -0
- ranobelib/exporters/epub.py +117 -0
- ranobelib/exporters/fb2.py +164 -0
- ranobelib/exporters/pdf.py +147 -0
- ranobelib/exporters/txt.py +89 -0
- ranobelib/models.py +245 -0
- ranobelib/numbering.py +34 -0
- ranobelib/py.typed +0 -0
- ranobelib/sdk.py +321 -0
- ranobelib_python_sdk-0.1.0.dist-info/METADATA +100 -0
- ranobelib_python_sdk-0.1.0.dist-info/RECORD +18 -0
- ranobelib_python_sdk-0.1.0.dist-info/WHEEL +4 -0
ranobelib/models.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Pydantic models: Title, Chapter, Volume, Team, Branch, and related types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
SITE_BASE_URL = "https://ranobelib.me"
|
|
12
|
+
"""Base URL used to resolve relative asset paths (e.g. in-chapter image attachments)."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _prosemirror_to_text(doc: dict[str, Any]) -> str:
|
|
16
|
+
"""Flatten a prosemirror-doc JSON structure into plain text paragraphs.
|
|
17
|
+
|
|
18
|
+
This is a minimal parser (text leaves only, joined paragraph by paragraph) sufficient
|
|
19
|
+
for a title's summary. Chapter content will need a richer version that preserves
|
|
20
|
+
formatting (bold/italic/headings/lists) once that feature is implemented.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def extract(node: dict[str, Any]) -> str:
|
|
24
|
+
if node.get("type") == "text":
|
|
25
|
+
return str(node.get("text", ""))
|
|
26
|
+
return "".join(extract(child) for child in node.get("content", []))
|
|
27
|
+
|
|
28
|
+
paragraphs = [extract(block) for block in doc.get("content", [])]
|
|
29
|
+
return "\n\n".join(paragraph for paragraph in paragraphs if paragraph)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _child_nodes(node: dict[str, Any]) -> list[dict[str, Any]]:
|
|
33
|
+
content: list[dict[str, Any]] = node.get("content") or []
|
|
34
|
+
return content
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_attachment_url(image_id: str, attachments: list[dict[str, Any]]) -> str | None:
|
|
38
|
+
for attachment in attachments:
|
|
39
|
+
if attachment.get("name") == image_id:
|
|
40
|
+
url = attachment.get("url")
|
|
41
|
+
return f"{SITE_BASE_URL}{url}" if url else None
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _render_prosemirror_marks(text: str, marks: list[dict[str, Any]]) -> str:
|
|
46
|
+
for mark in marks:
|
|
47
|
+
if mark.get("type") == "bold":
|
|
48
|
+
text = f"<strong>{text}</strong>"
|
|
49
|
+
elif mark.get("type") == "italic":
|
|
50
|
+
text = f"<em>{text}</em>"
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _render_prosemirror_inline(node: dict[str, Any], attachments: list[dict[str, Any]]) -> str:
|
|
55
|
+
node_type = node.get("type")
|
|
56
|
+
if node_type == "text":
|
|
57
|
+
text = html.escape(str(node.get("text", "")))
|
|
58
|
+
return _render_prosemirror_marks(text, node.get("marks") or [])
|
|
59
|
+
if node_type == "hardBreak":
|
|
60
|
+
return "<br />"
|
|
61
|
+
if node_type == "image":
|
|
62
|
+
images = node.get("attrs", {}).get("images") or []
|
|
63
|
+
urls = (_resolve_attachment_url(image.get("image", ""), attachments) for image in images)
|
|
64
|
+
return "".join(f'<img loading="lazy" src="{url}" />' for url in urls if url)
|
|
65
|
+
return "".join(_render_prosemirror_inline(child, attachments) for child in _child_nodes(node))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_prosemirror_block(node: dict[str, Any], attachments: list[dict[str, Any]]) -> str:
|
|
69
|
+
node_type = node.get("type")
|
|
70
|
+
if node_type == "paragraph":
|
|
71
|
+
inner = "".join(
|
|
72
|
+
_render_prosemirror_inline(child, attachments) for child in _child_nodes(node)
|
|
73
|
+
)
|
|
74
|
+
return f"<p>{inner}</p>"
|
|
75
|
+
if node_type == "image":
|
|
76
|
+
return _render_prosemirror_inline(node, attachments)
|
|
77
|
+
if node_type == "horizontalRule":
|
|
78
|
+
return "<hr />"
|
|
79
|
+
return "".join(_render_prosemirror_block(child, attachments) for child in _child_nodes(node))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _prosemirror_to_html(doc: dict[str, Any], attachments: list[dict[str, Any]]) -> str:
|
|
83
|
+
"""Render a prosemirror-doc JSON structure as an HTML fragment.
|
|
84
|
+
|
|
85
|
+
Chapter content comes from the API in one of two formats — an HTML string, or
|
|
86
|
+
prosemirror-doc JSON (see docs/api-notes.md) — this makes the latter match the tag
|
|
87
|
+
vocabulary (``p``/``img``/``strong``/``em``) the former already uses natively, so
|
|
88
|
+
downstream consumers (exporters) only ever handle one shape. Image nodes reference
|
|
89
|
+
attachments by an opaque id, resolved against the chapter response's ``attachments``
|
|
90
|
+
array.
|
|
91
|
+
"""
|
|
92
|
+
return "".join(_render_prosemirror_block(block, attachments) for block in _child_nodes(doc))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Cover(BaseModel):
|
|
96
|
+
"""A set of cover image URLs at different sizes."""
|
|
97
|
+
|
|
98
|
+
filename: str | None = None
|
|
99
|
+
thumbnail: str | None = None
|
|
100
|
+
default: str | None = None
|
|
101
|
+
md: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Label(BaseModel):
|
|
105
|
+
"""A small ``{id, label}`` enum value used throughout the API (status, age rating, ...)."""
|
|
106
|
+
|
|
107
|
+
id: int
|
|
108
|
+
label: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Genre(BaseModel):
|
|
112
|
+
"""A genre tag (e.g. Fantasy, Romance)."""
|
|
113
|
+
|
|
114
|
+
id: int
|
|
115
|
+
name: str
|
|
116
|
+
adult: bool = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Tag(BaseModel):
|
|
120
|
+
"""A free-form content tag."""
|
|
121
|
+
|
|
122
|
+
id: int
|
|
123
|
+
name: str
|
|
124
|
+
adult: bool = False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Person(BaseModel):
|
|
128
|
+
"""An author or artist credited on a title."""
|
|
129
|
+
|
|
130
|
+
id: int
|
|
131
|
+
slug: str
|
|
132
|
+
slug_url: str
|
|
133
|
+
name: str
|
|
134
|
+
rus_name: str | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Team(BaseModel):
|
|
138
|
+
"""A translation team."""
|
|
139
|
+
|
|
140
|
+
id: int
|
|
141
|
+
slug: str
|
|
142
|
+
slug_url: str
|
|
143
|
+
name: str
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Title(BaseModel):
|
|
147
|
+
"""Metadata for a single title (novel)."""
|
|
148
|
+
|
|
149
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
150
|
+
|
|
151
|
+
id: int
|
|
152
|
+
name: str
|
|
153
|
+
rus_name: str | None = None
|
|
154
|
+
eng_name: str | None = None
|
|
155
|
+
other_names: list[str] = Field(default_factory=list, alias="otherNames")
|
|
156
|
+
slug: str
|
|
157
|
+
slug_url: str
|
|
158
|
+
cover: Cover
|
|
159
|
+
age_restriction: Label = Field(alias="ageRestriction")
|
|
160
|
+
status: Label
|
|
161
|
+
summary: str | None = None
|
|
162
|
+
release_date: str | None = Field(default=None, alias="releaseDate")
|
|
163
|
+
is_licensed: bool = False
|
|
164
|
+
genres: list[Genre] = Field(default_factory=list)
|
|
165
|
+
tags: list[Tag] = Field(default_factory=list)
|
|
166
|
+
authors: list[Person] = Field(default_factory=list)
|
|
167
|
+
artists: list[Person] = Field(default_factory=list)
|
|
168
|
+
teams: list[Team] = Field(default_factory=list)
|
|
169
|
+
chapter_count: int | None = None
|
|
170
|
+
|
|
171
|
+
@model_validator(mode="before")
|
|
172
|
+
@classmethod
|
|
173
|
+
def _flatten_chapter_count(cls, data: Any) -> Any:
|
|
174
|
+
if isinstance(data, dict) and "chapter_count" not in data:
|
|
175
|
+
items_count = data.get("items_count")
|
|
176
|
+
if isinstance(items_count, dict):
|
|
177
|
+
data = {**data, "chapter_count": items_count.get("uploaded")}
|
|
178
|
+
return data
|
|
179
|
+
|
|
180
|
+
@field_validator("summary", mode="before")
|
|
181
|
+
@classmethod
|
|
182
|
+
def _parse_summary(cls, value: Any) -> str | None:
|
|
183
|
+
if value is None:
|
|
184
|
+
return None
|
|
185
|
+
if isinstance(value, dict):
|
|
186
|
+
return _prosemirror_to_text(value)
|
|
187
|
+
return str(value)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ChapterUser(BaseModel):
|
|
191
|
+
"""The uploader of a chapter branch."""
|
|
192
|
+
|
|
193
|
+
id: int
|
|
194
|
+
username: str
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ChapterBranch(BaseModel):
|
|
198
|
+
"""A single team's (or solo uploader's) translation of a chapter.
|
|
199
|
+
|
|
200
|
+
A chapter has more than one branch when several teams have translated it
|
|
201
|
+
independently; see ``Chapter.branches_count``.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
id: int
|
|
205
|
+
branch_id: int | None = None
|
|
206
|
+
created_at: datetime
|
|
207
|
+
teams: list[Team] = Field(default_factory=list)
|
|
208
|
+
user: ChapterUser
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Chapter(BaseModel):
|
|
212
|
+
"""A chapter: volume, number, name, available translations, and optionally its content.
|
|
213
|
+
|
|
214
|
+
``index``/``item_number``/``branches_count``/``branches`` come from the chapter-list
|
|
215
|
+
endpoint (see ``RanobeLib.get_table_of_contents()``) and are ``None``/empty when a
|
|
216
|
+
``Chapter`` instead comes from fetching a single chapter's content, which has a
|
|
217
|
+
different response shape (see docs/api-notes.md). ``content`` is the reverse: only
|
|
218
|
+
populated by the single-chapter endpoint.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
id: int
|
|
222
|
+
volume: str
|
|
223
|
+
number: str
|
|
224
|
+
name: str | None = None
|
|
225
|
+
index: int | None = None
|
|
226
|
+
item_number: int | None = None
|
|
227
|
+
branches_count: int = 1
|
|
228
|
+
branches: list[ChapterBranch] = Field(default_factory=list)
|
|
229
|
+
bundle_id: int | None = None
|
|
230
|
+
content: str | None = None
|
|
231
|
+
|
|
232
|
+
@model_validator(mode="before")
|
|
233
|
+
@classmethod
|
|
234
|
+
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)}
|
|
238
|
+
return data
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Volume(BaseModel):
|
|
242
|
+
"""A volume: its number and the chapters it contains, in title order."""
|
|
243
|
+
|
|
244
|
+
number: str
|
|
245
|
+
chapters: list[Chapter] = Field(default_factory=list)
|
ranobelib/numbering.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Chapter numbering helpers (``number`` / ``number_secondary``) and title URL parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
_SLUG_URL_RE = re.compile(r"^\d+--[^/?#]+$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_slug_url(source: str) -> str:
|
|
12
|
+
"""Extract a title's ``slug_url`` (``{id}--{slug}``) from a URL or pass it through.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
source: Either a full title URL (e.g.
|
|
16
|
+
``https://ranobelib.me/ru/book/6712--high-school-dxd-novel``) or an already
|
|
17
|
+
bare ``slug_url`` (``6712--high-school-dxd-novel``).
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The bare ``slug_url``, as used by the ``/api/manga/{slug_url}`` endpoint.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ValueError: If no ``{id}--{slug}`` segment could be found in ``source``.
|
|
24
|
+
"""
|
|
25
|
+
candidate = source
|
|
26
|
+
if "://" in source:
|
|
27
|
+
path = urlsplit(source).path
|
|
28
|
+
segments = [segment for segment in path.split("/") if segment]
|
|
29
|
+
candidate = segments[-1] if segments else ""
|
|
30
|
+
|
|
31
|
+
if not _SLUG_URL_RE.match(candidate):
|
|
32
|
+
raise ValueError(f"Could not extract a title slug from: {source!r}")
|
|
33
|
+
|
|
34
|
+
return candidate
|
ranobelib/py.typed
ADDED
|
File without changes
|
ranobelib/sdk.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""Public facade of the SDK: the ``RanobeLib`` class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, Self
|
|
8
|
+
|
|
9
|
+
from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
|
|
10
|
+
from ranobelib.client import ApiClient
|
|
11
|
+
from ranobelib.exceptions import (
|
|
12
|
+
ChapterNotFoundError,
|
|
13
|
+
MultipleTranslationsError,
|
|
14
|
+
VolumeNotFoundError,
|
|
15
|
+
)
|
|
16
|
+
from ranobelib.exporters import EXPORTERS
|
|
17
|
+
from ranobelib.models import Chapter, ChapterBranch, Title, Volume
|
|
18
|
+
from ranobelib.numbering import parse_slug_url
|
|
19
|
+
|
|
20
|
+
_INFO_FIELDS = [
|
|
21
|
+
"background",
|
|
22
|
+
"eng_name",
|
|
23
|
+
"otherNames",
|
|
24
|
+
"summary",
|
|
25
|
+
"releaseDate",
|
|
26
|
+
"genres",
|
|
27
|
+
"tags",
|
|
28
|
+
"teams",
|
|
29
|
+
"authors",
|
|
30
|
+
"artists",
|
|
31
|
+
"chap_count",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RanobeLib:
|
|
36
|
+
"""Entry point for interacting with a single ranobelib.me title.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
```python
|
|
40
|
+
async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
|
|
41
|
+
info = await lib.get_info()
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
url: str,
|
|
48
|
+
*,
|
|
49
|
+
cache_dir: str | Path | None = None,
|
|
50
|
+
cache_ttl: float | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Initialize the SDK for a title.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
url: A ranobelib.me title URL, or a bare ``{id}--{slug}`` identifier.
|
|
56
|
+
cache_dir: Where to cache raw API responses (title metadata, chapter list,
|
|
57
|
+
chapter content) on disk, so a repeated export or downloading newly added
|
|
58
|
+
chapters doesn't re-fetch data already on hand. Defaults to
|
|
59
|
+
``.ranobelib_cache`` in the current working directory.
|
|
60
|
+
cache_ttl: Seconds after which a cached response is treated as stale and
|
|
61
|
+
re-fetched. ``None`` (the default) means cached responses never expire on
|
|
62
|
+
their own — see ``refresh=True`` on individual methods to force one anyway.
|
|
63
|
+
"""
|
|
64
|
+
self._slug_url = parse_slug_url(url)
|
|
65
|
+
self._client = ApiClient()
|
|
66
|
+
self._cache = DiskCache(
|
|
67
|
+
cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def __aenter__(self) -> Self:
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(
|
|
74
|
+
self,
|
|
75
|
+
exc_type: type[BaseException] | None,
|
|
76
|
+
exc: BaseException | None,
|
|
77
|
+
traceback: TracebackType | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
await self.aclose()
|
|
80
|
+
|
|
81
|
+
async def aclose(self) -> None:
|
|
82
|
+
"""Close the underlying HTTP client."""
|
|
83
|
+
await self._client.aclose()
|
|
84
|
+
|
|
85
|
+
async def get_info(self, *, refresh: bool = False) -> Title:
|
|
86
|
+
"""Fetch title metadata: names, cover, summary, genres, tags, authors, teams.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
refresh: Bypass the disk cache and re-fetch from the API even if a cached
|
|
90
|
+
response is on hand.
|
|
91
|
+
"""
|
|
92
|
+
data = await self._get_title(refresh=refresh)
|
|
93
|
+
return Title.model_validate(data)
|
|
94
|
+
|
|
95
|
+
async def get_table_of_contents(self, *, refresh: bool = False) -> list[Volume]:
|
|
96
|
+
"""Fetch the title's volumes and chapter names/numbers, without chapter content.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
refresh: Bypass the disk cache and re-fetch from the API even if a cached
|
|
100
|
+
response is on hand — needed to pick up newly published chapters, since
|
|
101
|
+
otherwise the cached chapter list would keep being reused.
|
|
102
|
+
"""
|
|
103
|
+
raw_chapters = await self._get_chapters(refresh=refresh)
|
|
104
|
+
chapters = [Chapter.model_validate(item) for item in raw_chapters]
|
|
105
|
+
return _group_into_volumes(chapters)
|
|
106
|
+
|
|
107
|
+
async def get_chapter(
|
|
108
|
+
self,
|
|
109
|
+
volume: int,
|
|
110
|
+
number: str,
|
|
111
|
+
*,
|
|
112
|
+
branch_id: int | None = None,
|
|
113
|
+
refresh: bool = False,
|
|
114
|
+
) -> Chapter:
|
|
115
|
+
"""Fetch a single chapter, including its content.
|
|
116
|
+
|
|
117
|
+
When a chapter has more than one team's translation and ``branch_id`` isn't given,
|
|
118
|
+
this raises ``MultipleTranslationsError`` rather than guessing — see
|
|
119
|
+
``get_translations()`` and docs/api-notes.md for why the API's own default (when
|
|
120
|
+
``branch_id`` is omitted) isn't relied on. Doing this check costs an extra request
|
|
121
|
+
to fetch the chapter list, only when ``branch_id`` isn't already given.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
volume: The chapter's volume number.
|
|
125
|
+
number: The chapter number, as returned by the API — may contain a decimal
|
|
126
|
+
(e.g. ``"51.6"``).
|
|
127
|
+
branch_id: Which translation to fetch, as returned by ``get_translations()``.
|
|
128
|
+
Only required when the chapter has more than one.
|
|
129
|
+
refresh: Bypass the disk cache and re-fetch from the API even if a cached
|
|
130
|
+
response is on hand.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
MultipleTranslationsError: If the chapter has more than one translation and
|
|
134
|
+
``branch_id`` wasn't given.
|
|
135
|
+
"""
|
|
136
|
+
volume_str = str(volume)
|
|
137
|
+
if branch_id is None:
|
|
138
|
+
raw_chapters = await self._get_chapters(refresh=refresh)
|
|
139
|
+
branch_id = self._resolve_branch_id(volume_str, number, raw_chapters)
|
|
140
|
+
return await self._fetch_chapter(volume_str, number, branch_id, refresh=refresh)
|
|
141
|
+
|
|
142
|
+
async def get_chapters(self, chapters: list[tuple[int, str]]) -> list[Chapter]:
|
|
143
|
+
"""Fetch several chapters, including their content.
|
|
144
|
+
|
|
145
|
+
Fetches the chapter list once, shared across all requested chapters (also used to
|
|
146
|
+
detect chapters with more than one translation — see ``get_chapter``), then each
|
|
147
|
+
chapter individually and sequentially, in the order given.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
chapters: A list of ``(volume, number)`` pairs identifying each chapter.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ChapterNotFoundError: If any requested chapter doesn't exist.
|
|
154
|
+
MultipleTranslationsError: If any requested chapter has more than one
|
|
155
|
+
translation.
|
|
156
|
+
"""
|
|
157
|
+
raw_chapters = await self._get_chapters()
|
|
158
|
+
result = []
|
|
159
|
+
for volume, number in chapters:
|
|
160
|
+
volume_str = str(volume)
|
|
161
|
+
branch_id = self._resolve_branch_id(volume_str, number, raw_chapters)
|
|
162
|
+
result.append(await self._fetch_chapter(volume_str, number, branch_id))
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
async def get_translations(self, volume: int, number: str) -> list[ChapterBranch]:
|
|
166
|
+
"""Fetch the available translations (branches) for a chapter.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
volume: The chapter's volume number.
|
|
170
|
+
number: The chapter number.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ChapterNotFoundError: If no chapter exists for this volume/number.
|
|
174
|
+
"""
|
|
175
|
+
volume_str = str(volume)
|
|
176
|
+
raw_chapters = await self._get_chapters()
|
|
177
|
+
item = _find_raw_chapter(raw_chapters, volume_str, number)
|
|
178
|
+
if item is None:
|
|
179
|
+
raise ChapterNotFoundError(self._slug_url, volume=volume_str, number=number)
|
|
180
|
+
return [ChapterBranch.model_validate(branch) for branch in item.get("branches") or []]
|
|
181
|
+
|
|
182
|
+
async def get_volume(self, volume: int) -> Volume:
|
|
183
|
+
"""Fetch a whole volume: all its chapters, each including content.
|
|
184
|
+
|
|
185
|
+
The API has no bulk "volume content" endpoint (see docs/api-notes.md), so this
|
|
186
|
+
fetches the chapter list once to find which numbers belong to the volume, then
|
|
187
|
+
fetches each of those chapters individually and sequentially.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
volume: The volume number.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
VolumeNotFoundError: If the title has no chapters for this volume.
|
|
194
|
+
MultipleTranslationsError: If any of the volume's chapters has more than one
|
|
195
|
+
translation.
|
|
196
|
+
"""
|
|
197
|
+
raw_chapters = await self._get_chapters()
|
|
198
|
+
return await self._build_volume(volume, raw_chapters)
|
|
199
|
+
|
|
200
|
+
async def get_volumes(self, volumes: list[int]) -> list[Volume]:
|
|
201
|
+
"""Fetch several whole volumes, each including chapter content.
|
|
202
|
+
|
|
203
|
+
Fetches the chapter list once, shared across all requested volumes, then each
|
|
204
|
+
chapter individually and sequentially — same approach as ``get_volume``, applied
|
|
205
|
+
to more than one volume without re-fetching the chapter list per volume.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
volumes: The volume numbers to fetch.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
VolumeNotFoundError: If the title has no chapters for one of the volumes.
|
|
212
|
+
MultipleTranslationsError: If any of the volumes' chapters has more than one
|
|
213
|
+
translation.
|
|
214
|
+
"""
|
|
215
|
+
raw_chapters = await self._get_chapters()
|
|
216
|
+
return [await self._build_volume(volume, raw_chapters) for volume in volumes]
|
|
217
|
+
|
|
218
|
+
async def export(self, chapters: list[Chapter], *, fmt: str, path: str | Path) -> Path:
|
|
219
|
+
"""Export chapters to a file.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
chapters: The chapters to include, in the order they should appear.
|
|
223
|
+
path: Where to write the exported file.
|
|
224
|
+
fmt: Export format — a key of ``ranobelib.exporters.EXPORTERS``
|
|
225
|
+
(currently: ``"txt"``, ``"fb2"``, ``"epub"``).
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
ValueError: If ``fmt`` isn't a registered export format.
|
|
229
|
+
"""
|
|
230
|
+
exporter_cls = EXPORTERS.get(fmt)
|
|
231
|
+
if exporter_cls is None:
|
|
232
|
+
available = ", ".join(sorted(EXPORTERS)) or "(none registered)"
|
|
233
|
+
raise ValueError(f"Unknown export format {fmt!r}. Available: {available}")
|
|
234
|
+
title = await self.get_info()
|
|
235
|
+
return await exporter_cls().export(title, chapters, Path(path))
|
|
236
|
+
|
|
237
|
+
async def _build_volume(self, volume: int, raw_chapters: list[dict[str, Any]]) -> Volume:
|
|
238
|
+
volume_str = str(volume)
|
|
239
|
+
numbers = [item["number"] for item in raw_chapters if item.get("volume") == volume_str]
|
|
240
|
+
if not numbers:
|
|
241
|
+
raise VolumeNotFoundError(self._slug_url, volume=volume_str)
|
|
242
|
+
|
|
243
|
+
chapters = []
|
|
244
|
+
for number in numbers:
|
|
245
|
+
branch_id = self._resolve_branch_id(volume_str, number, raw_chapters)
|
|
246
|
+
chapters.append(await self._fetch_chapter(volume_str, number, branch_id))
|
|
247
|
+
return Volume(number=volume_str, chapters=chapters)
|
|
248
|
+
|
|
249
|
+
async def _get_title(self, *, refresh: bool = False) -> dict[str, Any]:
|
|
250
|
+
key = f"title:{self._slug_url}:{','.join(sorted(_INFO_FIELDS))}"
|
|
251
|
+
if not refresh:
|
|
252
|
+
cached = self._cache.get(key)
|
|
253
|
+
if cached is not None:
|
|
254
|
+
return cached # type: ignore[no-any-return]
|
|
255
|
+
data = await self._client.get_title(self._slug_url, fields=_INFO_FIELDS)
|
|
256
|
+
self._cache.set(key, data)
|
|
257
|
+
return data
|
|
258
|
+
|
|
259
|
+
async def _get_chapters(self, *, refresh: bool = False) -> list[dict[str, Any]]:
|
|
260
|
+
key = f"chapters:{self._slug_url}"
|
|
261
|
+
if not refresh:
|
|
262
|
+
cached = self._cache.get(key)
|
|
263
|
+
if cached is not None:
|
|
264
|
+
return cached # type: ignore[no-any-return]
|
|
265
|
+
data = await self._client.get_chapters(self._slug_url)
|
|
266
|
+
self._cache.set(key, data)
|
|
267
|
+
return data
|
|
268
|
+
|
|
269
|
+
async def _fetch_chapter(
|
|
270
|
+
self, volume_str: str, number: str, branch_id: int | None, *, refresh: bool = False
|
|
271
|
+
) -> Chapter:
|
|
272
|
+
key = f"chapter:{self._slug_url}:{volume_str}:{number}:{branch_id}"
|
|
273
|
+
if not refresh:
|
|
274
|
+
cached = self._cache.get(key)
|
|
275
|
+
if cached is not None:
|
|
276
|
+
return Chapter.model_validate(cached)
|
|
277
|
+
data = await self._client.get_chapter(
|
|
278
|
+
self._slug_url, number=number, volume=volume_str, branch_id=branch_id
|
|
279
|
+
)
|
|
280
|
+
self._cache.set(key, data)
|
|
281
|
+
return Chapter.model_validate(data)
|
|
282
|
+
|
|
283
|
+
def _resolve_branch_id(
|
|
284
|
+
self, volume_str: str, number: str, raw_chapters: list[dict[str, Any]]
|
|
285
|
+
) -> int | None:
|
|
286
|
+
item = _find_raw_chapter(raw_chapters, volume_str, number)
|
|
287
|
+
if item is None:
|
|
288
|
+
return None # Let the chapter fetch itself raise ChapterNotFoundError.
|
|
289
|
+
|
|
290
|
+
branches = item.get("branches") or []
|
|
291
|
+
if len(branches) <= 1:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
raise MultipleTranslationsError(
|
|
295
|
+
self._slug_url,
|
|
296
|
+
volume=volume_str,
|
|
297
|
+
number=number,
|
|
298
|
+
branches=[ChapterBranch.model_validate(branch) for branch in branches],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _group_into_volumes(chapters: list[Chapter]) -> list[Volume]:
|
|
303
|
+
"""Group a flat, API-ordered chapter list into volumes, preserving that order."""
|
|
304
|
+
grouped: dict[str, list[Chapter]] = {}
|
|
305
|
+
for chapter in chapters:
|
|
306
|
+
grouped.setdefault(chapter.volume, []).append(chapter)
|
|
307
|
+
return [Volume(number=number, chapters=items) for number, items in grouped.items()]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _find_raw_chapter(
|
|
311
|
+
raw_chapters: list[dict[str, Any]], volume_str: str, number: str
|
|
312
|
+
) -> dict[str, Any] | None:
|
|
313
|
+
"""Find a chapter-list entry by volume/number, or ``None`` if there isn't one."""
|
|
314
|
+
return next(
|
|
315
|
+
(
|
|
316
|
+
item
|
|
317
|
+
for item in raw_chapters
|
|
318
|
+
if item.get("volume") == volume_str and item.get("number") == number
|
|
319
|
+
),
|
|
320
|
+
None,
|
|
321
|
+
)
|