ranobelib-python-sdk 0.9.0__tar.gz → 0.11.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.9.0 → ranobelib_python_sdk-0.11.0}/PKG-INFO +1 -1
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/pyproject.toml +1 -1
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/pyproject.toml.orig +1 -1
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/__init__.py +13 -1
- ranobelib_python_sdk-0.11.0/src/ranobelib/_http.py +45 -0
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/catalog.py +5 -1
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/client.py +25 -1
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exceptions.py +28 -0
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/__init__.py +6 -1
- ranobelib_python_sdk-0.11.0/src/ranobelib/exporters/_shared.py +29 -0
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/epub.py +16 -5
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/fb2.py +6 -3
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/pdf.py +13 -5
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/txt.py +6 -3
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/models.py +247 -21
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/sdk.py +17 -4
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/sizing.py +9 -6
- ranobelib_python_sdk-0.9.0/src/ranobelib/exporters/_shared.py +0 -11
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/README.md +0 -0
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/cache.py +0 -0
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/console.py +0 -0
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/_illustrations.py +0 -0
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/numbering.py +0 -0
- {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ranobelib-python-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from ranobelib.catalog import Catalog
|
|
4
4
|
from ranobelib.exceptions import (
|
|
5
|
+
AccessBlockedError,
|
|
5
6
|
AmbiguousChapter,
|
|
6
7
|
AuthRequiredError,
|
|
7
8
|
ChapterNotFoundError,
|
|
@@ -13,11 +14,21 @@ from ranobelib.exceptions import (
|
|
|
13
14
|
TitleNotFoundError,
|
|
14
15
|
VolumeNotFoundError,
|
|
15
16
|
)
|
|
16
|
-
from ranobelib.models import
|
|
17
|
+
from ranobelib.models import (
|
|
18
|
+
CatalogPage,
|
|
19
|
+
Chapter,
|
|
20
|
+
ChapterBranch,
|
|
21
|
+
Country,
|
|
22
|
+
Footnote,
|
|
23
|
+
Genre,
|
|
24
|
+
Title,
|
|
25
|
+
Volume,
|
|
26
|
+
)
|
|
17
27
|
from ranobelib.sdk import RanobeLib
|
|
18
28
|
from ranobelib.sizing import chapter_size, volume_size
|
|
19
29
|
|
|
20
30
|
__all__ = [
|
|
31
|
+
"AccessBlockedError",
|
|
21
32
|
"AmbiguousChapter",
|
|
22
33
|
"AuthRequiredError",
|
|
23
34
|
"Catalog",
|
|
@@ -27,6 +38,7 @@ __all__ = [
|
|
|
27
38
|
"ChapterNotFoundError",
|
|
28
39
|
"Country",
|
|
29
40
|
"DownloadTitleInterruptedError",
|
|
41
|
+
"Footnote",
|
|
30
42
|
"Genre",
|
|
31
43
|
"MultipleTitleTranslationsError",
|
|
32
44
|
"MultipleTranslationsError",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""HTTP settings shared by every client the SDK opens (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: the exporters' image-downloading clients (cover.cdnlibs.org,
|
|
5
|
+
ranobelib.me uploads) need the same headers, and importing them from here keeps the
|
|
6
|
+
exporters from depending on ``ApiClient``'s module just for a constant.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
SITE_ORIGIN = "https://ranobelib.me"
|
|
16
|
+
"""Origin of the ranobelib.me website, as its own pages send it."""
|
|
17
|
+
|
|
18
|
+
BROWSER_HEADERS: dict[str, str] = {
|
|
19
|
+
"Origin": SITE_ORIGIN,
|
|
20
|
+
"Referer": f"{SITE_ORIGIN}/",
|
|
21
|
+
"User-Agent": (
|
|
22
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 "
|
|
23
|
+
"(KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
|
|
24
|
+
),
|
|
25
|
+
}
|
|
26
|
+
"""Headers a browser on ranobelib.me sends, so requests look like they come from the site.
|
|
27
|
+
|
|
28
|
+
The DDoS-Guard edge in front of api.cdnlibs.org and cover.cdnlibs.org rejects requests
|
|
29
|
+
without a non-empty ``Referer`` with an HTML 403 page (see docs/api-notes.md, section
|
|
30
|
+
"WAF 403"). Any ``Referer`` value was enough when checked; the site's own one is sent,
|
|
31
|
+
together with ``Origin`` and a real browser ``User-Agent``, so the request as a whole matches
|
|
32
|
+
what the site itself sends rather than advertising ``python-httpx``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def browser_headers(overrides: Mapping[str, str] | None = None) -> httpx.Headers:
|
|
37
|
+
"""``BROWSER_HEADERS`` 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)
|
|
43
|
+
if overrides:
|
|
44
|
+
headers.update(overrides)
|
|
45
|
+
return headers
|
|
@@ -7,6 +7,7 @@ its own ``ApiClient``/``DiskCache`` rather than a method on ``RanobeLib``.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
from collections.abc import Mapping
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from types import TracebackType
|
|
12
13
|
from typing import Any, Self
|
|
@@ -40,6 +41,7 @@ class Catalog:
|
|
|
40
41
|
*,
|
|
41
42
|
cache_dir: str | Path | None = None,
|
|
42
43
|
cache_ttl: float | None = None,
|
|
44
|
+
headers: Mapping[str, str] | None = None,
|
|
43
45
|
) -> None:
|
|
44
46
|
"""Initialize the catalog client.
|
|
45
47
|
|
|
@@ -51,8 +53,10 @@ class Catalog:
|
|
|
51
53
|
cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
|
|
52
54
|
``None`` (the default) means cached pages never expire on their own — see
|
|
53
55
|
``refresh=True`` on ``list_titles()`` to force one anyway.
|
|
56
|
+
headers: Extra HTTP headers merged over the SDK's defaults, same as
|
|
57
|
+
``RanobeLib(headers=...)`` — see there.
|
|
54
58
|
"""
|
|
55
|
-
self._client = ApiClient()
|
|
59
|
+
self._client = ApiClient(headers=headers)
|
|
56
60
|
self._cache = DiskCache(
|
|
57
61
|
cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
|
|
58
62
|
)
|
|
@@ -4,13 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import time
|
|
7
|
-
from collections.abc import Awaitable, Callable
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
8
|
from types import TracebackType
|
|
9
9
|
from typing import Any, Self
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
|
|
13
|
+
from ranobelib._http import BROWSER_HEADERS
|
|
13
14
|
from ranobelib.exceptions import (
|
|
15
|
+
AccessBlockedError,
|
|
14
16
|
AuthRequiredError,
|
|
15
17
|
ChapterNotFoundError,
|
|
16
18
|
RanobeLibError,
|
|
@@ -36,6 +38,11 @@ class ApiClient:
|
|
|
36
38
|
Bounds concurrency with a semaphore, paces request starts with a small fixed delay, and
|
|
37
39
|
retries 429/5xx responses with exponential backoff — see docs/api-notes.md for why a
|
|
38
40
|
manual implementation was chosen over a dependency like ``tenacity`` for this.
|
|
41
|
+
|
|
42
|
+
Every request carries browser-like ``Origin``/``Referer``/``User-Agent`` headers on top
|
|
43
|
+
of the API-specific ``Site-Id``/``Accept``: without a non-empty ``Referer``, the
|
|
44
|
+
DDoS-Guard edge in front of the API answers every request with an HTML 403 page (see
|
|
45
|
+
docs/api-notes.md, section "WAF 403").
|
|
39
46
|
"""
|
|
40
47
|
|
|
41
48
|
def __init__(
|
|
@@ -44,6 +51,7 @@ class ApiClient:
|
|
|
44
51
|
base_url: str = API_BASE_URL,
|
|
45
52
|
timeout: float = 15.0,
|
|
46
53
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
54
|
+
headers: Mapping[str, str] | None = None,
|
|
47
55
|
max_concurrency: int = 5,
|
|
48
56
|
request_delay: float | None = None,
|
|
49
57
|
max_retries: int = 3,
|
|
@@ -57,6 +65,13 @@ class ApiClient:
|
|
|
57
65
|
base_url: API base URL.
|
|
58
66
|
timeout: Per-request timeout, in seconds.
|
|
59
67
|
transport: Custom ``httpx`` transport, e.g. a mock transport for tests.
|
|
68
|
+
headers: Extra headers merged over the defaults (browser-like ``Origin``/
|
|
69
|
+
``Referer``/``User-Agent`` plus the API's ``Site-Id``/``Accept``); a key
|
|
70
|
+
given here replaces the default one, matched case-insensitively. Meant for
|
|
71
|
+
swapping in a different ``User-Agent`` if the site's edge protection changes
|
|
72
|
+
its rules, without waiting for an SDK release. Overriding ``Site-Id`` or
|
|
73
|
+
``Accept`` is possible but breaks the API's contract (see
|
|
74
|
+
docs/api-notes.md) — only do it knowingly.
|
|
60
75
|
max_concurrency: Maximum number of requests in flight at once.
|
|
61
76
|
request_delay: Minimum time, in seconds, between the start of one request and
|
|
62
77
|
the next, even under ``max_concurrency``. ``None`` (the default) uses
|
|
@@ -74,10 +89,13 @@ class ApiClient:
|
|
|
74
89
|
timeout=timeout,
|
|
75
90
|
transport=transport,
|
|
76
91
|
headers={
|
|
92
|
+
**BROWSER_HEADERS,
|
|
77
93
|
"Site-Id": RANOBELIB_SITE_ID,
|
|
78
94
|
"Accept": "application/json",
|
|
79
95
|
},
|
|
80
96
|
)
|
|
97
|
+
if headers is not None:
|
|
98
|
+
self._http.headers.update(headers)
|
|
81
99
|
self._semaphore = asyncio.Semaphore(max_concurrency)
|
|
82
100
|
self._request_delay = DEFAULT_REQUEST_DELAY if request_delay is None else request_delay
|
|
83
101
|
self._max_retries = max_retries
|
|
@@ -302,6 +320,12 @@ class ApiClient:
|
|
|
302
320
|
if response.status_code == 404:
|
|
303
321
|
raise not_found
|
|
304
322
|
if response.status_code == 403:
|
|
323
|
+
# The API's own 403 is JSON; the DDoS-Guard edge in front of it answers with an
|
|
324
|
+
# HTML page instead. Told apart by Content-Type, not by the page's wording, which
|
|
325
|
+
# can change (see docs/api-notes.md, section "WAF 403").
|
|
326
|
+
media_type = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
|
|
327
|
+
if media_type != "application/json":
|
|
328
|
+
raise AccessBlockedError(str(response.request.url))
|
|
305
329
|
raise AuthRequiredError(str(response.request.url))
|
|
306
330
|
if response.status_code == 429:
|
|
307
331
|
retry_after = response.headers.get("Retry-After")
|
|
@@ -153,6 +153,34 @@ class AuthRequiredError(RanobeLibError):
|
|
|
153
153
|
super().__init__(f"Authorization required to access: {url}")
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
class AccessBlockedError(RanobeLibError):
|
|
157
|
+
"""Raised when the site's edge protection rejects a request before it reaches the API.
|
|
158
|
+
|
|
159
|
+
Not an authorization problem, unlike ``AuthRequiredError``: the DDoS-Guard edge in front
|
|
160
|
+
of api.cdnlibs.org answers with an HTML 403 page (the API's own 403 is JSON) when it
|
|
161
|
+
doesn't like the request — so far, a missing or empty ``Referer`` (see
|
|
162
|
+
docs/api-notes.md, section "WAF 403"). The SDK already sends browser-like headers, so
|
|
163
|
+
seeing this means either the edge changed its rules — check for an SDK update, or send
|
|
164
|
+
what it now expects via ``headers=`` on ``RanobeLib``/``Catalog`` (or ``ApiClient``, if
|
|
165
|
+
using it directly) — or the edge is blocking the network/IP itself, which no header
|
|
166
|
+
fixes. Not retried: resending the same request gets
|
|
167
|
+
the same answer.
|
|
168
|
+
|
|
169
|
+
Attributes:
|
|
170
|
+
url: The request URL that returned 403.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, url: str) -> None:
|
|
174
|
+
self.url = url
|
|
175
|
+
super().__init__(
|
|
176
|
+
f"Request blocked by the site's protection (not an authorization issue): {url}. "
|
|
177
|
+
"The site may have changed which requests it accepts: check for an SDK update, "
|
|
178
|
+
"or send different headers via RanobeLib(..., headers=...) / "
|
|
179
|
+
"Catalog(headers=...). If the same URL doesn't open in a browser on this network "
|
|
180
|
+
"either, the IP itself is blocked and no header change will help."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
156
184
|
class RateLimitError(RanobeLibError):
|
|
157
185
|
"""Raised when the API responds with 429 Too Many Requests.
|
|
158
186
|
|
{ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/__init__.py
RENAMED
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import importlib
|
|
11
11
|
import pkgutil
|
|
12
|
-
from collections.abc import Callable
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
from typing import ClassVar, Protocol, TypeVar, runtime_checkable
|
|
15
15
|
|
|
@@ -30,6 +30,7 @@ class Exporter(Protocol):
|
|
|
30
30
|
output_path: Path,
|
|
31
31
|
*,
|
|
32
32
|
on_chapter: Callable[[], None] | None = None,
|
|
33
|
+
headers: Mapping[str, str] | None = None,
|
|
33
34
|
) -> Path:
|
|
34
35
|
"""Write ``chapters`` (in the given order) to ``output_path``.
|
|
35
36
|
|
|
@@ -45,6 +46,10 @@ class Exporter(Protocol):
|
|
|
45
46
|
``RanobeLib.export()``'s progress bar (see CLAUDE.md's roadmap step 23).
|
|
46
47
|
For epub/pdf this covers the per-chapter embedding step, not the earlier
|
|
47
48
|
illustration-download step, which isn't itself progress-reported.
|
|
49
|
+
headers: Extra HTTP headers for any downloads the exporter makes (epub/pdf
|
|
50
|
+
illustrations), merged over the SDK's browser-like defaults —
|
|
51
|
+
``RanobeLib.export()`` passes its own ``headers=`` through here. Exporters
|
|
52
|
+
that make no requests (txt, fb2) accept and ignore it.
|
|
48
53
|
|
|
49
54
|
Returns:
|
|
50
55
|
``output_path``, once the file has been written.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Helpers shared between exporter implementations (not part of the public Exporter protocol)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ranobelib.models import Chapter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def chapter_heading(chapter: Chapter) -> str:
|
|
9
|
+
"""A human-readable "Volume X, Chapter Y[: Name]" heading for ``chapter``."""
|
|
10
|
+
heading = f"Volume {chapter.volume}, Chapter {chapter.number}"
|
|
11
|
+
return f"{heading}: {chapter.name}" if chapter.name else heading
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
FOOTNOTES_HEADING = "Notes"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def chapter_body_html(chapter: Chapter) -> str:
|
|
18
|
+
"""``chapter.content`` with its footnotes appended as a trailing "Notes" block.
|
|
19
|
+
|
|
20
|
+
Footnotes aren't part of ``Chapter.content`` (see ``Chapter.footnotes``), so exporters
|
|
21
|
+
render a chapter's body through this rather than reading ``content`` directly — otherwise
|
|
22
|
+
an export would silently drop them. There's no in-text link to point them back to (see
|
|
23
|
+
``Footnote``), so they're listed after the chapter, in source order.
|
|
24
|
+
"""
|
|
25
|
+
body = chapter.content or ""
|
|
26
|
+
if not chapter.footnotes:
|
|
27
|
+
return body
|
|
28
|
+
notes = "".join(f"<p>{footnote.content}</p>" for footnote in chapter.footnotes)
|
|
29
|
+
return f"{body}<hr /><p><strong>{FOOTNOTES_HEADING}</strong></p>{notes}"
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import html
|
|
6
|
-
from collections.abc import Callable
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import ClassVar
|
|
9
9
|
|
|
10
10
|
import httpx
|
|
11
11
|
from ebooklib import epub
|
|
12
12
|
|
|
13
|
+
from ranobelib._http import browser_headers
|
|
13
14
|
from ranobelib.exporters import register
|
|
14
15
|
from ranobelib.exporters._illustrations import (
|
|
15
16
|
download_images,
|
|
@@ -19,7 +20,7 @@ from ranobelib.exporters._illustrations import (
|
|
|
19
20
|
pick_cover_url,
|
|
20
21
|
rewrite_image_srcs,
|
|
21
22
|
)
|
|
22
|
-
from ranobelib.exporters._shared import chapter_heading
|
|
23
|
+
from ranobelib.exporters._shared import chapter_body_html, chapter_heading
|
|
23
24
|
from ranobelib.models import Chapter, Title
|
|
24
25
|
|
|
25
26
|
|
|
@@ -47,6 +48,7 @@ class EpubExporter:
|
|
|
47
48
|
output_path: Path,
|
|
48
49
|
*,
|
|
49
50
|
on_chapter: Callable[[], None] | None = None,
|
|
51
|
+
headers: Mapping[str, str] | None = None,
|
|
50
52
|
) -> Path:
|
|
51
53
|
"""Write ``chapters`` to ``output_path`` as an EPUB file.
|
|
52
54
|
|
|
@@ -60,6 +62,8 @@ class EpubExporter:
|
|
|
60
62
|
output_path: Where to write the ``.epub`` file.
|
|
61
63
|
on_chapter: Called once per chapter embedded, if given — after illustrations
|
|
62
64
|
have already been downloaded, see the ``Exporter`` protocol's docstring.
|
|
65
|
+
headers: Extra HTTP headers for illustration downloads, merged over the
|
|
66
|
+
SDK's browser-like defaults (a given key replaces the default one).
|
|
63
67
|
|
|
64
68
|
Returns:
|
|
65
69
|
``output_path``.
|
|
@@ -71,8 +75,13 @@ class EpubExporter:
|
|
|
71
75
|
for author in title.authors:
|
|
72
76
|
book.add_author(author.name)
|
|
73
77
|
|
|
78
|
+
# Browser headers: cover.cdnlibs.org's edge answers requests without a (non-empty)
|
|
79
|
+
# Referer with a 403, same as the API (see docs/api-notes.md, section "WAF 403").
|
|
74
80
|
async with httpx.AsyncClient(
|
|
75
|
-
timeout=15.0,
|
|
81
|
+
timeout=15.0,
|
|
82
|
+
follow_redirects=True,
|
|
83
|
+
transport=self._transport,
|
|
84
|
+
headers=browser_headers(headers),
|
|
76
85
|
) as client:
|
|
77
86
|
cover_url = pick_cover_url(title.cover)
|
|
78
87
|
if cover_url is not None:
|
|
@@ -83,7 +92,9 @@ class EpubExporter:
|
|
|
83
92
|
|
|
84
93
|
image_urls = list(
|
|
85
94
|
dict.fromkeys(
|
|
86
|
-
url
|
|
95
|
+
url
|
|
96
|
+
for chapter in chapters
|
|
97
|
+
for url in extract_image_urls(chapter_body_html(chapter))
|
|
87
98
|
)
|
|
88
99
|
)
|
|
89
100
|
images = await download_images(client, image_urls)
|
|
@@ -106,7 +117,7 @@ class EpubExporter:
|
|
|
106
117
|
toc: list[object] = []
|
|
107
118
|
for index, chapter in enumerate(chapters, start=1):
|
|
108
119
|
heading = chapter_heading(chapter)
|
|
109
|
-
body = rewrite_image_srcs(chapter
|
|
120
|
+
body = rewrite_image_srcs(chapter_body_html(chapter), url_to_local)
|
|
110
121
|
doc = epub.EpubHtml(
|
|
111
122
|
uid=f"chapter_{index}",
|
|
112
123
|
file_name=f"chapter_{index}.xhtml",
|
|
@@ -4,13 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import datetime
|
|
6
6
|
import xml.etree.ElementTree as ET
|
|
7
|
-
from collections.abc import Callable
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
8
|
from html.parser import HTMLParser
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import ClassVar
|
|
11
11
|
|
|
12
12
|
from ranobelib.exporters import register
|
|
13
|
-
from ranobelib.exporters._shared import chapter_heading
|
|
13
|
+
from ranobelib.exporters._shared import chapter_body_html, chapter_heading
|
|
14
14
|
from ranobelib.models import Chapter, Title
|
|
15
15
|
|
|
16
16
|
FB2_NS = "http://www.gribuser.ru/xml/fictionbook/2.0"
|
|
@@ -133,7 +133,7 @@ def _build_body(chapters: list[Chapter], on_chapter: Callable[[], None] | None)
|
|
|
133
133
|
section = ET.SubElement(body, _tag("section"))
|
|
134
134
|
section_title = ET.SubElement(section, _tag("title"))
|
|
135
135
|
ET.SubElement(section_title, _tag("p")).text = chapter_heading(chapter)
|
|
136
|
-
for paragraph in html_to_fb2_paragraphs(chapter
|
|
136
|
+
for paragraph in html_to_fb2_paragraphs(chapter_body_html(chapter)):
|
|
137
137
|
section.append(paragraph)
|
|
138
138
|
if on_chapter is not None:
|
|
139
139
|
on_chapter()
|
|
@@ -153,6 +153,7 @@ class Fb2Exporter:
|
|
|
153
153
|
output_path: Path,
|
|
154
154
|
*,
|
|
155
155
|
on_chapter: Callable[[], None] | None = None,
|
|
156
|
+
headers: Mapping[str, str] | None = None,
|
|
156
157
|
) -> Path:
|
|
157
158
|
"""Write ``chapters`` to ``output_path`` as FB2 XML.
|
|
158
159
|
|
|
@@ -161,6 +162,8 @@ class Fb2Exporter:
|
|
|
161
162
|
chapters: The chapters to include, in the order they should appear.
|
|
162
163
|
output_path: Where to write the ``.fb2`` file.
|
|
163
164
|
on_chapter: Called once per chapter written, if given.
|
|
165
|
+
headers: Accepted for the ``Exporter`` protocol and ignored — this format
|
|
166
|
+
downloads nothing (no illustrations in fb2, see CLAUDE.md).
|
|
164
167
|
|
|
165
168
|
Returns:
|
|
166
169
|
``output_path``.
|
|
@@ -5,12 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import base64
|
|
7
7
|
import html
|
|
8
|
-
from collections.abc import Callable
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import ClassVar
|
|
11
11
|
|
|
12
12
|
import httpx
|
|
13
13
|
|
|
14
|
+
from ranobelib._http import browser_headers
|
|
14
15
|
from ranobelib.exporters import register
|
|
15
16
|
from ranobelib.exporters._illustrations import (
|
|
16
17
|
download_images,
|
|
@@ -20,7 +21,7 @@ from ranobelib.exporters._illustrations import (
|
|
|
20
21
|
pick_cover_url,
|
|
21
22
|
rewrite_image_srcs,
|
|
22
23
|
)
|
|
23
|
-
from ranobelib.exporters._shared import chapter_heading
|
|
24
|
+
from ranobelib.exporters._shared import chapter_body_html, chapter_heading
|
|
24
25
|
from ranobelib.models import Chapter, Title
|
|
25
26
|
|
|
26
27
|
try:
|
|
@@ -63,7 +64,7 @@ def _title_page_html(title: Title, cover_data_uri: str | None) -> str:
|
|
|
63
64
|
|
|
64
65
|
def _chapter_html(chapter: Chapter, url_to_data_uri: dict[str, str]) -> str:
|
|
65
66
|
heading = html.escape(chapter_heading(chapter))
|
|
66
|
-
body = rewrite_image_srcs(chapter
|
|
67
|
+
body = rewrite_image_srcs(chapter_body_html(chapter), url_to_data_uri)
|
|
67
68
|
return f'<section class="chapter"><h1>{heading}</h1>{body}</section>'
|
|
68
69
|
|
|
69
70
|
|
|
@@ -106,6 +107,7 @@ if weasyprint is not None:
|
|
|
106
107
|
output_path: Path,
|
|
107
108
|
*,
|
|
108
109
|
on_chapter: Callable[[], None] | None = None,
|
|
110
|
+
headers: Mapping[str, str] | None = None,
|
|
109
111
|
) -> Path:
|
|
110
112
|
"""Write ``chapters`` to ``output_path`` as a PDF file.
|
|
111
113
|
|
|
@@ -116,12 +118,18 @@ if weasyprint is not None:
|
|
|
116
118
|
on_chapter: Called once per chapter rendered to HTML, if given — after
|
|
117
119
|
illustrations have already been downloaded, see the ``Exporter``
|
|
118
120
|
protocol's docstring.
|
|
121
|
+
headers: Extra HTTP headers for illustration downloads, merged over the
|
|
122
|
+
SDK's browser-like defaults (a given key replaces the default one).
|
|
119
123
|
|
|
120
124
|
Returns:
|
|
121
125
|
``output_path``.
|
|
122
126
|
"""
|
|
127
|
+
# Browser headers for the cover CDN's edge — see the same comment in epub.py.
|
|
123
128
|
async with httpx.AsyncClient(
|
|
124
|
-
timeout=15.0,
|
|
129
|
+
timeout=15.0,
|
|
130
|
+
follow_redirects=True,
|
|
131
|
+
transport=self._transport,
|
|
132
|
+
headers=browser_headers(headers),
|
|
125
133
|
) as client:
|
|
126
134
|
cover_url = pick_cover_url(title.cover)
|
|
127
135
|
cover_data_uri = None
|
|
@@ -136,7 +144,7 @@ if weasyprint is not None:
|
|
|
136
144
|
dict.fromkeys(
|
|
137
145
|
url
|
|
138
146
|
for chapter in chapters
|
|
139
|
-
for url in extract_image_urls(chapter
|
|
147
|
+
for url in extract_image_urls(chapter_body_html(chapter))
|
|
140
148
|
)
|
|
141
149
|
)
|
|
142
150
|
images = await download_images(client, image_urls)
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from collections.abc import Callable
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
6
|
from html.parser import HTMLParser
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import ClassVar
|
|
9
9
|
|
|
10
10
|
from ranobelib.exporters import register
|
|
11
|
-
from ranobelib.exporters._shared import chapter_heading
|
|
11
|
+
from ranobelib.exporters._shared import chapter_body_html, chapter_heading
|
|
12
12
|
from ranobelib.models import Chapter, Title
|
|
13
13
|
|
|
14
14
|
_BLOCK_TAGS = frozenset({"p", "div"})
|
|
@@ -77,6 +77,7 @@ class TxtExporter:
|
|
|
77
77
|
output_path: Path,
|
|
78
78
|
*,
|
|
79
79
|
on_chapter: Callable[[], None] | None = None,
|
|
80
|
+
headers: Mapping[str, str] | None = None,
|
|
80
81
|
) -> Path:
|
|
81
82
|
"""Write ``chapters`` to ``output_path`` as plain text.
|
|
82
83
|
|
|
@@ -85,13 +86,15 @@ class TxtExporter:
|
|
|
85
86
|
chapters: The chapters to include, in the order they should appear.
|
|
86
87
|
output_path: Where to write the ``.txt`` file.
|
|
87
88
|
on_chapter: Called once per chapter written, if given.
|
|
89
|
+
headers: Accepted for the ``Exporter`` protocol and ignored — this format
|
|
90
|
+
downloads nothing (no illustrations in txt, see CLAUDE.md).
|
|
88
91
|
|
|
89
92
|
Returns:
|
|
90
93
|
``output_path``.
|
|
91
94
|
"""
|
|
92
95
|
sections = [title.name]
|
|
93
96
|
for chapter in chapters:
|
|
94
|
-
body = html_to_text(chapter
|
|
97
|
+
body = html_to_text(chapter_body_html(chapter))
|
|
95
98
|
sections.append(f"{chapter_heading(chapter)}\n\n{body}")
|
|
96
99
|
if on_chapter is not None:
|
|
97
100
|
on_chapter()
|
|
@@ -67,31 +67,73 @@ def _render_prosemirror_inline(node: dict[str, Any], attachments: list[dict[str,
|
|
|
67
67
|
return "".join(_render_prosemirror_inline(child, attachments) for child in _child_nodes(node))
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
_FOOTNOTE_ARROW = "↑"
|
|
71
|
+
"""The "↑" a translator footnote paragraph starts with — see ``Chapter.footnotes``."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _strip_footnote_arrow(paragraph: dict[str, Any]) -> dict[str, Any] | None:
|
|
75
|
+
"""Return ``paragraph`` without its leading ``_FOOTNOTE_ARROW``, or ``None`` if its first
|
|
76
|
+
non-blank text doesn't start with one (i.e. it's a regular paragraph, not a footnote).
|
|
77
|
+
|
|
78
|
+
Non-text children (an image, a hard break) before that text are kept and skipped over,
|
|
79
|
+
same as ``_ContentSanitizer`` does for the HTML-string format.
|
|
80
|
+
"""
|
|
81
|
+
children = _child_nodes(paragraph)
|
|
82
|
+
for index, child in enumerate(children):
|
|
83
|
+
if child.get("type") != "text":
|
|
84
|
+
continue
|
|
85
|
+
text = str(child.get("text", "")).lstrip()
|
|
86
|
+
if not text:
|
|
87
|
+
continue
|
|
88
|
+
if not text.startswith(_FOOTNOTE_ARROW):
|
|
89
|
+
return None
|
|
90
|
+
stripped = {**child, "text": text[len(_FOOTNOTE_ARROW) :].lstrip()}
|
|
91
|
+
return {**paragraph, "content": [*children[:index], stripped, *children[index + 1 :]]}
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _render_prosemirror_block(
|
|
96
|
+
node: dict[str, Any], attachments: list[dict[str, Any]], footnotes: list[str]
|
|
97
|
+
) -> str:
|
|
71
98
|
node_type = node.get("type")
|
|
72
99
|
if node_type == "paragraph":
|
|
100
|
+
footnote = _strip_footnote_arrow(node)
|
|
101
|
+
paragraph = footnote if footnote is not None else node
|
|
73
102
|
inner = "".join(
|
|
74
|
-
_render_prosemirror_inline(child, attachments) for child in _child_nodes(
|
|
103
|
+
_render_prosemirror_inline(child, attachments) for child in _child_nodes(paragraph)
|
|
75
104
|
)
|
|
76
|
-
|
|
105
|
+
if footnote is None:
|
|
106
|
+
return f"<p>{inner}</p>"
|
|
107
|
+
if inner.strip():
|
|
108
|
+
footnotes.append(inner.strip())
|
|
109
|
+
return ""
|
|
77
110
|
if node_type == "image":
|
|
78
111
|
return _render_prosemirror_inline(node, attachments)
|
|
79
112
|
if node_type == "horizontalRule":
|
|
80
113
|
return "<hr />"
|
|
81
|
-
return "".join(
|
|
114
|
+
return "".join(
|
|
115
|
+
_render_prosemirror_block(child, attachments, footnotes) for child in _child_nodes(node)
|
|
116
|
+
)
|
|
82
117
|
|
|
83
118
|
|
|
84
|
-
def _prosemirror_to_html(
|
|
85
|
-
|
|
119
|
+
def _prosemirror_to_html(
|
|
120
|
+
doc: dict[str, Any], attachments: list[dict[str, Any]]
|
|
121
|
+
) -> tuple[str, list[str]]:
|
|
122
|
+
"""Render a prosemirror-doc JSON structure as an HTML fragment, plus its footnotes.
|
|
86
123
|
|
|
87
124
|
Chapter content comes from the API in one of two formats — an HTML string, or
|
|
88
125
|
prosemirror-doc JSON (see docs/api-notes.md) — this makes the latter match the tag
|
|
89
126
|
vocabulary (``p``/``img``/``strong``/``em``) the former already uses natively, so
|
|
90
127
|
downstream consumers (exporters) only ever handle one shape. Image nodes reference
|
|
91
128
|
attachments by an opaque id, resolved against the chapter response's ``attachments``
|
|
92
|
-
array.
|
|
129
|
+
array. Footnote paragraphs (see ``Chapter.footnotes``) are left out of the returned HTML
|
|
130
|
+
and returned separately instead, as inline HTML fragments without the leading arrow.
|
|
93
131
|
"""
|
|
94
|
-
|
|
132
|
+
footnotes: list[str] = []
|
|
133
|
+
rendered = "".join(
|
|
134
|
+
_render_prosemirror_block(block, attachments, footnotes) for block in _child_nodes(doc)
|
|
135
|
+
)
|
|
136
|
+
return rendered, footnotes
|
|
95
137
|
|
|
96
138
|
|
|
97
139
|
_ALLOWED_CONTENT_TAGS = frozenset({"p", "img", "strong", "em", "br", "hr"})
|
|
@@ -101,6 +143,70 @@ _DROPPED_CONTENT_TEXT_TAGS = frozenset({"script", "style"})
|
|
|
101
143
|
_SAFE_IMG_SCHEMES = frozenset({"http", "https"})
|
|
102
144
|
|
|
103
145
|
|
|
146
|
+
_DROPPED_BLOCK_TAGS = frozenset(
|
|
147
|
+
{
|
|
148
|
+
"address",
|
|
149
|
+
"article",
|
|
150
|
+
"aside",
|
|
151
|
+
"blockquote",
|
|
152
|
+
"caption",
|
|
153
|
+
"center",
|
|
154
|
+
"dd",
|
|
155
|
+
"details",
|
|
156
|
+
"div",
|
|
157
|
+
"dl",
|
|
158
|
+
"dt",
|
|
159
|
+
"fieldset",
|
|
160
|
+
"figcaption",
|
|
161
|
+
"figure",
|
|
162
|
+
"footer",
|
|
163
|
+
"form",
|
|
164
|
+
"h1",
|
|
165
|
+
"h2",
|
|
166
|
+
"h3",
|
|
167
|
+
"h4",
|
|
168
|
+
"h5",
|
|
169
|
+
"h6",
|
|
170
|
+
"header",
|
|
171
|
+
"hgroup",
|
|
172
|
+
"li",
|
|
173
|
+
"main",
|
|
174
|
+
"nav",
|
|
175
|
+
"ol",
|
|
176
|
+
"pre",
|
|
177
|
+
"section",
|
|
178
|
+
"summary",
|
|
179
|
+
"table",
|
|
180
|
+
"tbody",
|
|
181
|
+
"td",
|
|
182
|
+
"tfoot",
|
|
183
|
+
"th",
|
|
184
|
+
"thead",
|
|
185
|
+
"tr",
|
|
186
|
+
"ul",
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
_BLOCK_TAGS = _DROPPED_BLOCK_TAGS | {"p"}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _Block:
|
|
193
|
+
"""A ``p`` or dropped block-level tag still being parsed.
|
|
194
|
+
|
|
195
|
+
``run`` is the inline content collected since the block opened or since its last child
|
|
196
|
+
block — for a ``p`` that's its whole content; for a dropped block it's flushed into its
|
|
197
|
+
own ``<p>`` in ``segments`` whenever a child block (or ``hr``) starts, and when the block
|
|
198
|
+
closes. Each such paragraph is checked for a footnote on its own, so buffering is needed:
|
|
199
|
+
whether it is one is only known once its first non-blank text arrives.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
def __init__(self, tag: str) -> None:
|
|
203
|
+
self.tag = tag
|
|
204
|
+
self.segments: list[str] = []
|
|
205
|
+
self.run: list[str] = []
|
|
206
|
+
self.arrow_index: int | None = None
|
|
207
|
+
self.seen_text = False
|
|
208
|
+
|
|
209
|
+
|
|
104
210
|
class _ContentSanitizer(HTMLParser):
|
|
105
211
|
"""Rebuilds an HTML-string-format chapter body using only the same restricted tag
|
|
106
212
|
vocabulary ``_prosemirror_to_html`` already produces for the other content format
|
|
@@ -116,20 +222,87 @@ class _ContentSanitizer(HTMLParser):
|
|
|
116
222
|
safety concern. Text inside a dropped ``script``/``style`` tag is discarded rather than
|
|
117
223
|
kept as escaped text — anywhere else, a dropped tag's text content is kept (e.g. a
|
|
118
224
|
stripped ``<a href="...">text</a>`` still leaves ``text`` behind).
|
|
225
|
+
|
|
226
|
+
A dropped *block-level* tag (``li``, ``div``, ``h3``, ...) keeps the paragraph boundary
|
|
227
|
+
it gave its text: its inline content is wrapped in ``<p>`` rather than inlined into the
|
|
228
|
+
parent, one ``<p>`` per run of inline content between nested blocks, so the output stays
|
|
229
|
+
a flat sequence of paragraphs whatever the nesting was. Opening a block inside an open
|
|
230
|
+
``p`` closes that ``p`` first, as HTML itself does (a ``p`` can't contain blocks).
|
|
231
|
+
|
|
232
|
+
A paragraph — an explicit ``p`` or one made from a dropped block's text — whose first
|
|
233
|
+
non-blank text starts with ``_FOOTNOTE_ARROW`` is a translator footnote: it's collected
|
|
234
|
+
into ``footnotes`` (sanitized inner HTML, arrow stripped) instead of the output.
|
|
119
235
|
"""
|
|
120
236
|
|
|
121
237
|
def __init__(self) -> None:
|
|
122
238
|
super().__init__(convert_charrefs=True)
|
|
123
239
|
self._output: list[str] = []
|
|
240
|
+
self._blocks: list[_Block] = []
|
|
124
241
|
self._skip_text_depth = 0
|
|
242
|
+
self.footnotes: list[str] = []
|
|
243
|
+
|
|
244
|
+
def _out(self) -> list[str]:
|
|
245
|
+
return self._blocks[-1].run if self._blocks else self._output
|
|
125
246
|
|
|
126
247
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
127
|
-
|
|
248
|
+
if tag in _BLOCK_TAGS:
|
|
249
|
+
self._open_block(tag)
|
|
250
|
+
else:
|
|
251
|
+
self._emit(tag, attrs)
|
|
128
252
|
|
|
129
253
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
130
|
-
|
|
254
|
+
if tag in _BLOCK_TAGS:
|
|
255
|
+
self._open_block(tag)
|
|
256
|
+
self._close_block()
|
|
257
|
+
else:
|
|
258
|
+
self._emit(tag, attrs)
|
|
259
|
+
|
|
260
|
+
def _open_block(self, tag: str) -> None:
|
|
261
|
+
# Same implicit closes HTML itself does: any block ends an open <p>, and a new <li>
|
|
262
|
+
# ends an open <li>.
|
|
263
|
+
if self._blocks and (self._blocks[-1].tag == "p" or self._blocks[-1].tag == tag == "li"):
|
|
264
|
+
self._close_block()
|
|
265
|
+
self._blocks.append(_Block(tag))
|
|
266
|
+
|
|
267
|
+
def _close_block(self) -> None:
|
|
268
|
+
block = self._blocks.pop()
|
|
269
|
+
if block.tag == "p":
|
|
270
|
+
paragraph = self._take_paragraph(block, keep_empty=True)
|
|
271
|
+
else:
|
|
272
|
+
self._flush_run(block)
|
|
273
|
+
paragraph = "".join(block.segments)
|
|
274
|
+
if paragraph:
|
|
275
|
+
self._append_block_html(paragraph)
|
|
276
|
+
|
|
277
|
+
def _take_paragraph(self, block: _Block, *, keep_empty: bool) -> str:
|
|
278
|
+
"""Turn ``block.run`` into a ``<p>`` (or a footnote, returning ``""``) and reset it."""
|
|
279
|
+
run, arrow_index = block.run, block.arrow_index
|
|
280
|
+
block.run, block.arrow_index, block.seen_text = [], None, False
|
|
281
|
+
if arrow_index is not None:
|
|
282
|
+
run[arrow_index] = run[arrow_index].lstrip()[len(_FOOTNOTE_ARROW) :].lstrip()
|
|
283
|
+
inner = "".join(run).strip()
|
|
284
|
+
if inner:
|
|
285
|
+
self.footnotes.append(inner)
|
|
286
|
+
return ""
|
|
287
|
+
inner = "".join(run)
|
|
288
|
+
if keep_empty:
|
|
289
|
+
return f"<p>{inner}</p>"
|
|
290
|
+
return f"<p>{inner.strip()}</p>" if inner.strip() else ""
|
|
291
|
+
|
|
292
|
+
def _flush_run(self, block: _Block) -> None:
|
|
293
|
+
paragraph = self._take_paragraph(block, keep_empty=False)
|
|
294
|
+
if paragraph:
|
|
295
|
+
block.segments.append(paragraph)
|
|
296
|
+
|
|
297
|
+
def _append_block_html(self, block_html: str) -> None:
|
|
298
|
+
if not self._blocks:
|
|
299
|
+
self._output.append(block_html)
|
|
300
|
+
return
|
|
301
|
+
parent = self._blocks[-1]
|
|
302
|
+
self._flush_run(parent)
|
|
303
|
+
parent.segments.append(block_html)
|
|
131
304
|
|
|
132
|
-
def _emit(self, tag: str, attrs: list[tuple[str, str | None]]
|
|
305
|
+
def _emit(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
133
306
|
resolved = _CONTENT_TAG_ALIASES.get(tag, tag)
|
|
134
307
|
if resolved not in _ALLOWED_CONTENT_TAGS:
|
|
135
308
|
if tag in _DROPPED_CONTENT_TEXT_TAGS:
|
|
@@ -138,40 +311,61 @@ class _ContentSanitizer(HTMLParser):
|
|
|
138
311
|
if resolved == "img":
|
|
139
312
|
src = dict(attrs).get("src")
|
|
140
313
|
if src and urlsplit(src).scheme in _SAFE_IMG_SCHEMES:
|
|
141
|
-
self.
|
|
314
|
+
self._out().append(f'<img loading="lazy" src="{html.escape(src, quote=True)}" />')
|
|
315
|
+
return
|
|
316
|
+
if resolved == "hr" and self._blocks and self._blocks[-1].tag != "p":
|
|
317
|
+
# Between a dropped block's paragraphs, not inside one.
|
|
318
|
+
self._append_block_html("<hr />")
|
|
142
319
|
return
|
|
143
320
|
if resolved in _VOID_CONTENT_TAGS:
|
|
144
|
-
self.
|
|
321
|
+
self._out().append(f"<{resolved} />")
|
|
145
322
|
else:
|
|
146
|
-
self.
|
|
323
|
+
self._out().append(f"<{resolved}>")
|
|
147
324
|
|
|
148
325
|
def handle_endtag(self, tag: str) -> None:
|
|
326
|
+
if tag in _BLOCK_TAGS:
|
|
327
|
+
# Close up to the matching open block; a stray end tag with none open is dropped.
|
|
328
|
+
if any(block.tag == tag for block in self._blocks):
|
|
329
|
+
while self._blocks[-1].tag != tag:
|
|
330
|
+
self._close_block()
|
|
331
|
+
self._close_block()
|
|
332
|
+
return
|
|
149
333
|
resolved = _CONTENT_TAG_ALIASES.get(tag, tag)
|
|
150
334
|
if resolved not in _ALLOWED_CONTENT_TAGS:
|
|
151
335
|
if tag in _DROPPED_CONTENT_TEXT_TAGS and self._skip_text_depth > 0:
|
|
152
336
|
self._skip_text_depth -= 1
|
|
153
337
|
return
|
|
154
338
|
if resolved not in _VOID_CONTENT_TAGS:
|
|
155
|
-
self.
|
|
339
|
+
self._out().append(f"</{resolved}>")
|
|
156
340
|
|
|
157
341
|
def handle_data(self, data: str) -> None:
|
|
158
|
-
if self._skip_text_depth
|
|
159
|
-
|
|
342
|
+
if self._skip_text_depth > 0:
|
|
343
|
+
return
|
|
344
|
+
if self._blocks and not self._blocks[-1].seen_text and data.strip():
|
|
345
|
+
block = self._blocks[-1]
|
|
346
|
+
block.seen_text = True
|
|
347
|
+
if data.lstrip().startswith(_FOOTNOTE_ARROW):
|
|
348
|
+
block.arrow_index = len(block.run)
|
|
349
|
+
self._out().append(html.escape(data))
|
|
160
350
|
|
|
161
351
|
def get_html(self) -> str:
|
|
352
|
+
while self._blocks:
|
|
353
|
+
self._close_block()
|
|
162
354
|
return "".join(self._output)
|
|
163
355
|
|
|
164
356
|
|
|
165
|
-
def _sanitize_content_html(content: str) -> str:
|
|
357
|
+
def _sanitize_content_html(content: str) -> tuple[str, list[str]]:
|
|
166
358
|
"""Sanitize an HTML-string-format chapter body down to the restricted tag vocabulary.
|
|
167
359
|
|
|
168
360
|
Counterpart to ``_prosemirror_to_html`` for the other content format the API returns
|
|
169
361
|
(see docs/api-notes.md) — without this, ``Chapter.content`` would only actually be safe
|
|
170
362
|
to render as raw HTML for prosemirror-sourced chapters, not HTML-string-sourced ones.
|
|
363
|
+
Returns the sanitized HTML and, separately, the footnotes taken out of it (see
|
|
364
|
+
``Chapter.footnotes``).
|
|
171
365
|
"""
|
|
172
366
|
sanitizer = _ContentSanitizer()
|
|
173
367
|
sanitizer.feed(content)
|
|
174
|
-
return sanitizer.get_html()
|
|
368
|
+
return sanitizer.get_html(), sanitizer.footnotes
|
|
175
369
|
|
|
176
370
|
|
|
177
371
|
class Cover(BaseModel):
|
|
@@ -310,6 +504,22 @@ class ChapterBranch(BaseModel):
|
|
|
310
504
|
user: ChapterUser
|
|
311
505
|
|
|
312
506
|
|
|
507
|
+
class Footnote(BaseModel):
|
|
508
|
+
"""A translator footnote (term explanation, translation note) taken out of a chapter.
|
|
509
|
+
|
|
510
|
+
The API has no structured footnotes (see docs/api-notes.md): they're ordinary
|
|
511
|
+
paragraphs/list items whose text starts with ``↑``, so that arrow is what the SDK detects
|
|
512
|
+
them by — and strips from ``content``. There's no ``marker`` linking a footnote back to a
|
|
513
|
+
point in the text: translators mark the reference with an unnumbered ``*`` at best, which
|
|
514
|
+
the text also uses for scene breaks and censored words, so ``Chapter.footnotes``'s order
|
|
515
|
+
(the order they appear in the source) is the only link there is.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
content: str
|
|
519
|
+
"""Sanitized inline HTML (same tag vocabulary as ``Chapter.content``), without the arrow
|
|
520
|
+
and without a wrapping ``<p>``."""
|
|
521
|
+
|
|
522
|
+
|
|
313
523
|
class Chapter(BaseModel):
|
|
314
524
|
"""A chapter: volume, number, name, available translations, and optionally its content.
|
|
315
525
|
|
|
@@ -318,6 +528,10 @@ class Chapter(BaseModel):
|
|
|
318
528
|
``Chapter`` instead comes from fetching a single chapter's content, which has a
|
|
319
529
|
different response shape (see docs/api-notes.md). ``content`` is the reverse: only
|
|
320
530
|
populated by the single-chapter endpoint.
|
|
531
|
+
|
|
532
|
+
``footnotes`` holds the chapter's translator footnotes (see ``Footnote``), which are
|
|
533
|
+
removed from ``content`` rather than kept in both places — empty when the chapter has
|
|
534
|
+
none, or when ``content`` isn't fetched.
|
|
321
535
|
"""
|
|
322
536
|
|
|
323
537
|
id: int
|
|
@@ -330,6 +544,7 @@ class Chapter(BaseModel):
|
|
|
330
544
|
branches: list[ChapterBranch] = Field(default_factory=list)
|
|
331
545
|
bundle_id: int | None = None
|
|
332
546
|
content: str | None = None
|
|
547
|
+
footnotes: list[Footnote] = Field(default_factory=list)
|
|
333
548
|
|
|
334
549
|
@model_validator(mode="before")
|
|
335
550
|
@classmethod
|
|
@@ -338,9 +553,20 @@ class Chapter(BaseModel):
|
|
|
338
553
|
content = data.get("content")
|
|
339
554
|
if isinstance(content, dict):
|
|
340
555
|
attachments = data.get("attachments") or []
|
|
341
|
-
|
|
556
|
+
rendered, footnotes = _prosemirror_to_html(content, attachments)
|
|
342
557
|
elif isinstance(content, str):
|
|
343
|
-
|
|
558
|
+
rendered, footnotes = _sanitize_content_html(content)
|
|
559
|
+
else:
|
|
560
|
+
return data
|
|
561
|
+
# A Chapter being re-validated from its own dump (model_validate(model_dump()))
|
|
562
|
+
# already has its footnotes out of ``content`` — keep them rather than overwrite
|
|
563
|
+
# them with the now-empty re-extraction.
|
|
564
|
+
data = {
|
|
565
|
+
**data,
|
|
566
|
+
"content": rendered,
|
|
567
|
+
"footnotes": data.get("footnotes")
|
|
568
|
+
or [{"content": footnote} for footnote in footnotes],
|
|
569
|
+
}
|
|
344
570
|
return data
|
|
345
571
|
|
|
346
572
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
from collections.abc import Callable
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from types import TracebackType
|
|
9
9
|
from typing import Any, Self
|
|
@@ -68,6 +68,7 @@ class RanobeLib:
|
|
|
68
68
|
cache_dir: str | Path | None = None,
|
|
69
69
|
cache_ttl: float | None = None,
|
|
70
70
|
verbosity: Verbosity = False,
|
|
71
|
+
headers: Mapping[str, str] | None = None,
|
|
71
72
|
) -> None:
|
|
72
73
|
"""Initialize the SDK for a title.
|
|
73
74
|
|
|
@@ -85,9 +86,18 @@ class RanobeLib:
|
|
|
85
86
|
``"full"``: the same progress bars, plus a line logged for every title/
|
|
86
87
|
chapter-list/chapter-content fetch, noting whether it was served from the
|
|
87
88
|
disk cache or the API.
|
|
89
|
+
headers: Extra HTTP headers merged over the SDK's defaults (browser-like
|
|
90
|
+
``Origin``/``Referer``/``User-Agent``, see docs/api-notes.md, section "WAF
|
|
91
|
+
403") — a key given here replaces the default one, matched
|
|
92
|
+
case-insensitively. Applied to every API request and, through ``export()``,
|
|
93
|
+
to epub/pdf illustration downloads. Meant for when the site's edge protection
|
|
94
|
+
changes which requests it lets through (``AccessBlockedError``): send what it
|
|
95
|
+
now expects without waiting for an SDK release. ``None`` (the default) sends
|
|
96
|
+
the defaults as-is.
|
|
88
97
|
"""
|
|
89
98
|
self._slug_url = parse_slug_url(url)
|
|
90
|
-
self.
|
|
99
|
+
self._headers = dict(headers) if headers is not None else None
|
|
100
|
+
self._client = ApiClient(headers=self._headers)
|
|
91
101
|
self._cache = DiskCache(
|
|
92
102
|
cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
|
|
93
103
|
)
|
|
@@ -457,7 +467,8 @@ class RanobeLib:
|
|
|
457
467
|
chapters: The chapters to include, in the order they should appear.
|
|
458
468
|
path: Where to write the exported file.
|
|
459
469
|
fmt: Export format — a key of ``ranobelib.exporters.EXPORTERS``
|
|
460
|
-
(currently: ``"txt"``, ``"fb2"``, ``"epub"``
|
|
470
|
+
(currently: ``"txt"``, ``"fb2"``, ``"epub"``, and ``"pdf"`` where
|
|
471
|
+
WeasyPrint's native libraries are available).
|
|
461
472
|
|
|
462
473
|
Raises:
|
|
463
474
|
ValueError: If ``fmt`` isn't a registered export format.
|
|
@@ -468,7 +479,9 @@ class RanobeLib:
|
|
|
468
479
|
raise ValueError(f"Unknown export format {fmt!r}. Available: {available}")
|
|
469
480
|
title = await self.get_info()
|
|
470
481
|
with self._reporter.progress(f"Exporting to {fmt}", len(chapters)) as advance:
|
|
471
|
-
return await exporter_cls().export(
|
|
482
|
+
return await exporter_cls().export(
|
|
483
|
+
title, chapters, Path(path), on_chapter=advance, headers=self._headers
|
|
484
|
+
)
|
|
472
485
|
|
|
473
486
|
async def _build_volume(self, volume: int, raw_chapters: list[dict[str, Any]]) -> Volume:
|
|
474
487
|
volume_str = str(volume)
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
The API exposes no content-length or image-size field anywhere (see docs/api-notes.md), so
|
|
4
4
|
any size here is necessarily approximate for the image portion: chapter text is measured
|
|
5
|
-
exactly (the UTF-8 byte length of ``Chapter.content``), but each embedded
|
|
6
|
-
assumed to weigh ``average_image_size`` bytes rather than downloaded to measure
|
|
7
|
-
doing that would cost the same network traffic this module exists to help avoid
|
|
8
|
-
upfront. The result approximates the underlying content size, not a specific export
|
|
5
|
+
exactly (the UTF-8 byte length of ``Chapter.content`` plus its footnotes), but each embedded
|
|
6
|
+
``<img>`` is assumed to weigh ``average_image_size`` bytes rather than downloaded to measure
|
|
7
|
+
precisely — doing that would cost the same network traffic this module exists to help avoid
|
|
8
|
+
paying upfront. The result approximates the underlying content size, not a specific export
|
|
9
9
|
format's file size (epub's zip compression, pdf's layout overhead, fb2's XML verbosity are
|
|
10
10
|
not modeled) — enough to decide whether a title is worth downloading, not to predict an
|
|
11
11
|
exported file's exact byte count.
|
|
@@ -40,8 +40,11 @@ def chapter_size(chapter: Chapter, *, average_image_size: int = DEFAULT_AVERAGE_
|
|
|
40
40
|
f"Chapter {chapter.volume}/{chapter.number} has no content to size — "
|
|
41
41
|
"fetch it first, e.g. via get_chapter()."
|
|
42
42
|
)
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# Footnotes are split out of ``content`` (see ``Chapter.footnotes``) but are still part
|
|
44
|
+
# of what was downloaded — and of what every exporter writes out.
|
|
45
|
+
text = chapter.content + "".join(footnote.content for footnote in chapter.footnotes)
|
|
46
|
+
text_bytes = len(text.encode("utf-8"))
|
|
47
|
+
image_count = len(extract_image_urls(text))
|
|
45
48
|
return text_bytes + image_count * average_image_size
|
|
46
49
|
|
|
47
50
|
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
"""Helpers shared between exporter implementations (not part of the public Exporter protocol)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from ranobelib.models import Chapter
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def chapter_heading(chapter: Chapter) -> str:
|
|
9
|
-
"""A human-readable "Volume X, Chapter Y[: Name]" heading for ``chapter``."""
|
|
10
|
-
heading = f"Volume {chapter.volume}, Chapter {chapter.number}"
|
|
11
|
-
return f"{heading}: {chapter.name}" if chapter.name else heading
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/_illustrations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|