novel-downloader 1.3.1__py3-none-any.whl → 1.3.3__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 (98) hide show
  1. novel_downloader/__init__.py +1 -1
  2. novel_downloader/cli/download.py +1 -1
  3. novel_downloader/config/adapter.py +3 -0
  4. novel_downloader/config/models.py +3 -0
  5. novel_downloader/core/downloaders/__init__.py +23 -1
  6. novel_downloader/core/downloaders/biquge/__init__.py +2 -0
  7. novel_downloader/core/downloaders/biquge/biquge_async.py +27 -0
  8. novel_downloader/core/downloaders/biquge/biquge_sync.py +5 -3
  9. novel_downloader/core/downloaders/common/common_async.py +5 -11
  10. novel_downloader/core/downloaders/common/common_sync.py +18 -18
  11. novel_downloader/core/downloaders/esjzone/__init__.py +14 -0
  12. novel_downloader/core/downloaders/esjzone/esjzone_async.py +27 -0
  13. novel_downloader/core/downloaders/esjzone/esjzone_sync.py +27 -0
  14. novel_downloader/core/downloaders/qianbi/__init__.py +14 -0
  15. novel_downloader/core/downloaders/qianbi/qianbi_async.py +27 -0
  16. novel_downloader/core/downloaders/qianbi/qianbi_sync.py +27 -0
  17. novel_downloader/core/downloaders/qidian/qidian_sync.py +9 -14
  18. novel_downloader/core/downloaders/sfacg/__init__.py +14 -0
  19. novel_downloader/core/downloaders/sfacg/sfacg_async.py +27 -0
  20. novel_downloader/core/downloaders/sfacg/sfacg_sync.py +27 -0
  21. novel_downloader/core/downloaders/yamibo/__init__.py +14 -0
  22. novel_downloader/core/downloaders/yamibo/yamibo_async.py +27 -0
  23. novel_downloader/core/downloaders/yamibo/yamibo_sync.py +27 -0
  24. novel_downloader/core/factory/downloader.py +35 -7
  25. novel_downloader/core/factory/parser.py +23 -2
  26. novel_downloader/core/factory/requester.py +32 -7
  27. novel_downloader/core/factory/saver.py +14 -2
  28. novel_downloader/core/interfaces/async_requester.py +3 -3
  29. novel_downloader/core/interfaces/parser.py +7 -2
  30. novel_downloader/core/interfaces/sync_requester.py +3 -3
  31. novel_downloader/core/parsers/__init__.py +15 -5
  32. novel_downloader/core/parsers/base.py +7 -2
  33. novel_downloader/core/parsers/biquge/main_parser.py +13 -4
  34. novel_downloader/core/parsers/common/main_parser.py +13 -4
  35. novel_downloader/core/parsers/esjzone/__init__.py +10 -0
  36. novel_downloader/core/parsers/esjzone/main_parser.py +220 -0
  37. novel_downloader/core/parsers/qianbi/__init__.py +10 -0
  38. novel_downloader/core/parsers/qianbi/main_parser.py +142 -0
  39. novel_downloader/core/parsers/qidian/browser/main_parser.py +13 -4
  40. novel_downloader/core/parsers/qidian/session/main_parser.py +13 -4
  41. novel_downloader/core/parsers/sfacg/__init__.py +10 -0
  42. novel_downloader/core/parsers/sfacg/main_parser.py +166 -0
  43. novel_downloader/core/parsers/yamibo/__init__.py +10 -0
  44. novel_downloader/core/parsers/yamibo/main_parser.py +194 -0
  45. novel_downloader/core/requesters/__init__.py +33 -3
  46. novel_downloader/core/requesters/base/async_session.py +14 -10
  47. novel_downloader/core/requesters/base/browser.py +4 -7
  48. novel_downloader/core/requesters/base/session.py +25 -11
  49. novel_downloader/core/requesters/biquge/__init__.py +2 -0
  50. novel_downloader/core/requesters/biquge/async_session.py +71 -0
  51. novel_downloader/core/requesters/biquge/session.py +6 -6
  52. novel_downloader/core/requesters/common/async_session.py +4 -4
  53. novel_downloader/core/requesters/common/session.py +6 -6
  54. novel_downloader/core/requesters/esjzone/__init__.py +13 -0
  55. novel_downloader/core/requesters/esjzone/async_session.py +211 -0
  56. novel_downloader/core/requesters/esjzone/session.py +235 -0
  57. novel_downloader/core/requesters/qianbi/__init__.py +13 -0
  58. novel_downloader/core/requesters/qianbi/async_session.py +96 -0
  59. novel_downloader/core/requesters/qianbi/session.py +125 -0
  60. novel_downloader/core/requesters/qidian/broswer.py +9 -9
  61. novel_downloader/core/requesters/qidian/session.py +14 -11
  62. novel_downloader/core/requesters/sfacg/__init__.py +13 -0
  63. novel_downloader/core/requesters/sfacg/async_session.py +204 -0
  64. novel_downloader/core/requesters/sfacg/session.py +242 -0
  65. novel_downloader/core/requesters/yamibo/__init__.py +13 -0
  66. novel_downloader/core/requesters/yamibo/async_session.py +211 -0
  67. novel_downloader/core/requesters/yamibo/session.py +237 -0
  68. novel_downloader/core/savers/__init__.py +15 -3
  69. novel_downloader/core/savers/base.py +3 -7
  70. novel_downloader/core/savers/common/epub.py +21 -33
  71. novel_downloader/core/savers/common/main_saver.py +3 -1
  72. novel_downloader/core/savers/common/txt.py +1 -2
  73. novel_downloader/core/savers/epub_utils/__init__.py +14 -5
  74. novel_downloader/core/savers/epub_utils/css_builder.py +1 -0
  75. novel_downloader/core/savers/epub_utils/image_loader.py +89 -0
  76. novel_downloader/core/savers/epub_utils/initializer.py +1 -0
  77. novel_downloader/core/savers/epub_utils/text_to_html.py +48 -1
  78. novel_downloader/core/savers/epub_utils/volume_intro.py +1 -0
  79. novel_downloader/core/savers/esjzone.py +25 -0
  80. novel_downloader/core/savers/qianbi.py +25 -0
  81. novel_downloader/core/savers/sfacg.py +25 -0
  82. novel_downloader/core/savers/yamibo.py +25 -0
  83. novel_downloader/locales/en.json +1 -0
  84. novel_downloader/locales/zh.json +1 -0
  85. novel_downloader/resources/config/settings.toml +40 -4
  86. novel_downloader/utils/constants.py +4 -0
  87. novel_downloader/utils/file_utils/io.py +1 -1
  88. novel_downloader/utils/network.py +51 -38
  89. novel_downloader/utils/time_utils/__init__.py +2 -1
  90. novel_downloader/utils/time_utils/datetime_utils.py +3 -1
  91. novel_downloader/utils/time_utils/sleep_utils.py +44 -2
  92. {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.3.dist-info}/METADATA +29 -24
  93. novel_downloader-1.3.3.dist-info/RECORD +166 -0
  94. novel_downloader-1.3.1.dist-info/RECORD +0 -127
  95. {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.3.dist-info}/WHEEL +0 -0
  96. {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.3.dist-info}/entry_points.txt +0 -0
  97. {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.3.dist-info}/licenses/LICENSE +0 -0
  98. {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.requesters.sfacg.async_session
4
+ ----------------------------------------------------
5
+
6
+ """
7
+
8
+ import asyncio
9
+ from http.cookies import SimpleCookie
10
+ from typing import Any
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
16
+
17
+
18
+ class SfacgAsyncSession(BaseAsyncSession):
19
+ """
20
+ A async session class for interacting with the
21
+ Sfacg (m.sfacg.com) novel website.
22
+ """
23
+
24
+ BOOKCASE_URL = "https://m.sfacg.com/sheets/"
25
+ BOOK_INFO_URL = "https://m.sfacg.com/b/{book_id}/"
26
+ BOOK_CATALOG_URL = "https://m.sfacg.com/i/{book_id}/"
27
+ CHAPTER_URL = "https://m.sfacg.com/c/{chapter_id}/"
28
+
29
+ def __init__(
30
+ self,
31
+ config: RequesterConfig,
32
+ ):
33
+ super().__init__(config)
34
+ self._logged_in: bool = False
35
+ self._retry_times = config.retry_times
36
+
37
+ async def login(
38
+ self,
39
+ username: str = "",
40
+ password: str = "",
41
+ manual_login: bool = False,
42
+ **kwargs: Any,
43
+ ) -> bool:
44
+ """
45
+ Restore cookies persisted by the session-based workflow.
46
+ """
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
+
71
+ self.update_cookies(cookies)
72
+ self._logged_in = await self._check_login_status()
73
+ return self._logged_in
74
+
75
+ async def get_book_info(
76
+ self,
77
+ book_id: str,
78
+ **kwargs: Any,
79
+ ) -> list[str]:
80
+ """
81
+ Fetch the raw HTML of the book info page asynchronously.
82
+
83
+ Order: [info, catalog]
84
+
85
+ :param book_id: The book identifier.
86
+ :return: The page content as a string.
87
+ """
88
+ info_url = self.book_info_url(book_id=book_id)
89
+ catalog_url = self.book_catalog_url(book_id=book_id)
90
+
91
+ info_html, catalog_html = await asyncio.gather(
92
+ self.fetch(info_url, **kwargs),
93
+ self.fetch(catalog_url, **kwargs),
94
+ )
95
+ return [info_html, catalog_html]
96
+
97
+ async def get_book_chapter(
98
+ self,
99
+ book_id: str,
100
+ chapter_id: str,
101
+ **kwargs: Any,
102
+ ) -> list[str]:
103
+ """
104
+ Fetch the raw HTML of a single chapter asynchronously.
105
+
106
+ :param book_id: The book identifier.
107
+ :param chapter_id: The chapter identifier.
108
+ :return: The chapter content as a string.
109
+ """
110
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
111
+ return [await self.fetch(url, **kwargs)]
112
+
113
+ async def get_bookcase(
114
+ self,
115
+ page: int = 1,
116
+ **kwargs: Any,
117
+ ) -> list[str]:
118
+ """
119
+ Retrieve the user's *bookcase* page.
120
+
121
+ :return: The HTML markup of the bookcase page.
122
+ """
123
+ url = self.bookcase_url()
124
+ return [await self.fetch(url, **kwargs)]
125
+
126
+ @classmethod
127
+ def bookcase_url(cls) -> str:
128
+ """
129
+ Construct the URL for the user's bookcase page.
130
+
131
+ :return: Fully qualified URL of the bookcase.
132
+ """
133
+ return cls.BOOKCASE_URL
134
+
135
+ @classmethod
136
+ def book_info_url(cls, book_id: str) -> str:
137
+ """
138
+ Construct the URL for fetching a book's info page.
139
+
140
+ :param book_id: The identifier of the book.
141
+ :return: Fully qualified URL for the book info page.
142
+ """
143
+ return cls.BOOK_INFO_URL.format(book_id=book_id)
144
+
145
+ @classmethod
146
+ def book_catalog_url(cls, book_id: str) -> str:
147
+ """
148
+ Construct the URL for fetching a book's catalog page.
149
+
150
+ :param book_id: The identifier of the book.
151
+ :return: Fully qualified catalog page URL.
152
+ """
153
+ return cls.BOOK_CATALOG_URL.format(book_id=book_id)
154
+
155
+ @classmethod
156
+ def chapter_url(cls, book_id: str, chapter_id: str) -> str:
157
+ """
158
+ Construct the URL for fetching a specific chapter.
159
+
160
+ :param book_id: The identifier of the book.
161
+ :param chapter_id: The identifier of the chapter.
162
+ :return: Fully qualified chapter URL.
163
+ """
164
+ return cls.CHAPTER_URL.format(chapter_id=chapter_id)
165
+
166
+ async def _check_login_status(self) -> bool:
167
+ """
168
+ Check whether the user is currently logged in by
169
+ inspecting the bookcase page content.
170
+
171
+ :return: True if the user is logged in, False otherwise.
172
+ """
173
+ keywords = [
174
+ "请输入用户名和密码",
175
+ "用户未登录",
176
+ "可输入用户名",
177
+ ]
178
+ resp_text = await self.get_bookcase()
179
+ if not resp_text:
180
+ return False
181
+ 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,242 @@
1
+ """
2
+ novel_downloader.core.requesters.sfacg.session
3
+ ----------------------------------------------
4
+
5
+ """
6
+
7
+ from http.cookies import SimpleCookie
8
+ from typing import Any
9
+
10
+ from novel_downloader.config.models import RequesterConfig
11
+ from novel_downloader.core.requesters.base import BaseSession
12
+ from novel_downloader.utils.i18n import t
13
+ from novel_downloader.utils.state import state_mgr
14
+
15
+
16
+ class SfacgSession(BaseSession):
17
+ """
18
+ A session class for interacting with the
19
+ Sfacg (m.sfacg.com) novel website.
20
+ """
21
+
22
+ BOOKCASE_URL = "https://m.sfacg.com/sheets/"
23
+ BOOK_INFO_URL = "https://m.sfacg.com/b/{book_id}/"
24
+ BOOK_CATALOG_URL = "https://m.sfacg.com/i/{book_id}/"
25
+ CHAPTER_URL = "https://m.sfacg.com/c/{chapter_id}/"
26
+
27
+ def __init__(
28
+ self,
29
+ config: RequesterConfig,
30
+ ):
31
+ super().__init__(config)
32
+ self._logged_in: bool = False
33
+ self._retry_times = config.retry_times
34
+
35
+ def login(
36
+ self,
37
+ username: str = "",
38
+ password: str = "",
39
+ manual_login: bool = False,
40
+ **kwargs: Any,
41
+ ) -> bool:
42
+ """
43
+ Restore cookies persisted by the session-based workflow.
44
+ """
45
+ cookies: dict[str, str] = state_mgr.get_cookies("sfacg")
46
+
47
+ self.update_cookies(cookies)
48
+ for attempt in range(1, self._retry_times + 1):
49
+ if self._check_login_status():
50
+ self.logger.debug("[auth] Already logged in.")
51
+ self._logged_in = True
52
+ return True
53
+
54
+ if attempt == 1:
55
+ print(t("session_login_prompt_intro"))
56
+ cookie_str = input(
57
+ t(
58
+ "session_login_prompt_paste_cookie",
59
+ attempt=attempt,
60
+ max_retries=self._retry_times,
61
+ )
62
+ ).strip()
63
+
64
+ cookies = self._parse_cookie_input(cookie_str)
65
+ if not cookies:
66
+ print(t("session_login_prompt_invalid_cookie"))
67
+ continue
68
+
69
+ self.update_cookies(cookies)
70
+ self._logged_in = self._check_login_status()
71
+ return self._logged_in
72
+
73
+ def get_book_info(
74
+ self,
75
+ book_id: str,
76
+ **kwargs: Any,
77
+ ) -> list[str]:
78
+ """
79
+ Fetch the raw HTML of the book info and catalog pages.
80
+
81
+ Order: [info, catalog]
82
+
83
+ :param book_id: The book identifier.
84
+ :return: The page content as a string.
85
+ """
86
+ info_url = self.book_info_url(book_id=book_id)
87
+ catalog_url = self.book_catalog_url(book_id=book_id)
88
+
89
+ pages = []
90
+ try:
91
+ resp = self.get(info_url, **kwargs)
92
+ resp.raise_for_status()
93
+ pages.append(resp.text)
94
+ except Exception as exc:
95
+ self.logger.warning(
96
+ "[session] get_book_info(info:%s) failed: %s",
97
+ book_id,
98
+ exc,
99
+ )
100
+ pages.append("")
101
+
102
+ try:
103
+ resp = self.get(catalog_url, **kwargs)
104
+ resp.raise_for_status()
105
+ pages.append(resp.text)
106
+ except Exception as exc:
107
+ self.logger.warning(
108
+ "[session] get_book_info(catalog:%s) failed: %s",
109
+ book_id,
110
+ exc,
111
+ )
112
+ pages.append("")
113
+
114
+ return pages
115
+
116
+ def get_book_chapter(
117
+ self,
118
+ book_id: str,
119
+ chapter_id: str,
120
+ **kwargs: Any,
121
+ ) -> list[str]:
122
+ """
123
+ Fetch the HTML of a single chapter.
124
+
125
+ :param book_id: The book identifier.
126
+ :param chapter_id: The chapter identifier.
127
+ :return: The chapter content as a string.
128
+ """
129
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
130
+ try:
131
+ resp = self.get(url, **kwargs)
132
+ resp.raise_for_status()
133
+ return [resp.text]
134
+ except Exception as exc:
135
+ self.logger.warning(
136
+ "[session] get_book_chapter(%s) failed: %s",
137
+ book_id,
138
+ exc,
139
+ )
140
+ return []
141
+
142
+ def get_bookcase(
143
+ self,
144
+ page: int = 1,
145
+ **kwargs: Any,
146
+ ) -> list[str]:
147
+ """
148
+ Retrieve the user's *bookcase* page.
149
+
150
+ :return: The HTML markup of the bookcase page.
151
+ """
152
+ url = self.bookcase_url()
153
+ try:
154
+ resp = self.get(url, **kwargs)
155
+ resp.raise_for_status()
156
+ return [resp.text]
157
+ except Exception as exc:
158
+ self.logger.warning(
159
+ "[session] get_bookcase failed: %s",
160
+ exc,
161
+ )
162
+ return []
163
+
164
+ @classmethod
165
+ def bookcase_url(cls) -> str:
166
+ """
167
+ Construct the URL for the user's bookcase page.
168
+
169
+ :return: Fully qualified URL of the bookcase.
170
+ """
171
+ return cls.BOOKCASE_URL
172
+
173
+ @classmethod
174
+ def book_info_url(cls, book_id: str) -> str:
175
+ """
176
+ Construct the URL for fetching a book's info page.
177
+
178
+ :param book_id: The identifier of the book.
179
+ :return: Fully qualified URL for the book info page.
180
+ """
181
+ return cls.BOOK_INFO_URL.format(book_id=book_id)
182
+
183
+ @classmethod
184
+ def book_catalog_url(cls, book_id: str) -> str:
185
+ """
186
+ Construct the URL for fetching a book's catalog page.
187
+
188
+ :param book_id: The identifier of the book.
189
+ :return: Fully qualified catalog page URL.
190
+ """
191
+ return cls.BOOK_CATALOG_URL.format(book_id=book_id)
192
+
193
+ @classmethod
194
+ def chapter_url(cls, book_id: str, chapter_id: str) -> str:
195
+ """
196
+ Construct the URL for fetching a specific chapter.
197
+
198
+ :param book_id: The identifier of the book.
199
+ :param chapter_id: The identifier of the chapter.
200
+ :return: Fully qualified chapter URL.
201
+ """
202
+ return cls.CHAPTER_URL.format(chapter_id=chapter_id)
203
+
204
+ def _check_login_status(self) -> bool:
205
+ """
206
+ Check whether the user is currently logged in by
207
+ inspecting the bookcase page content.
208
+
209
+ :return: True if the user is logged in, False otherwise.
210
+ """
211
+ keywords = [
212
+ "请输入用户名和密码",
213
+ "用户未登录",
214
+ "可输入用户名",
215
+ ]
216
+ resp_text = self.get_bookcase()
217
+ if not resp_text:
218
+ return False
219
+ return not any(kw in resp_text[0] for kw in keywords)
220
+
221
+ @staticmethod
222
+ def _parse_cookie_input(cookie_str: str) -> dict[str, str]:
223
+ """
224
+ Parse a raw cookie string (e.g. from browser dev tools) into a dict.
225
+ Returns an empty dict if parsing fails.
226
+
227
+ :param cookie_str: The raw cookie header string.
228
+ :return: Parsed cookie dict.
229
+ """
230
+ filtered = "; ".join(pair for pair in cookie_str.split(";") if "=" in pair)
231
+ parsed = SimpleCookie()
232
+ try:
233
+ parsed.load(filtered)
234
+ return {k: v.value for k, v in parsed.items()}
235
+ except Exception:
236
+ return {}
237
+
238
+ def _on_close(self) -> None:
239
+ """
240
+ Save cookies to the state manager before closing.
241
+ """
242
+ state_mgr.set_cookies("sfacg", self.cookies)
@@ -0,0 +1,13 @@
1
+ """
2
+ novel_downloader.core.requesters.yamibo
3
+ ---------------------------------------
4
+
5
+ """
6
+
7
+ from .async_session import YamiboAsyncSession
8
+ from .session import YamiboSession
9
+
10
+ __all__ = [
11
+ "YamiboAsyncSession",
12
+ "YamiboSession",
13
+ ]
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.requesters.yamibo.async_session
4
+ -----------------------------------------------------
5
+
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from lxml import etree
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
16
+ from novel_downloader.utils.time_utils import async_sleep_with_random_delay
17
+
18
+
19
+ class YamiboAsyncSession(BaseAsyncSession):
20
+ """
21
+ A async session class for interacting with the
22
+ yamibo (www.yamibo.com) novel website.
23
+ """
24
+
25
+ BASE_URL = "https://www.yamibo.com"
26
+ BOOKCASE_URL = "https://www.yamibo.com/my/fav"
27
+ BOOK_INFO_URL = "https://www.yamibo.com/novel/{book_id}"
28
+ CHAPTER_URL = "https://www.yamibo.com/novel/view-chapter?id={chapter_id}"
29
+
30
+ LOGIN_URL = "https://www.yamibo.com/user/login"
31
+
32
+ def __init__(
33
+ 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
42
+
43
+ async def login(
44
+ self,
45
+ username: str = "",
46
+ password: str = "",
47
+ manual_login: bool = False,
48
+ **kwargs: Any,
49
+ ) -> bool:
50
+ """
51
+ Restore cookies persisted by the session-based workflow.
52
+ """
53
+ cookies: dict[str, str] = state_mgr.get_cookies("yamibo")
54
+ username = username or self._username
55
+ password = password or self._password
56
+
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
62
+ return True
63
+ if username and password and not await self._api_login(username, password):
64
+ print(t("session_login_failed", site="esjzone"))
65
+ await async_sleep_with_random_delay(
66
+ self._request_interval,
67
+ mul_spread=1.1,
68
+ max_sleep=self._request_interval + 2,
69
+ )
70
+
71
+ self._logged_in = await self._check_login_status()
72
+ return self._logged_in
73
+
74
+ async def get_book_info(
75
+ self,
76
+ book_id: str,
77
+ **kwargs: Any,
78
+ ) -> list[str]:
79
+ """
80
+ Fetch the raw HTML of the book info page asynchronously.
81
+
82
+ Order: [info, catalog]
83
+
84
+ :param book_id: The book identifier.
85
+ :return: The page content as a string.
86
+ """
87
+ url = self.book_info_url(book_id=book_id)
88
+ return [await self.fetch(url, **kwargs)]
89
+
90
+ async def get_book_chapter(
91
+ self,
92
+ book_id: str,
93
+ chapter_id: str,
94
+ **kwargs: Any,
95
+ ) -> list[str]:
96
+ """
97
+ Fetch the raw HTML of a single chapter asynchronously.
98
+
99
+ :param book_id: The book identifier.
100
+ :param chapter_id: The chapter identifier.
101
+ :return: The chapter content as a string.
102
+ """
103
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
104
+ return [await self.fetch(url, **kwargs)]
105
+
106
+ async def get_bookcase(
107
+ self,
108
+ page: int = 1,
109
+ **kwargs: Any,
110
+ ) -> list[str]:
111
+ """
112
+ Retrieve the user's *bookcase* page.
113
+
114
+ :return: The HTML markup of the bookcase page.
115
+ """
116
+ url = self.bookcase_url()
117
+ return [await self.fetch(url, **kwargs)]
118
+
119
+ @classmethod
120
+ def bookcase_url(cls) -> str:
121
+ """
122
+ Construct the URL for the user's bookcase page.
123
+
124
+ :return: Fully qualified URL of the bookcase.
125
+ """
126
+ return cls.BOOKCASE_URL
127
+
128
+ @classmethod
129
+ def book_info_url(cls, book_id: str) -> str:
130
+ """
131
+ Construct the URL for fetching a book's info page.
132
+
133
+ :param book_id: The identifier of the book.
134
+ :return: Fully qualified URL for the book info page.
135
+ """
136
+ return cls.BOOK_INFO_URL.format(book_id=book_id)
137
+
138
+ @classmethod
139
+ def chapter_url(cls, book_id: str, chapter_id: str) -> str:
140
+ """
141
+ Construct the URL for fetching a specific chapter.
142
+
143
+ :param book_id: The identifier of the book.
144
+ :param chapter_id: The identifier of the chapter.
145
+ :return: Fully qualified chapter URL.
146
+ """
147
+ return cls.CHAPTER_URL.format(chapter_id=chapter_id)
148
+
149
+ async def _api_login(self, username: str, password: str) -> bool:
150
+ """
151
+ Login to the API using a 2-step token-based process.
152
+
153
+ Step 1: Get token `_csrf-frontend`.
154
+ Step 2: Use token and credentials to perform login.
155
+ Return True if login succeeds, False otherwise.
156
+ """
157
+ try:
158
+ resp_1 = await self.get(self.LOGIN_URL)
159
+ resp_1.raise_for_status()
160
+ text_1 = await resp_1.text()
161
+ tree = etree.HTML(text_1)
162
+ csrf_value = tree.xpath('//input[@name="_csrf-frontend"]/@value')
163
+ csrf_value = csrf_value[0] if csrf_value else ""
164
+ if not csrf_value:
165
+ self.logger.warning("[session] _api_login: CSRF token not found.")
166
+ return False
167
+ except Exception as exc:
168
+ self.logger.warning("[session] _api_login failed at step 1: %s", exc)
169
+ return False
170
+
171
+ data_2 = {
172
+ "_csrf-frontend": csrf_value,
173
+ "LoginForm[username]": username,
174
+ "LoginForm[password]": password,
175
+ # "LoginForm[rememberMe]": 0,
176
+ "LoginForm[rememberMe]": 1,
177
+ "login-button": "",
178
+ }
179
+ temp_headers = dict(self.headers)
180
+ temp_headers["Origin"] = self.BASE_URL
181
+ temp_headers["Referer"] = self.LOGIN_URL
182
+ try:
183
+ resp_2 = await self.post(self.LOGIN_URL, data=data_2, headers=temp_headers)
184
+ resp_2.raise_for_status()
185
+ text_2 = await resp_2.text()
186
+ return "登录成功" in text_2
187
+ except Exception as exc:
188
+ self.logger.warning("[session] _api_login failed at step 2: %s", exc)
189
+ return False
190
+
191
+ async def _check_login_status(self) -> bool:
192
+ """
193
+ Check whether the user is currently logged in by
194
+ inspecting the bookcase page content.
195
+
196
+ :return: True if the user is logged in, False otherwise.
197
+ """
198
+ keywords = [
199
+ "登录 - 百合会",
200
+ "用户名/邮箱",
201
+ ]
202
+ resp_text = await self.get_bookcase()
203
+ if not resp_text:
204
+ return False
205
+ 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)