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,42 +1,30 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.requesters.common.async_session
4
- -----------------------------------------------------
3
+ novel_downloader.core.fetchers.common.session
4
+ ---------------------------------------------
5
5
 
6
- This module defines a `CommonAsyncSession` class for handling HTTP requests
7
- to common novel sites **asynchronously**. It provides methods to retrieve
8
- raw book info pages and chapter contents using a flexible URL templating
9
- system defined by a site profile, with retry logic and random delays.
10
6
  """
11
7
 
12
8
  from typing import Any
13
9
 
14
- from novel_downloader.config import RequesterConfig, SiteProfile
15
- from novel_downloader.core.requesters.base import BaseAsyncSession
10
+ from novel_downloader.core.fetchers.base import BaseSession
11
+ from novel_downloader.models import FetcherConfig, SiteProfile
16
12
 
17
13
 
18
- class CommonAsyncSession(BaseAsyncSession):
14
+ class CommonSession(BaseSession):
19
15
  """
20
16
  A common async session for handling site-specific HTTP requests.
21
17
  """
22
18
 
23
19
  def __init__(
24
20
  self,
25
- config: RequesterConfig,
26
21
  site: str,
27
22
  profile: SiteProfile,
23
+ config: FetcherConfig,
28
24
  cookies: dict[str, str] | None = None,
25
+ **kwargs: Any,
29
26
  ) -> None:
30
- """
31
- Initialize a CommonAsyncSession instance.
32
-
33
- :param config: The RequesterConfig instance containing settings.
34
- :param site: The identifier or domain of the target site.
35
- :param profile: The site's metadata and URL templates.
36
- :param cookies: Optional cookies to preload into the session.
37
- """
38
- super().__init__(config, cookies)
39
- self._site = site
27
+ super().__init__(site, config, cookies, **kwargs)
40
28
  self._profile = profile
41
29
 
42
30
  async def get_book_info(
@@ -69,11 +57,6 @@ class CommonAsyncSession(BaseAsyncSession):
69
57
  url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
70
58
  return [await self.fetch(url, **kwargs)]
71
59
 
72
- @property
73
- def site(self) -> str:
74
- """Return the site name."""
75
- return self._site
76
-
77
60
  def book_info_url(self, book_id: str) -> str:
78
61
  """
