novel-downloader 1.2.2__py3-none-any.whl → 1.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. novel_downloader/__init__.py +1 -2
  2. novel_downloader/cli/__init__.py +0 -1
  3. novel_downloader/cli/clean.py +2 -10
  4. novel_downloader/cli/download.py +16 -22
  5. novel_downloader/cli/interactive.py +0 -1
  6. novel_downloader/cli/main.py +1 -3
  7. novel_downloader/cli/settings.py +8 -8
  8. novel_downloader/config/__init__.py +0 -1
  9. novel_downloader/config/adapter.py +32 -27
  10. novel_downloader/config/loader.py +116 -108
  11. novel_downloader/config/models.py +35 -29
  12. novel_downloader/config/site_rules.py +2 -4
  13. novel_downloader/core/__init__.py +0 -1
  14. novel_downloader/core/downloaders/__init__.py +4 -4
  15. novel_downloader/core/downloaders/base/__init__.py +14 -0
  16. novel_downloader/core/downloaders/{base_async_downloader.py → base/base_async.py} +49 -53
  17. novel_downloader/core/downloaders/{base_downloader.py → base/base_sync.py} +64 -43
  18. novel_downloader/core/downloaders/biquge/__init__.py +12 -0
  19. novel_downloader/core/downloaders/biquge/biquge_sync.py +25 -0
  20. novel_downloader/core/downloaders/common/__init__.py +14 -0
  21. novel_downloader/core/downloaders/{common_asynb_downloader.py → common/common_async.py} +42 -33
  22. novel_downloader/core/downloaders/{common_downloader.py → common/common_sync.py} +33 -21
  23. novel_downloader/core/downloaders/qidian/__init__.py +10 -0
  24. novel_downloader/core/downloaders/{qidian_downloader.py → qidian/qidian_sync.py} +79 -62
  25. novel_downloader/core/factory/__init__.py +4 -5
  26. novel_downloader/core/factory/{downloader_factory.py → downloader.py} +25 -26
  27. novel_downloader/core/factory/{parser_factory.py → parser.py} +12 -14
  28. novel_downloader/core/factory/{requester_factory.py → requester.py} +29 -16
  29. novel_downloader/core/factory/{saver_factory.py → saver.py} +4 -9
  30. novel_downloader/core/interfaces/__init__.py +8 -9
  31. novel_downloader/core/interfaces/{async_downloader_protocol.py → async_downloader.py} +4 -5
  32. novel_downloader/core/interfaces/{async_requester_protocol.py → async_requester.py} +23 -12
  33. novel_downloader/core/interfaces/{parser_protocol.py → parser.py} +11 -6
  34. novel_downloader/core/interfaces/{saver_protocol.py → saver.py} +2 -3
  35. novel_downloader/core/interfaces/{downloader_protocol.py → sync_downloader.py} +6 -7
  36. novel_downloader/core/interfaces/{requester_protocol.py → sync_requester.py} +31 -17
  37. novel_downloader/core/parsers/__init__.py +5 -4
  38. novel_downloader/core/parsers/{base_parser.py → base.py} +18 -9
  39. novel_downloader/core/parsers/biquge/__init__.py +10 -0
  40. novel_downloader/core/parsers/biquge/main_parser.py +126 -0
  41. novel_downloader/core/parsers/{common_parser → common}/__init__.py +2 -3
  42. novel_downloader/core/parsers/{common_parser → common}/helper.py +13 -13
  43. novel_downloader/core/parsers/{common_parser → common}/main_parser.py +15 -9
  44. novel_downloader/core/parsers/{qidian_parser → qidian}/__init__.py +2 -3
  45. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/__init__.py +2 -3
  46. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_encrypted.py +40 -48
  47. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_normal.py +17 -21
  48. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_router.py +10 -9
  49. novel_downloader/core/parsers/{qidian_parser → qidian}/browser/main_parser.py +14 -10
  50. novel_downloader/core/parsers/{qidian_parser → qidian}/session/__init__.py +2 -3
  51. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_encrypted.py +36 -44
  52. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_normal.py +19 -23
  53. novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_router.py +10 -9
  54. novel_downloader/core/parsers/{qidian_parser → qidian}/session/main_parser.py +14 -10
  55. novel_downloader/core/parsers/{qidian_parser → qidian}/session/node_decryptor.py +7 -10
  56. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/__init__.py +2 -3
  57. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/book_info_parser.py +5 -6
  58. novel_downloader/core/parsers/{qidian_parser → qidian}/shared/helpers.py +7 -8
  59. novel_downloader/core/requesters/__init__.py +9 -5
  60. novel_downloader/core/requesters/base/__init__.py +16 -0
  61. novel_downloader/core/requesters/{base_async_session.py → base/async_session.py} +177 -73
  62. novel_downloader/core/requesters/base/browser.py +340 -0
  63. novel_downloader/core/requesters/base/session.py +364 -0
  64. novel_downloader/core/requesters/biquge/__init__.py +12 -0
  65. novel_downloader/core/requesters/biquge/session.py +90 -0
  66. novel_downloader/core/requesters/{common_requester → common}/__init__.py +4 -5
  67. novel_downloader/core/requesters/common/async_session.py +96 -0
  68. novel_downloader/core/requesters/common/session.py +113 -0
  69. novel_downloader/core/requesters/qidian/__init__.py +21 -0
  70. novel_downloader/core/requesters/qidian/broswer.py +307 -0
  71. novel_downloader/core/requesters/qidian/session.py +287 -0
  72. novel_downloader/core/savers/__init__.py +5 -3
  73. novel_downloader/core/savers/{base_saver.py → base.py} +12 -13
  74. novel_downloader/core/savers/biquge.py +25 -0
  75. novel_downloader/core/savers/{common_saver → common}/__init__.py +2 -3
  76. novel_downloader/core/savers/{common_saver/common_epub.py → common/epub.py} +23 -51
  77. novel_downloader/core/savers/{common_saver → common}/main_saver.py +43 -9
  78. novel_downloader/core/savers/{common_saver/common_txt.py → common/txt.py} +16 -46
  79. novel_downloader/core/savers/epub_utils/__init__.py +0 -1
  80. novel_downloader/core/savers/epub_utils/css_builder.py +13 -7
  81. novel_downloader/core/savers/epub_utils/initializer.py +4 -5
  82. novel_downloader/core/savers/epub_utils/text_to_html.py +2 -3
  83. novel_downloader/core/savers/epub_utils/volume_intro.py +1 -3
  84. novel_downloader/core/savers/{qidian_saver.py → qidian.py} +12 -6
  85. novel_downloader/locales/en.json +8 -4
  86. novel_downloader/locales/zh.json +5 -1
  87. novel_downloader/resources/config/settings.toml +88 -0
  88. novel_downloader/utils/cache.py +2 -2
  89. novel_downloader/utils/chapter_storage.py +340 -0
  90. novel_downloader/utils/constants.py +6 -4
  91. novel_downloader/utils/crypto_utils.py +3 -3
  92. novel_downloader/utils/file_utils/__init__.py +0 -1
  93. novel_downloader/utils/file_utils/io.py +12 -17
  94. novel_downloader/utils/file_utils/normalize.py +1 -3
  95. novel_downloader/utils/file_utils/sanitize.py +2 -9
  96. novel_downloader/utils/fontocr/__init__.py +0 -1
  97. novel_downloader/utils/fontocr/ocr_v1.py +19 -22
  98. novel_downloader/utils/fontocr/ocr_v2.py +147 -60
  99. novel_downloader/utils/hash_store.py +19 -20
  100. novel_downloader/utils/hash_utils.py +0 -1
  101. novel_downloader/utils/i18n.py +3 -4
  102. novel_downloader/utils/logger.py +5 -6
  103. novel_downloader/utils/model_loader.py +5 -8
  104. novel_downloader/utils/network.py +9 -10
  105. novel_downloader/utils/state.py +6 -7
  106. novel_downloader/utils/text_utils/__init__.py +0 -1
  107. novel_downloader/utils/text_utils/chapter_formatting.py +2 -7
  108. novel_downloader/utils/text_utils/diff_display.py +0 -1
  109. novel_downloader/utils/text_utils/font_mapping.py +1 -4
  110. novel_downloader/utils/text_utils/text_cleaning.py +0 -1
  111. novel_downloader/utils/time_utils/__init__.py +0 -1
  112. novel_downloader/utils/time_utils/datetime_utils.py +8 -10
  113. novel_downloader/utils/time_utils/sleep_utils.py +1 -3
  114. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/METADATA +14 -17
  115. novel_downloader-1.3.1.dist-info/RECORD +127 -0
  116. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/WHEEL +1 -1
  117. novel_downloader/core/requesters/base_browser.py +0 -214
  118. novel_downloader/core/requesters/base_session.py +0 -246
  119. novel_downloader/core/requesters/common_requester/common_async_session.py +0 -98
  120. novel_downloader/core/requesters/common_requester/common_session.py +0 -126
  121. novel_downloader/core/requesters/qidian_requester/__init__.py +0 -22
  122. novel_downloader/core/requesters/qidian_requester/qidian_broswer.py +0 -396
  123. novel_downloader/core/requesters/qidian_requester/qidian_session.py +0 -202
  124. novel_downloader/resources/config/settings.yaml +0 -76
  125. novel_downloader-1.2.2.dist-info/RECORD +0 -115
  126. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/entry_points.txt +0 -0
  127. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/licenses/LICENSE +0 -0
  128. {novel_downloader-1.2.2.dist-info → novel_downloader-1.3.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.requesters.qidian.session
4
+ -----------------------------------------------
5
+
6
+ This module defines the QidianRequester class for interacting with
7
+ the Qidian website.
8
+ It extends the BaseSession by adding methods for logging in and
9
+ retrieving book information.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ from http.cookies import SimpleCookie
16
+ from typing import Any, ClassVar
17
+
18
+ from requests import Response
19
+
20
+ from novel_downloader.config.models import RequesterConfig
21
+ from novel_downloader.core.requesters.base import BaseSession
22
+ from novel_downloader.utils.crypto_utils import patch_qd_payload_token
23
+ from novel_downloader.utils.i18n import t
24
+ from novel_downloader.utils.state import state_mgr
25
+
26
+
27
+ class QidianSession(BaseSession):
28
+ """
29
+ QidianRequester provides methods for interacting with Qidian.com,
30
+ including checking login status and preparing book-related URLs.
31
+
32
+ Inherits base session setup from BaseSession.
33
+ """
34
+
35
+ BOOKCASE_URL = "https://my.qidian.com/bookcase/"
36
+ BOOK_INFO_URL = "https://book.qidian.com/info/{book_id}/"
37
+ CHAPTER_URL = "https://www.qidian.com/chapter/{book_id}/{chapter_id}/"
38
+
39
+ _cookie_keys: ClassVar[list[str]] = [
40
+ "X2NzcmZUb2tlbg==",
41
+ "eXdndWlk",
42
+ "eXdvcGVuaWQ=",
43
+ "eXdrZXk=",
44
+ "d190c2Zw",
45
+ ]
46
+
47
+ def __init__(
48
+ self,
49
+ config: RequesterConfig,
50
+ ):
51
+ """
52
+ Initialize the QidianSession with a session configuration.
53
+
54
+ :param config: The RequesterConfig instance containing request settings.
55
+ """
56
+ super().__init__(config)
57
+ self._logged_in: bool = False
58
+ self._retry_times = config.retry_times
59
+ self._retry_interval = config.backoff_factor
60
+ self._timeout = config.timeout
61
+
62
+ def login(
63
+ self,
64
+ username: str = "",
65
+ password: str = "",
66
+ manual_login: bool = False,
67
+ **kwargs: Any,
68
+ ) -> bool:
69
+ """
70
+ Restore cookies persisted by the browser-based workflow.
71
+ """
72
+ cookies: dict[str, str] = state_mgr.get_cookies("qidian")
73
+
74
+ # Merge cookies into both the internal cache and the live session
75
+ self.update_cookies(cookies)
76
+ for attempt in range(1, self._retry_times + 1):
77
+ if self._check_login_status():
78
+ self.logger.debug("[auth] Already logged in.")
79
+ return True
80
+
81
+ if attempt == 1:
82
+ print(t("session_login_prompt_intro"))
83
+ cookie_str = input(
84
+ t(
85
+ "session_login_prompt_paste_cookie",
86
+ attempt=attempt,
87
+ max_retries=self._retry_times,
88
+ )
89
+ ).strip()
90
+
91
+ cookies = self._parse_cookie_input(cookie_str)
92
+ if not self._check_cookies(cookies):
93
+ print(t("session_login_prompt_invalid_cookie"))
94
+ continue
95
+
96
+ self.update_cookies(cookies)
97
+ return self._check_login_status()
98
+
99
+ def get_book_info(
100
+ self,
101
+ book_id: str,
102
+ **kwargs: Any,
103
+ ) -> str:
104
+ """
105
+ Fetch the raw HTML of the book info page.
106
+
107
+ :param book_id: The book identifier.
108
+ :return: The page content as a string.
109
+ """
110
+ url = self.book_info_url(book_id=book_id)
111
+ try:
112
+ resp = self.get(url, **kwargs)
113
+ resp.raise_for_status()
114
+ return resp.text
115
+ except Exception as exc:
116
+ self.logger.warning(
117
+ "[session] get_book_info(%s) failed: %s",
118
+ book_id,
119
+ exc,
120
+ )
121
+ return ""
122
+
123
+ def get_book_chapter(
124
+ self,
125
+ book_id: str,
126
+ chapter_id: str,
127
+ **kwargs: Any,
128
+ ) -> str:
129
+ """
130
+ Fetch the HTML of a single chapter.
131
+
132
+ :param book_id: The book identifier.
133
+ :param chapter_id: The chapter identifier.
134
+ :return: The chapter content as a string.
135
+ """
136
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
137
+ try:
138
+ resp = self.get(url, **kwargs)
139
+ resp.raise_for_status()
140
+ return resp.text
141
+ except Exception as exc:
142
+ self.logger.warning(
143
+ "[session] get_book_chapter(%s) failed: %s",
144
+ book_id,
145
+ exc,
146
+ )
147
+ return ""
148
+
149
+ def get_bookcase(
150
+ self,
151
+ page: int = 1,
152
+ **kwargs: Any,
153
+ ) -> str:
154
+ """
155
+ Retrieve the user's *bookcase* page.
156
+
157
+ :return: The HTML markup of the bookcase page.
158
+ """
159
+ url = self.bookcase_url()
160
+ try:
161
+ resp = self.get(url, **kwargs)
162
+ resp.raise_for_status()
163
+ return resp.text
164
+ except Exception as exc:
165
+ self.logger.warning(
166
+ "[session] get_bookcase failed: %s",
167
+ exc,
168
+ )
169
+ return ""
170
+
171
+ def get(
172
+ self,
173
+ url: str,
174
+ params: dict[str, Any] | None = None,
175
+ **kwargs: Any,
176
+ ) -> Response:
177
+ """
178
+ Same as :py:meth:`BaseSession.get`, but transparently refreshes
179
+ a cookie-based token used for request validation.
180
+
181
+ The method:
182
+ 1. Reads the existing cookie (if any);
183
+ 2. Generates a new value tied to *url*;
184
+ 3. Updates both the live ``requests.Session`` and the internal cache;
185
+ 4. Delegates the actual request to ``super().get``.
186
+ """
187
+ if self._session is None:
188
+ raise RuntimeError("Session is not initialized or has been shut down.")
189
+
190
+ # ---- 1. refresh token cookie --------------------------------------
191
+ cookie_key = self._d("d190c2Zw")
192
+ old_token = self._session.cookies.get(cookie_key, "")
193
+
194
+ if old_token:
195
+ refreshed_token = patch_qd_payload_token(old_token, url)
196
+ self._session.cookies.set(cookie_key, refreshed_token)
197
+ self._cookies[cookie_key] = refreshed_token
198
+
199
+ # ---- 2. perform the real GET --------------------------------------------
200
+ resp: Response = super().get(url, params=params, **kwargs)
201
+
202
+ # ---- 3. persist any server-set cookies (optional) --------------
203
+ self.update_cookies(self._session.cookies.get_dict())
204
+ state_mgr.set_cookies("qidian", self._cookies)
205
+
206
+ return resp
207
+
208
+ @classmethod
209
+ def book_info_url(cls, book_id: str) -> str:
210
+ """
211
+ Construct the URL for fetching a book's info page.
212
+
213
+ :param book_id: The identifier of the book.
214
+ :return: Fully qualified URL for the book info page.
215
+ """
216
+ return cls.BOOK_INFO_URL.format(book_id=book_id)
217
+
218
+ @classmethod
219
+ def chapter_url(cls, book_id: str, chapter_id: str) -> str:
220
+ """
221
+ Construct the URL for fetching a specific chapter.
222
+
223
+ :param book_id: The identifier of the book.
224
+ :param chapter_id: The identifier of the chapter.
225
+ :return: Fully qualified chapter URL.
226
+ """
227
+ return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
228
+
229
+ @classmethod
230
+ def bookcase_url(cls) -> str:
231
+ """
232
+ Construct the URL for the user's bookcase page.
233
+
234
+ :return: Fully qualified URL of the bookcase.
235
+ """
236
+ return cls.BOOKCASE_URL
237
+
238
+ def _check_login_status(self) -> bool:
239
+ """
240
+ Check whether the user is currently logged in by
241
+ inspecting the bookcase page content.
242
+
243
+ :return: True if the user appears to be logged in, False otherwise.
244
+ """
245
+ keywords = [
246
+ 'var buid = "fffffffffffffffffff"',
247
+ "C2WF946J0/probe.js",
248
+ ]
249
+ resp_text = self.get_bookcase()
250
+ return not any(kw in resp_text for kw in keywords)
251
+
252
+ @staticmethod
253
+ def _parse_cookie_input(cookie_str: str) -> dict[str, str]:
254
+ """
255
+ Parse a raw cookie string (e.g. from browser dev tools) into a dict.
256
+ Returns an empty dict if parsing fails.
257
+
258
+ :param cookie_str: The raw cookie header string.
259
+ :return: Parsed cookie dict.
260
+ """
261
+ filtered = "; ".join(pair for pair in cookie_str.split(";") if "=" in pair)
262
+ parsed = SimpleCookie()
263
+ try:
264
+ parsed.load(filtered)
265
+ return {k: v.value for k, v in parsed.items()}
266
+ except Exception:
267
+ return {}
268
+
269
+ def _check_cookies(self, cookies: dict[str, str]) -> bool:
270
+ """
271
+ Check if the provided cookies contain all required keys.
272
+
273
+ Logs any missing keys as warnings.
274
+
275
+ :param cookies: The cookie dictionary to validate.
276
+ :return: True if all required keys are present, False otherwise.
277
+ """
278
+ required = {self._d(k) for k in self._cookie_keys}
279
+ actual = set(cookies)
280
+ missing = required - actual
281
+ if missing:
282
+ self.logger.warning("Missing required cookies: %s", ", ".join(missing))
283
+ return not missing
284
+
285
+ @staticmethod
286
+ def _d(b: str) -> str:
287
+ return base64.b64decode(b).decode()
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
3
  novel_downloader.core.savers
5
4
  ----------------------------
@@ -7,14 +6,17 @@ novel_downloader.core.savers
7
6
  This module defines saver classes for different novel platforms.
8
7
 
9
8
  Currently supported platforms:
9
+ - Biquge (笔趣阁)
10
10
  - Qidian (起点中文网)
11
11
  - CommonSaver (通用)
12
12
  """
13
13
 
14
- from .common_saver import CommonSaver
15
- from .qidian_saver import QidianSaver
14
+ from .biquge import BiqugeSaver
15
+ from .common import CommonSaver
16
+ from .qidian import QidianSaver
16
17
 
17
18
  __all__ = [
19
+ "BiqugeSaver",
18
20
  "CommonSaver",
19
21
  "QidianSaver",
20
22
  ]
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
- novel_downloader.core.savers.base_saver
5
- ---------------------------------------
3
+ novel_downloader.core.savers.base
4
+ ---------------------------------
6
5
 
7
6
  This module provides an abstract base class `BaseSaver` that defines the
8
7
  common interface and reusable logic for saving assembled novel content
@@ -13,17 +12,15 @@ import abc
13
12
  import logging
14
13
  from datetime import datetime
15
14
  from pathlib import Path
16
- from typing import Any, Dict, Optional
15
+ from typing import Any
17
16
 
18
17
  from novel_downloader.config.models import SaverConfig
19
18
  from novel_downloader.core.interfaces import SaverProtocol
20
19
 
21
- logger = logging.getLogger(__name__)
22
20
 
23
-
24
- class SafeDict(Dict[str, Any]):
21
+ class SafeDict(dict[str, Any]):
25
22
  def __missing__(self, key: str) -> str:
26
- return "{{{}}}".format(key)
23
+ return f"{{{key}}}"
27
24
 
28
25
 
29
26
  class BaseSaver(SaverProtocol, abc.ABC):
@@ -49,6 +46,8 @@ class BaseSaver(SaverProtocol, abc.ABC):
49
46
 
50
47
  self._filename_template = config.filename_template
51
48
 
49
+ self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
50
+
52
51
  def save(self, book_id: str) -> None:
53
52
  """
54
53
  Save the book in the formats specified in config.
@@ -67,23 +66,23 @@ class BaseSaver(SaverProtocol, abc.ABC):
67
66
  for flag_name, save_method in actions:
68
67
  if getattr(self._config, flag_name, False):
69
68
  try:
70
- logger.info(
69
+ self.logger.info(
71
70
  "%s Attempting to save book_id '%s' as %s...",
72
71
  TAG,
73
72
  book_id,
74
73
  flag_name,
75
74
  )
76
75
  save_method(book_id)
77
- logger.info("%s Successfully saved as %s.", TAG, flag_name)
76
+ self.logger.info("%s Successfully saved as %s.", TAG, flag_name)
78
77
  except NotImplementedError as e:
79
- logger.warning(
78
+ self.logger.warning(
80
79
  "%s Save method for %s not implemented: %s",
81
80
  TAG,
82
81
  flag_name,
83
82
  str(e),
84
83
  )
85
84
  except Exception as e:
86
- logger.error(
85
+ self.logger.error(
87
86
  "%s Error while saving as %s: %s", TAG, flag_name, str(e)
88
87
  )
89
88
  return
@@ -130,7 +129,7 @@ class BaseSaver(SaverProtocol, abc.ABC):
130
129
  self,
131
130
  *,
132
131
  title: str,
133
- author: Optional[str] = None,
132
+ author: str | None = None,
134
133
  ext: str = "txt",
135
134
  **extra_fields: str,
136
135
  ) -> str:
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.savers.biquge
4
+ -----------------------------------
5
+
6
+ """
7
+
8
+ from novel_downloader.config.models import SaverConfig
9
+
10
+ from .common import CommonSaver
11
+
12
+
13
+ class BiqugeSaver(CommonSaver):
14
+ def __init__(
15
+ self,
16
+ config: SaverConfig,
17
+ ):
18
+ super().__init__(
19
+ config,
20
+ site="biquge",
21
+ chap_folders=["chapters"],
22
+ )
23
+
24
+
25
+ __all__ = ["BiqugeSaver"]
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
- novel_downloader.core.savers.common_saver
5
- -----------------------------------------
3
+ novel_downloader.core.savers.common
4
+ -----------------------------------
6
5
 
7
6
  This module provides the `CommonSaver` class for handling the saving process
8
7
  of novels.
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
- novel_downloader.core.savers.common_saver.common_epub
5
- -----------------------------------------------------
3
+ novel_downloader.core.savers.common.epub
4
+ ----------------------------------------
6
5
 
7
6
  Contains the logic for exporting novel content as a single `.epub` file.
8
7
  """
@@ -10,9 +9,8 @@ Contains the logic for exporting novel content as a single `.epub` file.
10
9
  from __future__ import annotations
11
10
 
12
11
  import json
13
- import logging
14
12
  from pathlib import Path
15
- from typing import TYPE_CHECKING, List, Optional
13
+ from typing import TYPE_CHECKING
16
14
  from urllib.parse import unquote, urlparse
17
15
 
18
16
  from ebooklib import epub
@@ -35,29 +33,6 @@ from novel_downloader.utils.text_utils import clean_chapter_title
35
33
  if TYPE_CHECKING:
36
34
  from .main_saver import CommonSaver
37
35
 
38
- logger = logging.getLogger(__name__)
39
-
40
- CHAPTER_FOLDERS: List[str] = [
41
- "chapters",
42
- "encrypted_chapters",
43
- ]
44
-
45
-
46
- def _find_chapter_file(
47
- raw_base: Path,
48
- chapter_id: str,
49
- ) -> Optional[Path]:
50
- """
51
- Search for `<chapter_id>.json` under each folder in CHAPTER_FOLDERS
52
- inside raw_data_dir/site/book_id. Return the first existing Path,
53
- or None if not found.
54
- """
55
- for folder in CHAPTER_FOLDERS:
56
- candidate = raw_base / folder / f"{chapter_id}.json"
57
- if candidate.exists():
58
- return candidate
59
- return None
60
-
61
36
 
62
37
  def _image_url_to_filename(url: str) -> str:
63
38
  """
@@ -114,15 +89,17 @@ def common_save_as_epub(
114
89
  info_text = info_path.read_text(encoding="utf-8")
115
90
  book_info = json.loads(info_text)
116
91
  except Exception as e:
117
- logger.error("%s Failed to load %s: %s", TAG, info_path, e)
92
+ saver.logger.error("%s Failed to load %s: %s", TAG, info_path, e)
118
93
  return
119
94
 
120
95
  book_name = book_info.get("book_name", book_id)
121
- logger.info("%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id)
96
+ saver.logger.info(
97
+ "%s Starting EPUB generation: %s (ID: %s)", TAG, book_name, book_id
98
+ )
122
99
 
123
100
  # --- Generate intro + cover ---
124
101
  intro_html = generate_book_intro_html(book_info)
125
- cover_path: Optional[Path] = None
102
+ cover_path: Path | None = None
126
103
  if config.include_cover:
127
104
  cover_filename = _image_url_to_filename(book_info.get("cover_url", ""))
128
105
  if cover_filename:
@@ -147,7 +124,7 @@ def common_save_as_epub(
147
124
  for vol_index, vol in enumerate(volumes, start=1):
148
125
  raw_vol_name = vol.get("volume_name", "").strip()
149
126
  vol_name = clean_chapter_title(raw_vol_name) or f"Unknown Volume {vol_index}"
150
- logger.info("Processing volume %d: %s", vol_index, vol_name)
127
+ saver.logger.info("Processing volume %d: %s", vol_index, vol_name)
151
128
 
152
129
  # Volume intro
153
130
  vol_intro = epub.EpubHtml(
@@ -165,18 +142,18 @@ def common_save_as_epub(
165
142
  spine.append(vol_intro)
166
143
 
167
144
  section = epub.Section(vol_name, vol_intro.file_name)
168
- chapter_items: List[epub.EpubHtml] = []
145
+ chapter_items: list[epub.EpubHtml] = []
169
146
 
170
147
  for chap in vol.get("chapters", []):
171
148
  chap_id = chap.get("chapterId")
172
149
  chap_title = chap.get("title", "")
173
150
  if not chap_id:
174
- logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
151
+ saver.logger.warning("%s Missing chapterId, skipping: %s", TAG, chap)
175
152
  continue
176
153
 
177
- json_path = _find_chapter_file(raw_base, chap_id)
178
- if json_path is None:
179
- logger.info(
154
+ chapter_data = saver._get_chapter(book_id, chap_id)
155
+ if not chapter_data:
156
+ saver.logger.info(
180
157
  "%s Missing chapter file: %s (%s), skipping.",
181
158
  TAG,
182
159
  chap_title,
@@ -184,17 +161,12 @@ def common_save_as_epub(
184
161
  )
185
162
  continue
186
163
 
187
- try:
188
- data = json.loads(json_path.read_text(encoding="utf-8"))
189
- title = clean_chapter_title(data.get("title", "")) or chap_id
190
- chap_html = chapter_txt_to_html(
191
- chapter_title=title,
192
- chapter_text=data.get("content", ""),
193
- author_say=data.get("author_say", ""),
194
- )
195
- except Exception as e:
196
- logger.error("%s Error parsing chapter %s: %s", TAG, json_path, e)
197
- continue
164
+ title = clean_chapter_title(chapter_data.get("title", "")) or chap_id
165
+ chap_html = chapter_txt_to_html(
166
+ chapter_title=title,
167
+ chapter_text=chapter_data.get("content", ""),
168
+ author_say=chapter_data.get("author_say", ""),
169
+ )
198
170
 
199
171
  chap_path = f"{EPUB_TEXT_FOLDER}/{chap_id}.xhtml"
200
172
  item = epub.EpubHtml(title=chap_title, file_name=chap_path, lang="zh")
@@ -211,7 +183,7 @@ def common_save_as_epub(
211
183
  toc_list.append((section, chapter_items))
212
184
 
213
185
  # --- 5. Finalize EPUB ---
214
- logger.info("%s Building TOC and spine...", TAG)
186
+ saver.logger.info("%s Building TOC and spine...", TAG)
215
187
  book.toc = toc_list
216
188
  book.spine = spine
217
189
  book.add_item(epub.EpubNcx())
@@ -226,7 +198,7 @@ def common_save_as_epub(
226
198
 
227
199
  try:
228
200
  epub.write_epub(out_path, book, EPUB_OPTIONS)
229
- logger.info("%s EPUB successfully written to %s", TAG, out_path)
201
+ saver.logger.info("%s EPUB successfully written to %s", TAG, out_path)
230
202
  except Exception as e:
231
- logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
203
+ saver.logger.error("%s Failed to write EPUB to %s: %s", TAG, out_path, e)
232
204
  return
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
2
  """
4
- novel_downloader.core.savers.common_saver.main_saver
5
- ----------------------------------------------------
3
+ novel_downloader.core.savers.common.main_saver
4
+ ----------------------------------------------
6
5
 
7
6
  This module implements the `QidianSaver` class, a concrete saver for handling
8
7
  novel data from Qidian (起点中文网). It defines the logic to compile, structure,
@@ -10,10 +9,14 @@ and export novel content in plain text format based on the platform's metadata
10
9
  and chapter files.
11
10
  """
12
11
 
12
+ from collections.abc import Mapping
13
+ from typing import Any
14
+
13
15
  from novel_downloader.config.models import SaverConfig
16
+ from novel_downloader.utils.chapter_storage import ChapterStorage
14
17
 
15
- from ..base_saver import BaseSaver
16
- from .common_txt import common_save_as_txt
18
+ from ..base import BaseSaver
19
+ from .txt import common_save_as_txt
17
20
 
18
21
 
19
22
  class CommonSaver(BaseSaver):
@@ -23,7 +26,12 @@ class CommonSaver(BaseSaver):
23
26
  logic for exporting full novels as plain text (.txt) files.
24
27
  """
25
28
 
26
- def __init__(self, config: SaverConfig, site: str):
29
+ def __init__(
30
+ self,
31
+ config: SaverConfig,
32
+ site: str,
33
+ chap_folders: list[str] | None = None,
34
+ ):
27
35
  """
28
36
  Initialize the common saver with site information.
29
37
 
@@ -33,6 +41,8 @@ class CommonSaver(BaseSaver):
33
41
  """
34
42
  super().__init__(config)
35
43
  self._site = site
44
+ self._chapter_storage_cache: dict[str, list[ChapterStorage]] = {}
45
+ self._chap_folders: list[str] = chap_folders or ["chapters"]
36
46
 
37
47
  def save_as_txt(self, book_id: str) -> None:
38
48
  """
@@ -49,6 +59,7 @@ class CommonSaver(BaseSaver):
49
59
 
50
60
  :param book_id: The book identifier (used to locate raw data)
51
61
  """
62
+ self._init_chapter_storages(book_id)
52
63
  return common_save_as_txt(self, book_id)
53
64
 
54
65
  def save_as_epub(self, book_id: str) -> None:
@@ -59,12 +70,13 @@ class CommonSaver(BaseSaver):
59
70
  :raises NotImplementedError: If the method is not overridden.
60
71
  """
61
72
  try:
62
- from .common_epub import common_save_as_epub
63
- except ImportError:
73
+ from .epub import common_save_as_epub
74
+ except ImportError as err:
64
75
  raise NotImplementedError(
65
76
  "EPUB export not supported. Please install 'ebooklib'"
66
- )
77
+ ) from err
67
78
 
79
+ self._init_chapter_storages(book_id)
68
80
  return common_save_as_epub(self, book_id)
69
81
 
70
82
  @property
@@ -84,3 +96,25 @@ class CommonSaver(BaseSaver):
84
96
  :param value: New site string to set.
85
97
  """
86
98
  self._site = value
99
+
100
+ def _get_chapter(
101
+ self,
102
+ book_id: str,
103
+ chap_id: str,
104
+ ) -> Mapping[str, Any]:
105
+ for storage in self._chapter_storage_cache[book_id]:
106
+ data = storage.get(chap_id)
107
+ if data:
108
+ return data
109
+ return {}
110
+
111
+ def _init_chapter_storages(self, book_id: str) -> None:
112
+ raw_base = self.raw_data_dir / self._site / book_id
113
+ self._chapter_storage_cache[book_id] = [
114
+ ChapterStorage(
115
+ raw_base=raw_base,
116
+ namespace=ns,
117
+ backend_type=self._config.storage_backend,
118
+ )
119
+ for ns in self._chap_folders
120
+ ]