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.
Files changed (211) 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 -39
  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 +22 -22
  27. novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +35 -40
  28. novel_downloader/core/{savers → exporters}/common/txt.py +20 -23
  29. novel_downloader/core/{savers → exporters}/epub_utils/__init__.py +8 -3
  30. novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -2
  31. novel_downloader/core/{savers → exporters}/epub_utils/image_loader.py +46 -4
  32. novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -4
  33. novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +3 -3
  34. novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -2
  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 +193 -0
  65. novel_downloader/core/fetchers/linovelib/session.py +193 -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 +20 -14
  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 +11 -5
  127. novel_downloader/utils/cookies.py +66 -0
  128. novel_downloader/utils/crypto_utils.py +1 -74
  129. novel_downloader/utils/fontocr/ocr_v1.py +2 -1
  130. novel_downloader/utils/fontocr/ocr_v2.py +2 -2
  131. novel_downloader/utils/hash_store.py +10 -18
  132. novel_downloader/utils/hash_utils.py +3 -2
  133. novel_downloader/utils/logger.py +2 -3
  134. novel_downloader/utils/network.py +2 -1
  135. novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
  136. novel_downloader/utils/text_utils/font_mapping.py +1 -1
  137. novel_downloader/utils/text_utils/text_cleaning.py +1 -1
  138. novel_downloader/utils/time_utils/datetime_utils.py +3 -3
  139. novel_downloader/utils/time_utils/sleep_utils.py +1 -1
  140. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/METADATA +69 -35
  141. novel_downloader-1.4.1.dist-info/RECORD +170 -0
  142. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/WHEEL +1 -1
  143. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/entry_points.txt +1 -0
  144. novel_downloader/cli/interactive.py +0 -66
  145. novel_downloader/cli/settings.py +0 -177
  146. novel_downloader/config/models.py +0 -187
  147. novel_downloader/core/downloaders/base/__init__.py +0 -14
  148. novel_downloader/core/downloaders/base/base_async.py +0 -153
  149. novel_downloader/core/downloaders/base/base_sync.py +0 -208
  150. novel_downloader/core/downloaders/biquge/__init__.py +0 -14
  151. novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
  152. novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
  153. novel_downloader/core/downloaders/common/__init__.py +0 -14
  154. novel_downloader/core/downloaders/common/common_async.py +0 -210
  155. novel_downloader/core/downloaders/common/common_sync.py +0 -202
  156. novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
  157. novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
  158. novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
  159. novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
  160. novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
  161. novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
  162. novel_downloader/core/downloaders/qidian/__init__.py +0 -10
  163. novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -219
  164. novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
  165. novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
  166. novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
  167. novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
  168. novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
  169. novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
  170. novel_downloader/core/factory/requester.py +0 -144
  171. novel_downloader/core/factory/saver.py +0 -56
  172. novel_downloader/core/interfaces/async_downloader.py +0 -36
  173. novel_downloader/core/interfaces/async_requester.py +0 -84
  174. novel_downloader/core/interfaces/sync_downloader.py +0 -36
  175. novel_downloader/core/interfaces/sync_requester.py +0 -82
  176. novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
  177. novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
  178. novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
  179. novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
  180. novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
  181. novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
  182. novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
  183. novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
  184. novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
  185. novel_downloader/core/requesters/base/async_session.py +0 -410
  186. novel_downloader/core/requesters/base/browser.py +0 -337
  187. novel_downloader/core/requesters/base/session.py +0 -378
  188. novel_downloader/core/requesters/biquge/__init__.py +0 -14
  189. novel_downloader/core/requesters/common/__init__.py +0 -17
  190. novel_downloader/core/requesters/common/session.py +0 -113
  191. novel_downloader/core/requesters/esjzone/__init__.py +0 -13
  192. novel_downloader/core/requesters/esjzone/session.py +0 -235
  193. novel_downloader/core/requesters/qianbi/__init__.py +0 -13
  194. novel_downloader/core/requesters/qidian/__init__.py +0 -21
  195. novel_downloader/core/requesters/qidian/broswer.py +0 -307
  196. novel_downloader/core/requesters/qidian/session.py +0 -290
  197. novel_downloader/core/requesters/sfacg/__init__.py +0 -13
  198. novel_downloader/core/requesters/sfacg/session.py +0 -242
  199. novel_downloader/core/requesters/yamibo/__init__.py +0 -13
  200. novel_downloader/core/requesters/yamibo/session.py +0 -237
  201. novel_downloader/core/savers/__init__.py +0 -34
  202. novel_downloader/core/savers/biquge.py +0 -25
  203. novel_downloader/core/savers/common/__init__.py +0 -12
  204. novel_downloader/core/savers/esjzone.py +0 -25
  205. novel_downloader/core/savers/qianbi.py +0 -25
  206. novel_downloader/core/savers/sfacg.py +0 -25
  207. novel_downloader/core/savers/yamibo.py +0 -25
  208. novel_downloader/resources/config/rules.toml +0 -196
  209. novel_downloader-1.3.3.dist-info/RECORD +0 -166
  210. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/licenses/LICENSE +0 -0
  211. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.base.session
