novel-downloader 1.3.2__py3-none-any.whl → 1.4.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.
Files changed (213) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/clean.py +97 -78
  3. novel_downloader/cli/config.py +177 -0
  4. novel_downloader/cli/download.py +132 -87
  5. novel_downloader/cli/export.py +77 -0
  6. novel_downloader/cli/main.py +21 -28
  7. novel_downloader/config/__init__.py +1 -25
  8. novel_downloader/config/adapter.py +32 -31
  9. novel_downloader/config/loader.py +3 -3
  10. novel_downloader/config/site_rules.py +1 -2
  11. novel_downloader/core/__init__.py +3 -6
  12. novel_downloader/core/downloaders/__init__.py +10 -13
  13. novel_downloader/core/downloaders/base.py +233 -0
  14. novel_downloader/core/downloaders/biquge.py +27 -0
  15. novel_downloader/core/downloaders/common.py +414 -0
  16. novel_downloader/core/downloaders/esjzone.py +27 -0
  17. novel_downloader/core/downloaders/linovelib.py +27 -0
  18. novel_downloader/core/downloaders/qianbi.py +27 -0
  19. novel_downloader/core/downloaders/qidian.py +352 -0
  20. novel_downloader/core/downloaders/sfacg.py +27 -0
  21. novel_downloader/core/downloaders/yamibo.py +27 -0
  22. novel_downloader/core/exporters/__init__.py +37 -0
  23. novel_downloader/core/{savers → exporters}/base.py +73 -44
  24. novel_downloader/core/exporters/biquge.py +25 -0
  25. novel_downloader/core/exporters/common/__init__.py +12 -0
  26. novel_downloader/core/{savers → exporters}/common/epub.py +40 -52
  27. novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +36 -39
  28. novel_downloader/core/{savers → exporters}/common/txt.py +20 -24
  29. novel_downloader/core/exporters/epub_utils/__init__.py +40 -0
  30. novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -1
  31. novel_downloader/core/exporters/epub_utils/image_loader.py +131 -0
  32. novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -3
  33. novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +49 -2
  34. novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -1
  35. novel_downloader/core/exporters/esjzone.py +25 -0
  36. novel_downloader/core/exporters/linovelib/__init__.py +10 -0
  37. novel_downloader/core/exporters/linovelib/epub.py +449 -0
  38. novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
  39. novel_downloader/core/exporters/linovelib/txt.py +129 -0
  40. novel_downloader/core/exporters/qianbi.py +25 -0
  41. novel_downloader/core/{savers → exporters}/qidian.py +8 -8
  42. novel_downloader/core/exporters/sfacg.py +25 -0
  43. novel_downloader/core/exporters/yamibo.py +25 -0
  44. novel_downloader/core/factory/__init__.py +5 -17
  45. novel_downloader/core/factory/downloader.py +24 -126
  46. novel_downloader/core/factory/exporter.py +58 -0
  47. novel_downloader/core/factory/fetcher.py +96 -0
  48. novel_downloader/core/factory/parser.py +17 -12
  49. novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
  50. novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
  51. novel_downloader/core/fetchers/base/browser.py +383 -0
  52. novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
  53. novel_downloader/core/fetchers/base/session.py +419 -0
  54. novel_downloader/core/fetchers/biquge/__init__.py +14 -0
  55. novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
  56. novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
  57. novel_downloader/core/fetchers/common/__init__.py +14 -0
  58. novel_downloader/core/fetchers/common/browser.py +79 -0
  59. novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
  60. novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
  61. novel_downloader/core/fetchers/esjzone/browser.py +202 -0
  62. novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
  63. novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
  64. novel_downloader/core/fetchers/linovelib/browser.py +178 -0
  65. novel_downloader/core/fetchers/linovelib/session.py +178 -0
  66. novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
  67. novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
  68. novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
  69. novel_downloader/core/fetchers/qidian/__init__.py +14 -0
  70. novel_downloader/core/fetchers/qidian/browser.py +266 -0
  71. novel_downloader/core/fetchers/qidian/session.py +326 -0
  72. novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
  73. novel_downloader/core/fetchers/sfacg/browser.py +189 -0
  74. novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
  75. novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
  76. novel_downloader/core/fetchers/yamibo/browser.py +229 -0
  77. novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
  78. novel_downloader/core/interfaces/__init__.py +8 -12
  79. novel_downloader/core/interfaces/downloader.py +54 -0
  80. novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
  81. novel_downloader/core/interfaces/fetcher.py +162 -0
  82. novel_downloader/core/interfaces/parser.py +6 -7
  83. novel_downloader/core/parsers/__init__.py +5 -6
  84. novel_downloader/core/parsers/base.py +9 -13
  85. novel_downloader/core/parsers/biquge/main_parser.py +12 -13
  86. novel_downloader/core/parsers/common/helper.py +3 -3
  87. novel_downloader/core/parsers/common/main_parser.py +39 -34
  88. novel_downloader/core/parsers/esjzone/main_parser.py +24 -17
  89. novel_downloader/core/parsers/linovelib/__init__.py +10 -0
  90. novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
  91. novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
  92. novel_downloader/core/parsers/qidian/__init__.py +2 -11
  93. novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
  94. novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
  95. novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
  96. novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
  97. novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
  98. novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
  99. novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
  100. novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
  101. novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
  102. novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
  103. novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
  104. novel_downloader/locales/en.json +18 -2
  105. novel_downloader/locales/zh.json +18 -2
  106. novel_downloader/models/__init__.py +64 -0
  107. novel_downloader/models/browser.py +21 -0
  108. novel_downloader/models/chapter.py +25 -0
  109. novel_downloader/models/config.py +100 -0
  110. novel_downloader/models/login.py +20 -0
  111. novel_downloader/models/site_rules.py +99 -0
  112. novel_downloader/models/tasks.py +33 -0
  113. novel_downloader/models/types.py +15 -0
  114. novel_downloader/resources/config/settings.toml +31 -25
  115. novel_downloader/resources/json/linovelib_font_map.json +3573 -0
  116. novel_downloader/tui/__init__.py +7 -0
  117. novel_downloader/tui/app.py +32 -0
  118. novel_downloader/tui/main.py +17 -0
  119. novel_downloader/tui/screens/__init__.py +14 -0
  120. novel_downloader/tui/screens/home.py +191 -0
  121. novel_downloader/tui/screens/login.py +74 -0
  122. novel_downloader/tui/styles/home_layout.tcss +79 -0
  123. novel_downloader/tui/widgets/richlog_handler.py +24 -0
  124. novel_downloader/utils/__init__.py +6 -0
  125. novel_downloader/utils/chapter_storage.py +25 -38
  126. novel_downloader/utils/constants.py +15 -5
  127. novel_downloader/utils/cookies.py +66 -0
  128. novel_downloader/utils/crypto_utils.py +1 -74
  129. novel_downloader/utils/file_utils/io.py +1 -1
  130. novel_downloader/utils/fontocr/ocr_v1.py +2 -1
  131. novel_downloader/utils/fontocr/ocr_v2.py +2 -2
  132. novel_downloader/utils/hash_store.py +10 -18
  133. novel_downloader/utils/hash_utils.py +3 -2
  134. novel_downloader/utils/logger.py +2 -3
  135. novel_downloader/utils/network.py +53 -39
  136. novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
  137. novel_downloader/utils/text_utils/font_mapping.py +1 -1
  138. novel_downloader/utils/text_utils/text_cleaning.py +1 -1
  139. novel_downloader/utils/time_utils/datetime_utils.py +3 -3
  140. novel_downloader/utils/time_utils/sleep_utils.py +3 -3
  141. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +72 -38
  142. novel_downloader-1.4.0.dist-info/RECORD +170 -0
  143. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
  144. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
  145. novel_downloader/cli/interactive.py +0 -66
  146. novel_downloader/cli/settings.py +0 -177
  147. novel_downloader/config/models.py +0 -187
  148. novel_downloader/core/downloaders/base/__init__.py +0 -14
  149. novel_downloader/core/downloaders/base/base_async.py +0 -153
  150. novel_downloader/core/downloaders/base/base_sync.py +0 -208
  151. novel_downloader/core/downloaders/biquge/__init__.py +0 -14
  152. novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
  153. novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
  154. novel_downloader/core/downloaders/common/__init__.py +0 -14
  155. novel_downloader/core/downloaders/common/common_async.py +0 -218
  156. novel_downloader/core/downloaders/common/common_sync.py +0 -210
  157. novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
  158. novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
  159. novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
  160. novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
  161. novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
  162. novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
  163. novel_downloader/core/downloaders/qidian/__init__.py +0 -10
  164. novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -227
  165. novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
  166. novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
  167. novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
  168. novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
  169. novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
  170. novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
  171. novel_downloader/core/factory/requester.py +0 -144
  172. novel_downloader/core/factory/saver.py +0 -56
  173. novel_downloader/core/interfaces/async_downloader.py +0 -36
  174. novel_downloader/core/interfaces/async_requester.py +0 -84
  175. novel_downloader/core/interfaces/sync_downloader.py +0 -36
  176. novel_downloader/core/interfaces/sync_requester.py +0 -82
  177. novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
  178. novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
  179. novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
  180. novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
  181. novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
  182. novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
  183. novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
  184. novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
  185. novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
  186. novel_downloader/core/requesters/base/async_session.py +0 -410
  187. novel_downloader/core/requesters/base/browser.py +0 -337
  188. novel_downloader/core/requesters/base/session.py +0 -378
  189. novel_downloader/core/requesters/biquge/__init__.py +0 -14
  190. novel_downloader/core/requesters/common/__init__.py +0 -17
  191. novel_downloader/core/requesters/common/session.py +0 -113
  192. novel_downloader/core/requesters/esjzone/__init__.py +0 -13
  193. novel_downloader/core/requesters/esjzone/session.py +0 -235
  194. novel_downloader/core/requesters/qianbi/__init__.py +0 -13
  195. novel_downloader/core/requesters/qidian/__init__.py +0 -21
  196. novel_downloader/core/requesters/qidian/broswer.py +0 -307
  197. novel_downloader/core/requesters/qidian/session.py +0 -290
  198. novel_downloader/core/requesters/sfacg/__init__.py +0 -13
  199. novel_downloader/core/requesters/sfacg/session.py +0 -242
  200. novel_downloader/core/requesters/yamibo/__init__.py +0 -13
  201. novel_downloader/core/requesters/yamibo/session.py +0 -237
  202. novel_downloader/core/savers/__init__.py +0 -34
  203. novel_downloader/core/savers/biquge.py +0 -25
  204. novel_downloader/core/savers/common/__init__.py +0 -12
  205. novel_downloader/core/savers/epub_utils/__init__.py +0 -26
  206. novel_downloader/core/savers/esjzone.py +0 -25
  207. novel_downloader/core/savers/qianbi.py +0 -25
  208. novel_downloader/core/savers/sfacg.py +0 -25
  209. novel_downloader/core/savers/yamibo.py +0 -25
  210. novel_downloader/resources/config/rules.toml +0 -196
  211. novel_downloader-1.3.2.dist-info/RECORD +0 -165
  212. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
  213. {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.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,14 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.sfacg
4
+ ------------------------------------
5
+
6
+ """
7
+
8
+ from .browser import SfacgBrowser
9
+ from .session import SfacgSession
10
+
11
+ __all__ = [
12
+ "SfacgBrowser",
13
+ "SfacgSession",
14
+ ]
@@ -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)