79
62
  Construct the URL for fetching a book's info page.
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.esjzone
4
+ --------------------------------------
5
+
6
+ """
7
+
8
+ from .browser import EsjzoneBrowser
9
+ from .session import EsjzoneSession
10
+
11
+ __all__ = [
12
+ "EsjzoneBrowser",
13
+ "EsjzoneSession",
14
+ ]
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.esjzone.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 EsjzoneBrowser(BaseBrowser):
15
+ """
16
+ A browser class for interacting with the Esjzone (www.esjzone.cc) novel website.
17
+ """
18
+
19
+ BOOKCASE_URL = "https://www.esjzone.cc/my/favorite"
20
+ BOOK_INFO_URL = "https://www.esjzone.cc/detail/{book_id}.html"
21
+ CHAPTER_URL = "https://www.esjzone.cc/forum/{book_id}/{chapter_id}.html"
22
+
23
+ API_LOGIN_URL_1 = "https://www.esjzone.cc/my/login"
24
+ API_LOGIN_URL_2 = "https://www.esjzone.cc/inc/mem_login.php"
25
+
26
+ def __init__(
27
+ self,
28
+ config: FetcherConfig,
29
+ reuse_page: bool = False,
30
+ **kwargs: Any,
31
+ ) -> None:
32
+ super().__init__("esjzone", 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
+ login_page = await self.context.new_page()
51
+
52
+ await login_page.goto(self.API_LOGIN_URL_1, wait_until="networkidle")
53
+
54
+ await login_page.fill('input[name="email"]', username)
55
+ await login_page.fill('input[name="pwd"]', password)
56
+
57
+ await login_page.click('a.btn-send[data-send="mem_login"]')
58
+
59
+ await login_page.wait_for_load_state("networkidle")
60
+ await login_page.close()
61
+
62
+ self._is_logged_in = await self._check_login_status()
63
+
64
+ return self._is_logged_in
65
+
66
+ async def get_book_info(
67
+ self,
68
+ book_id: str,
69
+ **kwargs: Any,
70
+ ) -> list[str]:
71
+ """
72
+ Fetch the raw HTML of the book info page asynchronously.
73
+
74
+ :param book_id: The book identifier.
75
+ :return: The page content as a string.
76
+ """
77
+ url = self.book_info_url(book_id=book_id)
78
+ return [await self.fetch(url, **kwargs)]
79
+
80
+ async def get_book_chapter(
81
+ self,
82
+ book_id: str,
83
+ chapter_id: str,
84
+ **kwargs: Any,
85
+ ) -> list[str]:
86
+ """
87
+ Fetch the raw HTML of a single chapter asynchronously.
88
+
89
+ :param book_id: The book identifier.
90
+ :param chapter_id: The chapter identifier.
91
+ :return: The chapter content as a string.
92
+ """
93
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
94
+ return [await self.fetch(url, **kwargs)]
95
+
96
+ async def get_bookcase(
97
+ self,
98
+ **kwargs: Any,
99
+ ) -> list[str]:
100
+ """
101
+ Retrieve the user's *bookcase* page.
102
+
103
+ :return: The HTML markup of the bookcase page.
104
+ """
105
+ url = self.bookcase_url()
106
+ return [await self.fetch(url, **kwargs)]
107
+
108
+ async def set_interactive_mode(self, enable: bool) -> bool:
109
+ """
110
+ Enable or disable interactive mode for manual login.
111
+
112
+ :param enable: True to enable, False to disable interactive mode.
113
+ :return: True if operation or login check succeeded, False otherwise.
114
+ """
115
+ if enable:
116
+ if self.headless:
117
+ await self._restart_browser(headless=False)
118
+ if self._manual_page is None:
119
+ self._manual_page = await self.context.new_page()
120
+ await self._manual_page.goto(self.API_LOGIN_URL_1)
121
+ return True
122
+
123
+ # restore
124
+ if self._manual_page:
125
+ await self._manual_page.close()
126
+ self._manual_page = None
127
+ if self.headless:
128
+ await self._restart_browser(headless=True)
129
+ self._is_logged_in = await self._check_login_status()
130
+ return self.is_logged_in
131
+
132
+ @property
133
+ def login_fields(self) -> list[LoginField]:
134
+ return [
135
+ LoginField(
136
+ name="username",
137
+ label="用户名",
138
+ type="text",
139
+ required=True,
140
+ placeholder="请输入你的用户名",
141
+ description="用于登录 esjzone.cc 的用户名",
142
+ ),
143
+ LoginField(
144
+ name="password",
145
+ label="密码",
146
+ type="password",
147
+ required=True,
148
+ placeholder="请输入你的密码",
149
+ description="用于登录 esjzone.cc 的密码",
150
+ ),
151
+ ]
152
+
153
+ @classmethod
154
+ def bookcase_url(cls) -> str:
155
+ """
156
+ Construct the URL for the user's bookcase page.
157
+
158
+ :return: Fully qualified URL of the bookcase.
159
+ """
160
+ return cls.BOOKCASE_URL
161
+
162
+ @classmethod
163
+ def book_info_url(cls, book_id: str) -> str:
164
+ """
165
+ Construct the URL for fetching a book's info page.
166
+
167
+ :param book_id: The identifier of the book.
168
+ :return: Fully qualified URL for the book info page.
169
+ """
170
+ return cls.BOOK_INFO_URL.format(book_id=book_id)
171
+
172
+ @classmethod
173
+ def chapter_url(cls, book_id: str, chapter_id: str) -> str:
174
+ """
175
+ Construct the URL for fetching a specific chapter.
176
+
177
+ :param book_id: The identifier of the book.
178
+ :param chapter_id: The identifier of the chapter.
179
+ :return: Fully qualified chapter URL.
180
+ """
181
+ return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
182
+
183
+ async def _check_login_status(self) -> bool:
184
+ """
185
+ Check whether the user is currently logged in by
186
+ inspecting the bookcase page content.
187
+
188
+ :return: True if the user is logged in, False otherwise.
189
+ """
190
+ keywords = [
191
+ "window.location.href='/my/login'",
192
+ "會員登入",
193
+ "會員註冊 SIGN UP",
194
+ ]
195
+ resp_text = await self.get_bookcase()
196
+ if not resp_text:
197
+ return False
198
+ return not any(kw in resp_text[0] for kw in keywords)
199
+
200
+ @property
201
+ def hostname(self) -> str:
202
+ return "www.esjzone.cc"
@@ -1,24 +1,21 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- novel_downloader.core.requesters.esjzone.async_session
4
- ------------------------------------------------------
3
+ novel_downloader.core.fetchers.esjzone.session
4
+ ----------------------------------------------
5
5
 
6
6
  """
7
7
 
8
8
  import re
9
9
  from typing import Any
10
10
 
