novel-downloader 1.3.0__py3-none-any.whl → 1.3.2__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 (85) 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 -3
  10. novel_downloader/core/downloaders/common/common_sync.py +18 -10
  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 -6
  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 +219 -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 +11 -10
  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 +1 -0
  70. novel_downloader/core/savers/esjzone.py +25 -0
  71. novel_downloader/core/savers/qianbi.py +25 -0
  72. novel_downloader/core/savers/sfacg.py +25 -0
  73. novel_downloader/core/savers/yamibo.py +25 -0
  74. novel_downloader/locales/en.json +1 -0
  75. novel_downloader/locales/zh.json +1 -0
  76. novel_downloader/resources/config/settings.toml +40 -4
  77. novel_downloader/utils/time_utils/__init__.py +2 -1
  78. novel_downloader/utils/time_utils/datetime_utils.py +3 -1
  79. novel_downloader/utils/time_utils/sleep_utils.py +43 -1
  80. {novel_downloader-1.3.0.dist-info → novel_downloader-1.3.2.dist-info}/METADATA +25 -20
  81. {novel_downloader-1.3.0.dist-info → novel_downloader-1.3.2.dist-info}/RECORD +85 -47
  82. {novel_downloader-1.3.0.dist-info → novel_downloader-1.3.2.dist-info}/WHEEL +0 -0
  83. {novel_downloader-1.3.0.dist-info → novel_downloader-1.3.2.dist-info}/entry_points.txt +0 -0
  84. {novel_downloader-1.3.0.dist-info → novel_downloader-1.3.2.dist-info}/licenses/LICENSE +0 -0
  85. {novel_downloader-1.3.0.dist-info → novel_downloader-1.3.2.dist-info}/top_level.txt +0 -0
@@ -122,17 +122,14 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
122
122
 
123
123
  :returns: True if login succeeded, False otherwise.
124
124
  """
125
- raise NotImplementedError(
126
- "Login is not supported by this session type. "
127
- "Override login() in your subclass to enable it."
128
- )
125
+ return True
129
126
 
130
127
  @abc.abstractmethod
131
128
  async def get_book_info(
132
129
  self,
133
130
  book_id: str,
134
131
  **kwargs: Any,
135
- ) -> str:
132
+ ) -> list[str]:
136
133
  """
137
134
  Fetch the raw HTML (or JSON) of the book info page asynchronously.
138
135
 
@@ -148,7 +145,7 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
148
145
  book_id: str,
149
146
  chapter_id: str,
150
147
  **kwargs: Any,
151
- ) -> str:
148
+ ) -> list[str]:
152
149
  """
153
150
  Fetch the raw HTML (or JSON) of a single chapter asynchronously.
154
151
 
@@ -163,7 +160,7 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
163
160
  self,
164
161
  page: int = 1,
165
162
  **kwargs: Any,
166
- ) -> str:
163
+ ) -> list[str]:
167
164
  """
168
165
  Optional: Retrieve the HTML content of the authenticated user's bookcase page.
169
166
  Subclasses that support user login/bookcase should override this.
@@ -264,14 +261,13 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
264
261
  @property
265
262
  def headers(self) -> dict[str, str]:
266
263
  """
267
- Get the current session headers.
264
+ Get a copy of the current session headers for temporary use.
268
265
 
269
266
  :return: A dict mapping header names to their values.
270
267
  """
271
268
  if self._session:
272
269
  return dict(self._session.headers)
273
- else:
274
- return self._headers
270
+ return self._headers.copy()
275
271
 
276
272
  def get_header(self, key: str, default: Any = None) -> Any:
277
273
  """
@@ -349,10 +345,18 @@ class BaseAsyncSession(AsyncRequesterProtocol, abc.ABC):
349
345
  await self._rate_limiter.wait()
350
346
  return await self.session.request(method, url, **kwargs)
351
347
 
348
+ async def _on_close(self) -> None:
349
+ """
350
+ Async hook method called before closing.
351
+ Override in subclass.
352
+ """
353
+ pass
354
+
352
355
  async def close(self) -> None:
353
356
  """
354
357
  Shutdown and clean up the session. Closes connection pool.
355
358
  """
359
+ await self._on_close()
356
360
  if self._session:
357
361
  await self._session.close()
358
362
  self._session = None
@@ -116,17 +116,14 @@ class BaseBrowser(SyncRequesterProtocol, abc.ABC):
116
116
  """
117
117
  Attempt to log in
