novel-downloader 1.3.1__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.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/download.py +1 -1
- novel_downloader/config/adapter.py +3 -0
- novel_downloader/config/models.py +3 -0
- novel_downloader/core/downloaders/__init__.py +23 -1
- novel_downloader/core/downloaders/biquge/__init__.py +2 -0
- novel_downloader/core/downloaders/biquge/biquge_async.py +27 -0
- novel_downloader/core/downloaders/biquge/biquge_sync.py +5 -3
- novel_downloader/core/downloaders/common/common_async.py +5 -3
- novel_downloader/core/downloaders/common/common_sync.py +18 -10
- novel_downloader/core/downloaders/esjzone/__init__.py +14 -0
- novel_downloader/core/downloaders/esjzone/esjzone_async.py +27 -0
- novel_downloader/core/downloaders/esjzone/esjzone_sync.py +27 -0
- novel_downloader/core/downloaders/qianbi/__init__.py +14 -0
- novel_downloader/core/downloaders/qianbi/qianbi_async.py +27 -0
- novel_downloader/core/downloaders/qianbi/qianbi_sync.py +27 -0
- novel_downloader/core/downloaders/qidian/qidian_sync.py +9 -6
- novel_downloader/core/downloaders/sfacg/__init__.py +14 -0
- novel_downloader/core/downloaders/sfacg/sfacg_async.py +27 -0
- novel_downloader/core/downloaders/sfacg/sfacg_sync.py +27 -0
- novel_downloader/core/downloaders/yamibo/__init__.py +14 -0
- novel_downloader/core/downloaders/yamibo/yamibo_async.py +27 -0
- novel_downloader/core/downloaders/yamibo/yamibo_sync.py +27 -0
- novel_downloader/core/factory/downloader.py +35 -7
- novel_downloader/core/factory/parser.py +23 -2
- novel_downloader/core/factory/requester.py +32 -7
- novel_downloader/core/factory/saver.py +14 -2
- novel_downloader/core/interfaces/async_requester.py +3 -3
- novel_downloader/core/interfaces/parser.py +7 -2
- novel_downloader/core/interfaces/sync_requester.py +3 -3
- novel_downloader/core/parsers/__init__.py +15 -5
- novel_downloader/core/parsers/base.py +7 -2
- novel_downloader/core/parsers/biquge/main_parser.py +13 -4
- novel_downloader/core/parsers/common/main_parser.py +13 -4
- novel_downloader/core/parsers/esjzone/__init__.py +10 -0
- novel_downloader/core/parsers/esjzone/main_parser.py +219 -0
- novel_downloader/core/parsers/qianbi/__init__.py +10 -0
- novel_downloader/core/parsers/qianbi/main_parser.py +142 -0
- novel_downloader/core/parsers/qidian/browser/main_parser.py +13 -4
- novel_downloader/core/parsers/qidian/session/main_parser.py +13 -4
- novel_downloader/core/parsers/sfacg/__init__.py +10 -0
- novel_downloader/core/parsers/sfacg/main_parser.py +166 -0
- novel_downloader/core/parsers/yamibo/__init__.py +10 -0
- novel_downloader/core/parsers/yamibo/main_parser.py +194 -0
- novel_downloader/core/requesters/__init__.py +33 -3
- novel_downloader/core/requesters/base/async_session.py +14 -10
- novel_downloader/core/requesters/base/browser.py +4 -7
- novel_downloader/core/requesters/base/session.py +25 -11
- novel_downloader/core/requesters/biquge/__init__.py +2 -0
- novel_downloader/core/requesters/biquge/async_session.py +71 -0
- novel_downloader/core/requesters/biquge/session.py +6 -6
- novel_downloader/core/requesters/common/async_session.py +4 -4
- novel_downloader/core/requesters/common/session.py +6 -6
- novel_downloader/core/requesters/esjzone/__init__.py +13 -0
- novel_downloader/core/requesters/esjzone/async_session.py +211 -0
- novel_downloader/core/requesters/esjzone/session.py +235 -0
- novel_downloader/core/requesters/qianbi/__init__.py +13 -0
- novel_downloader/core/requesters/qianbi/async_session.py +96 -0
- novel_downloader/core/requesters/qianbi/session.py +125 -0
- novel_downloader/core/requesters/qidian/broswer.py +9 -9
- novel_downloader/core/requesters/qidian/session.py +14 -11
- novel_downloader/core/requesters/sfacg/__init__.py +13 -0
- novel_downloader/core/requesters/sfacg/async_session.py +204 -0
- novel_downloader/core/requesters/sfacg/session.py +242 -0
- novel_downloader/core/requesters/yamibo/__init__.py +13 -0
- novel_downloader/core/requesters/yamibo/async_session.py +211 -0
- novel_downloader/core/requesters/yamibo/session.py +237 -0
- novel_downloader/core/savers/__init__.py +15 -3
- novel_downloader/core/savers/base.py +1 -0
- novel_downloader/core/savers/esjzone.py +25 -0
- novel_downloader/core/savers/qianbi.py +25 -0
- novel_downloader/core/savers/sfacg.py +25 -0
- novel_downloader/core/savers/yamibo.py +25 -0
- novel_downloader/locales/en.json +1 -0
- novel_downloader/locales/zh.json +1 -0
- novel_downloader/resources/config/settings.toml +40 -4
- novel_downloader/utils/time_utils/__init__.py +2 -1
- novel_downloader/utils/time_utils/datetime_utils.py +3 -1
- novel_downloader/utils/time_utils/sleep_utils.py +43 -1
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/METADATA +25 -20
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/RECORD +85 -47
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/WHEEL +0 -0
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/entry_points.txt +0 -0
- {novel_downloader-1.3.1.dist-info → novel_downloader-1.3.2.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.1.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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
@@ -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)
|