novel-downloader 1.2.1__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novel_downloader/__init__.py +1 -2
- novel_downloader/cli/__init__.py +0 -1
- novel_downloader/cli/clean.py +2 -10
- novel_downloader/cli/download.py +18 -22
- novel_downloader/cli/interactive.py +0 -1
- novel_downloader/cli/main.py +1 -3
- novel_downloader/cli/settings.py +8 -8
- novel_downloader/config/__init__.py +0 -1
- novel_downloader/config/adapter.py +48 -18
- novel_downloader/config/loader.py +116 -108
- novel_downloader/config/models.py +41 -32
- novel_downloader/config/site_rules.py +2 -4
- novel_downloader/core/__init__.py +0 -1
- novel_downloader/core/downloaders/__init__.py +4 -4
- novel_downloader/core/downloaders/base/__init__.py +14 -0
- novel_downloader/core/downloaders/{base_async_downloader.py → base/base_async.py} +49 -53
- novel_downloader/core/downloaders/{base_downloader.py → base/base_sync.py} +64 -43
- novel_downloader/core/downloaders/biquge/__init__.py +12 -0
- novel_downloader/core/downloaders/biquge/biquge_sync.py +25 -0
- novel_downloader/core/downloaders/common/__init__.py +14 -0
- novel_downloader/core/downloaders/{common_asynb_downloader.py → common/common_async.py} +42 -33
- novel_downloader/core/downloaders/{common_downloader.py → common/common_sync.py} +34 -23
- novel_downloader/core/downloaders/qidian/__init__.py +10 -0
- novel_downloader/core/downloaders/{qidian_downloader.py → qidian/qidian_sync.py} +80 -64
- novel_downloader/core/factory/__init__.py +4 -5
- novel_downloader/core/factory/{downloader_factory.py → downloader.py} +36 -35
- novel_downloader/core/factory/{parser_factory.py → parser.py} +12 -14
- novel_downloader/core/factory/{requester_factory.py → requester.py} +29 -16
- novel_downloader/core/factory/{saver_factory.py → saver.py} +4 -9
- novel_downloader/core/interfaces/__init__.py +8 -9
- novel_downloader/core/interfaces/{async_downloader_protocol.py → async_downloader.py} +4 -5
- novel_downloader/core/interfaces/{async_requester_protocol.py → async_requester.py} +26 -12
- novel_downloader/core/interfaces/{parser_protocol.py → parser.py} +11 -6
- novel_downloader/core/interfaces/{saver_protocol.py → saver.py} +2 -3
- novel_downloader/core/interfaces/{downloader_protocol.py → sync_downloader.py} +6 -7
- novel_downloader/core/interfaces/{requester_protocol.py → sync_requester.py} +34 -17
- novel_downloader/core/parsers/__init__.py +5 -4
- novel_downloader/core/parsers/{base_parser.py → base.py} +20 -11
- novel_downloader/core/parsers/biquge/__init__.py +10 -0
- novel_downloader/core/parsers/biquge/main_parser.py +126 -0
- novel_downloader/core/parsers/{common_parser → common}/__init__.py +2 -3
- novel_downloader/core/parsers/{common_parser → common}/helper.py +20 -18
- novel_downloader/core/parsers/{common_parser → common}/main_parser.py +15 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_encrypted.py +41 -49
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_normal.py +17 -21
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/chapter_router.py +10 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/browser/main_parser.py +16 -12
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/__init__.py +2 -3
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_encrypted.py +37 -45
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_normal.py +19 -23
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/chapter_router.py +10 -9
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/main_parser.py +16 -12
- novel_downloader/core/parsers/{qidian_parser → qidian}/session/node_decryptor.py +7 -10
- novel_downloader/core/parsers/{qidian_parser → qidian}/shared/__init__.py +2 -3
- novel_downloader/core/parsers/qidian/shared/book_info_parser.py +150 -0
- novel_downloader/core/parsers/{qidian_parser → qidian}/shared/helpers.py +9 -10
- novel_downloader/core/requesters/__init__.py +9 -5
- novel_downloader/core/requesters/base/__init__.py +16 -0
- novel_downloader/core/requesters/{base_async_session.py → base/async_session.py} +180 -73
- novel_downloader/core/requesters/base/browser.py +340 -0
- novel_downloader/core/requesters/base/session.py +364 -0
- novel_downloader/core/requesters/biquge/__init__.py +12 -0
- novel_downloader/core/requesters/biquge/session.py +90 -0
- novel_downloader/core/requesters/{common_requester → common}/__init__.py +4 -5
- novel_downloader/core/requesters/common/async_session.py +96 -0
- novel_downloader/core/requesters/common/session.py +113 -0
- novel_downloader/core/requesters/qidian/__init__.py +21 -0
- novel_downloader/core/requesters/qidian/broswer.py +306 -0
- novel_downloader/core/requesters/qidian/session.py +287 -0
- novel_downloader/core/savers/__init__.py +5 -3
- novel_downloader/core/savers/{base_saver.py → base.py} +12 -13
- novel_downloader/core/savers/biquge.py +25 -0
- novel_downloader/core/savers/{common_saver → common}/__init__.py +2 -3
- novel_downloader/core/savers/{common_saver/common_epub.py → common/epub.py} +24 -52
- novel_downloader/core/savers/{common_saver → common}/main_saver.py +43 -9
- novel_downloader/core/savers/{common_saver/common_txt.py → common/txt.py} +16 -46
- novel_downloader/core/savers/epub_utils/__init__.py +0 -1
- novel_downloader/core/savers/epub_utils/css_builder.py +13 -7
- novel_downloader/core/savers/epub_utils/initializer.py +4 -5
- novel_downloader/core/savers/epub_utils/text_to_html.py +2 -3
- novel_downloader/core/savers/epub_utils/volume_intro.py +1 -3
- novel_downloader/core/savers/{qidian_saver.py → qidian.py} +12 -6
- novel_downloader/locales/en.json +12 -4
- novel_downloader/locales/zh.json +9 -1
- novel_downloader/resources/config/settings.toml +88 -0
- novel_downloader/utils/cache.py +2 -2
- novel_downloader/utils/chapter_storage.py +340 -0
- novel_downloader/utils/constants.py +8 -5
- novel_downloader/utils/crypto_utils.py +3 -3
- novel_downloader/utils/file_utils/__init__.py +0 -1
- novel_downloader/utils/file_utils/io.py +12 -17
- novel_downloader/utils/file_utils/normalize.py +1 -3
- novel_downloader/utils/file_utils/sanitize.py +2 -9
- novel_downloader/utils/fontocr/__init__.py +0 -1
- novel_downloader/utils/fontocr/ocr_v1.py +19 -22
- novel_downloader/utils/fontocr/ocr_v2.py +147 -60
- novel_downloader/utils/hash_store.py +19 -20
- novel_downloader/utils/hash_utils.py +0 -1
- novel_downloader/utils/i18n.py +3 -4
- novel_downloader/utils/logger.py +5 -6
- novel_downloader/utils/model_loader.py +5 -8
- novel_downloader/utils/network.py +9 -10
- novel_downloader/utils/state.py +6 -7
- novel_downloader/utils/text_utils/__init__.py +0 -1
- novel_downloader/utils/text_utils/chapter_formatting.py +2 -7
- novel_downloader/utils/text_utils/diff_display.py +0 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -4
- novel_downloader/utils/text_utils/text_cleaning.py +0 -1
- novel_downloader/utils/time_utils/__init__.py +0 -1
- novel_downloader/utils/time_utils/datetime_utils.py +9 -11
- novel_downloader/utils/time_utils/sleep_utils.py +27 -13
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/METADATA +14 -17
- novel_downloader-1.3.0.dist-info/RECORD +127 -0
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/WHEEL +1 -1
- novel_downloader/core/parsers/qidian_parser/shared/book_info_parser.py +0 -95
- novel_downloader/core/requesters/base_browser.py +0 -210
- novel_downloader/core/requesters/base_session.py +0 -243
- novel_downloader/core/requesters/common_requester/common_async_session.py +0 -98
- novel_downloader/core/requesters/common_requester/common_session.py +0 -126
- novel_downloader/core/requesters/qidian_requester/__init__.py +0 -22
- novel_downloader/core/requesters/qidian_requester/qidian_broswer.py +0 -377
- novel_downloader/core/requesters/qidian_requester/qidian_session.py +0 -202
- novel_downloader/resources/config/settings.yaml +0 -76
- novel_downloader-1.2.1.dist-info/RECORD +0 -115
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.2.1.dist-info → novel_downloader-1.3.0.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 .
|
15
|
-
from .
|
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.
|
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
|
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 "{{{}}}"
|
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:
|
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.
|
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.
|
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
|
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(
|
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:
|
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:
|
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
|
-
|
178
|
-
if
|
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
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
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,8 +183,8 @@ 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)
|
215
|
-
book.toc =
|
186
|
+
saver.logger.info("%s Building TOC and spine...", TAG)
|
187
|
+
book.toc = toc_list
|
216
188
|
book.spine = spine
|
217
189
|
book.add_item(epub.EpubNcx())
|
218
190
|
book.add_item(epub.EpubNav())
|
@@ -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.
|
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 ..
|
16
|
-
from .
|
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__(
|
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 .
|
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
|
+
]
|