4
+ -------------------------------------------
5
+
6
+ This module defines the BaseSession class, which provides asynchronous
7
+ HTTP request capabilities using aiohttp. It maintains a persistent
8
+ client session and supports retries, headers, timeout configurations,
9
+ cookie handling, and defines abstract methods for subclasses.
10
+ """
11
+
12
+
13
+ import abc
14
+ import json
15
+ import logging
16
+ import types
17
+ from typing import Any, Self
18
+
19
+ import aiohttp
20
+ from aiohttp import ClientResponse, ClientSession, ClientTimeout, TCPConnector
21
+
22
+ from novel_downloader.core.interfaces import FetcherProtocol
23
+ from novel_downloader.models import FetcherConfig, LoginField
24
+ from novel_downloader.utils.constants import (
25
+ DATA_DIR,
26
+ DEFAULT_USER_HEADERS,
27
+ )
28
+ from novel_downloader.utils.cookies import parse_cookie_expires
29
+ from novel_downloader.utils.time_utils import async_sleep_with_random_delay
30
+
31
+ from .rate_limiter import TokenBucketRateLimiter
32
+
33
+
34
+ class BaseSession(FetcherProtocol, abc.ABC):
35
+ """
36
+ BaseSession wraps basic HTTP operations using aiohttp.ClientSession.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ site: str,
42
+ config: FetcherConfig,
43
+ cookies: dict[str, str] | None = None,
44
+ **kwargs: Any,
45
+ ) -> None:
46
+ """
47
+ Initialize the async session with configuration.
48
+
49
+ :param config: Configuration object for session behavior
50
+ :param cookies: Optional initial cookies to set on the session.
51
+ """
52
+ self._site = site
53
+ self._config = config
54
+
55
+ self._state_file = DATA_DIR / site / "session_state.cookies"
56
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ self._is_logged_in = False
59
+ self._headers = (
60
+ config.headers.copy() if config.headers else DEFAULT_USER_HEADERS.copy()
61
+ )
62
+ if config.user_agent:
63
+ self._headers["User-Agent"] = config.user_agent
64
+ self._cookies = cookies or {}
65
+ self._session: ClientSession | None = None
66
+ self._rate_limiter: TokenBucketRateLimiter | None = None
67
+
68
+ if config.max_rps is not None and config.max_rps > 0:
69
+ self._rate_limiter = TokenBucketRateLimiter(config.max_rps)
70
+
71
+ self.logger = logging.getLogger(f"{self.__class__.__name__}")
72
+
73
+ async def login(
74
+ self,
75
+ username: str = "",
76
+ password: str = "",
77
+ cookies: dict[str, str] | None = None,
78
+ attempt: int = 1,
79
+ **kwargs: Any,
80
+ ) -> bool:
81
+ """
82
+ Attempt to log in asynchronously.
83
+ :returns: True if login succeeded.
84
+ """
85
+ return False
86
+
87
+ @abc.abstractmethod
88
+ async def get_book_info(
89
+ self,
90
+ book_id: str,
91
+ **kwargs: Any,
92
+ ) -> list[str]:
93
+ """
94
+ Fetch the raw HTML (or JSON) of the book info page asynchronously.
95
+
96
+ :param book_id: The book identifier.
97
+ :return: The page content as a string.
98
+ """
99
+ ...
100
+
101
+ @abc.abstractmethod
102
+ async def get_book_chapter(
103
+ self,
104
+ book_id: str,
105
+ chapter_id: str,
106
+ **kwargs: Any,
107
+ ) -> list[str]:
108
+ """
109
+ Fetch the raw HTML (or JSON) of a single chapter asynchronously.
110
+
111
+ :param book_id: The book identifier.
112
+ :param chapter_id: The chapter identifier.
113
+ :return: The chapter content as a string.
114
+ """
115
+ ...
116
+
117
+ async def get_bookcase(
118
+ self,
119
+ **kwargs: Any,
120
+ ) -> list[str]:
121
+ """
122
+ Optional: Retrieve the HTML content of the authenticated user's bookcase page.
123
+ Subclasses that support user login/bookcase should override this.
124
+
125
+ :return: The HTML of the bookcase page.
126
+ """
127
+ raise NotImplementedError(
128
+ "Bookcase fetching is not supported by this session type. "
129
+ "Override get_bookcase() in your subclass to enable it."
130
+ )
131
+
132
+ async def init(
133
+ self,
134
+ **kwargs: Any,
135
+ ) -> None:
136
+ """
137
+ Set up the aiohttp.ClientSession with timeout, connector, headers.
138
+ """
139
+ timeout = ClientTimeout(total=self.timeout)
140
+ connector = TCPConnector(
141
+ ssl=self._config.verify_ssl,
142
+ limit_per_host=self.max_connections,
143
+ )
144
+ self._session = ClientSession(
145
+ timeout=timeout,
146
+ connector=connector,
147
+ headers=self._headers,
148
+ cookies=self._cookies,
149
+ )
150
+
151
+ async def close(self) -> None:
152
+ """
153
+ Shutdown and clean up any resources.
154
+ """
155
+ if self._session and not self._session.closed:
156
+ await self._session.close()
157
+ self._session = None
158
+
159
+ async def fetch(self, url: str, **kwargs: Any) -> str:
160
+ """
161
+ Fetch the content from the given URL asynchronously, with retry support.
162
+
163
+ :param url: The target URL to fetch.
164
+ :param kwargs: Additional keyword arguments to pass to `session.get`.
165
+ :return: The response body as text.
166
+ :raises: aiohttp.ClientError on final failure.
167
+ """
168
+ if self._rate_limiter:
169
+ await self._rate_limiter.wait()
170
+
171
+ for attempt in range(self.retry_times + 1):
172
+ try:
173
+ async with self.session.get(url, **kwargs) as resp:
174
+ resp.raise_for_status()
175
+ text: str = await resp.text()
176
+ return text
177
+ except aiohttp.ClientError:
178
+ if attempt < self.retry_times:
179
+ await async_sleep_with_random_delay(
180
+ self.backoff_factor,
181
+ mul_spread=1.1,
182
+ max_sleep=self.backoff_factor + 2,
183
+ )
184
+ continue
185
+ raise
186
+
187
+ raise RuntimeError("Unreachable code reached in fetch()")
188
+
189
+ async def get(
190
+ self,
191
+ url: str,
192
+ params: dict[str, Any] | None = None,
193
+ **kwargs: Any,
194
+ ) -> ClientResponse:
195
+ """
196
+ Send an HTTP GET request asynchronously.
197
+
198
+ :param url: The target URL.
199
+ :param params: Query parameters to include in the request.
200
+ :param kwargs: Additional args passed to session.get().
201
+ :return: aiohttp.ClientResponse object.
202
+ :raises RuntimeError: If the session is not initialized.
203
+ """
204
+ return await self._request("GET", url, params=params, **kwargs)
205
+
206
+ async def post(
207
+ self,
208
+ url: str,
209
+ data: dict[str, Any] | bytes | None = None,
210
+ json: dict[str, Any] | None = None,
211
+ **kwargs: Any,
212
+ ) -> ClientResponse:
213
+ """
214
+ Send an HTTP POST request asynchronously.
215
+
216
+ :param url: The target URL.
217
+ :param data: Form data to include in the request body.
218
+ :param json: JSON body to include in the request.
219
+ :param kwargs: Additional args passed to session.post().
220
+ :return: aiohttp.ClientResponse object.
221
+ :raises RuntimeError: If the session is not initialized.
222
+ """
223
+ return await self._request("POST", url, data=data, json=json, **kwargs)
224
+
225
+ async def load_state(self) -> bool:
226
+ """
227
+ Load session cookies from a file to restore previous login state.
228
+
229
+ :return: True if the session state was loaded, False otherwise.
230
+ """
231
+ # if not self._state_file.exists() or self._session is None:
232
+ # return False
233
+ # try:
234
+ # self._session.cookie_jar.load(self._state_file)
235
+ # self._is_logged_in = await self._check_login_status()
236
+ # return self._is_logged_in
237
+ # except Exception as e:
238
+ # self.logger.warning("Failed to load state: %s", e)
239
+ # return False
240
+ if not self._state_file.exists() or self._session is None:
241
+ return False
242
+ try:
243
+ storage = json.loads(self._state_file.read_text(encoding="utf-8"))
244
+ for c in storage.get("cookies", []):
245
+ self._session.cookie_jar.update_cookies({c["name"]: c["value"]})
246
+ self._is_logged_in = await self._check_login_status()
247
+ return self._is_logged_in
248
+ except Exception as e:
249
+ self.logger.warning("Failed to load state: %s", e)
250
+ return False
251
+
252
+ async def save_state(self) -> bool:
253
+ """
254
+ Save the current session cookies to a file for future reuse.
255
+
256
+ :return: True if the session state was saved, False otherwise.
257
+ """
258
+ # if self._session is None:
259
+ # return False
260
+ # try:
261
+ # self._session.cookie_jar.save(self._state_file)
262
+ # return True
263
+ # except Exception as e:
264
+ # self.logger.warning("Failed to save state: %s", e)
265
+ # return False
266
+ if self._session is None:
267
+ return False
268
+ try:
269
+ cookies = []
270
+ for cookie in self._session.cookie_jar:
271
+ cookies.append(
272
+ {
273
+ "name": cookie.key,
274
+ "value": cookie.value,
275
+ "domain": cookie.get("domain", ""),
276
+ "path": cookie.get("path", "/"),
277
+ "expires": parse_cookie_expires(cookie.get("expires")),
278
+ "httpOnly": bool(cookie.get("httponly", False)),
279
+ "secure": bool(cookie.get("secure", False)),
280
+ "sameSite": cookie.get("samesite") or "Lax",
281
+ }
282
+ )
283
+ storage_state = {
284
+ "cookies": cookies,
285
+ "origins": [],
286
+ }
287
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
288
+ self._state_file.write_text(
289
+ json.dumps(storage_state, indent=2, ensure_ascii=False),
290
+ encoding="utf-8",
291
+ )
292
+ return True
293
+ except Exception as e:
294
+ self.logger.warning("Failed to save state: %s", e)
295
+ return False
296
+
297
+ async def set_interactive_mode(self, enable: bool) -> bool:
298
+ """
299
+ Enable or disable interactive mode for manual login.
300
+
301
+ :param enable: True to enable, False to disable interactive mode.
302
+ :return: True if operation or login check succeeded, False otherwise.
303
+ """
304
+ return False
305
+
306
+ def get_cookie_value(self, key: str) -> str | None:
307
+ for cookie in self.session.cookie_jar:
308
+ if cookie.key == key:
309
+ return str(cookie.value)
310
+ return None
311
+
312
+ def update_cookies(
313
+ self,
314
+ cookies: dict[str, str],
315
+ ) -> None:
316
+ """
317
+ Update or add multiple cookies in the session.
318
+
319
+ :param cookies: A dictionary of cookie key-value pairs.
320
+ """
321
+ self._cookies.update(cookies)
322
+ if self._session:
323
+ self._session.cookie_jar.update_cookies(cookies)
324
+
325
+ async def _request(
326
+ self,
327
+ method: str,
328
+ url: str,
329
+ **kwargs: Any,
330
+ ) -> ClientResponse:
331
+ if self._rate_limiter:
332
+ await self._rate_limiter.wait()
333
+ return await self.session.request(method, url, **kwargs)
334
+
335
+ async def _check_login_status(self) -> bool:
336
+ """
337
+ Check whether the user is currently logged in
338
+
339
+ :return: True if the user is logged in, False otherwise.
340
+ """
341
+ return False
342
+
343
+ @property
344
+ def hostname(self) -> str:
345
+ return ""
346
+
347
+ @property
348
+ def site(self) -> str:
349
+ return self._site
350
+
351
+ @property
352
+ def requester_type(self) -> str:
353
+ return "session"
354
+
355
+ @property
356
+ def is_logged_in(self) -> bool:
357
+ """
358
+ Indicates whether the requester is currently authenticated.
359
+ """
360
+ return self._is_logged_in
361
+
362
+ @property
363
+ def login_fields(self) -> list[LoginField]:
364
+ return []
365
+
366
+ @property
367
+ def session(self) -> ClientSession:
368
+ """
369
+ Return the active aiohttp.ClientSession.
370
+
371
+ :raises RuntimeError: If the session is uninitialized.
372
+ """
373
+ if self._session is None:
374
+ raise RuntimeError("Session is not initialized or has been shut down.")
375
+ return self._session
376
+
377
+ @property
378
+ def backoff_factor(self) -> float:
379
+ return self._config.backoff_factor
380
+
381
+ @property
382
+ def retry_times(self) -> int:
383
+ return self._config.retry_times
384
+
385
+ @property
386
+ def request_interval(self) -> float:
387
+ return self._config.request_interval
388
+
389
+ @property
390
+ def timeout(self) -> float:
391
+ return self._config.timeout
392
+
393
+ @property
394
+ def max_connections(self) -> int:
395
+ return self._config.max_connections
396
+
397
+ @property
398
+ def headers(self) -> dict[str, str]:
399
+ """
400
+ Get a copy of the current session headers for temporary use.
401
+
402
+ :return: A dict mapping header names to their values.
403
+ """
404
+ if self._session:
405
+ return dict(self._session.headers)
406
+ return self._headers.copy()
407
+
408
+ async def __aenter__(self) -> Self:
409
+ if self._session is None or self._session.closed:
410
+ await self.init()
411
+ return self
412
+
413
+ async def __aexit__(
414
+ self,
415
+ exc_type: type[BaseException] | None,
416
+ exc_val: BaseException | None,
417
+ tb: types.TracebackType | None,
418
+ ) -> None:
419
+ await self.close()
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.biquge
4
+ -------------------------------------
5
+
6
+ """
7
+
8
+ from .browser import BiqugeBrowser
9
+ from .session import BiqugeSession
10
+
11
+ __all__ = [
12
+ "BiqugeBrowser",
13
+ "BiqugeSession",
14
+ ]
@@ -1,24 +1,32 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.requesters.biquge.async_session
4
- -----------------------------------------------------
3
+ novel_downloader.core.fetchers.biquge.browser
4
+ ---------------------------------------------
5
5
 
6
6
  """
