novel-downloader 1.3.3__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 (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 +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 +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.0.dist-info}/METADATA +69 -35
  141. novel_downloader-1.4.0.dist-info/RECORD +170 -0
  142. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
  143. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.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.0.dist-info}/licenses/LICENSE +0 -0
  211. {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.0.dist-info}/top_level.txt +0 -0
@@ -1,26 +1,22 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.requesters.sfacg.async_session
4
- ----------------------------------------------------
3
+ novel_downloader.core.fetchers.sfacg.session
4
+ --------------------------------------------
5
5
 
6
6
  """
7
7
 
8
- import asyncio
9
- from http.cookies import SimpleCookie
10
8
  from typing import Any
11
9
 
12
- from novel_downloader.config.models import RequesterConfig
13
- from novel_downloader.core.requesters.base import BaseAsyncSession
14
- from novel_downloader.utils.i18n import t
15
- from novel_downloader.utils.state import state_mgr
10
+ from novel_downloader.core.fetchers.base import BaseSession
11
+ from novel_downloader.models import FetcherConfig, LoginField
16
12
 
17
13
 
18
- class SfacgAsyncSession(BaseAsyncSession):
14
+ class SfacgSession(BaseSession):
19
15
  """
20
- A async session class for interacting with the
21
- Sfacg (m.sfacg.com) novel website.
16
+ A session class for interacting with the Sfacg (m.sfacg.com) novel website.
22
17
  """
23
18
 
19
+ LOGIN_URL = "https://m.sfacg.com/login"
24
20
  BOOKCASE_URL = "https://m.sfacg.com/sheets/"
25
21
  BOOK_INFO_URL = "https://m.sfacg.com/b/{book_id}/"
26
22
  BOOK_CATALOG_URL = "https://m.sfacg.com/i/{book_id}/"
@@ -28,49 +24,33 @@ class SfacgAsyncSession(BaseAsyncSession):
28
24
 
29
25
  def __init__(
30
26
  self,
31
- config: RequesterConfig,
32
- ):
33
- super().__init__(config)
34
- self._logged_in: bool = False
35
- self._retry_times = config.retry_times
27
+ config: FetcherConfig,
28
+ cookies: dict[str, str] | None = None,
29
+ **kwargs: Any,
30
+ ) -> None:
31
+ super().__init__("sfacg", config, cookies, **kwargs)
36
32
 
37
33
  async def login(
38
34
  self,
39
35
  username: str = "",
40
36
  password: str = "",
41
- manual_login: bool = False,
37
+ cookies: dict[str, str] | None = None,
38
+ attempt: int = 1,
42
39
  **kwargs: Any,
43
40
  ) -> bool:
44
41
  """
45
42
  Restore cookies persisted by the session-based workflow.
46
43
  """
47
- cookies: dict[str, str] = state_mgr.get_cookies("sfacg")
48
-
49
- self.update_cookies(cookies)
50
- for attempt in range(1, self._retry_times + 1):
51
- if await self._check_login_status():
52
- self.logger.debug("[auth] Already logged in.")
53
- self._logged_in = True
54
- return True
55
-
56
- if attempt == 1:
57
- print(t("session_login_prompt_intro"))
58
- cookie_str = input(
59
- t(
60
- "session_login_prompt_paste_cookie",
61
- attempt=attempt,
62
- max_retries=self._retry_times,
63
- )
64
- ).strip()
65
-
66
- cookies = self._parse_cookie_input(cookie_str)
67
- if not cookies:
68
- print(t("session_login_prompt_invalid_cookie"))
69
- continue
70
-
44
+ if cookies:
71
45
  self.update_cookies(cookies)
72
- self._logged_in = await self._check_login_status()
73
- return self._logged_in
46
+
47
+ if await self._check_login_status():
48
+ self._is_logged_in = True
49
+ self.logger.debug("[auth] Logged in via cookies.")
50
+ return True
51
+
52
+ self._is_logged_in = False
53
+ return False
74
54
 
75
55
  async def get_book_info(
76
56
  self,
@@ -80,18 +60,15 @@ class SfacgAsyncSession(BaseAsyncSession):
80
60
  """
81
61
  Fetch the raw HTML of the book info page asynchronously.
82
62
 
83
- Order: [info, catalog]
84
-
85
63
  :param book_id: The book identifier.
86
64
  :return: The page content as a string.
