novel-downloader 1.3.3__py3-none-any.whl → 1.4.1__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 -39
- 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 +22 -22
- novel_downloader/core/{savers/common/main_saver.py → exporters/common/main_exporter.py} +35 -40
- novel_downloader/core/{savers → exporters}/common/txt.py +20 -23
- novel_downloader/core/{savers → exporters}/epub_utils/__init__.py +8 -3
- novel_downloader/core/{savers → exporters}/epub_utils/css_builder.py +2 -2
- novel_downloader/core/{savers → exporters}/epub_utils/image_loader.py +46 -4
- novel_downloader/core/{savers → exporters}/epub_utils/initializer.py +6 -4
- novel_downloader/core/{savers → exporters}/epub_utils/text_to_html.py +3 -3
- novel_downloader/core/{savers → exporters}/epub_utils/volume_intro.py +2 -2
- 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 +193 -0
- novel_downloader/core/fetchers/linovelib/session.py +193 -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 +20 -14
- 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 +11 -5
- novel_downloader/utils/cookies.py +66 -0
- novel_downloader/utils/crypto_utils.py +1 -74
- 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 +2 -1
- 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 +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/METADATA +69 -35
- novel_downloader-1.4.1.dist-info/RECORD +170 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/WHEEL +1 -1
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.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 -210
- novel_downloader/core/downloaders/common/common_sync.py +0 -202
- 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 -219
- 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/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.3.dist-info/RECORD +0 -166
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/licenses/LICENSE +0 -0
- {novel_downloader-1.3.3.dist-info → novel_downloader-1.4.1.dist-info}/top_level.txt +0 -0
@@ -1,26 +1,22 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.
|
4
|
-
|
3
|
+
novel_downloader.core.fetchers.sfacg.session
|
4
|
+
--------------------------------------------
|
5
5
|
|
6
6
|
"""
|
7
7
|
|
8
|
-
import asyncio
|
9
|
-
from http.cookies import SimpleCookie
|
10
8
|
from typing import Any
|
11
9
|
|
12
|
-
from novel_downloader.
|
13
|
-
from novel_downloader.
|
14
|
-
from novel_downloader.utils.i18n import t
|
15
|
-
from novel_downloader.utils.state import state_mgr
|
10
|
+
from novel_downloader.core.fetchers.base import BaseSession
|
11
|
+
from novel_downloader.models import FetcherConfig, LoginField
|
16
12
|
|
17
13
|
|
18
|
-
class
|
14
|
+
class SfacgSession(BaseSession):
|
19
15
|
"""
|
20
|
-
A
|
21
|
-
Sfacg (m.sfacg.com) novel website.
|
16
|
+
A session class for interacting with the Sfacg (m.sfacg.com) novel website.
|
22
17
|
"""
|
23
18
|
|
19
|
+
LOGIN_URL = "https://m.sfacg.com/login"
|
24
20
|
BOOKCASE_URL = "https://m.sfacg.com/sheets/"
|
25
21
|
BOOK_INFO_URL = "https://m.sfacg.com/b/{book_id}/"
|
26
22
|
BOOK_CATALOG_URL = "https://m.sfacg.com/i/{book_id}/"
|
@@ -28,49 +24,33 @@ class SfacgAsyncSession(BaseAsyncSession):
|
|
28
24
|
|
29
25
|
def __init__(
|
30
26
|
self,
|
31
|
-
config:
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
27
|
+
config: FetcherConfig,
|
28
|
+
cookies: dict[str, str] | None = None,
|
29
|
+
**kwargs: Any,
|
30
|
+
) -> None:
|
31
|
+
super().__init__("sfacg", config, cookies, **kwargs)
|
36
32
|
|
37
33
|
async def login(
|
38
34
|
self,
|
39
35
|
username: str = "",
|
40
36
|
password: str = "",
|
41
|
-
|
37
|
+
cookies: dict[str, str] | None = None,
|
38
|
+
attempt: int = 1,
|
42
39
|
**kwargs: Any,
|
43
40
|
) -> bool:
|
44
41
|
"""
|
45
42
|
Restore cookies persisted by the session-based workflow.
|
46
43
|
"""
|
47
|
-
cookies:
|
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
|
-
|
44
|
+
if cookies:
|
71
45
|
self.update_cookies(cookies)
|
72
|
-
|
73
|
-
|
46
|
+
|
47
|
+
if await self._check_login_status():
|
48
|
+
self._is_logged_in = True
|
49
|
+
self.logger.debug("[auth] Logged in via cookies.")
|
50
|
+
return True
|
51
|
+
|
52
|
+
self._is_logged_in = False
|
53
|
+
return False
|
74
54
|
|
75
55
|
async def get_book_info(
|
76
56
|
self,
|
@@ -80,18 +60,15 @@ class SfacgAsyncSession(BaseAsyncSession):
|
|
80
60
|
"""
|
81
61
|
Fetch the raw HTML of the book info page asynchronously.
|
82
62
|
|
83
|
-
Order: [info, catalog]
|
84
|
-
|
85
63
|
:param book_id: The book identifier.
|
86
64
|
:return: The page content as a string.
|
87
65
|
"""
|
88
66
|
info_url = self.book_info_url(book_id=book_id)
|
89
67
|
catalog_url = self.book_catalog_url(book_id=book_id)
|
90
68
|
|
91
|
-
info_html
|
92
|
-
|
93
|
-
|
94
|
-
)
|
69
|
+
info_html = await self.fetch(info_url, **kwargs)
|
70
|
+
catalog_html = await self.fetch(catalog_url, **kwargs)
|
71
|
+
|
95
72
|
return [info_html, catalog_html]
|
96
73
|
|
97
74
|
async def get_book_chapter(
|
@@ -112,7 +89,6 @@ class SfacgAsyncSession(BaseAsyncSession):
|
|
112
89
|
|
113
90
|
async def get_bookcase(
|
114
91
|
self,
|
115
|
-
page: int = 1,
|
116
92
|
**kwargs: Any,
|
117
93
|
) -> list[str]:
|
118
94
|
"""
|
@@ -123,6 +99,19 @@ class SfacgAsyncSession(BaseAsyncSession):
|
|
123
99
|
url = self.bookcase_url()
|
124
100
|
return [await self.fetch(url, **kwargs)]
|
125
101
|
|
102
|
+
@property
|
103
|
+
def login_fields(self) -> list[LoginField]:
|
104
|
+
return [
|
105
|
+
LoginField(
|
106
|
+
name="cookies",
|
107
|
+
label="Cookie",
|
108
|
+
type="cookie",
|
109
|
+
required=True,
|
110
|
+
placeholder="请输入你的登录 Cookie",
|
111
|
+
description="可以通过浏览器开发者工具复制已登录状态下的 Cookie",
|
112
|
+
),
|
113
|
+
]
|
114
|
+
|
126
115
|
@classmethod
|
127
116
|
def bookcase_url(cls) -> str:
|
128
117
|
"""
|
@@ -163,6 +152,10 @@ class SfacgAsyncSession(BaseAsyncSession):
|
|
163
152
|
"""
|
164
153
|
return cls.CHAPTER_URL.format(chapter_id=chapter_id)
|
165
154
|
|
155
|
+
@property
|
156
|
+
def hostname(self) -> str:
|
157
|
+
return "m.sfacg.com"
|
158
|
+
|
166
159
|
async def _check_login_status(self) -> bool:
|
167
160
|
"""
|
168
161
|
Check whether the user is currently logged in by
|
@@ -179,26 +172,3 @@ class SfacgAsyncSession(BaseAsyncSession):
|
|
179
172
|
if not resp_text:
|
180
173
|
return False
|
181
174
|
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,14 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.fetchers.yamibo
|
4
|
+
-------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from .browser import YamiboBrowser
|
9
|
+
from .session import YamiboSession
|
10
|
+
|
11
|
+
__all__ = [
|
12
|
+
"YamiboBrowser",
|
13
|
+
"YamiboSession",
|
14
|
+
]
|
@@ -0,0 +1,229 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.fetchers.yamibo.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 YamiboBrowser(BaseBrowser):
|
15
|
+
"""
|
16
|
+
A browser class for interacting with the Yamibo (www.yamibo.com) novel website.
|
17
|
+
"""
|
18
|
+
|
19
|
+
BASE_URL = "https://www.yamibo.com"
|
20
|
+
BOOKCASE_URL = "https://www.yamibo.com/my/fav"
|
21
|
+
BOOK_INFO_URL = "https://www.yamibo.com/novel/{book_id}"
|
22
|
+
CHAPTER_URL = "https://www.yamibo.com/novel/view-chapter?id={chapter_id}"
|
23
|
+
|
24
|
+
LOGIN_URL = "https://www.yamibo.com/user/login"
|
25
|
+
|
26
|
+
def __init__(
|
27
|
+
self,
|
28
|
+
config: FetcherConfig,
|
29
|
+
reuse_page: bool = False,
|
30
|
+
**kwargs: Any,
|
31
|
+
) -> None:
|
32
|
+
super().__init__("yamibo", 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
|
+
for i in range(1, attempt + 1):
|
51
|
+
try:
|
52
|
+
login_page = await self.context.new_page()
|
53
|
+
await login_page.goto(self.LOGIN_URL, wait_until="networkidle")
|
54
|
+
|
55
|
+
await login_page.fill("#loginform-username", username)
|
56
|
+
await login_page.fill("#loginform-password", password)
|
57
|
+
|
58
|
+
before_url = login_page.url
|
59
|
+
await login_page.click('button[name="login-button"]')
|
60
|
+
|
61
|
+
try:
|
62
|
+
await login_page.wait_for_url(
|
63
|
+
lambda url, before_url=before_url: url_changed(url, before_url),
|
64
|
+
timeout=15000,
|
65
|
+
)
|
66
|
+
except Exception as e:
|
67
|
+
self.logger.debug(
|
68
|
+
f"[auth] No URL change after login attempt {i}: {e}"
|
69
|
+
)
|
70
|
+
|
71
|
+
await login_page.close()
|
72
|
+
|
73
|
+
self._is_logged_in = await self._check_login_status()
|
74
|
+
if self._is_logged_in:
|
75
|
+
self.logger.info(f"[auth] Login successful on attempt {i}.")
|
76
|
+
return True
|
77
|
+
else:
|
78
|
+
self.logger.warning(
|
79
|
+
f"[auth] Login check failed after attempt {i}. Retrying..."
|
80
|
+
)
|
81
|
+
|
82
|
+
except Exception as e:
|
83
|
+
self.logger.error(
|
84
|
+
f"[auth] Unexpected error during login attempt {i}: {e}"
|
85
|
+
)
|
86
|
+
|
87
|
+
self.logger.error(f"[auth] Login failed after {attempt} attempt(s).")
|
88
|
+
return False
|
89
|
+
|
90
|
+
async def get_book_info(
|
91
|
+
self,
|
92
|
+
book_id: str,
|
93
|
+
**kwargs: Any,
|
94
|
+
) -> list[str]:
|
95
|
+
"""
|
96
|
+
Fetch the raw HTML of the book info page asynchronously.
|
97
|
+
|
98
|
+
:param book_id: The book identifier.
|
99
|
+
:return: The page content as a string.
|
100
|
+
"""
|
101
|
+
url = self.book_info_url(book_id=book_id)
|
102
|
+
return [await self.fetch(url, **kwargs)]
|
103
|
+
|
104
|
+
async def get_book_chapter(
|
105
|
+
self,
|
106
|
+
book_id: str,
|
107
|
+
chapter_id: str,
|
108
|
+
**kwargs: Any,
|
109
|
+
) -> list[str]:
|
110
|
+
"""
|
111
|
+
Fetch the raw HTML of a single chapter asynchronously.
|
112
|
+
|
113
|
+
:param book_id: The book identifier.
|
114
|
+
:param chapter_id: The chapter identifier.
|
115
|
+
:return: The chapter content as a string.
|
116
|
+
"""
|
117
|
+
url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
|
118
|
+
return [await self.fetch(url, **kwargs)]
|
119
|
+
|
120
|
+
async def get_bookcase(
|
121
|
+
self,
|
122
|
+
**kwargs: Any,
|
123
|
+
) -> list[str]:
|
124
|
+
"""
|
125
|
+
Retrieve the user's *bookcase* page.
|
126
|
+
|
127
|
+
:return: The HTML markup of the bookcase page.
|
128
|
+
"""
|
129
|
+
url = self.bookcase_url()
|
130
|
+
return [await self.fetch(url, **kwargs)]
|
131
|
+
|
132
|
+
async def set_interactive_mode(self, enable: bool) -> bool:
|
133
|
+
"""
|
134
|
+
Enable or disable interactive mode for manual login.
|
135
|
+
|
136
|
+
:param enable: True to enable, False to disable interactive mode.
|
137
|
+
:return: True if operation or login check succeeded, False otherwise.
|
138
|
+
"""
|
139
|
+
if enable:
|
140
|
+
if self.headless:
|
141
|
+
await self._restart_browser(headless=False)
|
142
|
+
if self._manual_page is None:
|
143
|
+
self._manual_page = await self.context.new_page()
|
144
|
+
await self._manual_page.goto(self.LOGIN_URL)
|
145
|
+
return True
|
146
|
+
|
147
|
+
# restore
|
148
|
+
if self._manual_page:
|
149
|
+
await self._manual_page.close()
|
150
|
+
self._manual_page = None
|
151
|
+
if self.headless:
|
152
|
+
await self._restart_browser(headless=True)
|
153
|
+
self._is_logged_in = await self._check_login_status()
|
154
|
+
return self.is_logged_in
|
155
|
+
|
156
|
+
@property
|
157
|
+
def login_fields(self) -> list[LoginField]:
|
158
|
+
return [
|
159
|
+
LoginField(
|
160
|
+
name="username",
|
161
|
+
label="用户名",
|
162
|
+
type="text",
|
163
|
+
required=True,
|
164
|
+
placeholder="请输入你的用户名",
|
165
|
+
description="用于登录 www.yamibo.com 的用户名",
|
166
|
+
),
|
167
|
+
LoginField(
|
168
|
+
name="password",
|
169
|
+
label="密码",
|
170
|
+
type="password",
|
171
|
+
required=True,
|
172
|
+
placeholder="请输入你的密码",
|
173
|
+
description="用于登录 www.yamibo.com 的密码",
|
174
|
+
),
|
175
|
+
]
|
176
|
+
|
177
|
+
@classmethod
|
178
|
+
def bookcase_url(cls) -> str:
|
179
|
+
"""
|
180
|
+
Construct the URL for the user's bookcase page.
|
181
|
+
|
182
|
+
:return: Fully qualified URL of the bookcase.
|
183
|
+
"""
|
184
|
+
return cls.BOOKCASE_URL
|
185
|
+
|
186
|
+
@classmethod
|
187
|
+
def book_info_url(cls, book_id: str) -> str:
|
188
|
+
"""
|
189
|
+
Construct the URL for fetching a book's info page.
|
190
|
+
|
191
|
+
:param book_id: The identifier of the book.
|
192
|
+
:return: Fully qualified URL for the book info page.
|
193
|
+
"""
|
194
|
+
return cls.BOOK_INFO_URL.format(book_id=book_id)
|
195
|
+
|
196
|
+
@classmethod
|
197
|
+
def chapter_url(cls, book_id: str, chapter_id: str) -> str:
|
198
|
+
"""
|
199
|
+
Construct the URL for fetching a specific chapter.
|
200
|
+
|
201
|
+
:param book_id: The identifier of the book.
|
202
|
+
:param chapter_id: The identifier of the chapter.
|
203
|
+
:return: Fully qualified chapter URL.
|
204
|
+
"""
|
205
|
+
return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
|
206
|
+
|
207
|
+
@property
|
208
|
+
def hostname(self) -> str:
|
209
|
+
return "www.yamibo.com"
|
210
|
+
|
211
|
+
async def _check_login_status(self) -> bool:
|
212
|
+
"""
|
213
|
+
Check whether the user is currently logged in by
|
214
|
+
inspecting the bookcase page content.
|
215
|
+
|
216
|
+
:return: True if the user is logged in, False otherwise.
|
217
|
+
"""
|
218
|
+
keywords = [
|
219
|
+
"登录 - 百合会",
|
220
|
+
"用户名/邮箱",
|
221
|
+
]
|
222
|
+
resp_text = await self.get_bookcase()
|
223
|
+
if not resp_text:
|
224
|
+
return False
|
225
|
+
return not any(kw in resp_text[0] for kw in keywords)
|
226
|
+
|
227
|
+
|
228
|
+
def url_changed(url: str, before: str) -> bool:
|
229
|
+
return url != before
|
@@ -1,25 +1,22 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.
|
4
|
-
|
3
|
+
novel_downloader.core.fetchers.yamibo.session
|
4
|
+
---------------------------------------------
|
5
5
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
from typing import Any
|
9
9
|
|
10
|
-
from lxml import
|
10
|
+
from lxml import html
|
11
11
|
|
12
|
-
from novel_downloader.
|
13
|
-
from novel_downloader.
|
14
|
-
from novel_downloader.utils.i18n import t
|
15
|
-
from novel_downloader.utils.state import state_mgr
|
12
|
+
from novel_downloader.core.fetchers.base import BaseSession
|
13
|
+
from novel_downloader.models import FetcherConfig, LoginField
|
16
14
|
from novel_downloader.utils.time_utils import async_sleep_with_random_delay
|
17
15
|
|
18
16
|
|
19
|
-
class
|
17
|
+
class YamiboSession(BaseSession):
|
20
18
|
"""
|
21
|
-
A
|
22
|
-
yamibo (www.yamibo.com) novel website.
|
19
|
+
A session class for interacting with the Yamibo (www.yamibo.com) novel website.
|
23
20
|
"""
|
24
21
|
|
25
22
|
BASE_URL = "https://www.yamibo.com"
|
@@ -31,45 +28,50 @@ class YamiboAsyncSession(BaseAsyncSession):
|
|
31
28
|
|
32
29
|
def __init__(
|
33
30
|
self,
|
34
|
-
config:
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
self._retry_times = config.retry_times
|
40
|
-
self._username = config.username
|
41
|
-
self._password = config.password
|
31
|
+
config: FetcherConfig,
|
32
|
+
cookies: dict[str, str] | None = None,
|
33
|
+
**kwargs: Any,
|
34
|
+
) -> None:
|
35
|
+
super().__init__("yamibo", config, cookies, **kwargs)
|
42
36
|
|
43
37
|
async def login(
|
44
38
|
self,
|
45
39
|
username: str = "",
|
46
40
|
password: str = "",
|
47
|
-
|
41
|
+
cookies: dict[str, str] | None = None,
|
42
|
+
attempt: int = 1,
|
48
43
|
**kwargs: Any,
|
49
44
|
) -> bool:
|
50
45
|
"""
|
51
46
|
Restore cookies persisted by the session-based workflow.
|
52
47
|
"""
|
53
|
-
cookies:
|
54
|
-
|
55
|
-
|
48
|
+
if cookies:
|
49
|
+
self.update_cookies(cookies)
|
50
|
+
|
51
|
+
if await self._check_login_status():
|
52
|
+
self._is_logged_in = True
|
53
|
+
self.logger.debug("[auth] Logged in via cookies.")
|
54
|
+
return True
|
55
|
+
|
56
|
+
if not (username and password):
|
57
|
+
self.logger.warning("[auth] No credentials provided.")
|
58
|
+
return False
|
56
59
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
self.
|
61
|
-
|
60
|
+
for _ in range(attempt):
|
61
|
+
if (
|
62
|
+
await self._api_login(username, password)
|
63
|
+
and await self._check_login_status()
|
64
|
+
):
|
65
|
+
self._is_logged_in = True
|
62
66
|
return True
|
63
|
-
if username and password and not await self._api_login(username, password):
|
64
|
-
print(t("session_login_failed", site="esjzone"))
|
65
67
|
await async_sleep_with_random_delay(
|
66
|
-
self.
|
68
|
+
self.backoff_factor,
|
67
69
|
mul_spread=1.1,
|
68
|
-
max_sleep=self.
|
70
|
+
max_sleep=self.backoff_factor + 2,
|
69
71
|
)
|
70
72
|
|
71
|
-
self.
|
72
|
-
return
|
73
|
+
self._is_logged_in = False
|
74
|
+
return False
|
73
75
|
|
74
76
|
async def get_book_info(
|
75
77
|
self,
|
@@ -79,8 +81,6 @@ class YamiboAsyncSession(BaseAsyncSession):
|
|
79
81
|
"""
|
80
82
|
Fetch the raw HTML of the book info page asynchronously.
|
81
83
|
|
82
|
-
Order: [info, catalog]
|
83
|
-
|
84
84
|
:param book_id: The book identifier.
|
85
85
|
:return: The page content as a string.
|
86
86
|
"""
|
@@ -105,7 +105,6 @@ class YamiboAsyncSession(BaseAsyncSession):
|
|
105
105
|
|
106
106
|
async def get_bookcase(
|
107
107
|
self,
|
108
|
-
page: int = 1,
|
109
108
|
**kwargs: Any,
|
110
109
|
) -> list[str]:
|
111
110
|
"""
|
@@ -116,6 +115,27 @@ class YamiboAsyncSession(BaseAsyncSession):
|
|
116
115
|
url = self.bookcase_url()
|
117
116
|
return [await self.fetch(url, **kwargs)]
|
118
117
|
|
118
|
+
@property
|
119
|
+
def login_fields(self) -> list[LoginField]:
|
120
|
+
return [
|
121
|
+
LoginField(
|
122
|
+
name="username",
|
123
|
+
label="用户名",
|
124
|
+
type="text",
|
125
|
+
required=True,
|
126
|
+
placeholder="请输入你的用户名",
|
127
|
+
description="用于登录 www.yamibo.com 的用户名",
|
128
|
+
),
|
129
|
+
LoginField(
|
130
|
+
name="password",
|
131
|
+
label="密码",
|
132
|
+
type="password",
|
133
|
+
required=True,
|
134
|
+
placeholder="请输入你的密码",
|
135
|
+
description="用于登录 www.yamibo.com 的密码",
|
136
|
+
),
|
137
|
+
]
|
138
|
+
|
119
139
|
@classmethod
|
120
140
|
def bookcase_url(cls) -> str:
|
121
141
|
"""
|
@@ -144,7 +164,11 @@ class YamiboAsyncSession(BaseAsyncSession):
|
|
144
164
|
:param chapter_id: The identifier of the chapter.
|
145
165
|
:return: Fully qualified chapter URL.
|
146
166
|
"""
|
147
|
-
return cls.CHAPTER_URL.format(chapter_id=chapter_id)
|
167
|
+
return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
|
168
|
+
|
169
|
+
@property
|
170
|
+
def hostname(self) -> str:
|
171
|
+
return "www.yamibo.com"
|
148
172
|
|
149
173
|
async def _api_login(self, username: str, password: str) -> bool:
|
150
174
|
"""
|
@@ -158,7 +182,7 @@ class YamiboAsyncSession(BaseAsyncSession):
|
|
158
182
|
resp_1 = await self.get(self.LOGIN_URL)
|
159
183
|
resp_1.raise_for_status()
|
160
184
|
text_1 = await resp_1.text()
|
161
|
-
tree =
|
185
|
+
tree = html.fromstring(text_1)
|
162
186
|
csrf_value = tree.xpath('//input[@name="_csrf-frontend"]/@value')
|
163
187
|
csrf_value = csrf_value[0] if csrf_value else ""
|
164
188
|
if not csrf_value:
|
@@ -203,9 +227,3 @@ class YamiboAsyncSession(BaseAsyncSession):
|
|
203
227
|
if not resp_text:
|
204
228
|
return False
|
205
229
|
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)
|
@@ -9,23 +9,19 @@ injection.
|
|
9
9
|
|
10
10
|
Included protocols:
|
11
11
|
- DownloaderProtocol
|
12
|
+
- FetcherProtocol
|
12
13
|
- ParserProtocol
|
13
|
-
-
|
14
|
-
- SaverProtocol
|
14
|
+
- ExporterProtocol
|
15
15
|
"""
|
16
16
|
|
17
|
-
from .
|
18
|
-
from .
|
17
|
+
from .downloader import DownloaderProtocol
|
18
|
+
from .exporter import ExporterProtocol
|
19
|
+
from .fetcher import FetcherProtocol
|
19
20
|
from .parser import ParserProtocol
|
20
|
-
from .saver import SaverProtocol
|
21
|
-
from .sync_downloader import SyncDownloaderProtocol
|
22
|
-
from .sync_requester import SyncRequesterProtocol
|
23
21
|
|
24
22
|
__all__ = [
|
25
|
-
"
|
26
|
-
"
|
23
|
+
"DownloaderProtocol",
|
24
|
+
"ExporterProtocol",
|
25
|
+
"FetcherProtocol",
|
27
26
|
"ParserProtocol",
|
28
|
-
"SaverProtocol",
|
29
|
-
"SyncDownloaderProtocol",
|
30
|
-
"SyncRequesterProtocol",
|
31
27
|
]
|