novel-downloader 1.3.3__py3-none-any.whl → 1.4.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.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/clean.py +97 -78
- novel_downloader/cli/config.py +177 -0
- novel_downloader/cli/download.py +132 -87
- novel_downloader/cli/export.py +77 -0
- novel_downloader/cli/main.py +21 -28
- novel_downloader/config/__init__.py +1 -25
- novel_downloader/config/adapter.py +32 -31
- novel_downloader/config/loader.py +3 -3
- novel_downloader/config/site_rules.py +1 -2
- novel_downloader/core/__init__.py +3 -6
- novel_downloader/core/downloaders/__init__.py +10 -13
- novel_downloader/core/downloaders/base.py +233 -0
- novel_downloader/core/downloaders/biquge.py +27 -0
- novel_downloader/core/downloaders/common.py +414 -0
- novel_downloader/core/downloaders/esjzone.py +27 -0
- novel_downloader/core/downloaders/linovelib.py +27 -0
- novel_downloader/core/downloaders/qianbi.py +27 -0
- novel_downloader/core/downloaders/qidian.py +352 -0
- novel_downloader/core/downloaders/sfacg.py +27 -0
- novel_downloader/core/downloaders/yamibo.py +27 -0
- novel_downloader/core/exporters/__init__.py +37 -0
- novel_downloader/core/{savers → exporters}/base.py +73 -39
- novel_downloader/core/exporters/biquge.py +25 -0
- novel_downloader/core/exporters/common/__init__.py +12 -0
- novel_downloader/core/{savers → exporters}/common/epub.py +22 -22
- novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +35 -40
- novel_downloader/core/{savers → exporters}/common/txt.py +20 -23
- novel_downloader/core/{savers → exporters}/epub_utils/__init__.py +8 -3
- novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -2
- novel_downloader/core/{savers → exporters}/epub_utils/image_loader.py +46 -4
- novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -4
- novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +3 -3
- novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -2
- novel_downloader/core/exporters/esjzone.py +25 -0
- novel_downloader/core/exporters/linovelib/__init__.py +10 -0
- novel_downloader/core/exporters/linovelib/epub.py +449 -0
- novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
- novel_downloader/core/exporters/linovelib/txt.py +129 -0
- novel_downloader/core/exporters/qianbi.py +25 -0
- novel_downloader/core/{savers → exporters}/qidian.py +8 -8
- novel_downloader/core/exporters/sfacg.py +25 -0
- novel_downloader/core/exporters/yamibo.py +25 -0
- novel_downloader/core/factory/__init__.py +5 -17
- novel_downloader/core/factory/downloader.py +24 -126
- novel_downloader/core/factory/exporter.py +58 -0
- novel_downloader/core/factory/fetcher.py +96 -0
- novel_downloader/core/factory/parser.py +17 -12
- novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
- novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
- novel_downloader/core/fetchers/base/browser.py +383 -0
- novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
- novel_downloader/core/fetchers/base/session.py +419 -0
- novel_downloader/core/fetchers/biquge/__init__.py +14 -0
- novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
- novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
- novel_downloader/core/fetchers/common/__init__.py +14 -0
- novel_downloader/core/fetchers/common/browser.py +79 -0
- novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
- novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
- novel_downloader/core/fetchers/esjzone/browser.py +202 -0
- novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
- novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
- novel_downloader/core/fetchers/linovelib/browser.py +193 -0
- novel_downloader/core/fetchers/linovelib/session.py +193 -0
- novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
- novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
- novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
- novel_downloader/core/fetchers/qidian/__init__.py +14 -0
- novel_downloader/core/fetchers/qidian/browser.py +266 -0
- novel_downloader/core/fetchers/qidian/session.py +326 -0
- novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
- novel_downloader/core/fetchers/sfacg/browser.py +189 -0
- novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
- novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
- novel_downloader/core/fetchers/yamibo/browser.py +229 -0
- novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
- novel_downloader/core/interfaces/__init__.py +8 -12
- novel_downloader/core/interfaces/downloader.py +54 -0
- novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
- novel_downloader/core/interfaces/fetcher.py +162 -0
- novel_downloader/core/interfaces/parser.py +6 -7
- novel_downloader/core/parsers/__init__.py +5 -6
- novel_downloader/core/parsers/base.py +9 -13
- novel_downloader/core/parsers/biquge/main_parser.py +12 -13
- novel_downloader/core/parsers/common/helper.py +3 -3
- novel_downloader/core/parsers/common/main_parser.py +39 -34
- novel_downloader/core/parsers/esjzone/main_parser.py +20 -14
- novel_downloader/core/parsers/linovelib/__init__.py +10 -0
- novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
- novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
- novel_downloader/core/parsers/qidian/__init__.py +2 -11
- novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
- novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
- novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
- novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
- novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
- novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
- novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
- novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
- novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
- novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
- novel_downloader/locales/en.json +18 -2
- novel_downloader/locales/zh.json +18 -2
- novel_downloader/models/__init__.py +64 -0
- novel_downloader/models/browser.py +21 -0
- novel_downloader/models/chapter.py +25 -0
- novel_downloader/models/config.py +100 -0
- novel_downloader/models/login.py +20 -0
- novel_downloader/models/site_rules.py +99 -0
- novel_downloader/models/tasks.py +33 -0
- novel_downloader/models/types.py +15 -0
- novel_downloader/resources/config/settings.toml +31 -25
- novel_downloader/resources/json/linovelib_font_map.json +3573 -0
- novel_downloader/tui/__init__.py +7 -0
- novel_downloader/tui/app.py +32 -0
- novel_downloader/tui/main.py +17 -0
- novel_downloader/tui/screens/__init__.py +14 -0
- novel_downloader/tui/screens/home.py +191 -0
- novel_downloader/tui/screens/login.py +74 -0
- novel_downloader/tui/styles/home_layout.tcss +79 -0
- novel_downloader/tui/widgets/richlog_handler.py +24 -0
- novel_downloader/utils/__init__.py +6 -0
- novel_downloader/utils/chapter_storage.py +25 -38
- novel_downloader/utils/constants.py +11 -5
- novel_downloader/utils/cookies.py +66 -0
- novel_downloader/utils/crypto_utils.py +1 -74
- novel_downloader/utils/fontocr/ocr_v1.py +2 -1
- novel_downloader/utils/fontocr/ocr_v2.py +2 -2
- novel_downloader/utils/hash_store.py +10 -18
- novel_downloader/utils/hash_utils.py +3 -2
- novel_downloader/utils/logger.py +2 -3
- novel_downloader/utils/network.py +2 -1
- novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -1
- novel_downloader/utils/text_utils/text_cleaning.py +1 -1
- novel_downloader/utils/time_utils/datetime_utils.py +3 -3
- novel_downloader/utils/time_utils/sleep_utils.py +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/METADATA +69 -35
- novel_downloader-1.4.1.dist-info/RECORD +170 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/WHEEL +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/entry_points.txt +1 -0
- novel_downloader/cli/interactive.py +0 -66
- novel_downloader/cli/settings.py +0 -177
- novel_downloader/config/models.py +0 -187
- novel_downloader/core/downloaders/base/__init__.py +0 -14
- novel_downloader/core/downloaders/base/base_async.py +0 -153
- novel_downloader/core/downloaders/base/base_sync.py +0 -208
- novel_downloader/core/downloaders/biquge/__init__.py +0 -14
- novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
- novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
- novel_downloader/core/downloaders/common/__init__.py +0 -14
- novel_downloader/core/downloaders/common/common_async.py +0 -210
- novel_downloader/core/downloaders/common/common_sync.py +0 -202
- novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
- novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
- novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
- novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
- novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
- novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
- novel_downloader/core/downloaders/qidian/__init__.py +0 -10
- novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -219
- novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
- novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
- novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
- novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
- novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
- novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
- novel_downloader/core/factory/requester.py +0 -144
- novel_downloader/core/factory/saver.py +0 -56
- novel_downloader/core/interfaces/async_downloader.py +0 -36
- novel_downloader/core/interfaces/async_requester.py +0 -84
- novel_downloader/core/interfaces/sync_downloader.py +0 -36
- novel_downloader/core/interfaces/sync_requester.py +0 -82
- novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
- novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
- novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
- novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
- novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
- novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
- novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
- novel_downloader/core/requesters/base/async_session.py +0 -410
- novel_downloader/core/requesters/base/browser.py +0 -337
- novel_downloader/core/requesters/base/session.py +0 -378
- novel_downloader/core/requesters/biquge/__init__.py +0 -14
- novel_downloader/core/requesters/common/__init__.py +0 -17
- novel_downloader/core/requesters/common/session.py +0 -113
- novel_downloader/core/requesters/esjzone/__init__.py +0 -13
- novel_downloader/core/requesters/esjzone/session.py +0 -235
- novel_downloader/core/requesters/qianbi/__init__.py +0 -13
- novel_downloader/core/requesters/qidian/__init__.py +0 -21
- novel_downloader/core/requesters/qidian/broswer.py +0 -307
- novel_downloader/core/requesters/qidian/session.py +0 -290
- novel_downloader/core/requesters/sfacg/__init__.py +0 -13
- novel_downloader/core/requesters/sfacg/session.py +0 -242
- novel_downloader/core/requesters/yamibo/__init__.py +0 -13
- novel_downloader/core/requesters/yamibo/session.py +0 -237
- novel_downloader/core/savers/__init__.py +0 -34
- novel_downloader/core/savers/biquge.py +0 -25
- novel_downloader/core/savers/common/__init__.py +0 -12
- novel_downloader/core/savers/esjzone.py +0 -25
- novel_downloader/core/savers/qianbi.py +0 -25
- novel_downloader/core/savers/sfacg.py +0 -25
- novel_downloader/core/savers/yamibo.py +0 -25
- novel_downloader/resources/config/rules.toml +0 -196
- novel_downloader-1.3.3.dist-info/RECORD +0 -166
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,326 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.fetchers.qidian.session
|
4
|
+
---------------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
import base64
|
9
|
+
import hashlib
|
10
|
+
import json
|
11
|
+
import random
|
12
|
+
import time
|
13
|
+
from typing import Any, ClassVar
|
14
|
+
|
15
|
+
import aiohttp
|
16
|
+
|
17
|
+
from novel_downloader.core.fetchers.base import BaseSession
|
18
|
+
from novel_downloader.models import FetcherConfig, LoginField
|
19
|
+
from novel_downloader.utils.crypto_utils import rc4_crypt
|
20
|
+
from novel_downloader.utils.time_utils import async_sleep_with_random_delay
|
21
|
+
|
22
|
+
|
23
|
+
class QidianSession(BaseSession):
|
24
|
+
"""
|
25
|
+
A session class for interacting with the Qidian (www.qidian.com) novel website.
|
26
|
+
"""
|
27
|
+
|
28
|
+
HOMEPAGE_URL = "https://www.qidian.com/"
|
29
|
+
BOOKCASE_URL = "https://my.qidian.com/bookcase/"
|
30
|
+
BOOK_INFO_URL = "https://book.qidian.com/info/{book_id}/"
|
31
|
+
CHAPTER_URL = "https://www.qidian.com/chapter/{book_id}/{chapter_id}/"
|
32
|
+
|
33
|
+
LOGIN_URL = "https://passport.qidian.com/"
|
34
|
+
|
35
|
+
_cookie_keys: ClassVar[list[str]] = [
|
36
|
+
"X2NzcmZUb2tlbg==",
|
37
|
+
"eXdndWlk",
|
38
|
+
"eXdvcGVuaWQ=",
|
39
|
+
"eXdrZXk=",
|
40
|
+
"d190c2Zw",
|
41
|
+
]
|
42
|
+
|
43
|
+
def __init__(
|
44
|
+
self,
|
45
|
+
config: FetcherConfig,
|
46
|
+
cookies: dict[str, str] | None = None,
|
47
|
+
**kwargs: Any,
|
48
|
+
) -> None:
|
49
|
+
super().__init__("qidian", config, cookies, **kwargs)
|
50
|
+
self._fp_key = _d("ZmluZ2VycHJpbnQ=")
|
51
|
+
self._ab_key = _d("YWJub3JtYWw=")
|
52
|
+
self._ck_key = _d("Y2hlY2tzdW0=")
|
53
|
+
self._lt_key = _d("bG9hZHRz")
|
54
|
+
self._ts_key = _d("dGltZXN0YW1w")
|
55
|
+
self._fp_val: str = ""
|
56
|
+
self._ab_val: str = ""
|
57
|
+
|
58
|
+
async def login(
|
59
|
+
self,
|
60
|
+
username: str = "",
|
61
|
+
password: str = "",
|
62
|
+
cookies: dict[str, str] | None = None,
|
63
|
+
attempt: int = 1,
|
64
|
+
**kwargs: Any,
|
65
|
+
) -> bool:
|
66
|
+
"""
|
67
|
+
Restore cookies persisted by the session-based workflow.
|
68
|
+
"""
|
69
|
+
if not cookies or not self._check_cookies(cookies):
|
70
|
+
return False
|
71
|
+
self.update_cookies(cookies)
|
72
|
+
|
73
|
+
self._is_logged_in = await self._check_login_status()
|
74
|
+
return self._is_logged_in
|
75
|
+
|
76
|
+
async def get_book_info(
|
77
|
+
self,
|
78
|
+
book_id: str,
|
79
|
+
**kwargs: Any,
|
80
|
+
) -> list[str]:
|
81
|
+
"""
|
82
|
+
Fetch the raw HTML of the book info page asynchronously.
|
83
|
+
|
84
|
+
:param book_id: The book identifier.
|
85
|
+
:return: The page content as a string.
|
86
|
+
"""
|
87
|
+
url = self.book_info_url(book_id=book_id)
|
88
|
+
return [await self.fetch(url, **kwargs)]
|
89
|
+
|
90
|
+
async def get_book_chapter(
|
91
|
+
self,
|
92
|
+
book_id: str,
|
93
|
+
chapter_id: str,
|
94
|
+
**kwargs: Any,
|
95
|
+
) -> list[str]:
|
96
|
+
"""
|
97
|
+
Fetch the raw HTML of a single chapter asynchronously.
|
98
|
+
|
99
|
+
:param book_id: The book identifier.
|
100
|
+
:param chapter_id: The chapter identifier.
|
101
|
+
:return: The chapter content as a string.
|
102
|
+
"""
|
103
|
+
url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
|
104
|
+
return [await self.fetch(url, **kwargs)]
|
105
|
+
|
106
|
+
async def get_bookcase(
|
107
|
+
self,
|
108
|
+
**kwargs: Any,
|
109
|
+
) -> list[str]:
|
110
|
+
"""
|
111
|
+
Retrieve the user's *bookcase* page.
|
112
|
+
|
113
|
+
:return: The HTML markup of the bookcase page.
|
114
|
+
"""
|
115
|
+
url = self.bookcase_url()
|
116
|
+
return [await self.fetch(url, **kwargs)]
|
117
|
+
|
118
|
+
async def get_homepage(
|
119
|
+
self,
|
120
|
+
**kwargs: Any,
|
121
|
+
) -> list[str]:
|
122
|
+
"""
|
123
|
+
Retrieve the site home page.
|
124
|
+
|
125
|
+
:return: The HTML markup of the home page.
|
126
|
+
"""
|
127
|
+
url = self.homepage_url()
|
128
|
+
return [await self.fetch(url, **kwargs)]
|
129
|
+
|
130
|
+
@property
|
131
|
+
def login_fields(self) -> list[LoginField]:
|
132
|
+
return [
|
133
|
+
LoginField(
|
134
|
+
name="cookies",
|
135
|
+
label="Cookie",
|
136
|
+
type="cookie",
|
137
|
+
required=True,
|
138
|
+
placeholder="请输入你的登录 Cookie",
|
139
|
+
description="可以通过浏览器开发者工具复制已登录状态下的 Cookie",
|
140
|
+
),
|
141
|
+
]
|
142
|
+
|
143
|
+
async def fetch(
|
144
|
+
self,
|
145
|
+
url: str,
|
146
|
+
**kwargs: Any,
|
147
|
+
) -> str:
|
148
|
+
"""
|
149
|
+
Same as :py:meth:`BaseSession.fetch`, but transparently refreshes
|
150
|
+
a cookie-based token used for request validation.
|
151
|
+
|
152
|
+
The method:
|
153
|
+
1. Reads the existing cookie (if any);
|
154
|
+
2. Generates a new value tied to *url*;
|
155
|
+
3. Updates the live ``requests.Session``;
|
156
|
+
"""
|
157
|
+
if self._rate_limiter:
|
158
|
+
await self._rate_limiter.wait()
|
159
|
+
|
160
|
+
cookie_key = _d("d190c2Zw")
|
161
|
+
|
162
|
+
for attempt in range(self.retry_times + 1):
|
163
|
+
try:
|
164
|
+
refreshed_token = self._build_payload_token(url)
|
165
|
+
self.update_cookies({cookie_key: refreshed_token})
|
166
|
+
|
167
|
+
async with self.session.get(url, **kwargs) as resp:
|
168
|
+
resp.raise_for_status()
|
169
|
+
text: str = await resp.text()
|
170
|
+
return text
|
171
|
+
except aiohttp.ClientError:
|
172
|
+
if attempt < self.retry_times:
|
173
|
+
await async_sleep_with_random_delay(
|
174
|
+
self.backoff_factor,
|
175
|
+
mul_spread=1.1,
|
176
|
+
max_sleep=self.backoff_factor + 2,
|
177
|
+
)
|
178
|
+
continue
|
179
|
+
raise
|
180
|
+
|
181
|
+
raise RuntimeError("Unreachable code reached in fetch()")
|
182
|
+
|
183
|
+
@classmethod
|
184
|
+
def homepage_url(cls) -> str:
|
185
|
+
"""
|
186
|
+
Construct the URL for the site home page.
|
187
|
+
|
188
|
+
:return: Fully qualified URL of the home page.
|
189
|
+
"""
|
190
|
+
return cls.HOMEPAGE_URL
|
191
|
+
|
192
|
+
@classmethod
|
193
|
+
def bookcase_url(cls) -> str:
|
194
|
+
"""
|
195
|
+
Construct the URL for the user's bookcase page.
|
196
|
+
|
197
|
+
:return: Fully qualified URL of the bookcase.
|
198
|
+
"""
|
199
|
+
return cls.BOOKCASE_URL
|
200
|
+
|
201
|
+
@classmethod
|
202
|
+
def book_info_url(cls, book_id: str) -> str:
|
203
|
+
"""
|
204
|
+
Construct the URL for fetching a book's info page.
|
205
|
+
|
206
|
+
:param book_id: The identifier of the book.
|
207
|
+
:return: Fully qualified URL for the book info page.
|
208
|
+
"""
|
209
|
+
return cls.BOOK_INFO_URL.format(book_id=book_id)
|
210
|
+
|
211
|
+
@classmethod
|
212
|
+
def chapter_url(cls, book_id: str, chapter_id: str) -> str:
|
213
|
+
"""
|
214
|
+
Construct the URL for fetching a specific chapter.
|
215
|
+
|
216
|
+
:param book_id: The identifier of the book.
|
217
|
+
:param chapter_id: The identifier of the chapter.
|
218
|
+
:return: Fully qualified chapter URL.
|
219
|
+
"""
|
220
|
+
return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
|
221
|
+
|
222
|
+
@property
|
223
|
+
def hostname(self) -> str:
|
224
|
+
return "www.qidian.com"
|
225
|
+
|
226
|
+
def _update_fp_val(
|
227
|
+
self,
|
228
|
+
*,
|
229
|
+
key: str = "",
|
230
|
+
) -> None:
|
231
|
+
""""""
|
232
|
+
enc_token = self.get_cookie_value(_d("d190c2Zw"))
|
233
|
+
if not enc_token:
|
234
|
+
return
|
235
|
+
if not key:
|
236
|
+
key = _get_key()
|
237
|
+
decrypted_json: str = rc4_crypt(key, enc_token, mode="decrypt")
|
238
|
+
payload: dict[str, Any] = json.loads(decrypted_json)
|
239
|
+
self._fp_val = payload.get(self._fp_key, "")
|
240
|
+
self._ab_val = payload.get(self._ab_key, "0" * 32)
|
241
|
+
|
242
|
+
def _build_payload_token(
|
243
|
+
self,
|
244
|
+
new_uri: str,
|
245
|
+
*,
|
246
|
+
key: str = "",
|
247
|
+
) -> str:
|
248
|
+
"""
|
249
|
+
Patch a timestamp-bearing token with fresh timing and checksum info.
|
250
|
+
|
251
|
+
:param new_uri: URI used in checksum generation.
|
252
|
+
:type new_uri: str
|
253
|
+
:param key: RC4 key extracted from front-end JavaScript (optional).
|
254
|
+
:type key: str, optional
|
255
|
+
|
256
|
+
:return: Updated token with new timing and checksum values.
|
257
|
+
:rtype: str
|
258
|
+
"""
|
259
|
+
if not self._fp_val or not self._ab_val:
|
260
|
+
self._update_fp_val()
|
261
|
+
if not key:
|
262
|
+
key = _get_key()
|
263
|
+
|
264
|
+
# rebuild timing fields
|
265
|
+
loadts = int(time.time() * 1000) # ms since epoch
|
266
|
+
# Simulate the JS duration: N(600, 150) pushed into [300, 1000]
|
267
|
+
duration = max(300, min(1000, int(random.normalvariate(600, 150))))
|
268
|
+
timestamp = loadts + duration
|
269
|
+
|
270
|
+
comb = f"{new_uri}{loadts}{self._fp_val}"
|
271
|
+
ck_val = hashlib.md5(comb.encode("utf-8")).hexdigest()
|
272
|
+
|
273
|
+
new_payload = {
|
274
|
+
self._lt_key: loadts,
|
275
|
+
self._ts_key: timestamp,
|
276
|
+
self._fp_key: self._fp_val,
|
277
|
+
self._ab_key: self._ab_val,
|
278
|
+
self._ck_key: ck_val,
|
279
|
+
}
|
280
|
+
return rc4_crypt(
|
281
|
+
key, json.dumps(new_payload, separators=(",", ":")), mode="encrypt"
|
282
|
+
)
|
283
|
+
|
284
|
+
async def _check_login_status(self) -> bool:
|
285
|
+
"""
|
286
|
+
Check whether the user is currently logged in by
|
287
|
+
inspecting the bookcase page content.
|
288
|
+
|
289
|
+
:return: True if the user is logged in, False otherwise.
|
290
|
+
"""
|
291
|
+
keywords = [
|
292
|
+
'var buid = "fffffffffffffffffff"',
|
293
|
+
"C2WF946J0/probe.js",
|
294
|
+
"login-area-wrap",
|
295
|
+
]
|
296
|
+
resp_text = await self.get_bookcase()
|
297
|
+
if not resp_text:
|
298
|
+
return False
|
299
|
+
return not any(kw in resp_text[0] for kw in keywords)
|
300
|
+
|
301
|
+
def _check_cookies(self, cookies: dict[str, str]) -> bool:
|
302
|
+
"""
|
303
|
+
Check if the provided cookies contain all required keys.
|
304
|
+
|
305
|
+
Logs any missing keys as warnings.
|
306
|
+
|
307
|
+
:param cookies: The cookie dictionary to validate.
|
308
|
+
:return: True if all required keys are present, False otherwise.
|
309
|
+
"""
|
310
|
+
required = {_d(k) for k in self._cookie_keys}
|
311
|
+
actual = set(cookies)
|
312
|
+
missing = required - actual
|
313
|
+
if missing:
|
314
|
+
self.logger.warning("Missing required cookies: %s", ", ".join(missing))
|
315
|
+
return not missing
|
316
|
+
|
317
|
+
|
318
|
+
def _d(b: str) -> str:
|
319
|
+
return base64.b64decode(b).decode()
|
320
|
+
|
321
|
+
|
322
|
+
def _get_key() -> str:
|
323
|
+
encoded = "Lj1qYxMuaXBjMg=="
|
324
|
+
decoded = base64.b64decode(encoded)
|
325
|
+
key = "".join([chr(b ^ 0x5A) for b in decoded])
|
326
|
+
return key
|
@@ -0,0 +1,189 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.fetchers.sfacg.browser
|
4
|
+
--------------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import Any
|
9
|
+
|
10
|
+
from novel_downloader.core.fetchers.base import BaseBrowser
|
11
|
+
from novel_downloader.models import FetcherConfig, LoginField
|
12
|
+
from novel_downloader.utils.i18n import t
|
13
|
+
|
14
|
+
|
15
|
+
class SfacgBrowser(BaseBrowser):
|
16
|
+
"""
|
17
|
+
A browser class for interacting with the Sfacg (m.sfacg.com) novel website.
|
18
|
+
"""
|
19
|
+
|
20
|
+
LOGIN_URL = "https://m.sfacg.com/login"
|
21
|
+
BOOKCASE_URL = "https://m.sfacg.com/sheets/"
|
22
|
+
BOOK_INFO_URL = "https://m.sfacg.com/b/{book_id}/"
|
23
|
+
BOOK_CATALOG_URL = "https://m.sfacg.com/i/{book_id}/"
|
24
|
+
CHAPTER_URL = "https://m.sfacg.com/c/{chapter_id}/"
|
25
|
+
|
26
|
+
def __init__(
|
27
|
+
self,
|
28
|
+
config: FetcherConfig,
|
29
|
+
reuse_page: bool = False,
|
30
|
+
**kwargs: Any,
|
31
|
+
) -> None:
|
32
|
+
super().__init__("sfacg", config, reuse_page, **kwargs)
|
33
|
+
|
34
|
+
async def login(
|
35
|
+
self,
|
36
|
+
username: str = "",
|
37
|
+
password: str = "",
|
38
|
+
cookies: dict[str, str] | None = None,
|
39
|
+
attempt: int = 1,
|
40
|
+
**kwargs: Any,
|
41
|
+
) -> bool:
|
42
|
+
self._is_logged_in = await self._check_login_status()
|
43
|
+
return self._is_logged_in
|
44
|
+
|
45
|
+
async def get_book_info(
|
46
|
+
self,
|
47
|
+
book_id: str,
|
48
|
+
**kwargs: Any,
|
49
|
+
) -> list[str]:
|
50
|
+
"""
|
51
|
+
Fetch the raw HTML of the book info page asynchronously.
|
52
|
+
|
53
|
+
Order: [info, catalog]
|
54
|
+
|
55
|
+
:param book_id: The book identifier.
|
56
|
+
:return: The page content as a string.
|
57
|
+
"""
|
58
|
+
info_url = self.book_info_url(book_id=book_id)
|
59
|
+
catalog_url = self.book_catalog_url(book_id=book_id)
|
60
|
+
|
61
|
+
info_html = await self.fetch(info_url, **kwargs)
|
62
|
+
catalog_html = await self.fetch(catalog_url, **kwargs)
|
63
|
+
|
64
|
+
return [info_html, catalog_html]
|
65
|
+
|
66
|
+
async def get_book_chapter(
|
67
|
+
self,
|
68
|
+
book_id: str,
|
69
|
+
chapter_id: str,
|
70
|
+
**kwargs: Any,
|
71
|
+
) -> list[str]:
|
72
|
+
"""
|
73
|
+
Fetch the raw HTML of a single chapter asynchronously.
|
74
|
+
|
75
|
+
:param book_id: The book identifier.
|
76
|
+
:param chapter_id: The chapter identifier.
|
77
|
+
:return: The chapter content as a string.
|
78
|
+
"""
|
79
|
+
url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
|
80
|
+
return [await self.fetch(url, **kwargs)]
|
81
|
+
|
82
|
+
async def get_bookcase(
|
83
|
+
self,
|
84
|
+
**kwargs: Any,
|
85
|
+
) -> list[str]:
|
86
|
+
"""
|
87
|
+
Retrieve the user's *bookcase* page.
|
88
|
+
|
89
|
+
:return: The HTML markup of the bookcase page.
|
90
|
+
"""
|
91
|
+
url = self.bookcase_url()
|
92
|
+
return [await self.fetch(url, **kwargs)]
|
93
|
+
|
94
|
+
async def set_interactive_mode(self, enable: bool) -> bool:
|
95
|
+
"""
|
96
|
+
Enable or disable interactive mode for manual login.
|
97
|
+
|
98
|
+
:param enable: True to enable, False to disable interactive mode.
|
99
|
+
:return: True if operation or login check succeeded, False otherwise.
|
100
|
+
"""
|
101
|
+
if enable:
|
102
|
+
if self.headless:
|
103
|
+
await self._restart_browser(headless=False)
|
104
|
+
if self._manual_page is None:
|
105
|
+
self._manual_page = await self.context.new_page()
|
106
|
+
await self._manual_page.goto(self.LOGIN_URL)
|
107
|
+
return True
|
108
|
+
|
109
|
+
# restore
|
110
|
+
if self._manual_page:
|
111
|
+
await self._manual_page.close()
|
112
|
+
self._manual_page = None
|
113
|
+
if self.headless:
|
114
|
+
await self._restart_browser(headless=True)
|
115
|
+
self._is_logged_in = await self._check_login_status()
|
116
|
+
return self.is_logged_in
|
117
|
+
|
118
|
+
@property
|
119
|
+
def login_fields(self) -> list[LoginField]:
|
120
|
+
return [
|
121
|
+
LoginField(
|
122
|
+
name="manual_login",
|
123
|
+
label="手动登录",
|
124
|
+
type="manual_login",
|
125
|
+
required=True,
|
126
|
+
description=t("login_prompt_intro"),
|
127
|
+
)
|
128
|
+
]
|
129
|
+
|
130
|
+
@classmethod
|
131
|
+
def bookcase_url(cls) -> str:
|
132
|
+
"""
|
133
|
+
Construct the URL for the user's bookcase page.
|
134
|
+
|
135
|
+
:return: Fully qualified URL of the bookcase.
|
136
|
+
"""
|
137
|
+
return cls.BOOKCASE_URL
|
138
|
+
|
139
|
+
@classmethod
|
140
|
+
def book_info_url(cls, book_id: str) -> str:
|
141
|
+
"""
|
142
|
+
Construct the URL for fetching a book's info page.
|
143
|
+
|
144
|
+
:param book_id: The identifier of the book.
|
145
|
+
:return: Fully qualified URL for the book info page.
|
146
|
+
"""
|
147
|
+
return cls.BOOK_INFO_URL.format(book_id=book_id)
|
148
|
+
|
149
|
+
@classmethod
|
150
|
+
def book_catalog_url(cls, book_id: str) -> str:
|
151
|
+
"""
|
152
|
+
Construct the URL for fetching a book's catalog page.
|
153
|
+
|
154
|
+
:param book_id: The identifier of the book.
|
155
|
+
:return: Fully qualified catalog page URL.
|
156
|
+
"""
|
157
|
+
return cls.BOOK_CATALOG_URL.format(book_id=book_id)
|
158
|
+
|
159
|
+
@classmethod
|
160
|
+
def chapter_url(cls, book_id: str, chapter_id: str) -> str:
|
161
|
+
"""
|
162
|
+
Construct the URL for fetching a specific chapter.
|
163
|
+
|
164
|
+
:param book_id: The identifier of the book.
|
165
|
+
:param chapter_id: The identifier of the chapter.
|
166
|
+
:return: Fully qualified chapter URL.
|
167
|
+
"""
|
168
|
+
return cls.CHAPTER_URL.format(chapter_id=chapter_id)
|
169
|
+
|
170
|
+
@property
|
171
|
+
def hostname(self) -> str:
|
172
|
+
return "m.sfacg.com"
|
173
|
+
|
174
|
+
async def _check_login_status(self) -> bool:
|
175
|
+
"""
|
176
|
+
Check whether the user is currently logged in by
|
177
|
+
inspecting the bookcase page content.
|
178
|
+
|
179
|
+
:return: True if the user is logged in, False otherwise.
|
180
|
+
"""
|
181
|
+
keywords = [
|
182
|
+
"请输入用户名和密码",
|
183
|
+
"用户未登录",
|
184
|
+
"可输入用户名",
|
185
|
+
]
|
186
|
+
resp_text = await self.get_bookcase()
|
187
|
+
if not resp_text:
|
188
|
+
return False
|
189
|
+
return not any(kw in resp_text[0] for kw in keywords)
|