novel-downloader 1.3.2__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.
- novel_downloader/__init__.py +1 -1
- novel_downloader/cli/clean.py +97 -78
- novel_downloader/cli/config.py +177 -0
- novel_downloader/cli/download.py +132 -87
- novel_downloader/cli/export.py +77 -0
- novel_downloader/cli/main.py +21 -28
- novel_downloader/config/__init__.py +1 -25
- novel_downloader/config/adapter.py +32 -31
- novel_downloader/config/loader.py +3 -3
- novel_downloader/config/site_rules.py +1 -2
- novel_downloader/core/__init__.py +3 -6
- novel_downloader/core/downloaders/__init__.py +10 -13
- novel_downloader/core/downloaders/base.py +233 -0
- novel_downloader/core/downloaders/biquge.py +27 -0
- novel_downloader/core/downloaders/common.py +414 -0
- novel_downloader/core/downloaders/esjzone.py +27 -0
- novel_downloader/core/downloaders/linovelib.py +27 -0
- novel_downloader/core/downloaders/qianbi.py +27 -0
- novel_downloader/core/downloaders/qidian.py +352 -0
- novel_downloader/core/downloaders/sfacg.py +27 -0
- novel_downloader/core/downloaders/yamibo.py +27 -0
- novel_downloader/core/exporters/__init__.py +37 -0
- novel_downloader/core/{savers → exporters}/base.py +73 -44
- novel_downloader/core/exporters/biquge.py +25 -0
- novel_downloader/core/exporters/common/__init__.py +12 -0
- novel_downloader/core/{savers → exporters}/common/epub.py +40 -52
- novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +36 -39
- novel_downloader/core/{savers → exporters}/common/txt.py +20 -24
- novel_downloader/core/exporters/epub_utils/__init__.py +40 -0
- novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -1
- novel_downloader/core/exporters/epub_utils/image_loader.py +131 -0
- novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -3
- novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +49 -2
- novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -1
- novel_downloader/core/exporters/esjzone.py +25 -0
- novel_downloader/core/exporters/linovelib/__init__.py +10 -0
- novel_downloader/core/exporters/linovelib/epub.py +449 -0
- novel_downloader/core/exporters/linovelib/main_exporter.py +127 -0
- novel_downloader/core/exporters/linovelib/txt.py +129 -0
- novel_downloader/core/exporters/qianbi.py +25 -0
- novel_downloader/core/{savers → exporters}/qidian.py +8 -8
- novel_downloader/core/exporters/sfacg.py +25 -0
- novel_downloader/core/exporters/yamibo.py +25 -0
- novel_downloader/core/factory/__init__.py +5 -17
- novel_downloader/core/factory/downloader.py +24 -126
- novel_downloader/core/factory/exporter.py +58 -0
- novel_downloader/core/factory/fetcher.py +96 -0
- novel_downloader/core/factory/parser.py +17 -12
- novel_downloader/core/{requesters → fetchers}/__init__.py +22 -15
- novel_downloader/core/{requesters → fetchers}/base/__init__.py +2 -4
- novel_downloader/core/fetchers/base/browser.py +383 -0
- novel_downloader/core/fetchers/base/rate_limiter.py +86 -0
- novel_downloader/core/fetchers/base/session.py +419 -0
- novel_downloader/core/fetchers/biquge/__init__.py +14 -0
- novel_downloader/core/{requesters/biquge/async_session.py → fetchers/biquge/browser.py} +18 -6
- novel_downloader/core/{requesters → fetchers}/biquge/session.py +23 -30
- novel_downloader/core/fetchers/common/__init__.py +14 -0
- novel_downloader/core/fetchers/common/browser.py +79 -0
- novel_downloader/core/{requesters/common/async_session.py → fetchers/common/session.py} +8 -25
- novel_downloader/core/fetchers/esjzone/__init__.py +14 -0
- novel_downloader/core/fetchers/esjzone/browser.py +202 -0
- novel_downloader/core/{requesters/esjzone/async_session.py → fetchers/esjzone/session.py} +62 -42
- novel_downloader/core/fetchers/linovelib/__init__.py +14 -0
- novel_downloader/core/fetchers/linovelib/browser.py +178 -0
- novel_downloader/core/fetchers/linovelib/session.py +178 -0
- novel_downloader/core/fetchers/qianbi/__init__.py +14 -0
- novel_downloader/core/{requesters/qianbi/session.py → fetchers/qianbi/browser.py} +30 -48
- novel_downloader/core/{requesters/qianbi/async_session.py → fetchers/qianbi/session.py} +18 -6
- novel_downloader/core/fetchers/qidian/__init__.py +14 -0
- novel_downloader/core/fetchers/qidian/browser.py +266 -0
- novel_downloader/core/fetchers/qidian/session.py +326 -0
- novel_downloader/core/fetchers/sfacg/__init__.py +14 -0
- novel_downloader/core/fetchers/sfacg/browser.py +189 -0
- novel_downloader/core/{requesters/sfacg/async_session.py → fetchers/sfacg/session.py} +43 -73
- novel_downloader/core/fetchers/yamibo/__init__.py +14 -0
- novel_downloader/core/fetchers/yamibo/browser.py +229 -0
- novel_downloader/core/{requesters/yamibo/async_session.py → fetchers/yamibo/session.py} +62 -44
- novel_downloader/core/interfaces/__init__.py +8 -12
- novel_downloader/core/interfaces/downloader.py +54 -0
- novel_downloader/core/interfaces/{saver.py → exporter.py} +12 -12
- novel_downloader/core/interfaces/fetcher.py +162 -0
- novel_downloader/core/interfaces/parser.py +6 -7
- novel_downloader/core/parsers/__init__.py +5 -6
- novel_downloader/core/parsers/base.py +9 -13
- novel_downloader/core/parsers/biquge/main_parser.py +12 -13
- novel_downloader/core/parsers/common/helper.py +3 -3
- novel_downloader/core/parsers/common/main_parser.py +39 -34
- novel_downloader/core/parsers/esjzone/main_parser.py +24 -17
- novel_downloader/core/parsers/linovelib/__init__.py +10 -0
- novel_downloader/core/parsers/linovelib/main_parser.py +210 -0
- novel_downloader/core/parsers/qianbi/main_parser.py +21 -15
- novel_downloader/core/parsers/qidian/__init__.py +2 -11
- novel_downloader/core/parsers/qidian/book_info_parser.py +113 -0
- novel_downloader/core/parsers/qidian/{browser/chapter_encrypted.py → chapter_encrypted.py} +162 -135
- novel_downloader/core/parsers/qidian/chapter_normal.py +150 -0
- novel_downloader/core/parsers/qidian/{session/chapter_router.py → chapter_router.py} +15 -15
- novel_downloader/core/parsers/qidian/{browser/main_parser.py → main_parser.py} +49 -40
- novel_downloader/core/parsers/qidian/utils/__init__.py +27 -0
- novel_downloader/core/parsers/qidian/utils/decryptor_fetcher.py +145 -0
- novel_downloader/core/parsers/qidian/{shared → utils}/helpers.py +41 -68
- novel_downloader/core/parsers/qidian/{session → utils}/node_decryptor.py +64 -50
- novel_downloader/core/parsers/sfacg/main_parser.py +12 -12
- novel_downloader/core/parsers/yamibo/main_parser.py +10 -10
- novel_downloader/locales/en.json +18 -2
- novel_downloader/locales/zh.json +18 -2
- novel_downloader/models/__init__.py +64 -0
- novel_downloader/models/browser.py +21 -0
- novel_downloader/models/chapter.py +25 -0
- novel_downloader/models/config.py +100 -0
- novel_downloader/models/login.py +20 -0
- novel_downloader/models/site_rules.py +99 -0
- novel_downloader/models/tasks.py +33 -0
- novel_downloader/models/types.py +15 -0
- novel_downloader/resources/config/settings.toml +31 -25
- novel_downloader/resources/json/linovelib_font_map.json +3573 -0
- novel_downloader/tui/__init__.py +7 -0
- novel_downloader/tui/app.py +32 -0
- novel_downloader/tui/main.py +17 -0
- novel_downloader/tui/screens/__init__.py +14 -0
- novel_downloader/tui/screens/home.py +191 -0
- novel_downloader/tui/screens/login.py +74 -0
- novel_downloader/tui/styles/home_layout.tcss +79 -0
- novel_downloader/tui/widgets/richlog_handler.py +24 -0
- novel_downloader/utils/__init__.py +6 -0
- novel_downloader/utils/chapter_storage.py +25 -38
- novel_downloader/utils/constants.py +15 -5
- novel_downloader/utils/cookies.py +66 -0
- novel_downloader/utils/crypto_utils.py +1 -74
- novel_downloader/utils/file_utils/io.py +1 -1
- novel_downloader/utils/fontocr/ocr_v1.py +2 -1
- novel_downloader/utils/fontocr/ocr_v2.py +2 -2
- novel_downloader/utils/hash_store.py +10 -18
- novel_downloader/utils/hash_utils.py +3 -2
- novel_downloader/utils/logger.py +2 -3
- novel_downloader/utils/network.py +53 -39
- novel_downloader/utils/text_utils/chapter_formatting.py +6 -1
- novel_downloader/utils/text_utils/font_mapping.py +1 -1
- novel_downloader/utils/text_utils/text_cleaning.py +1 -1
- novel_downloader/utils/time_utils/datetime_utils.py +3 -3
- novel_downloader/utils/time_utils/sleep_utils.py +3 -3
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/METADATA +72 -38
- novel_downloader-1.4.0.dist-info/RECORD +170 -0
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/WHEEL +1 -1
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/entry_points.txt +1 -0
- novel_downloader/cli/interactive.py +0 -66
- novel_downloader/cli/settings.py +0 -177
- novel_downloader/config/models.py +0 -187
- novel_downloader/core/downloaders/base/__init__.py +0 -14
- novel_downloader/core/downloaders/base/base_async.py +0 -153
- novel_downloader/core/downloaders/base/base_sync.py +0 -208
- novel_downloader/core/downloaders/biquge/__init__.py +0 -14
- novel_downloader/core/downloaders/biquge/biquge_async.py +0 -27
- novel_downloader/core/downloaders/biquge/biquge_sync.py +0 -27
- novel_downloader/core/downloaders/common/__init__.py +0 -14
- novel_downloader/core/downloaders/common/common_async.py +0 -218
- novel_downloader/core/downloaders/common/common_sync.py +0 -210
- novel_downloader/core/downloaders/esjzone/__init__.py +0 -14
- novel_downloader/core/downloaders/esjzone/esjzone_async.py +0 -27
- novel_downloader/core/downloaders/esjzone/esjzone_sync.py +0 -27
- novel_downloader/core/downloaders/qianbi/__init__.py +0 -14
- novel_downloader/core/downloaders/qianbi/qianbi_async.py +0 -27
- novel_downloader/core/downloaders/qianbi/qianbi_sync.py +0 -27
- novel_downloader/core/downloaders/qidian/__init__.py +0 -10
- novel_downloader/core/downloaders/qidian/qidian_sync.py +0 -227
- novel_downloader/core/downloaders/sfacg/__init__.py +0 -14
- novel_downloader/core/downloaders/sfacg/sfacg_async.py +0 -27
- novel_downloader/core/downloaders/sfacg/sfacg_sync.py +0 -27
- novel_downloader/core/downloaders/yamibo/__init__.py +0 -14
- novel_downloader/core/downloaders/yamibo/yamibo_async.py +0 -27
- novel_downloader/core/downloaders/yamibo/yamibo_sync.py +0 -27
- novel_downloader/core/factory/requester.py +0 -144
- novel_downloader/core/factory/saver.py +0 -56
- novel_downloader/core/interfaces/async_downloader.py +0 -36
- novel_downloader/core/interfaces/async_requester.py +0 -84
- novel_downloader/core/interfaces/sync_downloader.py +0 -36
- novel_downloader/core/interfaces/sync_requester.py +0 -82
- novel_downloader/core/parsers/qidian/browser/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/browser/chapter_normal.py +0 -93
- novel_downloader/core/parsers/qidian/browser/chapter_router.py +0 -71
- novel_downloader/core/parsers/qidian/session/__init__.py +0 -12
- novel_downloader/core/parsers/qidian/session/chapter_encrypted.py +0 -443
- novel_downloader/core/parsers/qidian/session/chapter_normal.py +0 -115
- novel_downloader/core/parsers/qidian/session/main_parser.py +0 -128
- novel_downloader/core/parsers/qidian/shared/__init__.py +0 -37
- novel_downloader/core/parsers/qidian/shared/book_info_parser.py +0 -150
- novel_downloader/core/requesters/base/async_session.py +0 -410
- novel_downloader/core/requesters/base/browser.py +0 -337
- novel_downloader/core/requesters/base/session.py +0 -378
- novel_downloader/core/requesters/biquge/__init__.py +0 -14
- novel_downloader/core/requesters/common/__init__.py +0 -17
- novel_downloader/core/requesters/common/session.py +0 -113
- novel_downloader/core/requesters/esjzone/__init__.py +0 -13
- novel_downloader/core/requesters/esjzone/session.py +0 -235
- novel_downloader/core/requesters/qianbi/__init__.py +0 -13
- novel_downloader/core/requesters/qidian/__init__.py +0 -21
- novel_downloader/core/requesters/qidian/broswer.py +0 -307
- novel_downloader/core/requesters/qidian/session.py +0 -290
- novel_downloader/core/requesters/sfacg/__init__.py +0 -13
- novel_downloader/core/requesters/sfacg/session.py +0 -242
- novel_downloader/core/requesters/yamibo/__init__.py +0 -13
- novel_downloader/core/requesters/yamibo/session.py +0 -237
- novel_downloader/core/savers/__init__.py +0 -34
- novel_downloader/core/savers/biquge.py +0 -25
- novel_downloader/core/savers/common/__init__.py +0 -12
- novel_downloader/core/savers/epub_utils/__init__.py +0 -26
- novel_downloader/core/savers/esjzone.py +0 -25
- novel_downloader/core/savers/qianbi.py +0 -25
- novel_downloader/core/savers/sfacg.py +0 -25
- novel_downloader/core/savers/yamibo.py +0 -25
- novel_downloader/resources/config/rules.toml +0 -196
- novel_downloader-1.3.2.dist-info/RECORD +0 -165
- {novel_downloader-1.3.2.dist-info → novel_downloader-1.4.0.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.2.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.
|
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.
|
15
|
-
from novel_downloader.
|
10
|
+
from novel_downloader.core.fetchers.base import BaseSession
|
11
|
+
from novel_downloader.models import FetcherConfig, SiteProfile
|
16
12
|
|
17
13
|
|
18
|
-
class
|
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.
|
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.
|
12
|
-
from novel_downloader.
|
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
|
16
|
+
class EsjzoneSession(BaseSession):
|
19
17
|
"""
|
20
|
-
A
|
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:
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
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
|
-
|
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:
|
53
|
-
|
54
|
-
|
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
|
-
|
57
|
-
|
58
|
-
|
59
|
-
self.
|
60
|
-
|
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.
|
67
|
+
self.backoff_factor,
|
66
68
|
mul_spread=1.1,
|
67
|
-
max_sleep=self.
|
69
|
+
max_sleep=self.backoff_factor + 2,
|
68
70
|
)
|
69
71
|
|
70
|
-
self.
|
71
|
-
return
|
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)
|