11
- from novel_downloader.config.models import RequesterConfig
12
- from novel_downloader.core.requesters.base import BaseAsyncSession
13
- from novel_downloader.utils.i18n import t
14
- from novel_downloader.utils.state import state_mgr
11
+ from novel_downloader.core.fetchers.base import BaseSession
12
+ from novel_downloader.models import FetcherConfig, LoginField
15
13
  from novel_downloader.utils.time_utils import async_sleep_with_random_delay
16
14
 
17
15
 
18
- class EsjzoneAsyncSession(BaseAsyncSession):
16
+ class EsjzoneSession(BaseSession):
19
17
  """
20
- A async session class for interacting with the
21
- esjzone (www.esjzone.cc) novel website.
18
+ A session class for interacting with the esjzone (www.esjzone.cc) novel website.
22
19
  """
23
20
 
24
21
  BOOKCASE_URL = "https://www.esjzone.cc/my/favorite"
@@ -30,45 +27,50 @@ class EsjzoneAsyncSession(BaseAsyncSession):
30
27
 
31
28
  def __init__(
32
29
  self,
33
- config: RequesterConfig,
34
- ):
35
- super().__init__(config)
36
- self._logged_in: bool = False
37
- self._request_interval = config.backoff_factor
38
- self._retry_times = config.retry_times
39
- self._username = config.username
40
- self._password = config.password
30
+ config: FetcherConfig,
31
+ cookies: dict[str, str] | None = None,
32
+ **kwargs: Any,
33
+ ) -> None:
34
+ super().__init__("esjzone", config, cookies, **kwargs)
41
35
 
42
36
  async def login(
43
37
  self,
44
38
  username: str = "",
45
39
  password: str = "",
46
- manual_login: bool = False,
40
+ cookies: dict[str, str] | None = None,
41
+ attempt: int = 1,
47
42
  **kwargs: Any,
48
43
  ) -> bool:
49
44
  """
50
45
  Restore cookies persisted by the session-based workflow.
51
46
  """
52
- cookies: dict[str, str] = state_mgr.get_cookies("esjzone")
53
- username = username or self._username
54
- password = password or self._password
47
+ if cookies:
48
+ self.update_cookies(cookies)
49
+
50
+ if await self._check_login_status():
51
+ self._is_logged_in = True
52
+ self.logger.debug("[auth] Logged in via cookies.")
53
+ return True
54
+
55
+ if not (username and password):
56
+ self.logger.warning("[auth] No credentials provided.")
57
+ return False
55
58
 
56
- self.update_cookies(cookies)
57
- for _ in range(self._retry_times):
58
- if await self._check_login_status():
59
- self.logger.debug("[auth] Already logged in.")
60
- self._logged_in = True
59
+ for _ in range(attempt):
60
+ if (
61
+ await self._api_login(username, password)
62
+ and await self._check_login_status()
63
+ ):
64
+ self._is_logged_in = True
61
65
  return True
62
- if username and password and not await self._api_login(username, password):
63
- print(t("session_login_failed", site="esjzone"))
64
66
  await async_sleep_with_random_delay(
65
- self._request_interval,
67
+ self.backoff_factor,
66
68
  mul_spread=1.1,
67
- max_sleep=self._request_interval + 2,
69
+ max_sleep=self.backoff_factor + 2,
68
70
  )
69
71
 
70
- self._logged_in = await self._check_login_status()
71
- return self._logged_in
72
+ self._is_logged_in = False
73
+ return False
72
74
 
73
75
  async def get_book_info(
74
76
  self,
@@ -78,8 +80,6 @@ class EsjzoneAsyncSession(BaseAsyncSession):
78
80
  """
79
81
  Fetch the raw HTML of the book info page asynchronously.
80
82
 
81
- Order: [info, catalog]
82
-
83
83
  :param book_id: The book identifier.
84
84
  :return: The page content as a string.
85
85
  """
@@ -104,7 +104,6 @@ class EsjzoneAsyncSession(BaseAsyncSession):
104
104
 
105
105
  async def get_bookcase(
106
106
  self,
107
- page: int = 1,
108
107
  **kwargs: Any,
109
108
  ) -> list[str]:
110
109
  """
@@ -115,6 +114,27 @@ class EsjzoneAsyncSession(BaseAsyncSession):
115
114
  url = self.bookcase_url()
116
115
  return [await self.fetch(url, **kwargs)]
117
116
 
117
+ @property
118
+ def login_fields(self) -> list[LoginField]:
119
+ return [
120
+ LoginField(
121
+ name="username",
122
+ label="用户名",
123
+ type="text",
124
+ required=True,
125
+ placeholder="请输入你的用户名",
126
+ description="用于登录 esjzone.cc 的用户名",
127
+ ),
128
+ LoginField(
129
+ name="password",
130
+ label="密码",
131
+ type="password",
132
+ required=True,
133
+ placeholder="请输入你的密码",
134
+ description="用于登录 esjzone.cc 的密码",
135
+ ),
136
+ ]
137
+
118
138
  @classmethod
119
139
  def bookcase_url(cls) -> str:
120
140
  """
@@ -145,6 +165,10 @@ class EsjzoneAsyncSession(BaseAsyncSession):
145
165
  """
146
166
  return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
147
167
 
168
+ @property
169
+ def hostname(self) -> str:
170
+ return "www.esjzone.cc"
171
+
148
172
  async def _api_login(self, username: str, password: str) -> bool:
149
173
  """
150
174
  Login to the API using a 2-step token-based process.
@@ -178,7 +202,7 @@ class EsjzoneAsyncSession(BaseAsyncSession):
178
202
  self.API_LOGIN_URL_2, data=data_2, headers=temp_headers
179
203
  )
