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/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
+ )