118
118
  """
119
- raise NotImplementedError(
120
- "Login is not supported by this browser type. "
121
- "Override login() in your subclass to enable it."
122
- )
119
+ return True
123
120
 
124
121
  @abc.abstractmethod
125
122
  def get_book_info(
126
123
  self,
127
124
  book_id: str,
128
125
  **kwargs: Any,
129
- ) -> str:
126
+ ) -> list[str]:
130
127
  """
131
128
  Fetch the raw HTML (or JSON) of the book info page.
132
129
 
@@ -142,7 +139,7 @@ class BaseBrowser(SyncRequesterProtocol, abc.ABC):
142
139
  book_id: str,
143
140
  chapter_id: str,
144
141
  **kwargs: Any,
145
- ) -> str:
142
+ ) -> list[str]:
146
143
  """
147
144
  Fetch the raw HTML (or JSON) of a single chapter.
148
145
 
@@ -157,7 +154,7 @@ class BaseBrowser(SyncRequesterProtocol, abc.ABC):
157
154
  self,
158
155
  page: int = 1,
159
156
  **kwargs: Any,
160
- ) -> str:
157
+ ) -> list[str]:
161
158
  """
162
159
  Optional: Retrieve the HTML content of the authenticated user's bookcase page.
163
160
 
@@ -89,17 +89,14 @@ class BaseSession(SyncRequesterProtocol, abc.ABC):
89
89
  """
90
90
  Attempt to log in
91
91
  """
92
- raise NotImplementedError(
93
- "Login is not supported by this session type. "
94
- "Override login() in your subclass to enable it."
95
- )
92
+ return True
96
93
 
97
94
  @abc.abstractmethod
98
95
  def get_book_info(
99
96
  self,
100
97
  book_id: str,
101
98
  **kwargs: Any,
102
- ) -> str:
99
+ ) -> list[str]:
103
100
  """
104
101
  Fetch the raw HTML (or JSON) of the book info page.
105
102
 
@@ -114,7 +111,7 @@ class BaseSession(SyncRequesterProtocol, abc.ABC):
114
111
  book_id: str,
115
112
  chapter_id: str,
116
113
  **kwargs: Any,
117
- ) -> str:
114
+ ) -> list[str]:
118
115
  """
119
116
  Fetch the raw HTML (or JSON) of a single chapter.
120
117
 
@@ -128,7 +125,7 @@ class BaseSession(SyncRequesterProtocol, abc.ABC):
128
125
  self,
129
126
  page: int = 1,
130
127
  **kwargs: Any,
131
- ) -> str:
128
+ ) -> list[str]:
132
129
  """
133
130
  Optional: Retrieve the HTML content of the authenticated user's bookcase page.
134
131
 
@@ -240,14 +237,13 @@ class BaseSession(SyncRequesterProtocol, abc.ABC):
240
237
  @property
241
238
  def headers(self) -> Mapping[str, str | bytes]:
242
239
  """
243
- Get the current session headers.
240
+ Get a copy of the current session headers for temporary use.
244
241
 
245
242
  :return: A dict mapping header names to their values.
246
243
  """
247
244
  if self._session:
248
- return self._session.headers
249
- else:
250
- return self._headers
245
+ return dict(self._session.headers)
246
+ return self._headers.copy()
251
247
 
252
248
  def get_header(self, key: str, default: Any = None) -> Any:
253
249
  """
@@ -283,6 +279,16 @@ class BaseSession(SyncRequesterProtocol, abc.ABC):
283
279
  if self._session:
284
280
  self._session.headers.update(headers)
285
281
 
282
+ def del_header(self, key: str) -> None:
283
+ """
284
+ Delete a header from the session if it exists.
285
+
286
+ :param key: The name of the header to remove.
287
+ """
288
+ self._headers.pop(key, None)
289
+ if self._session:
290
+ self._session.headers.pop(key, None)
291
+
286
292
  def update_cookie(self, key: str, value: str) -> None:
287
293
  """
288
294
  Update or add a single cookie in the session.
@@ -315,12 +321,20 @@ class BaseSession(SyncRequesterProtocol, abc.ABC):
315
321
  if self._session:
316
322
  self._session.cookies.clear()
317
323
 
324
+ def _on_close(self) -> None:
325
+ """
326
+ Hook method called at the beginning of close().
327
+ Override in subclass if needed.
328
+ """
329
+ pass
330
+
318
331
  def close(self) -> None:
319
332
  """
320
333
  Shutdown and clean up the session.
321
334
 
322
335
  This closes the underlying connection pool and removes the session.
323
336
  """
337
+ self._on_close()
324
338
  if self._session:
325
339
  self._session.close()
326
340
  self._session = None
@@ -5,8 +5,10 @@ novel_downloader.core.requesters.biquge
5
5
 
6
6
  """
