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
@@ -0,0 +1,419 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.fetchers.base.session
|
4
|
+
-------------------------------------------
|
5
|
+
|
6
|
+
This module defines the BaseSession class, which provides asynchronous
|
7
|
+
HTTP request capabilities using aiohttp. It maintains a persistent
|
8
|
+
client session and supports retries, headers, timeout configurations,
|
9
|
+
cookie handling, and defines abstract methods for subclasses.
|
10
|
+
"""
|
11
|
+
|
12
|
+
|
13
|
+
import abc
|
14
|
+
import json
|
15
|
+
import logging
|
16
|
+
import types
|
17
|
+
from typing import Any, Self
|
18
|
+
|
19
|
+
import aiohttp
|
20
|
+
from aiohttp import ClientResponse, ClientSession, ClientTimeout, TCPConnector
|
21
|
+
|
22
|
+
from novel_downloader.core.interfaces import FetcherProtocol
|
23
|
+
from novel_downloader.models import FetcherConfig, LoginField
|
24
|
+
from novel_downloader.utils.constants import (
|
25
|
+
DATA_DIR,
|
26
|
+
DEFAULT_USER_HEADERS,
|
27
|
+
)
|
28
|
+
from novel_downloader.utils.cookies import parse_cookie_expires
|
29
|
+
from novel_downloader.utils.time_utils import async_sleep_with_random_delay
|
30
|
+
|
31
|
+
from .rate_limiter import TokenBucketRateLimiter
|
32
|
+
|
33
|
+
|
34
|
+
class BaseSession(FetcherProtocol, abc.ABC):
|
35
|
+
"""
|
36
|
+
BaseSession wraps basic HTTP operations using aiohttp.ClientSession.
|
37
|
+
"""
|
38
|
+
|
39
|
+
def __init__(
|
40
|
+
self,
|
41
|
+
site: str,
|
42
|
+
config: FetcherConfig,
|
43
|
+
cookies: dict[str, str] | None = None,
|
44
|
+
**kwargs: Any,
|
45
|
+
) -> None:
|
46
|
+
"""
|
47
|
+
Initialize the async session with configuration.
|
48
|
+
|
49
|
+
:param config: Configuration object for session behavior
|
50
|
+
:param cookies: Optional initial cookies to set on the session.
|
51
|
+
"""
|
52
|
+
self._site = site
|
53
|
+
self._config = config
|
54
|
+
|
55
|
+
self._state_file = DATA_DIR / site / "session_state.cookies"
|
56
|
+
self._state_file.parent.mkdir(parents=True, exist_ok=True)
|
57
|
+
|
58
|
+
self._is_logged_in = False
|
59
|
+
self._headers = (
|
60
|
+
config.headers.copy() if config.headers else DEFAULT_USER_HEADERS.copy()
|
61
|
+
)
|
62
|
+
if config.user_agent:
|
63
|
+
self._headers["User-Agent"] = config.user_agent
|
64
|
+
self._cookies = cookies or {}
|
65
|
+
self._session: ClientSession | None = None
|
66
|
+
self._rate_limiter: TokenBucketRateLimiter | None = None
|
67
|
+
|
68
|
+
if config.max_rps is not None and config.max_rps > 0:
|
69
|
+
self._rate_limiter = TokenBucketRateLimiter(config.max_rps)
|
70
|
+
|
71
|
+
self.logger = logging.getLogger(f"{self.__class__.__name__}")
|
72
|
+
|
73
|
+
async def login(
|
74
|
+
self,
|
75
|
+
username: str = "",
|
76
|
+
password: str = "",
|
77
|
+
cookies: dict[str, str] | None = None,
|
78
|
+
attempt: int = 1,
|
79
|
+
**kwargs: Any,
|
80
|
+
) -> bool:
|
81
|
+
"""
|
82
|
+
Attempt to log in asynchronously.
|
83
|
+
:returns: True if login succeeded.
|
84
|
+
"""
|
85
|
+
return False
|
86
|
+
|
87
|
+
@abc.abstractmethod
|
88
|
+
async def get_book_info(
|
89
|
+
self,
|
90
|
+
book_id: str,
|
91
|
+
**kwargs: Any,
|
92
|
+
) -> list[str]:
|
93
|
+
"""
|
94
|
+
Fetch the raw HTML (or JSON) of the book info page asynchronously.
|
95
|
+
|
96
|
+
:param book_id: The book identifier.
|
97
|
+
:return: The page content as a string.
|
98
|
+
"""
|
99
|
+
...
|
100
|
+
|
101
|
+
@abc.abstractmethod
|
102
|
+
async def get_book_chapter(
|
103
|
+
self,
|
104
|
+
book_id: str,
|
105
|
+
chapter_id: str,
|
106
|
+
**kwargs: Any,
|
107
|
+
) -> list[str]:
|
108
|
+
"""
|
109
|
+
Fetch the raw HTML (or JSON) of a single chapter asynchronously.
|
110
|
+
|
111
|
+
:param book_id: The book identifier.
|
112
|
+
:param chapter_id: The chapter identifier.
|
113
|
+
:return: The chapter content as a string.
|
114
|
+
"""
|
115
|
+
...
|
116
|
+
|
117
|
+
async def get_bookcase(
|
118
|
+
self,
|
119
|
+
**kwargs: Any,
|
120
|
+
) -> list[str]:
|
121
|
+
"""
|
122
|
+
Optional: Retrieve the HTML content of the authenticated user's bookcase page.
|
123
|
+
Subclasses that support user login/bookcase should override this.
|
124
|
+
|
125
|
+
:return: The HTML of the bookcase page.
|
126
|
+
"""
|
127
|
+
raise NotImplementedError(
|
128
|
+
"Bookcase fetching is not supported by this session type. "
|
129
|
+
"Override get_bookcase() in your subclass to enable it."
|
130
|
+
)
|
131
|
+
|
132
|
+
async def init(
|
133
|
+
self,
|
134
|
+
**kwargs: Any,
|
135
|
+
) -> None:
|
136
|
+
"""
|
137
|
+
Set up the aiohttp.ClientSession with timeout, connector, headers.
|
138
|
+
"""
|
139
|
+
timeout = ClientTimeout(total=self.timeout)
|
140
|
+
connector = TCPConnector(
|
141
|
+
ssl=self._config.verify_ssl,
|
142
|
+
limit_per_host=self.max_connections,
|
143
|
+
)
|
144
|
+
self._session = ClientSession(
|
145
|
+
timeout=timeout,
|
146
|
+
connector=connector,
|
147
|
+
headers=self._headers,
|
148
|
+
cookies=self._cookies,
|
149
|
+
)
|
150
|
+
|
151
|
+
async def close(self) -> None:
|
152
|
+
"""
|
153
|
+
Shutdown and clean up any resources.
|
154
|
+
"""
|
155
|
+
if self._session and not self._session.closed:
|
156
|
+
await self._session.close()
|
157
|
+
self._session = None
|
158
|
+
|
159
|
+
async def fetch(self, url: str, **kwargs: Any) -> str:
|
160
|
+
"""
|
161
|
+
Fetch the content from the given URL asynchronously, with retry support.
|
162
|
+
|
163
|
+
:param url: The target URL to fetch.
|
164
|
+
:param kwargs: Additional keyword arguments to pass to `session.get`.
|
165
|
+
:return: The response body as text.
|
166
|
+
:raises: aiohttp.ClientError on final failure.
|
167
|
+
"""
|
168
|
+
if self._rate_limiter:
|
169
|
+
await self._rate_limiter.wait()
|
170
|
+
|
171
|
+
for attempt in range(self.retry_times + 1):
|
172
|
+
try:
|
173
|
+
async with self.session.get(url, **kwargs) as resp:
|
174
|
+
resp.raise_for_status()
|
175
|
+
text: str = await resp.text()
|
176
|
+
return text
|
177
|
+
except aiohttp.ClientError:
|
178
|
+
if attempt < self.retry_times:
|
179
|
+
await async_sleep_with_random_delay(
|
180
|
+
self.backoff_factor,
|
181
|
+
mul_spread=1.1,
|
182
|
+
max_sleep=self.backoff_factor + 2,
|
183
|
+
)
|
184
|
+
continue
|
185
|
+
raise
|
186
|
+
|
187
|
+
raise RuntimeError("Unreachable code reached in fetch()")
|
188
|
+
|
189
|
+
async def get(
|
190
|
+
self,
|
191
|
+
url: str,
|
192
|
+
params: dict[str, Any] | None = None,
|
193
|
+
**kwargs: Any,
|
194
|
+
) -> ClientResponse:
|
195
|
+
"""
|
196
|
+
Send an HTTP GET request asynchronously.
|
197
|
+
|
198
|
+
:param url: The target URL.
|
199
|
+
:param params: Query parameters to include in the request.
|
200
|
+
:param kwargs: Additional args passed to session.get().
|
201
|
+
:return: aiohttp.ClientResponse object.
|
202
|
+
:raises RuntimeError: If the session is not initialized.
|
203
|
+
"""
|
204
|
+
return await self._request("GET", url, params=params, **kwargs)
|
205
|
+
|
206
|
+
async def post(
|
207
|
+
self,
|
208
|
+
url: str,
|
209
|
+
data: dict[str, Any] | bytes | None = None,
|
210
|
+
json: dict[str, Any] | None = None,
|
211
|
+
**kwargs: Any,
|
212
|
+
) -> ClientResponse:
|
213
|
+
"""
|
214
|
+
Send an HTTP POST request asynchronously.
|
215
|
+
|
216
|
+
:param url: The target URL.
|
217
|
+
:param data: Form data to include in the request body.
|
218
|
+
:param json: JSON body to include in the request.
|
219
|
+
:param kwargs: Additional args passed to session.post().
|
220
|
+
:return: aiohttp.ClientResponse object.
|
221
|
+
:raises RuntimeError: If the session is not initialized.
|
222
|
+
"""
|
223
|
+
return await self._request("POST", url, data=data, json=json, **kwargs)
|
224
|
+
|
225
|
+
async def load_state(self) -> bool:
|
226
|
+
"""
|
227
|
+
Load session cookies from a file to restore previous login state.
|
228
|
+
|
229
|
+
:return: True if the session state was loaded, False otherwise.
|
230
|
+
"""
|
231
|
+
# if not self._state_file.exists() or self._session is None:
|
232
|
+
# return False
|
233
|
+
# try:
|
234
|
+
# self._session.cookie_jar.load(self._state_file)
|
235
|
+
# self._is_logged_in = await self._check_login_status()
|
236
|
+
# return self._is_logged_in
|
237
|
+
# except Exception as e:
|
238
|
+
# self.logger.warning("Failed to load state: %s", e)
|
239
|
+
# return False
|
240
|
+
if not self._state_file.exists() or self._session is None:
|
241
|
+
return False
|
242
|
+
try:
|
243
|
+
storage = json.loads(self._state_file.read_text(encoding="utf-8"))
|
244
|
+
for c in storage.get("cookies", []):
|
245
|
+
self._session.cookie_jar.update_cookies({c["name"]: c["value"]})
|
246
|
+
self._is_logged_in = await self._check_login_status()
|
247
|
+
return self._is_logged_in
|
248
|
+
except Exception as e:
|
249
|
+
self.logger.warning("Failed to load state: %s", e)
|
250
|
+
return False
|
251
|
+
|
252
|
+
async def save_state(self) -> bool:
|
253
|
+
"""
|
254
|
+
Save the current session cookies to a file for future reuse.
|
255
|
+
|
256
|
+
:return: True if the session state was saved, False otherwise.
|
257
|
+
"""
|
258
|
+
# if self._session is None:
|
259
|
+
# return False
|
260
|
+
# try:
|
261
|
+
# self._session.cookie_jar.save(self._state_file)
|
262
|
+
# return True
|
263
|
+
# except Exception as e:
|
264
|
+
# self.logger.warning("Failed to save state: %s", e)
|
265
|
+
# return False
|
266
|
+
if self._session is None:
|
267
|
+
return False
|
268
|
+
try:
|
269
|
+
cookies = []
|
270
|
+
for cookie in self._session.cookie_jar:
|
271
|
+
cookies.append(
|
272
|
+
{
|
273
|
+
"name": cookie.key,
|
274
|
+
"value": cookie.value,
|
275
|
+
"domain": cookie.get("domain", ""),
|
276
|
+
"path": cookie.get("path", "/"),
|
277
|
+
"expires": parse_cookie_expires(cookie.get("expires")),
|
278
|
+
"httpOnly": bool(cookie.get("httponly", False)),
|
279
|
+
"secure": bool(cookie.get("secure", False)),
|
280
|
+
"sameSite": cookie.get("samesite") or "Lax",
|
281
|
+
}
|
282
|
+
)
|
283
|
+
storage_state = {
|
284
|
+
"cookies": cookies,
|
285
|
+
"origins": [],
|
286
|
+
}
|
287
|
+
self._state_file.parent.mkdir(parents=True, exist_ok=True)
|
288
|
+
self._state_file.write_text(
|
289
|
+
json.dumps(storage_state, indent=2, ensure_ascii=False),
|
290
|
+
encoding="utf-8",
|
291
|
+
)
|
292
|
+
return True
|
293
|
+
except Exception as e:
|
294
|
+
self.logger.warning("Failed to save state: %s", e)
|
295
|
+
return False
|
296
|
+
|
297
|
+
async def set_interactive_mode(self, enable: bool) -> bool:
|
298
|
+
"""
|
299
|
+
Enable or disable interactive mode for manual login.
|
300
|
+
|
301
|
+
:param enable: True to enable, False to disable interactive mode.
|
302
|
+
:return: True if operation or login check succeeded, False otherwise.
|
303
|
+
"""
|
304
|
+
return False
|
305
|
+
|
306
|
+
def get_cookie_value(self, key: str) -> str | None:
|
307
|
+
for cookie in self.session.cookie_jar:
|
308
|
+
if cookie.key == key:
|
309
|
+
return str(cookie.value)
|
310
|
+
return None
|
311
|
+
|
312
|
+
def update_cookies(
|
313
|
+
self,
|
314
|
+
cookies: dict[str, str],
|
315
|
+
) -> None:
|
316
|
+
"""
|
317
|
+
Update or add multiple cookies in the session.
|
318
|
+
|
319
|
+
:param cookies: A dictionary of cookie key-value pairs.
|
320
|
+
"""
|
321
|
+
self._cookies.update(cookies)
|
322
|
+
if self._session:
|
323
|
+
self._session.cookie_jar.update_cookies(cookies)
|
324
|
+
|
325
|
+
async def _request(
|
326
|
+
self,
|
327
|
+
method: str,
|
328
|
+
url: str,
|
329
|
+
**kwargs: Any,
|
330
|
+
) -> ClientResponse:
|
331
|
+
if self._rate_limiter:
|
332
|
+
await self._rate_limiter.wait()
|
333
|
+
return await self.session.request(method, url, **kwargs)
|
334
|
+
|
335
|
+
async def _check_login_status(self) -> bool:
|
336
|
+
"""
|
337
|
+
Check whether the user is currently logged in
|
338
|
+
|
339
|
+
:return: True if the user is logged in, False otherwise.
|
340
|
+
"""
|
341
|
+
return False
|
342
|
+
|
343
|
+
@property
|
344
|
+
def hostname(self) -> str:
|
345
|
+
return ""
|
346
|
+
|
347
|
+
@property
|
348
|
+
def site(self) -> str:
|
349
|
+
return self._site
|
350
|
+
|
351
|
+
@property
|
352
|
+
def requester_type(self) -> str:
|
353
|
+
return "session"
|
354
|
+
|
355
|
+
@property
|
356
|
+
def is_logged_in(self) -> bool:
|
357
|
+
"""
|
358
|
+
Indicates whether the requester is currently authenticated.
|
359
|
+
"""
|
360
|
+
return self._is_logged_in
|
361
|
+
|
362
|
+
@property
|
363
|
+
def login_fields(self) -> list[LoginField]:
|
364
|
+
return []
|
365
|
+
|
366
|
+
@property
|
367
|
+
def session(self) -> ClientSession:
|
368
|
+
"""
|
369
|
+
Return the active aiohttp.ClientSession.
|
370
|
+
|
371
|
+
:raises RuntimeError: If the session is uninitialized.
|
372
|
+
"""
|
373
|
+
if self._session is None:
|
374
|
+
raise RuntimeError("Session is not initialized or has been shut down.")
|
375
|
+
return self._session
|
376
|
+
|
377
|
+
@property
|
378
|
+
def backoff_factor(self) -> float:
|
379
|
+
return self._config.backoff_factor
|
380
|
+
|
381
|
+
@property
|
382
|
+
def retry_times(self) -> int:
|
383
|
+
return self._config.retry_times
|
384
|
+
|
385
|
+
@property
|
386
|
+
def request_interval(self) -> float:
|
387
|
+
return self._config.request_interval
|
388
|
+
|
389
|
+
@property
|
390
|
+
def timeout(self) -> float:
|
391
|
+
return self._config.timeout
|
392
|
+
|
393
|
+
@property
|
394
|
+
def max_connections(self) -> int:
|
395
|
+
return self._config.max_connections
|
396
|
+
|
397
|
+
@property
|
398
|
+
def headers(self) -> dict[str, str]:
|
399
|
+
"""
|
400
|
+
Get a copy of the current session headers for temporary use.
|
401
|
+
|
402
|
+
:return: A dict mapping header names to their values.
|
403
|
+
"""
|
404
|
+
if self._session:
|
405
|
+
return dict(self._session.headers)
|
406
|
+
return self._headers.copy()
|
407
|
+
|
408
|
+
async def __aenter__(self) -> Self:
|
409
|
+
if self._session is None or self._session.closed:
|
410
|
+
await self.init()
|
411
|
+
return self
|
412
|
+
|
413
|
+
async def __aexit__(
|
414
|
+
self,
|
415
|
+
exc_type: type[BaseException] | None,
|
416
|
+
exc_val: BaseException | None,
|
417
|
+
tb: types.TracebackType | None,
|
418
|
+
) -> None:
|
419
|
+
await self.close()
|
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.fetchers.biquge
|
4
|
+
-------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from .browser import BiqugeBrowser
|
9
|
+
from .session import BiqugeSession
|
10
|
+
|
11
|
+
__all__ = [
|
12
|
+
"BiqugeBrowser",
|
13
|
+
"BiqugeSession",
|
14
|
+
]
|
@@ -1,24 +1,32 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.
|
4
|
-
|
3
|
+
novel_downloader.core.fetchers.biquge.browser
|
4
|
+
---------------------------------------------
|
5
5
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
from typing import Any
|
9
9
|
|
10
|
-
from novel_downloader.core.
|
10
|
+
from novel_downloader.core.fetchers.base import BaseBrowser
|
11
|
+
from novel_downloader.models import FetcherConfig
|
11
12
|
|
12
13
|
|
13
|
-
class
|
14
|
+
class BiqugeBrowser(BaseBrowser):
|
14
15
|
"""
|
15
|
-
A
|
16
|
-
the Biquge (www.b520.cc) novel website.
|
16
|
+
A browser class for interacting with the Biquge (www.b520.cc) novel website.
|
17
17
|
"""
|
18
18
|
|
19
19
|
BOOK_INFO_URL = "http://www.b520.cc/{book_id}/"
|
20
20
|
CHAPTER_URL = "http://www.b520.cc/{book_id}/{chapter_id}.html"
|
21
21
|
|
22
|
+
def __init__(
|
23
|
+
self,
|
24
|
+
config: FetcherConfig,
|
25
|
+
reuse_page: bool = False,
|
26
|
+
**kwargs: Any,
|
27
|
+
) -> None:
|
28
|
+
super().__init__("biquge", config, reuse_page, **kwargs)
|
29
|
+
|
22
30
|
async def get_book_info(
|
23
31
|
self,
|
24
32
|
book_id: str,
|
@@ -69,3 +77,7 @@ class BiqugeAsyncSession(BaseAsyncSession):
|
|
69
77
|
:return: Fully qualified chapter URL.
|
70
78
|
"""
|
71
79
|
return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
|
80
|
+
|
81
|
+
@property
|
82
|
+
def hostname(self) -> str:
|
83
|
+
return "www.b520.cc"
|
@@ -1,13 +1,14 @@
|
|
1
1
|
#!/usr/bin/env python3
|
2
2
|
"""
|
3
|
-
novel_downloader.core.
|
4
|
-
|
3
|
+
novel_downloader.core.fetchers.biquge.session
|
4
|
+
---------------------------------------------
|
5
5
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
from typing import Any
|
9
9
|
|
10
|
-
from novel_downloader.core.
|
10
|
+
from novel_downloader.core.fetchers.base import BaseSession
|
11
|
+
from novel_downloader.models import FetcherConfig
|
11
12
|
|
12
13
|
|
13
14
|
class BiqugeSession(BaseSession):
|
@@ -18,55 +19,43 @@ class BiqugeSession(BaseSession):
|
|
18
19
|
BOOK_INFO_URL = "http://www.b520.cc/{book_id}/"
|
19
20
|
CHAPTER_URL = "http://www.b520.cc/{book_id}/{chapter_id}.html"
|
20
21
|
|
21
|
-
def
|
22
|
+
def __init__(
|
23
|
+
self,
|
24
|
+
config: FetcherConfig,
|
25
|
+
cookies: dict[str, str] | None = None,
|
26
|
+
**kwargs: Any,
|
27
|
+
) -> None:
|
28
|
+
super().__init__("biquge", config, cookies, **kwargs)
|
29
|
+
|
30
|
+
async def get_book_info(
|
22
31
|
self,
|
23
32
|
book_id: str,
|
24
33
|
**kwargs: Any,
|
25
34
|
) -> list[str]:
|
26
35
|
"""
|
27
|
-
Fetch the raw HTML of the book info page.
|
36
|
+
Fetch the raw HTML of the book info page asynchronously.
|
28
37
|
|
29
38
|
:param book_id: The book identifier.
|
30
39
|
:return: The page content as a string.
|
31
40
|
"""
|
32
41
|
url = self.book_info_url(book_id=book_id)
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
return [resp.text]
|
37
|
-
except Exception as exc:
|
38
|
-
self.logger.warning(
|
39
|
-
"[session] get_book_info(%s) failed: %s",
|
40
|
-
book_id,
|
41
|
-
exc,
|
42
|
-
)
|
43
|
-
return []
|
44
|
-
|
45
|
-
def get_book_chapter(
|
42
|
+
return [await self.fetch(url, **kwargs)]
|
43
|
+
|
44
|
+
async def get_book_chapter(
|
46
45
|
self,
|
47
46
|
book_id: str,
|
48
47
|
chapter_id: str,
|
49
48
|
**kwargs: Any,
|
50
49
|
) -> list[str]:
|
51
50
|
"""
|
52
|
-
Fetch the HTML of a single chapter.
|
51
|
+
Fetch the raw HTML of a single chapter asynchronously.
|
53
52
|
|
54
53
|
:param book_id: The book identifier.
|
55
54
|
:param chapter_id: The chapter identifier.
|
56
55
|
:return: The chapter content as a string.
|
57
56
|
"""
|
58
57
|
url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
|
59
|
-
|
60
|
-
resp = self.get(url, **kwargs)
|
61
|
-
resp.raise_for_status()
|
62
|
-
return [resp.text]
|
63
|
-
except Exception as exc:
|
64
|
-
self.logger.warning(
|
65
|
-
"[session] get_book_chapter(%s) failed: %s",
|
66
|
-
book_id,
|
67
|
-
exc,
|
68
|
-
)
|
69
|
-
return []
|
58
|
+
return [await self.fetch(url, **kwargs)]
|
70
59
|
|
71
60
|
@classmethod
|
72
61
|
def book_info_url(cls, book_id: str) -> str:
|
@@ -88,3 +77,7 @@ class BiqugeSession(BaseSession):
|
|
88
77
|
:return: Fully qualified chapter URL.
|
89
78
|
"""
|
90
79
|
return cls.CHAPTER_URL.format(book_id=book_id, chapter_id=chapter_id)
|
80
|
+
|
81
|
+
@property
|
82
|
+
def hostname(self) -> str:
|
83
|
+
return "www.b520.cc"
|
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.fetchers.common
|
4
|
+
-------------------------------------
|
5
|
+
|
6
|
+
"""
|
7
|
+
|
8
|
+
from .browser import CommonBrowser
|
9
|
+
from .session import CommonSession
|
10
|
+
|
11
|
+
__all__ = [
|
12
|
+
"CommonBrowser",
|
13
|
+
"CommonSession",
|
14
|
+
]
|
@@ -0,0 +1,79 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
novel_downloader.core.fetchers.common.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, SiteProfile
|
12
|
+
|
13
|
+
|
14
|
+
class CommonBrowser(BaseBrowser):
|
15
|
+
"""
|
16
|
+
A common async browser for handling site-specific HTTP requests.
|
17
|
+
"""
|
18
|
+
|
19
|
+
def __init__(
|
20
|
+
self,
|
21
|
+
site: str,
|
22
|
+
profile: SiteProfile,
|
23
|
+
config: FetcherConfig,
|
24
|
+
reuse_page: bool = False,
|
25
|
+
**kwargs: Any,
|
26
|
+
) -> None:
|
27
|
+
super().__init__(site, config, reuse_page, **kwargs)
|
28
|
+
self._profile = profile
|
29
|
+
|
30
|
+
async def get_book_info(
|
31
|
+
self,
|
32
|
+
book_id: str,
|
33
|
+
**kwargs: Any,
|
34
|
+
) -> list[str]:
|
35
|
+
"""
|
36
|
+
Fetch the raw HTML of the book info page asynchronously.
|
37
|
+
|
38
|
+
:param book_id: The book identifier.
|
39
|
+
:return: The page content as a string.
|
40
|
+
"""
|
41
|
+
url = self.book_info_url(book_id=book_id)
|
42
|
+
return [await self.fetch(url, **kwargs)]
|
43
|
+
|
44
|
+
async def get_book_chapter(
|
45
|
+
self,
|
46
|
+
book_id: str,
|
47
|
+
chapter_id: str,
|
48
|
+
**kwargs: Any,
|
49
|
+
) -> list[str]:
|
50
|
+
"""
|
51
|
+
Fetch the raw HTML of a single chapter asynchronously.
|
52
|
+
|
53
|
+
:param book_id: The book identifier.
|
54
|
+
:param chapter_id: The chapter identifier.
|
55
|
+
:return: The chapter content as a string.
|
56
|
+
"""
|
57
|
+
url = self.chapter_url(book_id=book_id, chapter_id=chapter_id)
|
58
|
+
return [await self.fetch(url, **kwargs)]
|
59
|
+
|
60
|
+
def book_info_url(self, book_id: str) -> str:
|
61
|
+
"""
|
62
|
+
Construct the URL for fetching a book's info page.
|
63
|
+
|
64
|
+
:param book_id: The identifier of the book.
|
65
|
+
:return: Fully qualified URL for the book info page.
|
66
|
+
"""
|
67
|
+
return self._profile["book_info_url"].format(book_id=book_id)
|
68
|
+
|
69
|
+
def chapter_url(self, book_id: str, chapter_id: str) -> str:
|
70
|
+
"""
|
71
|
+
Construct the URL for fetching a specific chapter.
|
72
|
+
|
73
|
+
:param book_id: The identifier of the book.
|
74
|
+
:param chapter_id: The identifier of the chapter.
|
75
|
+
:return: Fully qualified chapter URL.
|
76
|
+
"""
|
77
|
+
return self._profile["chapter_url"].format(
|
78
|
+
book_id=book_id, chapter_id=chapter_id
|
79
|
+
)
|