novel-downloader 1.3.1__py3-none-any.whl → 1.3.2__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 -1
- novel_downloader/cli/download.py +1 -1
- novel_downloader/config/adapter.py +3 -0
- novel_downloader/config/models.py +3 -0
- novel_downloader/core/downloaders/__init__.py +23 -1
- novel_downloader/core/downloaders/biquge/__init__.py +2 -0
- novel_downloader/core/downloaders/biquge/biquge_async.py +27 -0
- novel_downloader/core/downloaders/biquge/biquge_sync.py +5 -3
- novel_downloader/core/downloaders/common/common_async.py +5 -3
- novel_downloader/core/downloaders/common/common_sync.py +18 -10
- novel_downloader/core/downloaders/esjzone/__init__.py +14 -0
- novel_downloader/core/downloaders/esjzone/esjzone_async.py +27 -0
- novel_downloader/core/downloaders/esjzone/esjzone_sync.py +27 -0
- novel_downloader/core/downloaders/qianbi/__init__.py +14 -0
- novel_downloader/core/downloaders/qianbi/qianbi_async.py +27 -0
- novel_downloader/core/downloaders/qianbi/qianbi_sync.py +27 -0
- novel_downloader/core/downloaders/qidian/qidian_sync.py +9 -6
- novel_downloader/core/downloaders/sfacg/__init__.py +14 -0
- novel_downloader/core/downloaders/sfacg/sfacg_async.py +27 -0
- novel_downloader/core/downloaders/sfacg/sfacg_sync.py +27 -0
- novel_downloader/core/downloaders/yamibo/__init__.py +14 -0
- novel_downloader/core/downloaders/yamibo/yamibo_async.py +27 -0
- novel_downloader/core/downloaders/yamibo/yamibo_sync.py +27 -0
- novel_downloader/core/factory/downloader.py +35 -7
- novel_downloader/core/factory/parser.py +23 -2
- novel_downloader/core/factory/requester.py +32 -7
- novel_downloader/core/factory/saver.py +14 -2
- novel_downloader/core/interfaces/async_requester.py +3 -3
- novel_downloader/core/interfaces/parser.py +7 -2
- novel_downloader/core/interfaces/sync_requester.py +3 -3
- novel_downloader/core/parsers/__init__.py +15 -5
- novel_downloader/core/parsers/base.py +7 -2
- novel_downloader/core/parsers/biquge/main_parser.py +13 -4
- novel_downloader/core/parsers/common/main_parser.py +13 -4
- novel_downloader/core/parsers/esjzone/__init__.py +10 -0
- novel_downloader/core/parsers/esjzone/main_parser.py +219 -0
- novel_downloader/core/parsers/qianbi/__init__.py +10 -0
- novel_downloader/core/parsers/qianbi/main_parser.py +142 -0
- novel_downloader/core/parsers/qidian/browser/main_parser.py +13 -4
- novel_downloader/core/parsers/qidian/session/main_parser.py +13 -4
- novel_downloader/core/parsers/sfacg/__init__.py +10 -0
- novel_downloader/core/parsers/sfacg/main_parser.py +166 -0
- novel_downloader/core/parsers/yamibo/__init__.py +10 -0
- novel_downloader/core/parsers/yamibo/main_parser.py +194 -0
- novel_downloader/core/requesters/__init__.py +33 -3
- novel_downloader/core/requesters/base/async_session.py +14 -10
- novel_downloader/core/requesters/base/browser.py +4 -7
- novel_downloader/core/requesters/base/session.py +25 -11
- novel_downloader/core/requesters/biquge/__init__.py +2 -0
- novel_downloader/core/requesters/biquge/async_session.py +71 -0
- novel_downloader/core/requesters/biquge/session.py +6 -6
- novel_downloader/core/requesters/common/async_session.py +4 -4
- novel_downloader/core/requesters/common/session.py +6 -6
- novel_downloader/core/requesters/esjzone/__init__.py +13 -0
- novel_downloader/core/requesters/esjzone/async_session.py +211 -0
- novel_downloader/core/requesters/esjzone/session.py +235 -0
- novel_downloader/core/requesters/qianbi/__init__.py +13 -0
- novel_downloader/core/requesters/qianbi/async_session.py +96 -0
- novel_downloader/core/requesters/qianbi/session.py +125 -0
- novel_downloader/core/requesters/qidian/broswer.py +9 -9
- novel_downloader/core/requesters/qidian/session.py +14 -11
- novel_downloader/core/requesters/sfacg/__init__.py +13 -0
- novel_downloader/core/requesters/sfacg/async_session.py +204 -0
- novel_downloader/core/requesters/sfacg/session.py +242 -0
- novel_downloader/core/requesters/yamibo/__init__.py +13 -0
- novel_downloader/core/requesters/yamibo/async_session.py +211 -0
- novel_downloader/core/requesters/yamibo/session.py +237 -0
- novel_downloader/core/savers/__init__.py +15 -3
- novel_downloader/core/savers/base.py +1 -0
- novel_downloader/core/savers/esjzone.py +25 -0
- novel_downloader/core/savers/qianbi.py +25 -0
- novel_downloader/core/savers/sfacg.py +25 -0
- novel_downloader/core/savers/yamibo.py +25 -0
- novel_downloader/locales/en.json +1 -0
- novel_downloader/locales/zh.json +1 -0
- novel_downloader/resources/config/settings.toml +40 -4
- novel_downloader/utils/time_utils/__init__.py +2 -1
- novel_downloader/utils/time_utils/datetime_utils.py +3 -1
- novel_downloader/utils/time_utils/sleep_utils.py +43 -1
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/METADATA +25 -20
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/RECORD +85 -47
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/WHEEL +0 -0
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,237 @@
|
|
1
|
+
"""
|
2
|
+
novel_downloader.core.requesters.yamibo.session
|
3
|
+
-----------------------------------------------
|
4
|
+
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import Any
|
8
|
+
|
9
|
+
from lxml import etree
|
10
|
+
|
11
|
+
from novel_downloader.config.models import RequesterConfig
|
12
|
+
from novel_downloader.core.requesters.base import BaseSession
|
13
|
+
from novel_downloader.utils.i18n import t
|
14
|
+
from novel_downloader.utils.state import state_mgr
|
15
|
+
from novel_downloader.utils.time_utils import sleep_with_random_delay
|
16
|
+
|
17
|
+
|
18
|
+
class YamiboSession(BaseSession):
|
19
|
+
"""
|
20
|
+
A session class for interacting with the
|
21
|
+
yamibo (www.yamibo.com) novel website.
|
22
|
+
"""
|
23
|
+
|
24
|
+
BASE_URL = "https://www.yamibo.com"
|
25
|
+
BOOKCASE_URL = "https://www.yamibo.com/my/fav"
|
26
|
+
BOOK_INFO_URL = "https://www.yamibo.com/novel/{book_id}"
|
27
|
+
CHAPTER_URL = "https://www.yamibo.com/novel/view-chapter?id={chapter_id}"
|
28
|
+
|
29
|
+
LOGIN_URL = "https://www.yamibo.com/user/login"
|
30
|
+
|
31
|
+
def __init__(
|
32
|
+
self,
|
33
|
+
config: RequesterConfig,
|
34
|
+
):
|
35
|
+
super().__init__(config)
|
36
|
+
self._logged_in: bool = False
|
37
|
+
self._request_interval = config.backoff_factor
|
38
|
+
self._retry_times = config.retry_times
|
39
|
+
self._username = config.username
|
40
|
+
self._password = config.password
|
41
|
+
|
42
|
+
def login(
|
43
|
+
self,
|
44
|
+
username: str = "",
|
45
|
+
password: str = "",
|
46
|
+
manual_login: bool = False,
|
47
|
+
**kwargs: Any,
|
48
|
+
) -> bool:
|
49
|
+
"""
|
50
|
+
Restore cookies persisted by the session-based workflow.
|
51
|
+
"""
|
52
|
+
cookies: dict[str, str] = state_mgr.get_cookies("yamibo")
|
53
|
+
username = username or self._username
|
54
|
+
password = password or self._password
|
55
|
+
|
56
|
+
self.update_cookies(cookies)
|
57
|
+
for _ in range(self._retry_times):
|
58
|
+
if self._check_login_status():
|
59
|
+
self.logger.debug("[auth] Already logged in.")
|
60
|
+
self._logged_in = True
|
61
|
+
return True
|
62
|
+
if username and password and not self._api_login(username, password):
|
63
|
+
print(t("session_login_failed", site="esjzone"))
|
64
|
+
sleep_with_random_delay(
|
65
|
+
self._request_interval,
|
66
|
+
mul_spread=1.1,
|
67
|
+
max_sleep=self._request_interval + 2,
|
68
|
+
)
|
69
|
+
|
70
|
+
self._logged_in = self._check_login_status()
|
71
|
+
return self._logged_in
|
72
|
+
|
73
|
+
def get_book_info(
|
74
|
+
self,
|
75
|
+
book_id: str,
|
76
|
+
**kwargs: Any,
|
77
|
+
) -> list[str]:
|
78
|
+
"""
|
79
|
+
Fetch the raw HTML of the book info and catalog pages.
|
80
|
+
|
81
|
+
Order: [info, catalog]
|
82
|
+
|
83
|
+
:param book_id: The book identifier.
|
84
|
+
:return: The page content as a string.
|
85
|
+
"""
|
86
|
+
url = self.book_info_url(book_id=book_id)
|
87
|
+
try:
|
88
|
+
resp = self.get(url, **kwargs)
|
89
|
+
resp.raise_for_status()
|
90
|
+
return [resp.text]
|
91
|
+
except Exception as exc:
|
92
|
+
self.logger.warning(
|
93
|
+
"[session] get_book_info(%s) failed: %s",
|
94
|
+
book_id,
|
95
|
+
exc,
|
96
|
+
)
|
97
|
+
return []
|
98
|
+
|
99
|
+
def get_book_chapter(
|
100
|
+
self,
|
101
|
+
book_id: str,
|
102
|
+
chapter_id: str,
|
103
|
+
**kwargs: Any,
|
104
|
+
) -> list[str]:
|
105
|
+
"""
|
106
|
+
Fetch the HTML of a single chapter.
|
107
|
+
|
108
|
+
:param book_id: The book identifier.
|
109
|
+
:param chapter_id: The chapter identifier.
|
110
|
+
:return: The chapter content as a string.
|
111
|
+
"""
|
112
|
+
url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
|
113
|
+
try:
|
114
|
+
resp = self.get(url, **kwargs)
|
115
|
+
resp.raise_for_status()
|
116
|
+
return [resp.text]
|
117
|
+
except Exception as exc:
|
118
|
+
self.logger.warning(
|
119
|
+
"[session] get_book_chapter(%s) failed: %s",
|
120
|
+
book_id,
|
121
|
+
exc,
|
122
|
+
)
|
123
|
+
return []
|
124
|
+
|
125
|
+
def get_bookcase(
|
126
|
+
self,
|
127
|
+
page: int = 1,
|
128
|
+
**kwargs: Any,
|
129
|
+
) -> list[str]:
|
130
|
+
"""
|
131
|
+
Retrieve the user's *bookcase* page.
|
132
|
+
|
133
|
+
:return: The HTML markup of the bookcase page.
|
134
|
+
"""
|
135
|
+
url = self.bookcase_url()
|
136
|
+
try:
|
137
|
+
resp = self.get(url, **kwargs)
|
138
|
+
resp.raise_for_status()
|
139
|
+
return [resp.text]
|
140
|
+
except Exception as exc:
|
141
|
+
self.logger.warning(
|
142
|
+
"[session] get_bookcase failed: %s",
|
143
|
+
exc,
|
144
|
+
)
|
145
|
+
return []
|
146
|
+
|
147
|
+
@classmethod
|
148
|
+
def bookcase_url(cls) -> str:
|
149
|
+
"""
|
150
|
+
Construct the URL for the user's bookcase page.
|
151
|
+
|
152
|
+
:return: Fully qualified URL of the bookcase.
|
153
|
+
"""
|
154
|
+
return cls.BOOKCASE_URL
|
155
|
+
|
156
|
+
@classmethod
|
157
|
+
def book_info_url(cls, book_id: str) -> str:
|
158
|
+
"""
|
159
|
+
Construct the URL for fetching a book's info page.
|
160
|
+
|
161
|
+
:param book_id: The identifier of the book.
|
162
|
+
:return: Fully qualified URL for the book info page.
|
163
|
+
"""
|
164
|
+
return cls.BOOK_INFO_URL.format(book_id=book_id)
|
165
|
+
|
166
|
+
@classmethod
|
167
|
+
def chapter_url(cls, book_id: str, chapter_id: str) -> str:
|
168
|
+
"""
|
169
|
+
Construct the URL for fetching a specific chapter.
|
170
|
+
|
171
|
+
:param book_id: The identifier of the book.
|
172
|
+
:param chapter_id: The identifier of the chapter.
|
173
|
+
:return: Fully qualified chapter URL.
|
174
|
+
"""
|
175
|
+
return cls.CHAPTER_URL.format(chapter_id=chapter_id)
|
176
|
+
|
177
|
+
def _api_login(self, username: str, password: str) -> bool:
|
178
|
+
"""
|
179
|
+
Login to the API using a 2-step token-based process.
|
180
|
+
|
181
|
+
Step 1: Get token `_csrf-frontend`.
|
182
|
+
Step 2: Use token and credentials to perform login.
|
183
|
+
Return True if login succeeds, False otherwise.
|
184
|
+
"""
|
185
|
+
try:
|
186
|
+
resp_1 = self.get(self.LOGIN_URL)
|
187
|
+
resp_1.raise_for_status()
|
188
|
+
tree = etree.HTML(resp_1.text)
|
189
|
+
csrf_value = tree.xpath('//input[@name="_csrf-frontend"]/@value')
|
190
|
+
csrf_value = csrf_value[0] if csrf_value else ""
|
191
|
+
if not csrf_value:
|
192
|
+
self.logger.warning("[session] _api_login: CSRF token not found.")
|
193
|
+
return False
|
194
|
+
except Exception as exc:
|
195
|
+
self.logger.warning("[session] _api_login failed at step 1: %s", exc)
|
196
|
+
return False
|
197
|
+
|
198
|
+
data_2 = {
|
199
|
+
"_csrf-frontend": csrf_value,
|
200
|
+
"LoginForm[username]": username,
|
201
|
+
"LoginForm[password]": password,
|
202
|
+
# "LoginForm[rememberMe]": 0,
|
203
|
+
"LoginForm[rememberMe]": 1,
|
204
|
+
"login-button": "",
|
205
|
+
}
|
206
|
+
temp_headers = dict(self.headers)
|
207
|
+
temp_headers["Origin"] = self.BASE_URL
|
208
|
+
temp_headers["Referer"] = self.LOGIN_URL
|
209
|
+
try:
|
210
|
+
resp_2 = self.post(self.LOGIN_URL, data=data_2, headers=temp_headers)
|
211
|
+
resp_2.raise_for_status()
|
212
|
+
return "登录成功" in resp_2.text
|
213
|
+
except Exception as exc:
|
214
|
+
self.logger.warning("[session] _api_login failed at step 2: %s", exc)
|
215
|
+
return False
|
216
|
+
|
217
|
+
def _check_login_status(self) -> bool:
|
218
|
+
"""
|
219
|
+
Check whether the user is currently logged in by
|
220
|
+
inspecting the bookcase page content.
|
221
|
+
|
222
|
+
:return: True if the user is logged in, False otherwise.
|
223
|
+
"""
|
224
|
+
keywords = [
|
225
|
+
"登录 - 百合会",
|
226
|
+
"用户名/邮箱",
|
227
|
+
]
|
228
|
+
resp_text = self.get_bookcase()
|
229
|
+
if not resp_text:
|
230
|
+
return False
|
231
|
+
return not any(kw in resp_text[0] for kw in keywords)
|
232
|
+
|
233
|
+
def _on_close(self) -> None:
|
234
|
+
"""
|
235
|
+
Save cookies to the state manager before closing.
|
236
|
+
"""
|
237
|
+
state_mgr.set_cookies("yamibo", self.cookies)
|
@@ -6,17 +6,29 @@ novel_downloader.core.savers
|
|
6
6
|
This module defines saver classes for different novel platforms.
|
7
7
|
|
8
8
|
Currently supported platforms:
|
9
|
-
-
|
10
|
-
-
|
11
|
-
-
|
9
|
+
- biquge (笔趣阁)
|
10
|
+
- esjzone (ESJ Zone)
|
11
|
+
- qianbi (铅笔小说)
|
12
|
+
- qidian (起点中文网)
|
13
|
+
- sfacg (SF轻小说)
|
14
|
+
- yamibo (百合会)
|
15
|
+
- common (通用架构)
|
12
16
|
"""
|
13
17
|
|
14
18
|
from .biquge import BiqugeSaver
|
15
19
|
from .common import CommonSaver
|
20
|
+
from .esjzone import EsjzoneSaver
|
21
|
+
from .qianbi import QianbiSaver
|
16
22
|
from .qidian import QidianSaver
|
23
|
+
from .sfacg import SfacgSaver
|
24
|
+
from .yamibo import YamiboSaver
|
17
25
|
|
18
26
|
__all__ = [
|
19
27
|
"BiqugeSaver",
|
20
28
|
"CommonSaver",
|
29
|
+
"EsjzoneSaver",
|
30
|
+
"QianbiSaver",
|
21
31
|
"QidianSaver",
|
32
|
+
"SfacgSaver",
|
33
|
+
"YamiboSaver",
|
22
34
|
]
|
@@ -39,6 +39,7 @@ class BaseSaver(SaverProtocol, abc.ABC):
|
|
39
39
|
"""
|
40
40
|
self._config = config
|
41
41
|
|
42
|
+
self._base_cache_dir = Path(config.cache_dir)
|
42
43
|
self._raw_data_dir = Path(config.raw_data_dir)
|
43
44
|
self._output_dir = Path(config.output_dir)
|
44
45
|
self._raw_data_dir.mkdir(parents=True, exist_ok=True)
|
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.savers.esjzone
|
4
|
+
------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from novel_downloader.config.models import SaverConfig
|
9
|
+
|
10
|
+
from .common import CommonSaver
|
11
|
+
|
12
|
+
|
13
|
+
class EsjzoneSaver(CommonSaver):
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
config: SaverConfig,
|
17
|
+
):
|
18
|
+
super().__init__(
|
19
|
+
config,
|
20
|
+
site="esjzone",
|
21
|
+
chap_folders=["chapters"],
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
__all__ = ["EsjzoneSaver"]
|
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.savers.qianbi
|
4
|
+
-----------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from novel_downloader.config.models import SaverConfig
|
9
|
+
|
10
|
+
from .common import CommonSaver
|
11
|
+
|
12
|
+
|
13
|
+
class QianbiSaver(CommonSaver):
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
config: SaverConfig,
|
17
|
+
):
|
18
|
+
super().__init__(
|
19
|
+
config,
|
20
|
+
site="qianbi",
|
21
|
+
chap_folders=["chapters"],
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
__all__ = ["QianbiSaver"]
|
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.savers.sfacg
|
4
|
+
----------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from novel_downloader.config.models import SaverConfig
|
9
|
+
|
10
|
+
from .common import CommonSaver
|
11
|
+
|
12
|
+
|
13
|
+
class SfacgSaver(CommonSaver):
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
config: SaverConfig,
|
17
|
+
):
|
18
|
+
super().__init__(
|
19
|
+
config,
|
20
|
+
site="sfacg",
|
21
|
+
chap_folders=["chapters"],
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
__all__ = ["SfacgSaver"]
|
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.savers.yamibo
|
4
|
+
-----------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from novel_downloader.config.models import SaverConfig
|
9
|
+
|
10
|
+
from .common import CommonSaver
|
11
|
+
|
12
|
+
|
13
|
+
class YamiboSaver(CommonSaver):
|
14
|
+
def __init__(
|
15
|
+
self,
|
16
|
+
config: SaverConfig,
|
17
|
+
):
|
18
|
+
super().__init__(
|
19
|
+
config,
|
20
|
+
site="yamibo",
|
21
|
+
chap_folders=["chapters"],
|
22
|
+
)
|
23
|
+
|
24
|
+
|
25
|
+
__all__ = ["YamiboSaver"]
|
novel_downloader/locales/en.json
CHANGED
@@ -72,6 +72,7 @@
|
|
72
72
|
"session_login_prompt_intro": "Failed to restore login from saved cookies. Please log in via browser, then paste the cookie string below.",
|
73
73
|
"session_login_prompt_paste_cookie": "Attempt {attempt}/{max_retries}: Paste your browser cookie string and press Enter:",
|
74
74
|
"session_login_prompt_invalid_cookie": "Invalid cookie. Please copy and paste again.",
|
75
|
+
"session_login_failed": "Login to {site} failed. Please check your credentials or try again later.",
|
75
76
|
|
76
77
|
"clean_logs": "Clean log directory",
|
77
78
|
"clean_cache": "Clean scripts and browser cache",
|
novel_downloader/locales/zh.json
CHANGED
@@ -72,6 +72,7 @@
|
|
72
72
|
"session_login_prompt_intro": "尝试使用历史 Cookie 恢复登录失败, 请在浏览器登录后从开发者工具复制 Cookie 粘贴至下方",
|
73
73
|
"session_login_prompt_paste_cookie": "第 {attempt}/{max_retries} 次尝试, 请粘贴 Cookie 字符串并回车:",
|
74
74
|
"session_login_prompt_invalid_cookie": "Cookie 格式不正确, 请重新复制粘贴",
|
75
|
+
"session_login_failed": "登录 {site} 失败, 请检查账号或稍后再试",
|
75
76
|
|
76
77
|
"clean_logs": "清理日志目录",
|
77
78
|
"clean_cache": "清理脚本和浏览器缓存",
|
@@ -45,7 +45,7 @@ ocr_weight = 0.5
|
|
45
45
|
vec_weight = 0.5
|
46
46
|
|
47
47
|
# 各站点的特定配置
|
48
|
-
[sites.qidian]
|
48
|
+
[sites.qidian] # 起点中文网
|
49
49
|
# 小说 ID 列表
|
50
50
|
# 例如: 访问 https://www.qidian.com/book/1010868264/
|
51
51
|
# 该小说的 ID 就是 1010868264
|
@@ -56,17 +56,53 @@ book_ids = [
|
|
56
56
|
mode = "browser" # browser / session
|
57
57
|
login_required = true # 是否需要登录才能访问
|
58
58
|
|
59
|
-
[sites.biquge]
|
59
|
+
[sites.biquge] # 笔趣阁
|
60
60
|
book_ids = [
|
61
61
|
"0000000000",
|
62
62
|
"0000000000"
|
63
63
|
]
|
64
64
|
mode = "session" # async / session
|
65
|
-
login_required = false
|
65
|
+
login_required = false
|
66
|
+
|
67
|
+
[sites.qianbi] # 铅笔小说
|
68
|
+
book_ids = [
|
69
|
+
"0000000000",
|
70
|
+
"0000000000"
|
71
|
+
]
|
72
|
+
mode = "session" # async / session
|
73
|
+
login_required = false
|
74
|
+
|
75
|
+
[sites.sfacg] # SF轻小说
|
76
|
+
book_ids = [
|
77
|
+
"0000000000",
|
78
|
+
"0000000000"
|
79
|
+
]
|
80
|
+
mode = "session" # async / session
|
81
|
+
login_required = false
|
82
|
+
|
83
|
+
[sites.esjzone] # ESJ Zone
|
84
|
+
book_ids = [
|
85
|
+
"0000000000",
|
86
|
+
"0000000000"
|
87
|
+
]
|
88
|
+
mode = "session" # async / session
|
89
|
+
login_required = true
|
90
|
+
username = "youremail@domain.com" # 登录邮箱
|
91
|
+
password = "yourpassword" # 登录密码
|
92
|
+
|
93
|
+
[sites.yamibo] # 百合会
|
94
|
+
book_ids = [
|
95
|
+
"0000000000",
|
96
|
+
"0000000000"
|
97
|
+
]
|
98
|
+
mode = "session" # async / session
|
99
|
+
login_required = false
|
100
|
+
username = "yourusername" # 登录账户
|
101
|
+
password = "yourpassword" # 登录密码
|
66
102
|
|
67
103
|
[sites.common]
|
68
104
|
mode = "session" # async / session
|
69
|
-
login_required = false
|
105
|
+
login_required = false
|
70
106
|
|
71
107
|
# 输出文件格式及相关选项
|
72
108
|
[output]
|
@@ -13,9 +13,10 @@ Includes:
|
|
13
13
|
"""
|
14
14
|
|
15
15
|
from .datetime_utils import calculate_time_difference
|
16
|
-
from .sleep_utils import sleep_with_random_delay
|
16
|
+
from .sleep_utils import async_sleep_with_random_delay, sleep_with_random_delay
|
17
17
|
|
18
18
|
__all__ = [
|
19
19
|
"calculate_time_difference",
|
20
|
+
"async_sleep_with_random_delay",
|
20
21
|
"sleep_with_random_delay",
|
21
22
|
]
|
@@ -24,6 +24,8 @@ _DATETIME_FORMATS = [
|
|
24
24
|
(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}", "%Y-%m-%dT%H:%M:%S%z"),
|
25
25
|
# 完整年月日+时分秒 空格格式
|
26
26
|
(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", "%Y-%m-%d %H:%M:%S"),
|
27
|
+
(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}", "%Y-%m-%d %H:%M"),
|
28
|
+
(r"\d{2}-\d{2}-\d{2} \d{2}:\d{2}", "%y-%m-%d %H:%M"),
|
27
29
|
# 年月日 (无时间)
|
28
30
|
(r"\d{4}-\d{2}-\d{2}", "%Y-%m-%d"),
|
29
31
|
# Slashes 分隔
|
@@ -136,7 +138,7 @@ def calculate_time_difference(
|
|
136
138
|
|
137
139
|
except Exception as e:
|
138
140
|
logger.warning("[time] Failed to calculate time difference: %s", e)
|
139
|
-
return
|
141
|
+
return 999, 23, 59, 59
|
140
142
|
|
141
143
|
|
142
144
|
__all__ = [
|
@@ -10,6 +10,7 @@ Includes:
|
|
10
10
|
optionally capped with a max_sleep limit.
|
11
11
|
"""
|
12
12
|
|
13
|
+
import asyncio
|
13
14
|
import logging
|
14
15
|
import random
|
15
16
|
import time
|
@@ -60,4 +61,45 @@ def sleep_with_random_delay(
|
|
60
61
|
return
|
61
62
|
|
62
63
|
|
63
|
-
|
64
|
+
async def async_sleep_with_random_delay(
|
65
|
+
base: float,
|
66
|
+
add_spread: float = 0.0,
|
67
|
+
mul_spread: float = 1.0,
|
68
|
+
*,
|
69
|
+
max_sleep: float | None = None,
|
70
|
+
) -> None:
|
71
|
+
"""
|
72
|
+
Async sleep for a random duration by combining multiplicative and additive jitter.
|
73
|
+
|
74
|
+
The total sleep time is computed as:
|
75
|
+
|
76
|
+
duration = base * uniform(1.0, mul_spread) + uniform(0, add_spread)
|
77
|
+
|
78
|
+
If `max_sleep` is provided, the duration will be capped at that value.
|
79
|
+
|
80
|
+
:param base: Base sleep time in seconds. Must be >= 0.
|
81
|
+
:param add_spread: Maximum extra seconds to add after scaling base.
|
82
|
+
:param mul_spread: Maximum multiplier factor for base; drawn from [1.0, mul_spread].
|
83
|
+
:param max_sleep: Optional upper limit for the final sleep duration.
|
84
|
+
"""
|
85
|
+
if base < 0 or add_spread < 0 or mul_spread < 0:
|
86
|
+
logger.warning(
|
87
|
+
"[async sleep] Invalid parameters: base=%s, add_spread=%s, mul_spread=%s",
|
88
|
+
base,
|
89
|
+
add_spread,
|
90
|
+
mul_spread,
|
91
|
+
)
|
92
|
+
return
|
93
|
+
|
94
|
+
multiplicative_jitter = random.uniform(1.0, mul_spread)
|
95
|
+
additive_jitter = random.uniform(0, add_spread)
|
96
|
+
duration = base * multiplicative_jitter + additive_jitter
|
97
|
+
|
98
|
+
if max_sleep is not None:
|
99
|
+
duration = min(duration, max_sleep)
|
100
|
+
|
101
|
+
logger.info("[async time] Sleeping for %.2f seconds", duration)
|
102
|
+
await asyncio.sleep(duration)
|
103
|
+
|
104
|
+
|
105
|
+
__all__ = ["sleep_with_random_delay", "async_sleep_with_random_delay"]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: novel-downloader
|
3
|
-
Version: 1.3.
|
3
|
+
Version: 1.3.2
|
4
4
|
Summary: A command-line tool for downloading Chinese web novels from Qidian and similar platforms.
|
5
5
|
Author-email: Saudade Z <saudadez217@gmail.com>
|
6
6
|
License: MIT License
|
@@ -80,9 +80,26 @@ Dynamic: license-file
|
|
80
80
|
- 对于起点中文网 (Qidian), 可在配置中选择:
|
81
81
|
- `mode: session` : 纯 Requests 模式
|
82
82
|
- `mode: browser` : 基于 DrissionPage 驱动 Chrome 的浏览器模式 (可处理更复杂的 JS/加密)。
|
83
|
-
-
|
83
|
+
- 若配置 `login_required: true`, 程序会在运行时自动检查登录状态, 支持自动重用历史 Cookie, 仅在首次登录或 Cookie 失效时需要人工介入:
|
84
|
+
- 若使用 `browser` 模式, 请在程序打开的浏览器窗口登录, 登录后回车继续
|
85
|
+
- 若使用 `session` 模式, 请根据程序提示粘贴浏览器中登录成功后的 Cookie ([复制 Cookies](https://github.com/BowenZ217/novel-downloader/blob/main/docs/copy-cookies.md))
|
84
86
|
|
85
|
-
|
87
|
+
## 功能特性
|
88
|
+
|
89
|
+
- 爬取起点中文网的小说章节内容 (支持免费与已订阅章节)
|
90
|
+
- 爬取部分小说网站
|
91
|
+
- 断点续爬
|
92
|
+
- 自动整合所有章节并导出为
|
93
|
+
- TXT
|
94
|
+
- EPUB
|
95
|
+
- 支持活动广告过滤:
|
96
|
+
- [x] 章节标题
|
97
|
+
- [ ] 章节正文
|
98
|
+
- [ ] 作者说
|
99
|
+
|
100
|
+
---
|
101
|
+
|
102
|
+
## 快速开始
|
86
103
|
|
87
104
|
```bash
|
88
105
|
# 克隆 + 安装
|
@@ -101,7 +118,10 @@ novel-cli settings init
|
|
101
118
|
novel-cli download 123456
|
102
119
|
```
|
103
120
|
|
104
|
-
|
121
|
+
- 详细可见: [支持站点列表](https://github.com/BowenZ217/novel-downloader/blob/main/docs/6-supported-sites.md)
|
122
|
+
- 更多使用方法, 查看 [使用示例](https://github.com/BowenZ217/novel-downloader/blob/main/docs/5-usage-examples.md)
|
123
|
+
|
124
|
+
## 从 GitHub 安装 (开发版)
|
105
125
|
|
106
126
|
如需体验开发中的最新功能, 可通过 GitHub 安装:
|
107
127
|
|
@@ -113,22 +133,6 @@ pip install .
|
|
113
133
|
# pip install .[font-recovery]
|
114
134
|
```
|
115
135
|
|
116
|
-
更多使用方法, 查看 [使用示例](https://github.com/BowenZ217/novel-downloader/blob/main/docs/5-usage-examples.md)
|
117
|
-
|
118
|
-
---
|
119
|
-
|
120
|
-
## 功能特性
|
121
|
-
|
122
|
-
- 爬取起点中文网的小说章节内容 (支持免费与已订阅章节)
|
123
|
-
- 断点续爬
|
124
|
-
- 自动整合所有章节并导出为
|
125
|
-
- TXT
|
126
|
-
- EPUB
|
127
|
-
- 支持活动广告过滤:
|
128
|
-
- [x] 章节标题
|
129
|
-
- [ ] 章节正文
|
130
|
-
- [ ] 作者说
|
131
|
-
|
132
136
|
---
|
133
137
|
|
134
138
|
## 文档结构
|
@@ -140,6 +144,7 @@ pip install .
|
|
140
144
|
- [settings.toml 配置说明](https://github.com/BowenZ217/novel-downloader/blob/main/docs/4-settings-schema.md)
|
141
145
|
- [使用示例](https://github.com/BowenZ217/novel-downloader/blob/main/docs/5-usage-examples.md)
|
142
146
|
- [支持站点列表](https://github.com/BowenZ217/novel-downloader/blob/main/docs/6-supported-sites.md)
|
147
|
+
- [复制 Cookies](https://github.com/BowenZ217/novel-downloader/blob/main/docs/copy-cookies.md)
|
143
148
|
- [文件保存](https://github.com/BowenZ217/novel-downloader/blob/main/docs/file-saving.md)
|
144
149
|
- [TODO](https://github.com/BowenZ217/novel-downloader/blob/main/docs/todo.md)
|
145
150
|
- [开发](https://github.com/BowenZ217/novel-downloader/blob/main/docs/develop.md)
|