7
7
 
8
+ from .async_session import BiqugeAsyncSession
8
9
  from .session import BiqugeSession
9
10
 
10
11
  __all__ = [
12
+ "BiqugeAsyncSession",
11
13
  "BiqugeSession",
12
14
  ]
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.requesters.biquge.async_session
4
+ -----------------------------------------------------
5
+
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from novel_downloader.core.requesters.base import BaseAsyncSession
11
+
12
+
13
+ class BiqugeAsyncSession(BaseAsyncSession):
14
+ """
15
+ A async session class for interacting with
16
+ the Biquge (www.b520.cc) novel website.
17
+ """
18
+
19
+ BOOK_INFO_URL = "http://www.b520.cc/{book_id}/"
20
+ CHAPTER_URL = "http://www.b520.cc/{book_id}/{chapter_id}.html"
21
+
22
+ async def get_book_info(
23
+ self,
24
+ book_id: str,
25
+ **kwargs: Any,
26
+ ) -> list[str]:
27
+ """
28
+ Fetch the raw HTML of the book info page asynchronously.
29
+
30
+ :param book_id: The book identifier.
31
+ :return: The page content as a string.
32
+ """
33
+ url = self.book_info_url(book_id=book_id)
34
+ return [await self.fetch(url, **kwargs)]
35
+
36
+ async def get_book_chapter(
37
+ self,
38
+ book_id: str,
39
+ chapter_id: str,
40
+ **kwargs: Any,
41
+ ) -> list[str]:
42
+ """
43
+ Fetch the raw HTML of a single chapter asynchronously.
44
+
45
+ :param book_id: The book identifier.
46
+ :param chapter_id: The chapter identifier.
47
+ :return: The chapter content as a string.
48
+ """
49
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
50
+ return [await self.fetch(url, **kwargs)]
51
+
52
+ @classmethod
53
+ def book_info_url(cls, book_id: str) -> str:
54
+ """
55
+ Construct the URL for fetching a book's info page.
56
+
57
+ :param book_id: The identifier of the book.
58
+ :return: Fully qualified URL for the book info page.
59
+ """
60
+ return cls.BOOK_INFO_URL.format(book_id=book_id)
61
+
62
+ @classmethod
63
+ def chapter_url(cls, book_id: str, chapter_id: str) -> str:
64
+ """
65
+ Construct the URL for fetching a specific chapter.
66
+
67
+ :param book_id: The identifier of the book.
68
+ :param chapter_id: The identifier of the chapter.
69
+ :return: Fully qualified chapter URL.
70
+ """
71
+ return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
@@ -22,7 +22,7 @@ class BiqugeSession(BaseSession):
22
22
  self,
23
23
  book_id: str,
24
24
  **kwargs: Any,
25
- ) -> str:
25
+ ) -> list[str]:
26
26
  """
27
27
  Fetch the raw HTML of the book info page.
28
28
 
@@ -33,21 +33,21 @@ class BiqugeSession(BaseSession):
33
33
  try:
34
34
  resp = self.get(url, **kwargs)
35
35
  resp.raise_for_status()
36
- return resp.text
36
+ return [resp.text]
37
37
  except Exception as exc:
38
38
  self.logger.warning(
39
39
  "[session] get_book_info(%s) failed: %s",
40
40
  book_id,
41
41
  exc,
42
42
  )
43
- return ""
43
+ return []
44
44
 
45
45
  def get_book_chapter(
46
46
  self,
47
47
  book_id: str,
48
48
  chapter_id: str,
49
49
  **kwargs: Any,
50
- ) -> str:
50
+ ) -> list[str]:
51
51
  """
52
52
  Fetch the HTML of a single chapter.
53
53
 
@@ -59,14 +59,14 @@ class BiqugeSession(BaseSession):
59
59
  try:
60
60
  resp = self.get(url, **kwargs)
61
61
  resp.raise_for_status()
62
- return resp.text
62
+ return [resp.text]
63
63
  except Exception as exc:
64
64
  self.logger.warning(
65
65
  "[session] get_book_chapter(%s) failed: %s",
66
66
  book_id,
67
67
  exc,
68
68
  )
69
- return ""
69
+ return []
70
70
 
71
71
  @classmethod
72
72
  def book_info_url(cls, book_id: str) -> str:
@@ -43,7 +43,7 @@ class CommonAsyncSession(BaseAsyncSession):
43
43
  self,
44
44
  book_id: str,
45
45
  **kwargs: Any,