180
204
  resp_2.raise_for_status()
181
- json_2 = await resp_2.json()
205
+ json_2 = await resp_2.json(content_type="text/html", encoding="utf-8")
182
206
  resp_code: int = json_2.get("status", 301)
183
207
  return resp_code == 200
184
208
  except Exception as exc:
@@ -194,6 +218,8 @@ class EsjzoneAsyncSession(BaseAsyncSession):
194
218
  """
195
219
  keywords = [
196
220
  "window.location.href='/my/login'",
221
+ "會員登入",
222
+ "會員註冊 SIGN UP",
197
223
  ]
198
224
  resp_text = await self.get_bookcase()
199
225
  if not resp_text:
@@ -203,9 +229,3 @@ class EsjzoneAsyncSession(BaseAsyncSession):
203
229
  def _extract_token(self, text: str) -> str:
204
230
  match = re.search(r"<JinJing>(.+?)</JinJing>", text)
205
231
  return match.group(1) if match else ""
206
-
207
- async def _on_close(self) -> None:
208
- """
209
- Save cookies to the state manager before closing.
210
- """
211
- state_mgr.set_cookies("esjzone", self.cookies)
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.linovelib
4
+ ----------------------------------------
5
+
6
+ """
7
+
8
+ from .browser import LinovelibBrowser
9
+ from .session import LinovelibSession
10
+
11
+ __all__ = [
12
+ "LinovelibBrowser",
13
+ "LinovelibSession",
14
+ ]
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.fetchers.linovelib.browser
4
+ ------------------------------------------------
5
+
6
+ """
7
+
8
+ import re
9
+ from typing import Any
10
+
11
+ from novel_downloader.core.fetchers.base import BaseBrowser
12
+ from novel_downloader.models import FetcherConfig
13
+ from novel_downloader.utils.time_utils import async_sleep_with_random_delay
14
+
15
+
16
+ class LinovelibBrowser(BaseBrowser):
17
+ """
18
+ A browser class for interacting with Linovelib (www.linovelib.com) novel website.
19
+ """
20
+
21
+ BASE_URL = "https://www.linovelib.com"
22
+ BOOK_INFO_URL = "https://www.linovelib.com/novel/{book_id}.html"
23
+ BOOK_VOL_URL = "https://www.linovelib.com/novel/{book_id}/{vol_id}.html"
24
+ CHAPTER_URL = "https://www.linovelib.com/novel/{book_id}/{chapter_id}.html"
25
+
26
+ _VOL_ID_PATTERN: re.Pattern[str] = re.compile(r"/novel/\d+/(vol_\d+)\.html")
27
+
28
+ def __init__(
29
+ self,
30
+ config: FetcherConfig,
31
+ reuse_page: bool = False,
32
+ **kwargs: Any,
33
+ ) -> None:
34
+ super().__init__("linovelib", config, reuse_page, **kwargs)
35
+
36
+ async def get_book_info(
37
+ self,
38
+ book_id: str,
39
+ **kwargs: Any,
40
+ ) -> list[str]:
41
+ """
42
+ Fetch the raw HTML of the book info page.
43
+
44
+ :param book_id: The book identifier.
45
+ :return: A list of HTML strings: [info_html, vol1_html, ..., volN_html]
46
+ """
47
+ url = self.book_info_url(book_id=book_id)
48
+ info_html = await self.fetch(url, **kwargs)
49
+
50
+ vol_ids = self._extract_vol_ids(info_html)
51
+ vol_ids.reverse()
52
+
53
+ vol_htmls = []
54
+ for vol_id in vol_ids:
55
+ await async_sleep_with_random_delay(
56
+ self.request_interval,
57
+ mul_spread=1.1,
58
+ max_sleep=self.request_interval + 2,
59
+ )
60
+ html = await self.get_book_volume(book_id, vol_id, **kwargs)
61
+ if html:
62
+ vol_htmls.append(html)
63
+
64
+ return [info_html] + vol_htmls
65
+
66
+ async def get_book_volume(
67
+ self,
68
+ book_id: str,
69
+ vol_id: str,
70
+ **kwargs: Any,
71
+ ) -> str:
72
+ """
73
+ Fetch the HTML content of a specific volume.
74
+
75
+ :param book_id: The book identifier.
76
+ :param vol_id: The volume identifier.
77
+ :return: The volume content as a string.
78
+ """
79
+ url = self.volume_url(book_id=book_id, vol_id=vol_id)
80
+ return await self.fetch(url, **kwargs)
81
+
82
+ async def get_book_chapter(
83
+ self,
84
+ book_id: str,
85
+ chapter_id: str,
86
+ **kwargs: Any,
87
+ ) -> list[str]:
88
+ """
89
+ Fetch the raw HTML of a single chapter asynchronously.
90
+
91
+ :param book_id: The book identifier.
92
+ :param chapter_id: The chapter identifier.
93
+ :return: The chapter content as a string.
94
+ """
95
+ html_pages: list[str] = []
96
+ idx = 1
97
+
98
+ while True:
99
+ chapter_suffix = chapter_id if idx == 1 else f"{chapter_id}_{idx}"
100
+ relative_path = self.relative_chapter_url(book_id, chapter_suffix)
101
+ full_url = self.BASE_URL + relative_path
102
+
103
+ if idx > 1 and relative_path not in html_pages[-1]:
104
+ break
105
+
106
+ try:
107
+ html = await self.fetch(full_url, **kwargs)
108
+ except Exception as exc:
109
+ self.logger.warning(
110
+ "[async] get_book_chapter(%s page %d) failed: %s",
111
+ chapter_id,
112
+ idx,
113
+ exc,
114
+ )
115
+ break
116
+
117
+ html_pages.append(html)
118
+ idx += 1
119
+ await async_sleep_with_random_delay(
120
+ self.request_interval,
121
+ mul_spread=1.1,
122
+ max_sleep=self.request_interval + 2,
123
+ )
124
+
125
+ return html_pages
126
+
127
+ @classmethod
128
+ def book_info_url(cls, book_id: str) -> str:
129
+ """
130
+ Construct the URL for fetching a book's info page.
131
+
132
+ :param book_id: The identifier of the book.
133
+ :return: Fully qualified URL for the book info page.
134
+ """
135
+ return cls.BOOK_INFO_URL.format(book_id=book_id)
136
+
137
+ @classmethod
138
+ def volume_url(cls, book_id: str, vol_id: str) -> str:
139
+ """
140
+ Construct the URL for fetching a specific volume.
141
+
142
+ :param book_id: The identifier of the book.
143
+ :param vol_id: The identifier of the volume.
144
+ :return: Fully qualified volume URL.
145
+ """
146
+ return cls.BOOK_VOL_URL.format(book_id=book_id, vol_id=vol_id)
147
+
148
+ @classmethod
149
+ def chapter_url(cls, book_id: str, chapter_id: str) -> str:
150
+ """
151
+ Construct the URL for fetching a specific chapter.
152
+
153
+ :param book_id: The identifier of the book.
154
+ :param chapter_id: The identifier of the chapter.
155
+ :return: Fully qualified chapter URL.
156
+ """
157
+ return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
158
+
159
+ @property
160
+ def hostname(self) -> str:
161
+ return "www.linovelib.com"
162
+
163
+ @classmethod
164
+ def relative_chapter_url(cls, book_id: str, chapter_id: str) -> str:
165
+ """
166
+ Return the relative URL path for a given chapter.
167
+ """
168
+ return f"/novel/{book_id}/{chapter_id}.html"
169
+
170
+ def _extract_vol_ids(self, html_str: str) -> list[str]:
171
+ """
172
+ Extract volume IDs (like 'vol_12345') from the info HTML.
173
+
174
+ :param html_str: Raw HTML of the info page.
175
+ :return: List of volume ID strings.
176
+ """
177
+ # /novel/{book_id}/{vol_id}.html
178
+ return self._VOL_ID_PATTERN.findall(html_str)