7
7
 
8
8
  from typing import Any
9
9
 
10
- from novel_downloader.core.requesters.base import BaseAsyncSession
10
+ from novel_downloader.core.fetchers.base import BaseBrowser
11
+ from novel_downloader.models import FetcherConfig
11
12
 
12
13
 
13
- class BiqugeAsyncSession(BaseAsyncSession):
14
+ class BiqugeBrowser(BaseBrowser):
14
15
  """
15
- A async session class for interacting with
16
- the Biquge (www.b520.cc) novel website.
16
+ A browser class for interacting with the Biquge (www.b520.cc) novel website.
17
17
  """
18
18
 
19
19
  BOOK_INFO_URL = "http://www.b520.cc/{book_id}/"
20
20
  CHAPTER_URL = "http://www.b520.cc/{book_id}/{chapter_id}.html"
21
21
 
22
+ def __init__(
23
+ self,
24
+ config: FetcherConfig,
25
+ reuse_page: bool = False,
26
+ **kwargs: Any,
27
+ ) -> None:
28
+ super().__init__("biquge", config, reuse_page, **kwargs)
29
+
22
30
  async def get_book_info(
23
31
  self,
24
32
  book_id: str,
@@ -69,3 +77,7 @@ class BiqugeAsyncSession(BaseAsyncSession):
69
77
  :return: Fully qualified chapter URL.
70
78
  """
71
79
  return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
80
+
81
+ @property
82
+ def hostname(self) -> str:
83
+ return "www.b520.cc"
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.requesters.biquge.session
4
- -----------------------------------------------
3
+ novel_downloader.core.fetchers.biquge.session
4
+ ---------------------------------------------
5
5
 
6
6
  """
7
7
 
8
8
  from typing import Any
9
9
 
10
- from novel_downloader.core.requesters.base import BaseSession
10
+ from novel_downloader.core.fetchers.base import BaseSession
11
+ from novel_downloader.models import FetcherConfig
11
12
 
12
13
 
13
14
  class BiqugeSession(BaseSession):
@@ -18,55 +19,43 @@ class BiqugeSession(BaseSession):
18
19
  BOOK_INFO_URL = "http://www.b520.cc/{book_id}/"
19
20
  CHAPTER_URL = "http://www.b520.cc/{book_id}/{chapter_id}.html"
20
21
 
21
- def get_book_info(
22
+ def __init__(
23
+ self,
24
+ config: FetcherConfig,
25
+ cookies: dict[str, str] | None = None,
26
+ **kwargs: Any,
27
+ ) -> None:
28
+ super().__init__("biquge", config, cookies, **kwargs)
29
+
30
+ async def get_book_info(
22
31
  self,
23
32
  book_id: str,
24
33
  **kwargs: Any,
25
34
  ) -> list[str]:
26
35
  """
27
- Fetch the raw HTML of the book info page.
36
+ Fetch the raw HTML of the book info page asynchronously.
28
37
 
29
38
  :param book_id: The book identifier.
30
39
  :return: The page content as a string.
31
40
  """
32
41
  url = self.book_info_url(book_id=book_id)
33
- try:
34
- resp = self.get(url, **kwargs)
35
- resp.raise_for_status()
36
- return [resp.text]
37
- except Exception as exc:
38
- self.logger.warning(
39
- "[session] get_book_info(%s) failed: %s",
40
- book_id,
41
- exc,
42
- )
43
- return []
44
-
45
- def get_book_chapter(
42
+ return [await self.fetch(url, **kwargs)]
43
+
44
+ async def get_book_chapter(
46
45
  self,
47
46
  book_id: str,
48
47
  chapter_id: str,
49
48
  **kwargs: Any,
50
49
  ) -> list[str]:
51
50
  """
52
- Fetch the HTML of a single chapter.
51
+ Fetch the raw HTML of a single chapter asynchronously.
53
52
 
54
53
  :param book_id: The book identifier.
55
54
  :param chapter_id: The chapter identifier.
56
55
  :return: The chapter content as a string.
57
56
  """
58
57
  url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
59
- try:
60
- resp = self.get(url, **kwargs)
61
- resp.raise_for_status()
62
- return [resp.text]
63
- except Exception as exc:
64
- self.logger.warning(
65
- "[session] get_book_chapter(%s) failed: %s",
66
- book_id,
67
- exc,
68
- )
69
- return []
58
+ return [await self.fetch(url, **kwargs)]
70
59
 
71
60
  @classmethod
72
61
  def book_info_url(cls, book_id: str) -> str:
@@ -88,3 +77,7 @@ class BiqugeSession(BaseSession):
88
77
  :return: Fully qualified chapter URL.
89
78
  """
90
79
  return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
80
+
81
+ @property
82
+ def hostname(self) -> str:
83
+ return "www.b520.cc"
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.common
4
+ -------------------------------------
5
+
6
+ """
7
+
8
+ from .browser import CommonBrowser
9
+ from .session import CommonSession
10
+
11
+ __all__ = [
12
+ "CommonBrowser",
13
+ "CommonSession",
14
+ ]
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.common.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, SiteProfile
12
+
13
+
14
+ class CommonBrowser(BaseBrowser):
15
+ """
16
+ A common async browser for handling site-specific HTTP requests.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ site: str,
22
+ profile: SiteProfile,
23
+ config: FetcherConfig,
24
+ reuse_page: bool = False,
25
+ **kwargs: Any,
26
+ ) -> None:
27
+ super().__init__(site, config, reuse_page, **kwargs)
28
+ self._profile = profile
29
+
30
+ async def get_book_info(
31
+ self,
32
+ book_id: str,
33
+ **kwargs: Any,
34
+ ) -> list[str]:
35
+ """
36
+ Fetch the raw HTML of the book info page asynchronously.
37
+
38
+ :param book_id: The book identifier.
39
+ :return: The page content as a string.
40
+ """
41
+ url = self.book_info_url(book_id=book_id)
42
+ return [await self.fetch(url, **kwargs)]
43
+
44
+ async def get_book_chapter(
45
+ self,
46
+ book_id: str,
47
+ chapter_id: str,
48
+ **kwargs: Any,
49
+ ) -> list[str]:
50
+ """
51
+ Fetch the raw HTML of a single chapter asynchronously.
52
+
53
+ :param book_id: The book identifier.
54
+ :param chapter_id: The chapter identifier.
55
+ :return: The chapter content as a string.
56
+ """
57
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
58
+ return [await self.fetch(url, **kwargs)]
59
+
60
+ def book_info_url(self, book_id: str) -> str:
61
+ """
62
+ Construct the URL for fetching a book's info page.
63
+
64
+ :param book_id: The identifier of the book.
65
+ :return: Fully qualified URL for the book info page.
66
+ """
67
+ return self._profile["book_info_url"].format(book_id=book_id)
68
+
69
+ def chapter_url(self, book_id: str, chapter_id: str) -> str:
70
+ """
71
+ Construct the URL for fetching a specific chapter.
72
+
73
+ :param book_id: The identifier of the book.
74
+ :param chapter_id: The identifier of the chapter.
75
+ :return: Fully qualified chapter URL.
76
+ """
77
+ return self._profile["chapter_url"].format(
78
+ book_id=book_id, chapter_id=chapter_id
79
+ )