46
- ) -> str:
46
+ ) -> list[str]:
47
47
  """
48
48
  Fetch the raw HTML of the book info page asynchronously.
49
49
 
@@ -51,14 +51,14 @@ class CommonAsyncSession(BaseAsyncSession):
51
51
  :return: The page content as a string.
52
52
  """
53
53
  url = self.book_info_url(book_id=book_id)
54
- return await self.fetch(url, **kwargs)
54
+ return [await self.fetch(url, **kwargs)]
55
55
 
56
56
  async def get_book_chapter(
57
57
  self,
58
58
  book_id: str,
59
59
  chapter_id: str,
60
60
  **kwargs: Any,
61
- ) -> str:
61
+ ) -> list[str]:
62
62
  """
63
63
  Fetch the raw HTML of a single chapter asynchronously.
64
64
 
@@ -67,7 +67,7 @@ class CommonAsyncSession(BaseAsyncSession):
67
67
  :return: The chapter content as a string.
68
68
  """
69
69
  url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
70
- return await self.fetch(url, **kwargs)
70
+ return [await self.fetch(url, **kwargs)]
71
71
 
72
72
  @property
73
73
  def site(self) -> str:
@@ -43,7 +43,7 @@ class CommonSession(BaseSession):
43
43
  self,
44
44
  book_id: str,
45
45
  **kwargs: Any,
46
- ) -> str:
46
+ ) -> list[str]:
47
47
  """
48
48
  Fetch the raw HTML of the book info page.
49
49
 
@@ -54,17 +54,17 @@ class CommonSession(BaseSession):
54
54
  try:
55
55
  resp = self.get(url, **kwargs)
56
56
  resp.raise_for_status()
57
- return resp.text
57
+ return [resp.text]
58
58
  except Exception as e:
59
59
  self.logger.warning("Failed to fetch book info for %s: %s", book_id, e)
60
- return ""
60
+ return []
61
61
 
62
62
  def get_book_chapter(
63
63
  self,
64
64
  book_id: str,
65
65
  chapter_id: str,
66
66
  **kwargs: Any,
67
- ) -> str:
67
+ ) -> list[str]:
68
68
  """
69
69
  Fetch the raw HTML of a single chapter.
70
70
 
@@ -76,7 +76,7 @@ class CommonSession(BaseSession):
76
76
  try:
77
77
  resp = self.get(url, **kwargs)
78
78
  resp.raise_for_status()
79
- return resp.text
79
+ return [resp.text]
80
80
  except Exception as e:
81
81
  self.logger.warning(
82
82
  "Failed to fetch book chapter for %s(%s): %s",
@@ -84,7 +84,7 @@ class CommonSession(BaseSession):
84
84
  chapter_id,
85
85
  e,
86
86
  )
87
- return ""
87
+ return []
88
88
 
89
89
  @property
90
90
  def site(self) -> str:
