ranobelib-python-sdk 0.11.0__tar.gz → 0.13.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.11.0 → ranobelib_python_sdk-0.13.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/__init__.py +11 -0
- ranobelib_python_sdk-0.13.0/src/ranobelib/cache.py +179 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/catalog.py +70 -10
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/client.py +31 -4
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/_illustrations.py +26 -13
- ranobelib_python_sdk-0.13.0/src/ranobelib/images.py +73 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/models.py +54 -2
- ranobelib_python_sdk-0.13.0/src/ranobelib/paragraphs.py +85 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/sdk.py +1 -0
- ranobelib_python_sdk-0.11.0/src/ranobelib/cache.py +0 -64
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/README.md +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/_http.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exceptions.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/__init__.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/_shared.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/epub.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/fb2.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/pdf.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/txt.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/py.typed +0 -0
- {ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/sizing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ranobelib-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.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
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Async Python SDK for ranobelib.me."""
|
|
2
2
|
|
|
3
|
+
from ranobelib.cache import CacheStats, cache_stats, clear_cache
|
|
3
4
|
from ranobelib.catalog import Catalog
|
|
4
5
|
from ranobelib.exceptions import (
|
|
5
6
|
AccessBlockedError,
|
|
@@ -14,6 +15,7 @@ from ranobelib.exceptions import (
|
|
|
14
15
|
TitleNotFoundError,
|
|
15
16
|
VolumeNotFoundError,
|
|
16
17
|
)
|
|
18
|
+
from ranobelib.images import chapter_image_urls, rewrite_image_urls
|
|
17
19
|
from ranobelib.models import (
|
|
18
20
|
CatalogPage,
|
|
19
21
|
Chapter,
|
|
@@ -21,9 +23,11 @@ from ranobelib.models import (
|
|
|
21
23
|
Country,
|
|
22
24
|
Footnote,
|
|
23
25
|
Genre,
|
|
26
|
+
Label,
|
|
24
27
|
Title,
|
|
25
28
|
Volume,
|
|
26
29
|
)
|
|
30
|
+
from ranobelib.paragraphs import chapter_paragraphs
|
|
27
31
|
from ranobelib.sdk import RanobeLib
|
|
28
32
|
from ranobelib.sizing import chapter_size, volume_size
|
|
29
33
|
|
|
@@ -31,6 +35,7 @@ __all__ = [
|
|
|
31
35
|
"AccessBlockedError",
|
|
32
36
|
"AmbiguousChapter",
|
|
33
37
|
"AuthRequiredError",
|
|
38
|
+
"CacheStats",
|
|
34
39
|
"Catalog",
|
|
35
40
|
"CatalogPage",
|
|
36
41
|
"Chapter",
|
|
@@ -40,6 +45,7 @@ __all__ = [
|
|
|
40
45
|
"DownloadTitleInterruptedError",
|
|
41
46
|
"Footnote",
|
|
42
47
|
"Genre",
|
|
48
|
+
"Label",
|
|
43
49
|
"MultipleTitleTranslationsError",
|
|
44
50
|
"MultipleTranslationsError",
|
|
45
51
|
"RanobeLib",
|
|
@@ -49,6 +55,11 @@ __all__ = [
|
|
|
49
55
|
"TitleNotFoundError",
|
|
50
56
|
"Volume",
|
|
51
57
|
"VolumeNotFoundError",
|
|
58
|
+
"cache_stats",
|
|
59
|
+
"chapter_image_urls",
|
|
60
|
+
"chapter_paragraphs",
|
|
52
61
|
"chapter_size",
|
|
62
|
+
"clear_cache",
|
|
63
|
+
"rewrite_image_urls",
|
|
53
64
|
"volume_size",
|
|
54
65
|
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Disk cache for raw JSON API responses, keyed by request identity.
|
|
2
|
+
|
|
3
|
+
Also the public cache-maintenance helpers, ``cache_stats()`` and ``clear_cache()`` — the
|
|
4
|
+
on-disk layout is an SDK internal, so code outside the SDK should go through these rather
|
|
5
|
+
than measure or delete the cache's files itself.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
DEFAULT_CACHE_DIR = Path(".ranobelib_cache")
|
|
20
|
+
"""Default cache directory, relative to the current working directory."""
|
|
21
|
+
|
|
22
|
+
_ENTRY_NAME = re.compile(r"[0-9a-f]{64}\.json")
|
|
23
|
+
"""Name of a file ``DiskCache`` writes (``_path_for``) — anything else in the directory
|
|
24
|
+
isn't the cache's and is never counted or removed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CacheStats:
|
|
29
|
+
"""How many cache entries there are (or were removed), and their total size on disk."""
|
|
30
|
+
|
|
31
|
+
entries: int
|
|
32
|
+
bytes: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DiskCache:
|
|
36
|
+
"""Caches JSON-serializable values to disk, keyed by an arbitrary string.
|
|
37
|
+
|
|
38
|
+
One file per key, named by the key's hash (so keys built from slugs, chapter numbers,
|
|
39
|
+
etc. are always safe filenames), holding the cached value plus the time it was written
|
|
40
|
+
so ``ttl`` can be checked on read without touching any other entry. Corrupted or
|
|
41
|
+
unreadable entries are treated as a cache miss rather than raised as an error, since a
|
|
42
|
+
stale/broken cache file shouldn't stop the SDK from just re-fetching the data.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
cache_dir: str | Path,
|
|
48
|
+
*,
|
|
49
|
+
ttl: float | None = None,
|
|
50
|
+
clock: Callable[[], float] = time.time,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Initialize the cache.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
cache_dir: Directory to store cache files in. Created lazily on first write.
|
|
56
|
+
ttl: Seconds after which a cache entry is treated as a miss. ``None`` (the
|
|
57
|
+
default) means entries never expire.
|
|
58
|
+
clock: Time source used to stamp and check entries. Overridable for tests.
|
|
59
|
+
"""
|
|
60
|
+
self._dir = Path(cache_dir)
|
|
61
|
+
self._ttl = ttl
|
|
62
|
+
self._clock = clock
|
|
63
|
+
|
|
64
|
+
def get(self, key: str) -> Any | None:
|
|
65
|
+
"""Return the cached value for ``key``, or ``None`` on a miss or expiry."""
|
|
66
|
+
try:
|
|
67
|
+
payload = json.loads(self._path_for(key).read_text(encoding="utf-8"))
|
|
68
|
+
except (OSError, json.JSONDecodeError, KeyError):
|
|
69
|
+
return None
|
|
70
|
+
if self._ttl is not None and self._clock() - payload["cached_at"] > self._ttl:
|
|
71
|
+
return None
|
|
72
|
+
return payload["data"]
|
|
73
|
+
|
|
74
|
+
def set(self, key: str, data: Any) -> None:
|
|
75
|
+
"""Write ``data`` to disk under ``key``, creating the cache directory if needed."""
|
|
76
|
+
path = self._path_for(key)
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
payload = {"cached_at": self._clock(), "data": data}
|
|
79
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
def stats(self) -> CacheStats:
|
|
82
|
+
"""Count the cache's entries and their total size. See ``cache_stats()``."""
|
|
83
|
+
entries = size = 0
|
|
84
|
+
for path in self._entry_paths():
|
|
85
|
+
try:
|
|
86
|
+
size += path.stat().st_size
|
|
87
|
+
except OSError: # Removed since it was listed.
|
|
88
|
+
continue
|
|
89
|
+
entries += 1
|
|
90
|
+
return CacheStats(entries=entries, bytes=size)
|
|
91
|
+
|
|
92
|
+
def clear(self, *, older_than: float | None = None) -> CacheStats:
|
|
93
|
+
"""Remove the cache's entries, or only those older than ``older_than`` seconds.
|
|
94
|
+
|
|
95
|
+
See ``clear_cache()``.
|
|
96
|
+
"""
|
|
97
|
+
if older_than is not None and older_than < 0:
|
|
98
|
+
raise ValueError(f"older_than must be >= 0, got {older_than}")
|
|
99
|
+
now = self._clock()
|
|
100
|
+
entries = size = 0
|
|
101
|
+
for path in self._entry_paths():
|
|
102
|
+
if older_than is not None and not self._is_older_than(path, now - older_than):
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
entry_size = path.stat().st_size
|
|
106
|
+
path.unlink()
|
|
107
|
+
except OSError:
|
|
108
|
+
# Already removed by someone else, or (on Windows) open in another process
|
|
109
|
+
# right now — either way, not ours to count as removed.
|
|
110
|
+
continue
|
|
111
|
+
entries += 1
|
|
112
|
+
size += entry_size
|
|
113
|
+
return CacheStats(entries=entries, bytes=size)
|
|
114
|
+
|
|
115
|
+
def _is_older_than(self, path: Path, cutoff: float) -> bool:
|
|
116
|
+
try:
|
|
117
|
+
cached_at = float(json.loads(path.read_text(encoding="utf-8"))["cached_at"])
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
return False
|
|
120
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
121
|
+
# Unreadable/corrupted: get() can never serve it, so it's as good as expired.
|
|
122
|
+
return True
|
|
123
|
+
return cached_at < cutoff
|
|
124
|
+
|
|
125
|
+
def _entry_paths(self) -> Iterator[Path]:
|
|
126
|
+
try:
|
|
127
|
+
children = list(self._dir.iterdir())
|
|
128
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
129
|
+
return
|
|
130
|
+
for path in children:
|
|
131
|
+
if _ENTRY_NAME.fullmatch(path.name) and path.is_file() and not path.is_symlink():
|
|
132
|
+
yield path
|
|
133
|
+
|
|
134
|
+
def _path_for(self, key: str) -> Path:
|
|
135
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
136
|
+
return self._dir / f"{digest}.json"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cache_stats(cache_dir: str | Path = DEFAULT_CACHE_DIR) -> CacheStats:
|
|
140
|
+
"""Count the entries in an SDK disk cache and their total size on disk.
|
|
141
|
+
|
|
142
|
+
Only files the cache itself writes are counted, not anything else that happens to be in
|
|
143
|
+
the directory.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
cache_dir: The cache directory — the same ``cache_dir`` passed to ``RanobeLib`` /
|
|
147
|
+
``Catalog`` (default: theirs, ``.ranobelib_cache`` in the current directory).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The number of entries and their total size in bytes. A missing directory is an empty
|
|
151
|
+
cache, ``CacheStats(entries=0, bytes=0)``, not an error.
|
|
152
|
+
"""
|
|
153
|
+
return DiskCache(cache_dir).stats()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def clear_cache(
|
|
157
|
+
cache_dir: str | Path = DEFAULT_CACHE_DIR, *, older_than: float | None = None
|
|
158
|
+
) -> CacheStats:
|
|
159
|
+
"""Remove entries from an SDK disk cache.
|
|
160
|
+
|
|
161
|
+
Only files the cache itself writes are removed — anything else in the directory, and the
|
|
162
|
+
directory itself, is left alone. Safe to call while clients are using the same cache: an
|
|
163
|
+
entry being read as it's removed is just a cache miss, and one written after it's
|
|
164
|
+
removed is simply recreated.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
cache_dir: The cache directory — see ``cache_stats()``.
|
|
168
|
+
older_than: If given, only remove entries written more than this many seconds ago
|
|
169
|
+
(the same age check ``cache_ttl`` uses), plus any corrupted entry, which the
|
|
170
|
+
cache can't serve anyway. ``None`` (the default) removes every entry.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The number of entries removed and the bytes they took. A missing directory removes
|
|
174
|
+
nothing, ``CacheStats(entries=0, bytes=0)``, rather than raising.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If ``older_than`` is negative.
|
|
178
|
+
"""
|
|
179
|
+
return DiskCache(cache_dir).clear(older_than=older_than)
|
|
@@ -14,7 +14,7 @@ from typing import Any, Self
|
|
|
14
14
|
|
|
15
15
|
from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
|
|
16
16
|
from ranobelib.client import RANOBELIB_SITE_ID, ApiClient
|
|
17
|
-
from ranobelib.models import CatalogPage, Country, Genre, Title
|
|
17
|
+
from ranobelib.models import CatalogPage, Country, Genre, Label, Title
|
|
18
18
|
|
|
19
19
|
DEFAULT_SORT = "last_chapter_at"
|
|
20
20
|
"""Default sort order: title's last-chapter timestamp — the closest real equivalent to
|
|
@@ -30,7 +30,7 @@ class Catalog:
|
|
|
30
30
|
Example:
|
|
31
31
|
```python
|
|
32
32
|
async with Catalog() as catalog:
|
|
33
|
-
page = await catalog.list_titles(query="dxd", genres=[34],
|
|
33
|
+
page = await catalog.list_titles(query="dxd", genres=[34], statuses=[1])
|
|
34
34
|
for title in page.items:
|
|
35
35
|
print(title.name)
|
|
36
36
|
```
|
|
@@ -84,8 +84,10 @@ class Catalog:
|
|
|
84
84
|
query: str | None = None,
|
|
85
85
|
genres: list[int] | None = None,
|
|
86
86
|
tags: list[int] | None = None,
|
|
87
|
-
|
|
87
|
+
statuses: list[int] | None = None,
|
|
88
88
|
countries: list[int] | None = None,
|
|
89
|
+
min_chapters: int | None = None,
|
|
90
|
+
max_chapters: int | None = None,
|
|
89
91
|
sort: str = DEFAULT_SORT,
|
|
90
92
|
refresh: bool = False,
|
|
91
93
|
) -> CatalogPage:
|
|
@@ -107,7 +109,10 @@ class Catalog:
|
|
|
107
109
|
against the live API, see docs/api-notes.md; not the OR some might expect
|
|
108
110
|
from tags being more numerous/specific than genres). Also not validated
|
|
109
111
|
before sending, same as ``genres``.
|
|
110
|
-
|
|
112
|
+
statuses: ``Title.status.id``s to filter by (e.g. ongoing, completed). A title
|
|
113
|
+
matches if its own status is *any* of these (OR, same as ``countries`` — a
|
|
114
|
+
title only has one status, see docs/api-notes.md). ``None`` or an empty list
|
|
115
|
+
applies no status filter. Ids come from ``list_statuses()``.
|
|
111
116
|
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
112
117
|
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
113
118
|
country of origin, so requiring all of them could never match past the first,
|
|
@@ -116,6 +121,11 @@ class Catalog:
|
|
|
116
121
|
parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
|
|
117
122
|
why (``Country`` mirrors the API's own "type" concept, which isn't strictly
|
|
118
123
|
limited to literal countries).
|
|
124
|
+
min_chapters: Only titles with at least this many chapters (inclusive). Counts
|
|
125
|
+
the same number as ``Title.chapter_count`` (uploaded chapters). ``None``
|
|
126
|
+
(the default) applies no lower bound.
|
|
127
|
+
max_chapters: Only titles with at most this many chapters (inclusive). ``None``
|
|
128
|
+
(the default) applies no upper bound.
|
|
119
129
|
sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
|
|
120
130
|
parameter — an actual ``sort`` parameter exists but is silently ignored by
|
|
121
131
|
the API (see docs/api-notes.md). Known accepted values: ``"name"``,
|
|
@@ -130,8 +140,9 @@ class Catalog:
|
|
|
130
140
|
page exists.
|
|
131
141
|
|
|
132
142
|
Raises:
|
|
133
|
-
ValueError: If ``page`` is less than 1,
|
|
134
|
-
``MIN_PER_PAGE..MAX_PER_PAGE
|
|
143
|
+
ValueError: If ``page`` is less than 1, ``per_page`` is outside
|
|
144
|
+
``MIN_PER_PAGE..MAX_PER_PAGE``, ``min_chapters``/``max_chapters`` is
|
|
145
|
+
negative, or ``min_chapters`` is greater than ``max_chapters``.
|
|
135
146
|
"""
|
|
136
147
|
if page < 1:
|
|
137
148
|
raise ValueError(f"page must be at least 1, got {page}.")
|
|
@@ -139,6 +150,16 @@ class Catalog:
|
|
|
139
150
|
raise ValueError(
|
|
140
151
|
f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
|
|
141
152
|
)
|
|
153
|
+
for name, value in (("min_chapters", min_chapters), ("max_chapters", max_chapters)):
|
|
154
|
+
if value is not None and value < 0:
|
|
155
|
+
raise ValueError(f"{name} must be at least 0, got {value}.")
|
|
156
|
+
if min_chapters is not None and max_chapters is not None and min_chapters > max_chapters:
|
|
157
|
+
# The API itself silently swaps the bounds instead (see docs/api-notes.md) —
|
|
158
|
+
# raising here rather than relying on that undocumented behavior.
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"min_chapters ({min_chapters}) must not be greater than "
|
|
161
|
+
f"max_chapters ({max_chapters})."
|
|
162
|
+
)
|
|
142
163
|
|
|
143
164
|
key = _cache_key(
|
|
144
165
|
page=page,
|
|
@@ -146,8 +167,10 @@ class Catalog:
|
|
|
146
167
|
query=query,
|
|
147
168
|
genres=genres,
|
|
148
169
|
tags=tags,
|
|
149
|
-
|
|
170
|
+
statuses=statuses,
|
|
150
171
|
countries=countries,
|
|
172
|
+
min_chapters=min_chapters,
|
|
173
|
+
max_chapters=max_chapters,
|
|
151
174
|
sort=sort,
|
|
152
175
|
)
|
|
153
176
|
if not refresh:
|
|
@@ -161,8 +184,10 @@ class Catalog:
|
|
|
161
184
|
query=query,
|
|
162
185
|
genres=genres,
|
|
163
186
|
tags=tags,
|
|
164
|
-
|
|
187
|
+
statuses=statuses,
|
|
165
188
|
countries=countries,
|
|
189
|
+
min_chapters=min_chapters,
|
|
190
|
+
max_chapters=max_chapters,
|
|
166
191
|
sort=sort,
|
|
167
192
|
)
|
|
168
193
|
self._cache.set(key, data)
|
|
@@ -223,6 +248,33 @@ class Catalog:
|
|
|
223
248
|
self._cache.set(key, data)
|
|
224
249
|
return _build_countries(data)
|
|
225
250
|
|
|
251
|
+
async def list_statuses(self, *, refresh: bool = False) -> list[Label]:
|
|
252
|
+
"""Fetch the full list of title statuses (id → label), for use as filter options
|
|
253
|
+
with ``list_titles(statuses=...)``.
|
|
254
|
+
|
|
255
|
+
Mirrors ``list_genres()``/``list_countries()``: the underlying endpoint
|
|
256
|
+
(``GET /api/constants?fields[]=status``) isn't site-scoped, so entries not tagged for
|
|
257
|
+
ranobelib.me are dropped before returning (see docs/api-notes.md). Returns the same
|
|
258
|
+
``Label`` model as ``Title.status``, so a title's status can be compared against
|
|
259
|
+
these directly.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
refresh: Bypass the disk cache and re-fetch from the API even if the status list
|
|
263
|
+
was already cached.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Every status (``Label``: id, label) that applies to ranobelib.me.
|
|
267
|
+
"""
|
|
268
|
+
key = "catalog:statuses"
|
|
269
|
+
if not refresh:
|
|
270
|
+
cached = self._cache.get(key)
|
|
271
|
+
if cached is not None:
|
|
272
|
+
return _build_statuses(cached)
|
|
273
|
+
|
|
274
|
+
data = await self._client.list_statuses()
|
|
275
|
+
self._cache.set(key, data)
|
|
276
|
+
return _build_statuses(data)
|
|
277
|
+
|
|
226
278
|
|
|
227
279
|
def _cache_key(
|
|
228
280
|
*,
|
|
@@ -231,16 +283,19 @@ def _cache_key(
|
|
|
231
283
|
query: str | None,
|
|
232
284
|
genres: list[int] | None,
|
|
233
285
|
tags: list[int] | None,
|
|
234
|
-
|
|
286
|
+
statuses: list[int] | None,
|
|
235
287
|
countries: list[int] | None,
|
|
288
|
+
min_chapters: int | None,
|
|
289
|
+
max_chapters: int | None,
|
|
236
290
|
sort: str,
|
|
237
291
|
) -> str:
|
|
238
292
|
genres_part = ",".join(str(genre_id) for genre_id in genres or [])
|
|
239
293
|
tags_part = ",".join(str(tag_id) for tag_id in tags or [])
|
|
294
|
+
statuses_part = ",".join(str(status_id) for status_id in statuses or [])
|
|
240
295
|
countries_part = ",".join(str(country_id) for country_id in countries or [])
|
|
241
296
|
return (
|
|
242
297
|
f"catalog:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
|
|
243
|
-
f"{
|
|
298
|
+
f"{statuses_part}:{countries_part}:{min_chapters}:{max_chapters}:{sort}"
|
|
244
299
|
)
|
|
245
300
|
|
|
246
301
|
|
|
@@ -258,3 +313,8 @@ def _build_genres(data: list[dict[str, Any]]) -> list[Genre]:
|
|
|
258
313
|
def _build_countries(data: list[dict[str, Any]]) -> list[Country]:
|
|
259
314
|
site_id = int(RANOBELIB_SITE_ID)
|
|
260
315
|
return [Country.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _build_statuses(data: list[dict[str, Any]]) -> list[Label]:
|
|
319
|
+
site_id = int(RANOBELIB_SITE_ID)
|
|
320
|
+
return [Label.model_validate(item) for item in data if site_id in item.get("site_ids", [])]
|
|
@@ -189,8 +189,10 @@ class ApiClient:
|
|
|
189
189
|
query: str | None,
|
|
190
190
|
genres: list[int] | None,
|
|
191
191
|
tags: list[int] | None,
|
|
192
|
-
|
|
192
|
+
statuses: list[int] | None,
|
|
193
193
|
countries: list[int] | None,
|
|
194
|
+
min_chapters: int | None,
|
|
195
|
+
max_chapters: int | None,
|
|
194
196
|
sort: str,
|
|
195
197
|
) -> dict[str, Any]:
|
|
196
198
|
"""Fetch one page of the catalog listing/search results.
|
|
@@ -209,12 +211,16 @@ class ApiClient:
|
|
|
209
211
|
see docs/api-notes.md), not just any one.
|
|
210
212
|
tags: Tag ids to filter by. Same AND semantics as ``genres`` — a title must have
|
|
211
213
|
*all* of them, not just any one (see docs/api-notes.md).
|
|
212
|
-
|
|
214
|
+
statuses: ``Title.status.id``s to filter by. A title matches if its own status
|
|
215
|
+
is *any* of these (OR, like ``countries`` — a title only has one status, see
|
|
216
|
+
docs/api-notes.md). Sent on the wire as repeated ``status[]`` parameters.
|
|
213
217
|
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
214
218
|
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
215
219
|
country, confirmed against the live API, see docs/api-notes.md). Sent on the
|
|
216
220
|
wire as repeated ``types[]`` parameters — the API's own name for this concept
|
|
217
221
|
is "type", not "country" (see docs/api-notes.md).
|
|
222
|
+
min_chapters: Minimum chapter count, inclusive. Sent as ``chap_count_min``.
|
|
223
|
+
max_chapters: Maximum chapter count, inclusive. Sent as ``chap_count_max``.
|
|
218
224
|
sort: Forwarded as the API's ``sort_by`` parameter, not ``sort`` — the API
|
|
219
225
|
silently ignores an actual ``sort`` parameter (see docs/api-notes.md); this
|
|
220
226
|
mismatch between the SDK's public keyword and the wire parameter name is
|
|
@@ -235,10 +241,14 @@ class ApiClient:
|
|
|
235
241
|
params.append(("genres[]", str(genre_id)))
|
|
236
242
|
for tag_id in tags or []:
|
|
237
243
|
params.append(("tags[]", str(tag_id)))
|
|
238
|
-
|
|
239
|
-
params.append(("status[]", str(
|
|
244
|
+
for status_id in statuses or []:
|
|
245
|
+
params.append(("status[]", str(status_id)))
|
|
240
246
|
for country_id in countries or []:
|
|
241
247
|
params.append(("types[]", str(country_id)))
|
|
248
|
+
if min_chapters is not None:
|
|
249
|
+
params.append(("chap_count_min", str(min_chapters)))
|
|
250
|
+
if max_chapters is not None:
|
|
251
|
+
params.append(("chap_count_max", str(max_chapters)))
|
|
242
252
|
|
|
243
253
|
response = await self._get("/manga", params=httpx.QueryParams(params))
|
|
244
254
|
self._raise_for_status(
|
|
@@ -283,6 +293,23 @@ class ApiClient:
|
|
|
283
293
|
data: list[dict[str, Any]] = response.json()["data"]["types"]
|
|
284
294
|
return data
|
|
285
295
|
|
|
296
|
+
async def list_statuses(self) -> list[dict[str, Any]]:
|
|
297
|
+
"""Fetch the raw title status list shared across the whole lib.social network.
|
|
298
|
+
|
|
299
|
+
Same shape and caveats as ``list_genres``: not site-scoped by any request parameter,
|
|
300
|
+
returns every status known to the network at once, each tagged with the site ids it
|
|
301
|
+
applies to via ``site_ids``.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The raw ``data.status`` array from the API response, unfiltered by site.
|
|
305
|
+
"""
|
|
306
|
+
response = await self._get("/constants", params=httpx.QueryParams([("fields[]", "status")]))
|
|
307
|
+
self._raise_for_status(
|
|
308
|
+
response, not_found=RanobeLibError("Unexpected 404 from status list")
|
|
309
|
+
)
|
|
310
|
+
data: list[dict[str, Any]] = response.json()["data"]["status"]
|
|
311
|
+
return data
|
|
312
|
+
|
|
286
313
|
async def _get(self, url: str, *, params: Any = None) -> httpx.Response:
|
|
287
314
|
"""Issue a GET request, bounded by concurrency/pacing and retried on 429/5xx."""
|
|
288
315
|
attempt = 0
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import html
|
|
14
|
+
from collections.abc import Callable
|
|
14
15
|
from html.parser import HTMLParser
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from urllib.parse import urlsplit
|
|
@@ -62,7 +63,7 @@ def extract_image_urls(html_fragment: str) -> list[str]:
|
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
class _ImageSrcRewriter(HTMLParser):
|
|
65
|
-
"""Re-emits chapter-content HTML with ``<img src="...">``
|
|
66
|
+
"""Re-emits chapter-content HTML with each ``<img src="...">`` passed through a function.
|
|
66
67
|
|
|
67
68
|
Rebuilds the markup tag-by-tag (rather than a targeted regex substitution) for the same
|
|
68
69
|
reason the txt/fb2 exporters use ``HTMLParser`` over regex: HTML-string-format chapters
|
|
@@ -71,15 +72,13 @@ class _ImageSrcRewriter(HTMLParser):
|
|
|
71
72
|
for epub, WeasyPrint's own HTML parser for pdf) are lenient enough not to need strict
|
|
72
73
|
XHTML here.
|
|
73
74
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
a dangling absolute-URL reference isn't a self-contained embed, just a broken-looking
|
|
77
|
-
image in most consumers, so "not embedded" reads as "not present" instead.
|
|
75
|
+
``replace`` gets each ``<img>``'s ``src`` (``""`` if it has none) and returns the new
|
|
76
|
+
value, or ``None`` to drop that ``<img>`` entirely.
|
|
78
77
|
"""
|
|
79
78
|
|
|
80
|
-
def __init__(self,
|
|
79
|
+
def __init__(self, replace: Callable[[str], str | None]) -> None:
|
|
81
80
|
super().__init__(convert_charrefs=True)
|
|
82
|
-
self.
|
|
81
|
+
self._replace = replace
|
|
83
82
|
self._output: list[str] = []
|
|
84
83
|
|
|
85
84
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
@@ -90,10 +89,10 @@ class _ImageSrcRewriter(HTMLParser):
|
|
|
90
89
|
|
|
91
90
|
def _emit(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
|
|
92
91
|
if tag == "img":
|
|
93
|
-
|
|
94
|
-
if
|
|
92
|
+
new_src = self._replace(dict(attrs).get("src") or "")
|
|
93
|
+
if new_src is None:
|
|
95
94
|
return
|
|
96
|
-
attrs = [(name,
|
|
95
|
+
attrs = [(name, new_src if name == "src" else value) for name, value in attrs]
|
|
97
96
|
|
|
98
97
|
parts = [f"<{tag}"]
|
|
99
98
|
for name, value in attrs:
|
|
@@ -115,13 +114,27 @@ class _ImageSrcRewriter(HTMLParser):
|
|
|
115
114
|
return "".join(self._output)
|
|
116
115
|
|
|
117
116
|
|
|
118
|
-
def
|
|
119
|
-
"""
|
|
120
|
-
|
|
117
|
+
def replace_image_srcs(html_fragment: str, replace: Callable[[str], str | None]) -> str:
|
|
118
|
+
"""Pass every ``<img src="...">`` in ``html_fragment`` through ``replace``.
|
|
119
|
+
|
|
120
|
+
``replace`` returns the new ``src``, or ``None`` to drop that ``<img>``.
|
|
121
|
+
"""
|
|
122
|
+
rewriter = _ImageSrcRewriter(replace)
|
|
121
123
|
rewriter.feed(html_fragment)
|
|
122
124
|
return rewriter.get_html()
|
|
123
125
|
|
|
124
126
|
|
|
127
|
+
def rewrite_image_srcs(html_fragment: str, url_to_local: dict[str, str]) -> str:
|
|
128
|
+
"""Rewrite ``<img src="...">`` URLs in ``html_fragment`` per ``url_to_local``.
|
|
129
|
+
|
|
130
|
+
An ``<img>`` whose URL isn't in ``url_to_local`` (download failed or was never
|
|
131
|
+
attempted) is dropped entirely rather than kept pointing at the original external URL —
|
|
132
|
+
a dangling absolute-URL reference isn't a self-contained embed, just a broken-looking
|
|
133
|
+
image in most consumers, so "not embedded" reads as "not present" instead.
|
|
134
|
+
"""
|
|
135
|
+
return replace_image_srcs(html_fragment, url_to_local.get)
|
|
136
|
+
|
|
137
|
+
|
|
125
138
|
def pick_cover_url(cover: Cover) -> str | None:
|
|
126
139
|
"""The best available cover URL, preferring the largest: default, then md, then thumbnail."""
|
|
127
140
|
return cover.default or cover.md or cover.thumbnail
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Listing and rewriting the images a fetched chapter references.
|
|
2
|
+
|
|
3
|
+
Chapter illustrations are ``<img>`` tags with absolute URLs on ranobelib.me /
|
|
4
|
+
``*.cdnlibs.org`` (see docs/api-notes.md), inside ``Chapter.content`` and, occasionally,
|
|
5
|
+
``Chapter.footnotes``. These helpers expose the same ``<img>`` parsing the epub/pdf
|
|
6
|
+
exporters and ``sizing.chapter_size()`` use internally, so a caller that serves chapters
|
|
7
|
+
itself (e.g. through its own same-origin image proxy, for offline caching) doesn't need a
|
|
8
|
+
second HTML parser of its own.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from ranobelib.exporters._illustrations import extract_image_urls, replace_image_srcs
|
|
16
|
+
from ranobelib.models import Chapter, Footnote
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_content(chapter: Chapter) -> str:
|
|
20
|
+
if chapter.content is None:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"Chapter {chapter.volume}/{chapter.number} has no content — "
|
|
23
|
+
"fetch it first, e.g. via get_chapter()."
|
|
24
|
+
)
|
|
25
|
+
return chapter.content
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def chapter_image_urls(chapter: Chapter) -> list[str]:
|
|
29
|
+
"""List the image URLs a fetched chapter references.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
chapter: A chapter with content already fetched (e.g. via ``get_chapter()``).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Every ``<img src>`` in ``chapter.content`` and then ``chapter.footnotes``, in
|
|
36
|
+
document order, each URL listed once (its first occurrence).
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If ``chapter.content`` is ``None`` (not yet fetched — see ``Chapter``'s
|
|
40
|
+
docstring for when that happens).
|
|
41
|
+
"""
|
|
42
|
+
fragments = [_require_content(chapter), *(footnote.content for footnote in chapter.footnotes)]
|
|
43
|
+
urls = (url for fragment in fragments for url in extract_image_urls(fragment))
|
|
44
|
+
return list(dict.fromkeys(urls))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rewrite_image_urls(chapter: Chapter, replace: Callable[[str], str]) -> Chapter:
|
|
48
|
+
"""Return a copy of a fetched chapter with every ``<img src>`` passed through ``replace``.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
chapter: A chapter with content already fetched (e.g. via ``get_chapter()``).
|
|
52
|
+
replace: Called with each image URL (once per ``<img>``, so possibly more than once
|
|
53
|
+
for the same URL); its result becomes the new ``src``, HTML-escaped as an
|
|
54
|
+
attribute value. An ``<img>`` without a ``src`` is left as it is.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A new ``Chapter`` whose ``content`` and ``footnotes`` have their ``<img src>``
|
|
58
|
+
rewritten; every other field is unchanged, and ``chapter`` itself is not modified.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If ``chapter.content`` is ``None`` — see ``chapter_image_urls()``.
|
|
62
|
+
"""
|
|
63
|
+
content = _require_content(chapter)
|
|
64
|
+
|
|
65
|
+
def rewrite(fragment: str) -> str:
|
|
66
|
+
return replace_image_srcs(fragment, lambda src: replace(src) if src else src)
|
|
67
|
+
|
|
68
|
+
return chapter.model_copy(
|
|
69
|
+
update={
|
|
70
|
+
"content": rewrite(content),
|
|
71
|
+
"footnotes": [Footnote(content=rewrite(f.content)) for f in chapter.footnotes],
|
|
72
|
+
}
|
|
73
|
+
)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import html
|
|
6
|
+
import re
|
|
6
7
|
from datetime import datetime
|
|
7
8
|
from html.parser import HTMLParser
|
|
8
9
|
from typing import Any
|
|
@@ -438,6 +439,28 @@ class Team(BaseModel):
|
|
|
438
439
|
name: str
|
|
439
440
|
|
|
440
441
|
|
|
442
|
+
_FORMAT_WORD = (
|
|
443
|
+
r"(?:(?:light|lite|web|лайт|веб|вэб)\s*-?\s*)?(?:novel(?:la|l|a)?|новелл?а)"
|
|
444
|
+
r"|web\s*-?\s*serial|ranobe|ранобэ|ln|wn|лн|quest|квест"
|
|
445
|
+
)
|
|
446
|
+
_FORMAT_SUFFIX = re.compile(
|
|
447
|
+
rf"[\s,:/]*(?:\(\s*(?:{_FORMAT_WORD})\s*\)|\[\s*(?:{_FORMAT_WORD})\s*\])\s*$",
|
|
448
|
+
re.IGNORECASE,
|
|
449
|
+
)
|
|
450
|
+
"""A publication-format marker at the end of a title name — ``(Новелла)``, ``(Novel)``,
|
|
451
|
+
``(LN)``, ``[квест]``, ... — plus the separator (``,``/``:``/``/``) sometimes left before it.
|
|
452
|
+
The word list comes from every name in the live catalog (see docs/api-notes.md, "Title
|
|
453
|
+
names without the format suffix"); other bracketed tails (a year, ``(18+)``, ``(Фанфик)``,
|
|
454
|
+
``[LitRPG]``, a franchise) tell titles apart, so they're not on it."""
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _strip_format_suffix(name: str) -> str:
|
|
458
|
+
"""``name`` without its trailing format marker(s) — they can stack: ``(LN) (Novel)``."""
|
|
459
|
+
while (match := _FORMAT_SUFFIX.search(name)) is not None:
|
|
460
|
+
name = name[: match.start()]
|
|
461
|
+
return name.strip()
|
|
462
|
+
|
|
463
|
+
|
|
441
464
|
class Title(BaseModel):
|
|
442
465
|
"""Metadata for a single title (novel)."""
|
|
443
466
|
|
|
@@ -463,6 +486,34 @@ class Title(BaseModel):
|
|
|
463
486
|
artists: list[Person] = Field(default_factory=list)
|
|
464
487
|
teams: list[Team] = Field(default_factory=list)
|
|
465
488
|
chapter_count: int | None = None
|
|
489
|
+
"""Number of uploaded chapters. Only ``RanobeLib.get_info()`` fills it in: the catalog
|
|
490
|
+
listing endpoint doesn't return it (see docs/api-notes.md), so it's ``None`` on
|
|
491
|
+
``Catalog.list_titles()`` items."""
|
|
492
|
+
last_chapter_at: datetime | None = Field(default=None, alias="last_item_at")
|
|
493
|
+
"""When the most recent chapter was published — the value the catalog's
|
|
494
|
+
``sort="last_chapter_at"`` orders by. Only ``RanobeLib.get_info()`` fills it in: the
|
|
495
|
+
catalog listing endpoint doesn't return it (see docs/api-notes.md), so it's ``None`` on
|
|
496
|
+
``Catalog.list_titles()`` items, and on a title with no chapters yet."""
|
|
497
|
+
|
|
498
|
+
@property
|
|
499
|
+
def base_names(self) -> list[str]:
|
|
500
|
+
"""The title's names without the format suffix ranobelib.me appends to them.
|
|
501
|
+
|
|
502
|
+
``rus_name``, ``eng_name``, ``name`` and ``other_names``, in that order, each with
|
|
503
|
+
trailing publication-format markers removed — ``"Старшая школа D×D (Новелла)"`` →
|
|
504
|
+
``"Старшая школа D×D"``, likewise ``(Novel)``, ``(LN)``, ``(WN)``, ``(Light Novel)``,
|
|
505
|
+
``(Ранобэ)``, ``[квест]``, ... (stacked ones too, ``(LN) (Novel)``). Other bracketed
|
|
506
|
+
tails — a year, ``(18+)``, ``(Фанфик)``, ``[LitRPG]``, a franchise name — distinguish
|
|
507
|
+
one title from another and are kept. Whitespace is trimmed; empty names and
|
|
508
|
+
duplicates (keeping the first) are dropped. Nothing else is normalized (case,
|
|
509
|
+
punctuation, ``×``): that's up to whatever compares the names.
|
|
510
|
+
|
|
511
|
+
The original fields are left as they are. On ``Catalog.list_titles()`` items
|
|
512
|
+
``other_names`` is empty (the listing doesn't return it), so it contributes nothing.
|
|
513
|
+
"""
|
|
514
|
+
names = [self.rus_name, self.eng_name, self.name, *self.other_names]
|
|
515
|
+
stripped = (_strip_format_suffix(name) for name in names if name)
|
|
516
|
+
return list(dict.fromkeys(name for name in stripped if name))
|
|
466
517
|
|
|
467
518
|
@model_validator(mode="before")
|
|
468
519
|
@classmethod
|
|
@@ -581,8 +632,9 @@ class CatalogPage(BaseModel):
|
|
|
581
632
|
"""One page of catalog listing/search results, as returned by ``Catalog.list_titles()``.
|
|
582
633
|
|
|
583
634
|
``items`` reuses ``Title`` as-is — a catalog list item has every field ``Title`` requires,
|
|
584
|
-
the ones it doesn't send (``genres``, ``summary``, ``chapter_count``,
|
|
585
|
-
at their defaults, same as any other partially-populated ``Title``
|
|
635
|
+
the ones it doesn't send (``genres``, ``summary``, ``chapter_count``, ``last_chapter_at``,
|
|
636
|
+
...) just come back at their defaults, same as any other partially-populated ``Title``
|
|
637
|
+
(see docs/api-notes.md).
|
|
586
638
|
"""
|
|
587
639
|
|
|
588
640
|
items: list[Title]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""A fetched chapter's top-level blocks as plain text, indexed like the DOM.
|
|
2
|
+
|
|
3
|
+
``Chapter.content`` is a flat, sanitized sequence of block-level elements (``<p>``,
|
|
4
|
+
``<hr />``, ``<img>`` — see ``Chapter``), so a client rendering it as HTML sees one child
|
|
5
|
+
element per block. ``chapter_paragraphs()`` gives each of those blocks' text at the same
|
|
6
|
+
index, so e.g. a comment keyed by a paragraph's position in the reader's DOM
|
|
7
|
+
(``content.children[i]``) can be shown next to that paragraph's text without a second HTML
|
|
8
|
+
parser on the caller's side.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from html.parser import HTMLParser
|
|
14
|
+
|
|
15
|
+
from ranobelib.models import Chapter
|
|
16
|
+
|
|
17
|
+
_VOID_TAGS = frozenset({"br", "hr", "img"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _TopLevelTextCollector(HTMLParser):
|
|
21
|
+
"""Collects the text of each top-level element of an HTML fragment, in order.
|
|
22
|
+
|
|
23
|
+
Every start tag at depth 0 begins a new entry, void ones (``br``/``hr``/``img``)
|
|
24
|
+
included, since each is an element child in the DOM too. Text outside any element at the
|
|
25
|
+
top level isn't an entry (it's a text node, not an element child) and is ignored. A
|
|
26
|
+
``<br />`` inside an entry separates words, like a space.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
super().__init__(convert_charrefs=True)
|
|
31
|
+
self._depth = 0
|
|
32
|
+
self.entries: list[list[str]] = []
|
|
33
|
+
|
|
34
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
35
|
+
if self._depth == 0:
|
|
36
|
+
self.entries.append([])
|
|
37
|
+
if tag in _VOID_TAGS:
|
|
38
|
+
if self._depth > 0:
|
|
39
|
+
self.entries[-1].append(" ")
|
|
40
|
+
else:
|
|
41
|
+
self._depth += 1
|
|
42
|
+
|
|
43
|
+
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
44
|
+
self.handle_starttag(tag, attrs)
|
|
45
|
+
if tag not in _VOID_TAGS:
|
|
46
|
+
self.handle_endtag(tag)
|
|
47
|
+
|
|
48
|
+
def handle_endtag(self, tag: str) -> None:
|
|
49
|
+
if tag not in _VOID_TAGS and self._depth > 0:
|
|
50
|
+
self._depth -= 1
|
|
51
|
+
|
|
52
|
+
def handle_data(self, data: str) -> None:
|
|
53
|
+
if self._depth > 0:
|
|
54
|
+
self.entries[-1].append(data)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def chapter_paragraphs(chapter: Chapter) -> list[str]:
|
|
58
|
+
"""A fetched chapter's top-level blocks as plain text, in document order.
|
|
59
|
+
|
|
60
|
+
Entry ``i`` is the text of the ``i``-th top-level element of ``chapter.content`` — the
|
|
61
|
+
same position a browser gives it among the rendered content's element children — so a
|
|
62
|
+
paragraph index counted in the DOM can be used to look it up directly.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
chapter: A chapter with content already fetched (e.g. via ``get_chapter()``).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
One string per top-level element: its text with entities decoded and whitespace
|
|
69
|
+
collapsed to single spaces (``""`` for a block without text, such as ``<hr />`` or
|
|
70
|
+
an image-only paragraph). ``chapter.footnotes`` aren't included — they're not part
|
|
71
|
+
of ``content`` (see ``Chapter.footnotes``).
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If ``chapter.content`` is ``None`` (not yet fetched — see ``Chapter``'s
|
|
75
|
+
docstring for when that happens).
|
|
76
|
+
"""
|
|
77
|
+
if chapter.content is None:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"Chapter {chapter.volume}/{chapter.number} has no content — "
|
|
80
|
+
"fetch it first, e.g. via get_chapter()."
|
|
81
|
+
)
|
|
82
|
+
collector = _TopLevelTextCollector()
|
|
83
|
+
collector.feed(chapter.content)
|
|
84
|
+
collector.close()
|
|
85
|
+
return [" ".join("".join(parts).split()) for parts in collector.entries]
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""Disk cache for raw JSON API responses, keyed by request identity."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
7
|
-
import time
|
|
8
|
-
from collections.abc import Callable
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
DEFAULT_CACHE_DIR = Path(".ranobelib_cache")
|
|
13
|
-
"""Default cache directory, relative to the current working directory."""
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
class DiskCache:
|
|
17
|
-
"""Caches JSON-serializable values to disk, keyed by an arbitrary string.
|
|
18
|
-
|
|
19
|
-
One file per key, named by the key's hash (so keys built from slugs, chapter numbers,
|
|
20
|
-
etc. are always safe filenames), holding the cached value plus the time it was written
|
|
21
|
-
so ``ttl`` can be checked on read without touching any other entry. Corrupted or
|
|
22
|
-
unreadable entries are treated as a cache miss rather than raised as an error, since a
|
|
23
|
-
stale/broken cache file shouldn't stop the SDK from just re-fetching the data.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
def __init__(
|
|
27
|
-
self,
|
|
28
|
-
cache_dir: str | Path,
|
|
29
|
-
*,
|
|
30
|
-
ttl: float | None = None,
|
|
31
|
-
clock: Callable[[], float] = time.time,
|
|
32
|
-
) -> None:
|
|
33
|
-
"""Initialize the cache.
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
cache_dir: Directory to store cache files in. Created lazily on first write.
|
|
37
|
-
ttl: Seconds after which a cache entry is treated as a miss. ``None`` (the
|
|
38
|
-
default) means entries never expire.
|
|
39
|
-
clock: Time source used to stamp and check entries. Overridable for tests.
|
|
40
|
-
"""
|
|
41
|
-
self._dir = Path(cache_dir)
|
|
42
|
-
self._ttl = ttl
|
|
43
|
-
self._clock = clock
|
|
44
|
-
|
|
45
|
-
def get(self, key: str) -> Any | None:
|
|
46
|
-
"""Return the cached value for ``key``, or ``None`` on a miss or expiry."""
|
|
47
|
-
try:
|
|
48
|
-
payload = json.loads(self._path_for(key).read_text(encoding="utf-8"))
|
|
49
|
-
except (OSError, json.JSONDecodeError, KeyError):
|
|
50
|
-
return None
|
|
51
|
-
if self._ttl is not None and self._clock() - payload["cached_at"] > self._ttl:
|
|
52
|
-
return None
|
|
53
|
-
return payload["data"]
|
|
54
|
-
|
|
55
|
-
def set(self, key: str, data: Any) -> None:
|
|
56
|
-
"""Write ``data`` to disk under ``key``, creating the cache directory if needed."""
|
|
57
|
-
path = self._path_for(key)
|
|
58
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
-
payload = {"cached_at": self._clock(), "data": data}
|
|
60
|
-
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
61
|
-
|
|
62
|
-
def _path_for(self, key: str) -> Path:
|
|
63
|
-
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
64
|
-
return self._dir / f"{digest}.json"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
File without changes
|
{ranobelib_python_sdk-0.11.0 → ranobelib_python_sdk-0.13.0}/src/ranobelib/exporters/_shared.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|