libsocial-core 1.0.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.
- libsocial/__init__.py +81 -0
- libsocial/_http.py +45 -0
- libsocial/cache.py +180 -0
- libsocial/catalog.py +350 -0
- libsocial/client.py +367 -0
- libsocial/console.py +72 -0
- libsocial/exceptions.py +240 -0
- libsocial/models.py +224 -0
- libsocial/numbering.py +34 -0
- libsocial/pacing.py +68 -0
- libsocial/py.typed +0 -0
- libsocial/site.py +35 -0
- libsocial/title.py +625 -0
- libsocial_core-1.0.0.dist-info/METADATA +74 -0
- libsocial_core-1.0.0.dist-info/RECORD +16 -0
- libsocial_core-1.0.0.dist-info/WHEEL +4 -0
libsocial/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Site-independent core of the lib.social SDKs (ranobelib.me, mangalib.me, ...)."""
|
|
2
|
+
|
|
3
|
+
from libsocial._http import browser_headers, browser_headers_for
|
|
4
|
+
from libsocial.cache import CacheStats, cache_stats, clear_cache
|
|
5
|
+
from libsocial.catalog import Catalog
|
|
6
|
+
from libsocial.client import ApiClient
|
|
7
|
+
from libsocial.console import Reporter, Verbosity
|
|
8
|
+
from libsocial.exceptions import (
|
|
9
|
+
AccessBlockedError,
|
|
10
|
+
AmbiguousChapter,
|
|
11
|
+
AuthRequiredError,
|
|
12
|
+
ChapterNotFoundError,
|
|
13
|
+
DownloadTitleInterruptedError,
|
|
14
|
+
LibSocialError,
|
|
15
|
+
MultipleTitleTranslationsError,
|
|
16
|
+
MultipleTranslationsError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
TitleNotFoundError,
|
|
19
|
+
VolumeNotFoundError,
|
|
20
|
+
)
|
|
21
|
+
from libsocial.models import (
|
|
22
|
+
CatalogPage,
|
|
23
|
+
Chapter,
|
|
24
|
+
ChapterBranch,
|
|
25
|
+
ChapterUser,
|
|
26
|
+
Country,
|
|
27
|
+
Cover,
|
|
28
|
+
Genre,
|
|
29
|
+
Label,
|
|
30
|
+
Person,
|
|
31
|
+
Tag,
|
|
32
|
+
Team,
|
|
33
|
+
Title,
|
|
34
|
+
Volume,
|
|
35
|
+
)
|
|
36
|
+
from libsocial.numbering import parse_slug_url
|
|
37
|
+
from libsocial.pacing import Pacer, shared_pacer
|
|
38
|
+
from libsocial.site import Site
|
|
39
|
+
from libsocial.title import PlannedChapter, TitleClient
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AccessBlockedError",
|
|
43
|
+
"AmbiguousChapter",
|
|
44
|
+
"ApiClient",
|
|
45
|
+
"AuthRequiredError",
|
|
46
|
+
"CacheStats",
|
|
47
|
+
"Catalog",
|
|
48
|
+
"CatalogPage",
|
|
49
|
+
"Chapter",
|
|
50
|
+
"ChapterBranch",
|
|
51
|
+
"ChapterNotFoundError",
|
|
52
|
+
"ChapterUser",
|
|
53
|
+
"Country",
|
|
54
|
+
"Cover",
|
|
55
|
+
"DownloadTitleInterruptedError",
|
|
56
|
+
"Genre",
|
|
57
|
+
"Label",
|
|
58
|
+
"LibSocialError",
|
|
59
|
+
"MultipleTitleTranslationsError",
|
|
60
|
+
"MultipleTranslationsError",
|
|
61
|
+
"Pacer",
|
|
62
|
+
"Person",
|
|
63
|
+
"PlannedChapter",
|
|
64
|
+
"RateLimitError",
|
|
65
|
+
"Reporter",
|
|
66
|
+
"Site",
|
|
67
|
+
"Tag",
|
|
68
|
+
"Team",
|
|
69
|
+
"Title",
|
|
70
|
+
"TitleClient",
|
|
71
|
+
"TitleNotFoundError",
|
|
72
|
+
"Verbosity",
|
|
73
|
+
"Volume",
|
|
74
|
+
"VolumeNotFoundError",
|
|
75
|
+
"browser_headers",
|
|
76
|
+
"browser_headers_for",
|
|
77
|
+
"cache_stats",
|
|
78
|
+
"clear_cache",
|
|
79
|
+
"parse_slug_url",
|
|
80
|
+
"shared_pacer",
|
|
81
|
+
]
|
libsocial/_http.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""HTTP headers shared by every client the SDKs open (not public API).
|
|
2
|
+
|
|
3
|
+
Lives in its own module rather than in ``client.py`` because it isn't specific to the
|
|
4
|
+
api.cdnlibs.org JSON API: a site package's image-downloading clients (cover.cdnlibs.org,
|
|
5
|
+
the site's own uploads) need the same headers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
BROWSER_USER_AGENT = (
|
|
15
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 "
|
|
16
|
+
"(KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
|
|
17
|
+
)
|
|
18
|
+
"""A real browser's ``User-Agent``, sent instead of advertising ``python-httpx``."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def browser_headers_for(origin: str) -> dict[str, str]:
|
|
22
|
+
"""Headers a browser on the site at ``origin`` sends, so requests look like the site's.
|
|
23
|
+
|
|
24
|
+
The DDoS-Guard edge in front of api.cdnlibs.org and cover.cdnlibs.org rejects requests
|
|
25
|
+
without a non-empty ``Referer`` with an HTML 403 page (see docs/api-notes.md, section
|
|
26
|
+
"WAF 403"). Any ``Referer`` value was enough when checked; the site's own one is sent,
|
|
27
|
+
together with ``Origin`` and a real browser ``User-Agent``, so the request as a whole
|
|
28
|
+
matches what the site itself sends.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
origin: The site's web origin, e.g. ``"https://ranobelib.me"`` (see ``Site.origin``).
|
|
32
|
+
"""
|
|
33
|
+
return {"Origin": origin, "Referer": f"{origin}/", "User-Agent": BROWSER_USER_AGENT}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def browser_headers(origin: str, overrides: Mapping[str, str] | None = None) -> httpx.Headers:
|
|
37
|
+
"""``browser_headers_for(origin)`` with ``overrides`` merged over them.
|
|
38
|
+
|
|
39
|
+
A key in ``overrides`` replaces the default one, matched case-insensitively (``httpx``
|
|
40
|
+
headers semantics) — the same merge ``ApiClient(headers=...)`` does for API requests.
|
|
41
|
+
"""
|
|
42
|
+
headers = httpx.Headers(browser_headers_for(origin))
|
|
43
|
+
if overrides:
|
|
44
|
+
headers.update(overrides)
|
|
45
|
+
return headers
|
libsocial/cache.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Disk cache for raw JSON API responses, keyed by request identity.
|
|
2
|
+
|
|
3
|
+
Also the cache-maintenance helpers, ``cache_stats()`` and ``clear_cache()`` — the
|
|
4
|
+
on-disk layout is internal, so code outside the SDKs should go through these rather than
|
|
5
|
+
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(".libsocial_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 a 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 ``TitleClient`` /
|
|
147
|
+
``Catalog`` (default: ``DEFAULT_CACHE_DIR``, ``.libsocial_cache`` in the current
|
|
148
|
+
directory; a site package may use its own default).
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The number of entries and their total size in bytes. A missing directory is an empty
|
|
152
|
+
cache, ``CacheStats(entries=0, bytes=0)``, not an error.
|
|
153
|
+
"""
|
|
154
|
+
return DiskCache(cache_dir).stats()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def clear_cache(
|
|
158
|
+
cache_dir: str | Path = DEFAULT_CACHE_DIR, *, older_than: float | None = None
|
|
159
|
+
) -> CacheStats:
|
|
160
|
+
"""Remove entries from a disk cache.
|
|
161
|
+
|
|
162
|
+
Only files the cache itself writes are removed — anything else in the directory, and the
|
|
163
|
+
directory itself, is left alone. Safe to call while clients are using the same cache: an
|
|
164
|
+
entry being read as it's removed is just a cache miss, and one written after it's
|
|
165
|
+
removed is simply recreated.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
cache_dir: The cache directory — see ``cache_stats()``.
|
|
169
|
+
older_than: If given, only remove entries written more than this many seconds ago
|
|
170
|
+
(the same age check ``cache_ttl`` uses), plus any corrupted entry, which the
|
|
171
|
+
cache can't serve anyway. ``None`` (the default) removes every entry.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The number of entries removed and the bytes they took. A missing directory removes
|
|
175
|
+
nothing, ``CacheStats(entries=0, bytes=0)``, rather than raising.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If ``older_than`` is negative.
|
|
179
|
+
"""
|
|
180
|
+
return DiskCache(cache_dir).clear(older_than=older_than)
|
libsocial/catalog.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Catalog listing/search: the ``Catalog`` class.
|
|
2
|
+
|
|
3
|
+
Unlike ``TitleClient`` (scoped to one title via its URL/slug), catalog listing isn't scoped
|
|
4
|
+
to any title at all — see CLAUDE.md's roadmap entry for why this is a separate entry point
|
|
5
|
+
with its own ``ApiClient``/``DiskCache`` rather than a method on the title client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any, Generic, Self, TypeVar
|
|
14
|
+
|
|
15
|
+
from libsocial.cache import DEFAULT_CACHE_DIR, DiskCache
|
|
16
|
+
from libsocial.client import ApiClient
|
|
17
|
+
from libsocial.models import CatalogPage, Country, Genre, Label, TitleT
|
|
18
|
+
from libsocial.site import Site
|
|
19
|
+
|
|
20
|
+
DEFAULT_SORT = "last_chapter_at"
|
|
21
|
+
"""Default sort order: title's last-chapter timestamp — the closest real equivalent to
|
|
22
|
+
"recently updated" (the API has no ``updated_at`` sort value, see docs/api-notes.md)."""
|
|
23
|
+
|
|
24
|
+
MIN_PER_PAGE = 10
|
|
25
|
+
MAX_PER_PAGE = 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Catalog(Generic[TitleT]):
|
|
29
|
+
"""Listing/searching one site's catalog.
|
|
30
|
+
|
|
31
|
+
Not used directly: a site package subclasses it, setting ``site`` and ``page_model``
|
|
32
|
+
(and optionally ``default_cache_dir``) as class attributes — so its own ``Catalog``
|
|
33
|
+
keeps a plain constructor and returns its own page and ``Title`` models.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
```python
|
|
37
|
+
class RanobeLibCatalog(Catalog[Title]):
|
|
38
|
+
site = Site(id=3, origin="https://ranobelib.me")
|
|
39
|
+
page_model = CatalogPage[Title]
|
|
40
|
+
|
|
41
|
+
async with RanobeLibCatalog() as catalog:
|
|
42
|
+
page = await catalog.list_titles(query="dxd", genres=[34], statuses=[1])
|
|
43
|
+
for title in page.items:
|
|
44
|
+
print(title.name)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
site: The site whose catalog is listed. Set by the subclass.
|
|
49
|
+
page_model: The model a listing page is validated as — ``CatalogPage[ItsTitle]``
|
|
50
|
+
or a subclass of it, which also decides the model of its items. Set by the
|
|
51
|
+
subclass.
|
|
52
|
+
default_cache_dir: ``cache_dir`` used when none is passed.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
site: Site
|
|
56
|
+
page_model: type[CatalogPage[TitleT]]
|
|
57
|
+
default_cache_dir: Path = DEFAULT_CACHE_DIR
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
cache_dir: str | Path | None = None,
|
|
63
|
+
cache_ttl: float | None = None,
|
|
64
|
+
headers: Mapping[str, str] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Initialize the catalog client.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
cache_dir: Where to cache raw API responses on disk, one entry per distinct set
|
|
70
|
+
of listing parameters (page/filters/sort) — same disk cache as
|
|
71
|
+
``TitleClient`` uses (``cache.py``), just keyed by listing parameters instead
|
|
72
|
+
of a title's slug. Defaults to ``default_cache_dir``.
|
|
73
|
+
cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
|
|
74
|
+
``None`` (the default) means cached pages never expire on their own — see
|
|
75
|
+
``refresh=True`` on ``list_titles()`` to force one anyway.
|
|
76
|
+
headers: Extra HTTP headers merged over the SDK's defaults, same as
|
|
77
|
+
``TitleClient(headers=...)`` — see there.
|
|
78
|
+
"""
|
|
79
|
+
self._client = self._create_client(headers)
|
|
80
|
+
self._cache = DiskCache(
|
|
81
|
+
cache_dir if cache_dir is not None else self.default_cache_dir, ttl=cache_ttl
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _create_client(self, headers: Mapping[str, str] | None) -> ApiClient:
|
|
85
|
+
"""The ``ApiClient`` requests go through — a site package may return its own subclass.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
headers: The ``headers`` passed to the constructor.
|
|
89
|
+
"""
|
|
90
|
+
return ApiClient(self.site, headers=headers)
|
|
91
|
+
|
|
92
|
+
async def __aenter__(self) -> Self:
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(
|
|
96
|
+
self,
|
|
97
|
+
exc_type: type[BaseException] | None,
|
|
98
|
+
exc: BaseException | None,
|
|
99
|
+
traceback: TracebackType | None,
|
|
100
|
+
) -> None:
|
|
101
|
+
await self.aclose()
|
|
102
|
+
|
|
103
|
+
async def aclose(self) -> None:
|
|
104
|
+
"""Close the underlying HTTP client."""
|
|
105
|
+
await self._client.aclose()
|
|
106
|
+
|
|
107
|
+
async def list_titles(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
page: int = 1,
|
|
111
|
+
per_page: int = 30,
|
|
112
|
+
query: str | None = None,
|
|
113
|
+
genres: list[int] | None = None,
|
|
114
|
+
tags: list[int] | None = None,
|
|
115
|
+
statuses: list[int] | None = None,
|
|
116
|
+
countries: list[int] | None = None,
|
|
117
|
+
min_chapters: int | None = None,
|
|
118
|
+
max_chapters: int | None = None,
|
|
119
|
+
sort: str = DEFAULT_SORT,
|
|
120
|
+
refresh: bool = False,
|
|
121
|
+
) -> CatalogPage[TitleT]:
|
|
122
|
+
"""Fetch one page of catalog listing/search results.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
page: 1-based page number.
|
|
126
|
+
per_page: How many titles per page. Must be between ``MIN_PER_PAGE`` (10) and
|
|
127
|
+
``MAX_PER_PAGE`` (60) — the API's own limits (see docs/api-notes.md);
|
|
128
|
+
anything outside that range raises here instead of a confusing 422 from
|
|
129
|
+
the API.
|
|
130
|
+
query: Free-text search term, matched against name/rus_name/eng_name. ``None``
|
|
131
|
+
(the default) or an empty string lists without searching.
|
|
132
|
+
genres: Genre ids to filter by. A title must have *all* of them, not just one
|
|
133
|
+
(AND, not OR — see docs/api-notes.md). Not validated against ``list_genres()``
|
|
134
|
+
before sending — an unrecognized id just matches nothing rather than erroring.
|
|
135
|
+
tags: ``Tag.id``s (from ``Title.tags``) to filter by. Same AND semantics as
|
|
136
|
+
``genres`` — a title must have *all* of them, not just any one (confirmed
|
|
137
|
+
against the live API, see docs/api-notes.md; not the OR some might expect
|
|
138
|
+
from tags being more numerous/specific than genres). Also not validated
|
|
139
|
+
before sending, same as ``genres``.
|
|
140
|
+
statuses: ``Title.status.id``s to filter by (e.g. ongoing, completed). A title
|
|
141
|
+
matches if its own status is *any* of these (OR, same as ``countries`` — a
|
|
142
|
+
title only has one status, see docs/api-notes.md). ``None`` or an empty list
|
|
143
|
+
applies no status filter. Ids come from ``list_statuses()``.
|
|
144
|
+
countries: ``Country.id``s to filter by. A title matches if its own country is
|
|
145
|
+
*any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
|
|
146
|
+
country of origin, so requiring all of them could never match past the first,
|
|
147
|
+
see docs/api-notes.md). ``None`` or an empty list applies no country filter.
|
|
148
|
+
Ids come from ``list_countries()``. Sent on the wire as the API's ``types[]``
|
|
149
|
+
parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
|
|
150
|
+
why (``Country`` mirrors the API's own "type" concept, which isn't strictly
|
|
151
|
+
limited to literal countries).
|
|
152
|
+
min_chapters: Only titles with at least this many chapters (inclusive). Counts
|
|
153
|
+
the same number as ``Title.chapter_count`` (uploaded chapters). ``None``
|
|
154
|
+
(the default) applies no lower bound.
|
|
155
|
+
max_chapters: Only titles with at most this many chapters (inclusive). ``None``
|
|
156
|
+
(the default) applies no upper bound.
|
|
157
|
+
sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
|
|
158
|
+
parameter — an actual ``sort`` parameter exists but is silently ignored by
|
|
159
|
+
the API (see docs/api-notes.md). Known accepted values: ``"name"``,
|
|
160
|
+
``"created_at"``, ``"views"``, ``"chap_count"``, ``"last_chapter_at"``
|
|
161
|
+
(the default), ``"rate_avg"``, ``"random"`` — this list isn't guaranteed
|
|
162
|
+
exhaustive.
|
|
163
|
+
refresh: Bypass the disk cache and re-fetch from the API even if this exact
|
|
164
|
+
combination of parameters was already cached.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
A ``page_model`` page: the matching titles plus whether a next page exists.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: If ``page`` is less than 1, ``per_page`` is outside
|
|
171
|
+
``MIN_PER_PAGE..MAX_PER_PAGE``, ``min_chapters``/``max_chapters`` is
|
|
172
|
+
negative, or ``min_chapters`` is greater than ``max_chapters``.
|
|
173
|
+
"""
|
|
174
|
+
if page < 1:
|
|
175
|
+
raise ValueError(f"page must be at least 1, got {page}.")
|
|
176
|
+
if not MIN_PER_PAGE <= per_page <= MAX_PER_PAGE:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
|
|
179
|
+
)
|
|
180
|
+
for name, value in (("min_chapters", min_chapters), ("max_chapters", max_chapters)):
|
|
181
|
+
if value is not None and value < 0:
|
|
182
|
+
raise ValueError(f"{name} must be at least 0, got {value}.")
|
|
183
|
+
if min_chapters is not None and max_chapters is not None and min_chapters > max_chapters:
|
|
184
|
+
# The API itself silently swaps the bounds instead (see docs/api-notes.md) —
|
|
185
|
+
# raising here rather than relying on that undocumented behavior.
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"min_chapters ({min_chapters}) must not be greater than "
|
|
188
|
+
f"max_chapters ({max_chapters})."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
key = _cache_key(
|
|
192
|
+
site_id=self.site.id,
|
|
193
|
+
page=page,
|
|
194
|
+
per_page=per_page,
|
|
195
|
+
query=query,
|
|
196
|
+
genres=genres,
|
|
197
|
+
tags=tags,
|
|
198
|
+
statuses=statuses,
|
|
199
|
+
countries=countries,
|
|
200
|
+
min_chapters=min_chapters,
|
|
201
|
+
max_chapters=max_chapters,
|
|
202
|
+
sort=sort,
|
|
203
|
+
)
|
|
204
|
+
if not refresh:
|
|
205
|
+
cached = self._cache.get(key)
|
|
206
|
+
if cached is not None:
|
|
207
|
+
return self._build_page(cached)
|
|
208
|
+
|
|
209
|
+
data = await self._client.list_titles(
|
|
210
|
+
page=page,
|
|
211
|
+
per_page=per_page,
|
|
212
|
+
query=query,
|
|
213
|
+
genres=genres,
|
|
214
|
+
tags=tags,
|
|
215
|
+
statuses=statuses,
|
|
216
|
+
countries=countries,
|
|
217
|
+
min_chapters=min_chapters,
|
|
218
|
+
max_chapters=max_chapters,
|
|
219
|
+
sort=sort,
|
|
220
|
+
)
|
|
221
|
+
self._cache.set(key, data)
|
|
222
|
+
return self._build_page(data)
|
|
223
|
+
|
|
224
|
+
async def list_genres(self, *, refresh: bool = False) -> list[Genre]:
|
|
225
|
+
"""Fetch the full list of catalog genres (id → name), for use as filter options
|
|
226
|
+
with ``list_titles(genres=[...])``.
|
|
227
|
+
|
|
228
|
+
The underlying endpoint (``GET /api/constants?fields[]=genres``) has no site-scoping
|
|
229
|
+
parameter — it returns the full genre list shared across the whole lib.social
|
|
230
|
+
network in one shot, each tagged with the site ids it applies to (see
|
|
231
|
+
docs/api-notes.md). This filters that down to genres tagged for ``site`` before
|
|
232
|
+
returning, so callers don't get filter options that could never match any of the
|
|
233
|
+
site's titles (e.g. "Детское", tagged only for site 5, on ranobelib.me).
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
refresh: Bypass the disk cache and re-fetch from the API even if the genre list
|
|
237
|
+
was already cached.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Every ``Genre`` (id, name, adult flag) that applies to ``site``.
|
|
241
|
+
"""
|
|
242
|
+
# No site in the key, unlike list_titles(): the response is the whole network's
|
|
243
|
+
# list, and _for_site() filters it on every read, cached or not.
|
|
244
|
+
key = "catalog:genres"
|
|
245
|
+
if not refresh:
|
|
246
|
+
cached = self._cache.get(key)
|
|
247
|
+
if cached is not None:
|
|
248
|
+
return _for_site(Genre, cached, self.site)
|
|
249
|
+
|
|
250
|
+
data = await self._client.list_genres()
|
|
251
|
+
self._cache.set(key, data)
|
|
252
|
+
return _for_site(Genre, data, self.site)
|
|
253
|
+
|
|
254
|
+
async def list_countries(self, *, refresh: bool = False) -> list[Country]:
|
|
255
|
+
"""Fetch the full list of catalog countries (id → name), for use as filter options
|
|
256
|
+
with ``list_titles(countries=...)``.
|
|
257
|
+
|
|
258
|
+
Mirrors ``list_genres()`` exactly, down to the network-wide, not-site-scoped shape
|
|
259
|
+
of the underlying endpoint (``GET /api/constants?fields[]=types`` — the API's own
|
|
260
|
+
name for what this SDK calls "country" is "type", see docs/api-notes.md and
|
|
261
|
+
``Country``'s docstring). This filters that down to entries tagged for ``site`` before
|
|
262
|
+
returning, same as ``list_genres()`` does for genres.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
refresh: Bypass the disk cache and re-fetch from the API even if the country
|
|
266
|
+
list was already cached.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Every ``Country`` (id, name) that applies to ``site``.
|
|
270
|
+
"""
|
|
271
|
+
key = "catalog:countries"
|
|
272
|
+
if not refresh:
|
|
273
|
+
cached = self._cache.get(key)
|
|
274
|
+
if cached is not None:
|
|
275
|
+
return _for_site(Country, cached, self.site)
|
|
276
|
+
|
|
277
|
+
data = await self._client.list_countries()
|
|
278
|
+
self._cache.set(key, data)
|
|
279
|
+
return _for_site(Country, data, self.site)
|
|
280
|
+
|
|
281
|
+
async def list_statuses(self, *, refresh: bool = False) -> list[Label]:
|
|
282
|
+
"""Fetch the full list of title statuses (id → label), for use as filter options
|
|
283
|
+
with ``list_titles(statuses=...)``.
|
|
284
|
+
|
|
285
|
+
Mirrors ``list_genres()``/``list_countries()``: the underlying endpoint
|
|
286
|
+
(``GET /api/constants?fields[]=status``) isn't site-scoped, so entries not tagged for
|
|
287
|
+
``site`` are dropped before returning (see docs/api-notes.md). Returns the same
|
|
288
|
+
``Label`` model as ``Title.status``, so a title's status can be compared against
|
|
289
|
+
these directly.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
refresh: Bypass the disk cache and re-fetch from the API even if the status list
|
|
293
|
+
was already cached.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Every status (``Label``: id, label) that applies to ``site``.
|
|
297
|
+
"""
|
|
298
|
+
key = "catalog:statuses"
|
|
299
|
+
if not refresh:
|
|
300
|
+
cached = self._cache.get(key)
|
|
301
|
+
if cached is not None:
|
|
302
|
+
return _for_site(Label, cached, self.site)
|
|
303
|
+
|
|
304
|
+
data = await self._client.list_statuses()
|
|
305
|
+
self._cache.set(key, data)
|
|
306
|
+
return _for_site(Label, data, self.site)
|
|
307
|
+
|
|
308
|
+
def _build_page(self, data: dict[str, Any]) -> CatalogPage[TitleT]:
|
|
309
|
+
meta = data["meta"]
|
|
310
|
+
return self.page_model.model_validate(
|
|
311
|
+
{
|
|
312
|
+
"items": data["data"],
|
|
313
|
+
"page": meta["current_page"],
|
|
314
|
+
"has_next_page": meta["has_next_page"],
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _cache_key(
|
|
320
|
+
*,
|
|
321
|
+
site_id: int,
|
|
322
|
+
page: int,
|
|
323
|
+
per_page: int,
|
|
324
|
+
query: str | None,
|
|
325
|
+
genres: list[int] | None,
|
|
326
|
+
tags: list[int] | None,
|
|
327
|
+
statuses: list[int] | None,
|
|
328
|
+
countries: list[int] | None,
|
|
329
|
+
min_chapters: int | None,
|
|
330
|
+
max_chapters: int | None,
|
|
331
|
+
sort: str,
|
|
332
|
+
) -> str:
|
|
333
|
+
genres_part = ",".join(str(genre_id) for genre_id in genres or [])
|
|
334
|
+
tags_part = ",".join(str(tag_id) for tag_id in tags or [])
|
|
335
|
+
statuses_part = ",".join(str(status_id) for status_id in statuses or [])
|
|
336
|
+
countries_part = ",".join(str(country_id) for country_id in countries or [])
|
|
337
|
+
# The listing is scoped to the site (site_id[]), so catalogs for different sites sharing
|
|
338
|
+
# a cache_dir must not read each other's pages.
|
|
339
|
+
return (
|
|
340
|
+
f"catalog:{site_id}:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
|
|
341
|
+
f"{statuses_part}:{countries_part}:{min_chapters}:{max_chapters}:{sort}"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
_ConstantT = TypeVar("_ConstantT", Genre, Country, Label)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _for_site(model: type[_ConstantT], data: list[dict[str, Any]], site: Site) -> list[_ConstantT]:
|
|
349
|
+
"""Validate a raw ``/constants`` list as ``model``, keeping entries tagged for ``site``."""
|
|
350
|
+
return [model.model_validate(item) for item in data if site.id in item.get("site_ids", [])]
|