87
65
  """
88
66
  info_url = self.book_info_url(book_id=book_id)
89
67
  catalog_url = self.book_catalog_url(book_id=book_id)
90
68
 
91
- info_html, catalog_html = await asyncio.gather(
92
- self.fetch(info_url, **kwargs),
93
- self.fetch(catalog_url, **kwargs),
94
- )
69
+ info_html = await self.fetch(info_url, **kwargs)
70
+ catalog_html = await self.fetch(catalog_url, **kwargs)
71
+
95
72
  return [info_html, catalog_html]
96
73
 
97
74
  async def get_book_chapter(
@@ -112,7 +89,6 @@ class SfacgAsyncSession(BaseAsyncSession):
112
89
 
113
90
  async def get_bookcase(
114
91
  self,
115
- page: int = 1,
116
92
  **kwargs: Any,
117
93
  ) -> list[str]:
118
94
  """
@@ -123,6 +99,19 @@ class SfacgAsyncSession(BaseAsyncSession):
123
99
  url = self.bookcase_url()
124
100
  return [await self.fetch(url, **kwargs)]
125
101
 
102
+ @property
103
+ def login_fields(self) -> list[LoginField]:
104
+ return [
105
+ LoginField(
106
+ name="cookies",
107
+ label="Cookie",
108
+ type="cookie",
109
+ required=True,
110
+ placeholder="请输入你的登录 Cookie",
111
+ description="可以通过浏览器开发者工具复制已登录状态下的 Cookie",
112
+ ),
113
+ ]
114
+
126
115
  @classmethod
127
116
  def bookcase_url(cls) -> str:
128
117
  """
@@ -163,6 +152,10 @@ class SfacgAsyncSession(BaseAsyncSession):
163
152
  """
164
153
  return cls.CHAPTER_URL.format(chapter_id=chapter_id)
165
154
 
155
+ @property
156
+ def hostname(self) -> str:
157
+ return "m.sfacg.com"
158
+
166
159
  async def _check_login_status(self) -> bool:
167
160
  """
168
161
  Check whether the user is currently logged in by
@@ -179,26 +172,3 @@ class SfacgAsyncSession(BaseAsyncSession):
179
172
  if not resp_text:
180
173
  return False
181
174
  return not any(kw in resp_text[0] for kw in keywords)
182
-
183
- @staticmethod
184
- def _parse_cookie_input(cookie_str: str) -> dict[str, str]:
185
- """
186
- Parse a raw cookie string (e.g. from browser dev tools) into a dict.
187
- Returns an empty dict if parsing fails.
188
-
189
- :param cookie_str: The raw cookie header string.
190
- :return: Parsed cookie dict.
191
- """
192
- filtered = "; ".join(pair for pair in cookie_str.split(";") if "=" in pair)
193
- parsed = SimpleCookie()
194
- try:
195
- parsed.load(filtered)
196
- return {k: v.value for k, v in parsed.items()}
197
- except Exception:
198
- return {}
199
-
200
- async def _on_close(self) -> None:
201
- """
202
- Save cookies to the state manager before closing.
203
- """
204
- state_mgr.set_cookies("sfacg", self.cookies)
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.yamibo
4
+ -------------------------------------
5
+
6
+ """
7
+
8
+ from .browser import YamiboBrowser
9
+ from .session import YamiboSession
10
+
11
+ __all__ = [
12
+ "YamiboBrowser",
13
+ "YamiboSession",
14
+ ]
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.yamibo.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
+
13
+
14
+ class YamiboBrowser(BaseBrowser):
15
+ """
16
+ A browser class for interacting with the Yamibo (www.yamibo.com) novel website.
17
+ """
18
+
19
+ BASE_URL = "https://www.yamibo.com"
20
+ BOOKCASE_URL = "https://www.yamibo.com/my/fav"
21
+ BOOK_INFO_URL = "https://www.yamibo.com/novel/{book_id}"
22
+ CHAPTER_URL = "https://www.yamibo.com/novel/view-chapter?id={chapter_id}"
23
+
24
+ LOGIN_URL = "https://www.yamibo.com/user/login"
25
+
26
+ def __init__(
27
+ self,
28
+ config: FetcherConfig,
29
+ reuse_page: bool = False,
30
+ **kwargs: Any,
31
+ ) -> None:
32
+ super().__init__("yamibo", 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
+ if self._is_logged_in:
44
+ return True
45
+
46
+ if not (username and password):
47
+ self.logger.warning("[auth] No credentials provided.")
48
+ return False
49
+
50
+ for i in range(1, attempt + 1):
51
+ try:
52
+ login_page = await self.context.new_page()
53
+ await login_page.goto(self.LOGIN_URL, wait_until="networkidle")
54
+
55
+ await login_page.fill("#loginform-username", username)
56
+ await login_page.fill("#loginform-password", password)
57
+
58
+ before_url = login_page.url
59
+ await login_page.click('button[name="login-button"]')
60
+
61
+ try:
62
+ await login_page.wait_for_url(
63
+ lambda url, before_url=before_url: url_changed(url, before_url),
64
+ timeout=15000,
65
+ )
66
+ except Exception as e:
67
+ self.logger.debug(
68
+ f"[auth] No URL change after login attempt {i}: {e}"
69
+ )
70
+
71
+ await login_page.close()
72
+
73
+ self._is_logged_in = await self._check_login_status()
74
+ if self._is_logged_in:
75
+ self.logger.info(f"[auth] Login successful on attempt {i}.")
76
+ return True
77
+ else:
78
+ self.logger.warning(
79
+ f"[auth] Login check failed after attempt {i}. Retrying..."
80
+ )
81
+
82
+ except Exception as e:
83
+ self.logger.error(
84
+ f"[auth] Unexpected error during login attempt {i}: {e}"
85
+ )
86
+
87
+ self.logger.error(f"[auth] Login failed after {attempt} attempt(s).")
88
+ return False
89
+
90
+ async def get_book_info(
91
+ self,
92
+ book_id: str,
93
+ **kwargs: Any,
94
+ ) -> list[str]:
95
+ """
96
+ Fetch the raw HTML of the book info page asynchronously.
97
+
98
+ :param book_id: The book identifier.
99
+ :return: The page content as a string.
100
+ """
101
+ url = self.book_info_url(book_id=book_id)
102
+ return [await self.fetch(url, **kwargs)]
103
+
104
+ async def get_book_chapter(
105
+ self,
106
+ book_id: str,
107
+ chapter_id: str,
108
+ **kwargs: Any,
109
+ ) -> list[str]:
110
+ """
111
+ Fetch the raw HTML of a single chapter asynchronously.
112
+
113
+ :param book_id: The book identifier.
114
+ :param chapter_id: The chapter identifier.
115
+ :return: The chapter content as a string.
116
+ """
117
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
118
+ return [await self.fetch(url, **kwargs)]
119
+
120
+ async def get_bookcase(
121
+ self,
122
+ **kwargs: Any,
123
+ ) -> list[str]:
124
+ """
125
+ Retrieve the user's *bookcase* page.
126
+
127
+ :return: The HTML markup of the bookcase page.
128
+ """
129
+ url = self.bookcase_url()
130
+ return [await self.fetch(url, **kwargs)]
131
+
132
+ async def set_interactive_mode(self, enable: bool) -> bool:
133
+ """
134
+ Enable or disable interactive mode for manual login.
135
+
136
+ :param enable: True to enable, False to disable interactive mode.
137
+ :return: True if operation or login check succeeded, False otherwise.
138
+ """
139
+ if enable:
140
+ if self.headless:
141
+ await self._restart_browser(headless=False)
142
+ if self._manual_page is None:
143
+ self._manual_page = await self.context.new_page()
144
+ await self._manual_page.goto(self.LOGIN_URL)
145
+ return True
146
+
147
+ # restore
148
+ if self._manual_page:
149
+ await self._manual_page.close()
150
+ self._manual_page = None
151
+ if self.headless:
152
+ await self._restart_browser(headless=True)
153
+ self._is_logged_in = await self._check_login_status()
154
+ return self.is_logged_in
155
+
156
+ @property
157
+ def login_fields(self) -> list[LoginField]:
158
+ return [
159
+ LoginField(
160
+ name="username",
161
+ label="用户名",
162
+ type="text",
163
+ required=True,
164
+ placeholder="请输入你的用户名",
165
+ description="用于登录 www.yamibo.com 的用户名",
166
+ ),
167
+ LoginField(
168
+ name="password",
169
+ label="密码",
170
+ type="password",
171
+ required=True,
172
+ placeholder="请输入你的密码",
173
+ description="用于登录 www.yamibo.com 的密码",
174
+ ),
175
+ ]
176
+
177
+ @classmethod
178
+ def bookcase_url(cls) -> str:
179
+ """
180
+ Construct the URL for the user's bookcase page.
181
+
182
+ :return: Fully qualified URL of the bookcase.
183
+ """
184
+ return cls.BOOKCASE_URL
185
+
186
+ @classmethod
187
+ def book_info_url(cls, book_id: str) -> str:
188
+ """
189
+ Construct the URL for fetching a book's info page.
190
+
191
+ :param book_id: The identifier of the book.
192
+ :return: Fully qualified URL for the book info page.
193
+ """
194
+ return cls.BOOK_INFO_URL.format(book_id=book_id)
195
+
196
+ @classmethod
197
+ def chapter_url(cls, book_id: str, chapter_id: str) -> str:
198
+ """
199
+ Construct the URL for fetching a specific chapter.
200
+
201
+ :param book_id: The identifier of the book.
202
+ :param chapter_id: The identifier of the chapter.
203
+ :return: Fully qualified chapter URL.
204
+ """
205
+ return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
206
+
207
+ @property
208
+ def hostname(self) -> str:
209
+ return "www.yamibo.com"
210
+
211
+ async def _check_login_status(self) -> bool:
212
+ """
213
+ Check whether the user is currently logged in by
214
+ inspecting the bookcase page content.
215
+
216
+ :return: True if the user is logged in, False otherwise.
217
+ """
218
+ keywords = [
219
+ "登录 - 百合会",
220
+ "用户名/邮箱",
221
+ ]
222
+ resp_text = await self.get_bookcase()
223
+ if not resp_text:
224
+ return False
225
+ return not any(kw in resp_text[0] for kw in keywords)
226
+
227
+
228
+ def url_changed(url: str, before: str) -> bool:
229
+ return url != before
@@ -1,25 +1,22 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.requesters.yamibo.async_session
4
- -----------------------------------------------------
3
+ novel_downloader.core.fetchers.yamibo.session
4
+ ---------------------------------------------
5
5
 
6
6
  """
7
7
 
8
8
  from typing import Any
9
9
 
10
- from lxml import etree
10
+ from lxml import html
11
11
 
12
- from novel_downloader.config.models import RequesterConfig
13
- from novel_downloader.core.requesters.base import BaseAsyncSession
14
- from novel_downloader.utils.i18n import t
15
- from novel_downloader.utils.state import state_mgr
12
+ from novel_downloader.core.fetchers.base import BaseSession
13
+ from novel_downloader.models import FetcherConfig, LoginField
16
14
  from novel_downloader.utils.time_utils import async_sleep_with_random_delay
17
15
 
18
16
 
19
- class YamiboAsyncSession(BaseAsyncSession):
17
+ class YamiboSession(BaseSession):
20
18
  """
21
- A async session class for interacting with the
22
- yamibo (www.yamibo.com) novel website.
19
+ A session class for interacting with the Yamibo (www.yamibo.com) novel website.
23
20
  """
24
21
 
25
22
  BASE_URL = "https://www.yamibo.com"
@@ -31,45 +28,50 @@ class YamiboAsyncSession(BaseAsyncSession):
31
28
 
32
29
  def __init__(
33
30
  self,
34
- config: RequesterConfig,
35
- ):
36
- super().__init__(config)
37
- self._logged_in: bool = False
38
- self._request_interval = config.backoff_factor
39
- self._retry_times = config.retry_times
40
- self._username = config.username
41
- self._password = config.password
31
+ config: FetcherConfig,
32
+ cookies: dict[str, str] | None = None,
33
+ **kwargs: Any,
34
+ ) -> None:
35
+ super().__init__("yamibo", config, cookies, **kwargs)
42
36
 
43
37
  async def login(
44
38
  self,
45
39
  username: str = "",
46
40
  password: str = "",
47
- manual_login: bool = False,
41
+ cookies: dict[str, str] | None = None,
42
+ attempt: int = 1,
48
43
  **kwargs: Any,
49
44
  ) -> bool:
50
45
  """
51
46
  Restore cookies persisted by the session-based workflow.
52
47
  """
53
- cookies: dict[str, str] = state_mgr.get_cookies("yamibo")
54
- username = username or self._username
55
- password = password or self._password
48
+ if cookies:
49
+ self.update_cookies(cookies)
50
+
51
+ if await self._check_login_status():
52
+ self._is_logged_in = True
53
+ self.logger.debug("[auth] Logged in via cookies.")
54
+ return True
55
+
56
+ if not (username and password):
57
+ self.logger.warning("[auth] No credentials provided.")
58
+ return False
56
59
 
57
- self.update_cookies(cookies)
58
- for _ in range(self._retry_times):
59
- if await self._check_login_status():
60
- self.logger.debug("[auth] Already logged in.")
61
- self._logged_in = True
60
+ for _ in range(attempt):
61
+ if (
62
+ await self._api_login(username, password)
63
+ and await self._check_login_status()
64
+ ):
65
+ self._is_logged_in = True
62
66
  return True
63
- if username and password and not await self._api_login(username, password):
64
- print(t("session_login_failed", site="esjzone"))
65
67
  await async_sleep_with_random_delay(
66
- self._request_interval,
68
+ self.backoff_factor,
67
69
  mul_spread=1.1,
68
- max_sleep=self._request_interval + 2,
70
+ max_sleep=self.backoff_factor + 2,
69
71
  )
70
72
 
71
- self._logged_in = await self._check_login_status()
72
- return self._logged_in
73
+ self._is_logged_in = False
74
+ return False
73
75
 
74
76
  async def get_book_info(
75
77
  self,
@@ -79,8 +81,6 @@ class YamiboAsyncSession(BaseAsyncSession):
79
81
  """
80
82
  Fetch the raw HTML of the book info page asynchronously.
81
83
 
82
- Order: [info, catalog]
83
-
84
84
  :param book_id: The book identifier.
85
85
  :return: The page content as a string.
86
86
  """
@@ -105,7 +105,6 @@ class YamiboAsyncSession(BaseAsyncSession):
105
105
 
106
106
  async def get_bookcase(
107
107
  self,
108
- page: int = 1,
109
108
  **kwargs: Any,
110
109
  ) -> list[str]:
111
110
  """
@@ -116,6 +115,27 @@ class YamiboAsyncSession(BaseAsyncSession):
116
115
  url = self.bookcase_url()
117
116
  return [await self.fetch(url, **kwargs)]
118
117
 
118
+ @property
119
+ def login_fields(self) -> list[LoginField]:
120
+ return [
121
+ LoginField(
122
+ name="username",
123
+ label="用户名",
124
+ type="text",
125
+ required=True,
126
+ placeholder="请输入你的用户名",
127
+ description="用于登录 www.yamibo.com 的用户名",
128
+ ),
129
+ LoginField(
130
+ name="password",
131
+ label="密码",
132
+ type="password",
133
+ required=True,
134
+ placeholder="请输入你的密码",
135
+ description="用于登录 www.yamibo.com 的密码",
136
+ ),
137
+ ]
138
+
119
139
  @classmethod
120
140
  def bookcase_url(cls) -> str:
121
141
  """
@@ -144,7 +164,11 @@ class YamiboAsyncSession(BaseAsyncSession):
144
164
  :param chapter_id: The identifier of the chapter.
145
165
  :return: Fully qualified chapter URL.
146
166
  """
147
- return cls.CHAPTER_URL.format(chapter_id=chapter_id)
167
+ return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
168
+
169
+ @property
170
+ def hostname(self) -> str:
171
+ return "www.yamibo.com"
148
172
 
149
173
  async def _api_login(self, username: str, password: str) -> bool:
150
174
  """
@@ -158,7 +182,7 @@ class YamiboAsyncSession(BaseAsyncSession):
158
182
  resp_1 = await self.get(self.LOGIN_URL)
159
183
  resp_1.raise_for_status()
160
184
  text_1 = await resp_1.text()
161
- tree = etree.HTML(text_1)
185
+ tree = html.fromstring(text_1)
162
186
  csrf_value = tree.xpath('//input[@name="_csrf-frontend"]/@value')
163
187
  csrf_value = csrf_value[0] if csrf_value else ""
164
188
  if not csrf_value:
@@ -203,9 +227,3 @@ class YamiboAsyncSession(BaseAsyncSession):
203
227
  if not resp_text:
204
228
  return False
205
229
  return not any(kw in resp_text[0] for kw in keywords)
206
-
207
- async def _on_close(self) -> None:
208
- """
209
- Save cookies to the state manager before closing.
210
- """
211
- state_mgr.set_cookies("yamibo", self.cookies)
@@ -9,23 +9,19 @@ injection.
9
9
 
10
10
  Included protocols:
11
11
  - DownloaderProtocol
12
+ - FetcherProtocol
12
13
  - ParserProtocol
13
- - RequesterProtocol
14
- - SaverProtocol
14
+ - ExporterProtocol
15
15
  """
16
16
 
17
- from .async_downloader import AsyncDownloaderProtocol
18
- from .async_requester import AsyncRequesterProtocol
17
+ from .downloader import DownloaderProtocol
18
+ from .exporter import ExporterProtocol
19
+ from .fetcher import FetcherProtocol
19
20
  from .parser import ParserProtocol
20
- from .saver import SaverProtocol
21
- from .sync_downloader import SyncDownloaderProtocol
22
- from .sync_requester import SyncRequesterProtocol
23
21
 
24
22
  __all__ = [
25
- "AsyncDownloaderProtocol",
26
- "AsyncRequesterProtocol",
23
+ "DownloaderProtocol",
24
+ "ExporterProtocol",
25
+ "FetcherProtocol",
27
26
  "ParserProtocol",
28
- "SaverProtocol",
29
- "SyncDownloaderProtocol",
30
- "SyncRequesterProtocol",
31
27
  ]