@@ -0,0 +1,13 @@
1
+ """
2
+ novel_downloader.core.requesters.esjzone
3
+ ----------------------------------------
4
+
5
+ """
6
+
7
+ from .async_session import EsjzoneAsyncSession
8
+ from .session import EsjzoneSession
9
+
10
+ __all__ = [
11
+ "EsjzoneAsyncSession",
12
+ "EsjzoneSession",
13
+ ]
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ novel_downloader.core.requesters.esjzone.async_session
4
+ ------------------------------------------------------
5
+
6
+ """
7
+
8
+ import re
9
+ from typing import Any
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
15
+ from novel_downloader.utils.time_utils import async_sleep_with_random_delay
16
+
17
+
18
+ class EsjzoneAsyncSession(BaseAsyncSession):
19
+ """
20
+ A async session class for interacting with the
21
+ esjzone (www.esjzone.cc) novel website.
22
+ """
23
+
24
+ BOOKCASE_URL = "https://www.esjzone.cc/my/favorite"
25
+ BOOK_INFO_URL = "https://www.esjzone.cc/detail/{book_id}.html"
26
+ CHAPTER_URL = "https://www.esjzone.cc/forum/{book_id}/{chapter_id}.html"
27
+
28
+ API_LOGIN_URL_1 = "https://www.esjzone.cc/my/login"
29
+ API_LOGIN_URL_2 = "https://www.esjzone.cc/inc/mem_login.php"
30
+
31
+ def __init__(
32
+ 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
41
+
42
+ async def login(
43
+ self,
44
+ username: str = "",
45
+ password: str = "",
46
+ manual_login: bool = False,
47
+ **kwargs: Any,
48
+ ) -> bool:
49
+ """
50
+ Restore cookies persisted by the session-based workflow.
51
+ """
52
+ cookies: dict[str, str] = state_mgr.get_cookies("esjzone")
53
+ username = username or self._username
54
+ password = password or self._password
55
+
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
61
+ return True
62
+ if username and password and not await self._api_login(username, password):
63
+ print(t("session_login_failed", site="esjzone"))
64
+ await async_sleep_with_random_delay(
65
+ self._request_interval,
66
+ mul_spread=1.1,
67
+ max_sleep=self._request_interval + 2,
68
+ )
69
+
70
+ self._logged_in = await self._check_login_status()
71
+ return self._logged_in
72
+
73
+ async 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 page asynchronously.
80
+
81
+ Order: [info, catalog]
82
+
83
+ :param book_id: The book identifier.
84
+ :return: The page content as a string.
85
+ """
86
+ url = self.book_info_url(book_id=book_id)
87
+ return [await self.fetch(url, **kwargs)]
88
+
89
+ async def get_book_chapter(
90
+ self,
91
+ book_id: str,
92
+ chapter_id: str,
93
+ **kwargs: Any,
94
+ ) -> list[str]:
95
+ """
96
+ Fetch the raw HTML of a single chapter asynchronously.
97
+
98
+ :param book_id: The book identifier.
99
+ :param chapter_id: The chapter identifier.
100
+ :return: The chapter content as a string.
101
+ """
102
+ url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
103
+ return [await self.fetch(url, **kwargs)]
104
+
105
+ async def get_bookcase(
106
+ self,
107
+ page: int = 1,
108
+ **kwargs: Any,
109
+ ) -> list[str]:
110
+ """
111
+ Retrieve the user's *bookcase* page.
112
+
113
+ :return: The HTML markup of the bookcase page.
114
+ """
115
+ url = self.bookcase_url()
116
+ return [await self.fetch(url, **kwargs)]
117
+
118
+ @classmethod
119
+ def bookcase_url(cls) -> str:
120
+ """
121
+ Construct the URL for the user's bookcase page.
122
+
123
+ :return: Fully qualified URL of the bookcase.
124
+ """
125
+ return cls.BOOKCASE_URL
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 chapter_url(cls, book_id: str, chapter_id: str) -> str:
139
+ """
140
+ Construct the URL for fetching a specific chapter.
141
+
142
+ :param book_id: The identifier of the book.
143
+ :param chapter_id: The identifier of the chapter.
144
+ :return: Fully qualified chapter URL.
145
+ """
146
+ return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
147
+
148
+ async def _api_login(self, username: str, password: str) -> bool:
149
+ """
150
+ Login to the API using a 2-step token-based process.
151
+
152
+ Step 1: Get auth token.
153
+ Step 2: Use token and credentials to perform login.
154
+ Return True if login succeeds, False otherwise.
155
+ """
156
+ data_1 = {
157
+ "plxf": "getAuthToken",
158
+ }
159
+ try:
160
+ resp_1 = await self.post(self.API_LOGIN_URL_1, data=data_1)
161
+ resp_1.raise_for_status()
162
+ # Example response: <JinJing>token_here</JinJing>
163
+ text_1 = await resp_1.text()
164
+ token = self._extract_token(text_1)
165
+ except Exception as exc:
166
+ self.logger.warning("[session] _api_login failed at step 1: %s", exc)
167
+ return False
168
+
169
+ data_2 = {
170
+ "email": username,
171
+ "pwd": password,
172
+ "remember_me": "on",
173
+ }
174
+ temp_headers = dict(self.headers)
175
+ temp_headers["Authorization"] = token
176
+ try:
177
+ resp_2 = await self.post(
178
+ self.API_LOGIN_URL_2, data=data_2, headers=temp_headers
179
+ )
180
+ resp_2.raise_for_status()
181
+ json_2 = await resp_2.json()
182
+ resp_code: int = json_2.get("status", 301)
183
+ return resp_code == 200
184
+ except Exception as exc:
185
+ self.logger.warning("[session] _api_login failed at step 2: %s", exc)
186
+ return False
187
+
188
+ async def _check_login_status(self) -> bool:
189
+ """
190
+ Check whether the user is currently logged in by
191
+ inspecting the bookcase page content.
192
+
193
+ :return: True if the user is logged in, False otherwise.
194
+ """
195
+ keywords = [
196
+ "window.location.href='/my/login'",
197
+ ]
198
+ resp_text = await self.get_bookcase()
199
+ if not resp_text:
200
+ return False
201
+ return not any(kw in resp_text[0] for kw in keywords)
202
+
203
+ def _extract_token(self, text: str) -> str:
204
+ match = re.search(r"<JinJing>(.+?)</JinJing>", text)
205
+ 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)