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.
Files changed (24) hide show
  1. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/PKG-INFO +1 -1
  2. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/pyproject.toml +1 -1
  3. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/pyproject.toml.orig +1 -1
  4. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/__init__.py +13 -1
  5. ranobelib_python_sdk-0.11.0/src/ranobelib/_http.py +45 -0
  6. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/catalog.py +5 -1
  7. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/client.py +25 -1
  8. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exceptions.py +28 -0
  9. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/__init__.py +6 -1
  10. ranobelib_python_sdk-0.11.0/src/ranobelib/exporters/_shared.py +29 -0
  11. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/epub.py +16 -5
  12. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/fb2.py +6 -3
  13. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/pdf.py +13 -5
  14. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/txt.py +6 -3
  15. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/models.py +247 -21
  16. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/sdk.py +17 -4
  17. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/sizing.py +9 -6
  18. ranobelib_python_sdk-0.9.0/src/ranobelib/exporters/_shared.py +0 -11
  19. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/README.md +0 -0
  20. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/cache.py +0 -0
  21. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/console.py +0 -0
  22. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  23. {ranobelib_python_sdk-0.9.0 → ranobelib_python_sdk-0.11.0}/src/ranobelib/numbering.py +0 -0
  24. {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.9.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.9.0"
3
+ version = "0.11.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.9.0"
3
+ version = "0.11.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 CatalogPage, Chapter, ChapterBranch, Country, Genre, Title, Volume
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
 
@@ -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, follow_redirects=True, transport=self._transport
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 for chapter in chapters for url in extract_image_urls(chapter.content or "")
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.content or "", url_to_local)
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.content or ""):
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.content or "", url_to_data_uri)
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, follow_redirects=True, transport=self._transport
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.content or "")
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.content or "")
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
- def _render_prosemirror_block(node: dict[str, Any], attachments: list[dict[str, Any]]) -> str:
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(node)
103
+ _render_prosemirror_inline(child, attachments) for child in _child_nodes(paragraph)
75
104
  )
76
- return f"<p>{inner}</p>"
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(_render_prosemirror_block(child, attachments) for child in _child_nodes(node))
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(doc: dict[str, Any], attachments: list[dict[str, Any]]) -> str:
85
- """Render a prosemirror-doc JSON structure as an HTML fragment.
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
- return "".join(_render_prosemirror_block(block, attachments) for block in _child_nodes(doc))
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
- self._emit(tag, attrs, self_closing=False)
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
- self._emit(tag, attrs, self_closing=True)
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]], *, self_closing: bool) -> 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._output.append(f'<img loading="lazy" src="{html.escape(src, quote=True)}" />')
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._output.append(f"<{resolved} />")
321
+ self._out().append(f"<{resolved} />")
145
322
  else:
146
- self._output.append(f"<{resolved}>")
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._output.append(f"</{resolved}>")
339
+ self._out().append(f"</{resolved}>")
156
340
 
157
341
  def handle_data(self, data: str) -> None:
158
- if self._skip_text_depth == 0:
159
- self._output.append(html.escape(data))
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
- data = {**data, "content": _prosemirror_to_html(content, attachments)}
556
+ rendered, footnotes = _prosemirror_to_html(content, attachments)
342
557
  elif isinstance(content, str):
343
- data = {**data, "content": _sanitize_content_html(content)}
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._client = ApiClient()
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(title, chapters, Path(path), on_chapter=advance)
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 ``<img>`` is
6
- assumed to weigh ``average_image_size`` bytes rather than downloaded to measure precisely —
7
- doing that would cost the same network traffic this module exists to help avoid paying
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
- text_bytes = len(chapter.content.encode("utf-8"))
44
- image_count = len(extract_image_urls(chapter.content))
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