kmoe-manga-downloader 1.2.0__py3-none-any.whl → 1.2.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.
- kmdr/core/bases.py +14 -2
- kmdr/core/constants.py +79 -0
- kmdr/core/context.py +19 -7
- kmdr/core/defaults.py +28 -5
- kmdr/core/error.py +9 -1
- kmdr/core/structure.py +2 -0
- kmdr/core/utils.py +11 -3
- kmdr/module/__init__.py +5 -5
- kmdr/module/authenticator/CookieAuthenticator.py +8 -5
- kmdr/module/authenticator/LoginAuthenticator.py +14 -4
- kmdr/module/authenticator/__init__.py +2 -0
- kmdr/module/authenticator/utils.py +15 -5
- kmdr/module/configurer/BaseUrlUpdator.py +16 -0
- kmdr/module/configurer/OptionLister.py +16 -11
- kmdr/module/configurer/__init__.py +5 -0
- kmdr/module/downloader/DirectDownloader.py +12 -2
- kmdr/module/downloader/ReferViaDownloader.py +11 -2
- kmdr/module/downloader/__init__.py +2 -0
- kmdr/module/downloader/{utils.py → download_utils.py} +33 -26
- kmdr/module/downloader/misc.py +62 -0
- kmdr/module/lister/FollowedBookLister.py +4 -3
- kmdr/module/lister/__init__.py +2 -0
- kmdr/module/lister/utils.py +13 -4
- kmdr/module/picker/__init__.py +2 -0
- {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/METADATA +42 -19
- kmoe_manga_downloader-1.2.1.dist-info/RECORD +43 -0
- kmoe_manga_downloader-1.2.0.dist-info/RECORD +0 -35
- {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/WHEEL +0 -0
- {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/entry_points.txt +0 -0
- {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/top_level.txt +0 -0
kmdr/core/bases.py
CHANGED
|
@@ -7,7 +7,7 @@ from aiohttp import ClientSession
|
|
|
7
7
|
from .error import LoginError
|
|
8
8
|
from .registry import Registry
|
|
9
9
|
from .structure import VolInfo, BookInfo
|
|
10
|
-
from .utils import construct_callback
|
|
10
|
+
from .utils import construct_callback, async_retry
|
|
11
11
|
|
|
12
12
|
from .context import TerminalContext, SessionContext, UserProfileContext, ConfigContext
|
|
13
13
|
|
|
@@ -23,6 +23,8 @@ class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalC
|
|
|
23
23
|
|
|
24
24
|
def __init__(self, *args, **kwargs):
|
|
25
25
|
super().__init__(*args, **kwargs)
|
|
26
|
+
# 这里的 base url 可能会在认证过程中被更新
|
|
27
|
+
self._inner_base_url: Optional[str] = None
|
|
26
28
|
|
|
27
29
|
# 在使用代理登录时,可能会出现问题,但是现在还不清楚是不是代理的问题。
|
|
28
30
|
# 主站正常情况下不使用代理也能登录成功。但是不排除特殊的网络环境下需要代理。
|
|
@@ -31,11 +33,21 @@ class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalC
|
|
|
31
33
|
async def authenticate(self) -> None:
|
|
32
34
|
with self._console.status("认证中..."):
|
|
33
35
|
try:
|
|
34
|
-
|
|
36
|
+
# 这里添加了 base_url_setter,以便在重定向时更新 base_url
|
|
37
|
+
assert await async_retry(
|
|
38
|
+
base_url_setter=self._configurer.set_base_url
|
|
39
|
+
)(self._authenticate)()
|
|
40
|
+
|
|
41
|
+
# 登录成功后,更新 base_url
|
|
42
|
+
self._base_url = self.base_url
|
|
35
43
|
except LoginError as e:
|
|
36
44
|
self._console.print("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
|
|
37
45
|
self._console.print(f"[yellow]详细信息:{e}[/yellow]")
|
|
38
46
|
exit(1)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def base_url(self) -> str:
|
|
50
|
+
return self._inner_base_url or self._configurer.base_url
|
|
39
51
|
|
|
40
52
|
@abstractmethod
|
|
41
53
|
async def _authenticate(self) -> bool: ...
|
kmdr/core/constants.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing_extensions import deprecated
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class _BaseUrl:
|
|
8
|
+
|
|
9
|
+
@property
|
|
10
|
+
@deprecated("KOX 已过时,请使用 KXO 或 KOZ。")
|
|
11
|
+
def KOX(self) -> str:
|
|
12
|
+
return 'https://kox.moe'
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def KXX(self) -> str:
|
|
16
|
+
return 'https://kxx.moe'
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def KXO(self) -> str:
|
|
20
|
+
return 'https://kxo.moe'
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def KOZ(self) -> str:
|
|
24
|
+
return 'https://koz.moe'
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def MOX(self) -> str:
|
|
28
|
+
return 'https://mox.moe'
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def DEFAULT(self) -> str:
|
|
32
|
+
"""默认基础 URL"""
|
|
33
|
+
return self.KXX
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class _ApiRoute():
|
|
38
|
+
PROFILE: str = '/my.php'
|
|
39
|
+
"""用户信息页面"""
|
|
40
|
+
|
|
41
|
+
LOGIN: str = '/login.php'
|
|
42
|
+
"""登录页面"""
|
|
43
|
+
|
|
44
|
+
LOGIN_DO: str = '/login_do.php'
|
|
45
|
+
"""登录接口"""
|
|
46
|
+
|
|
47
|
+
MY_FOLLOW: str = '/myfollow.php'
|
|
48
|
+
"""关注列表页面"""
|
|
49
|
+
|
|
50
|
+
BOOK_DATA: str = '/book_data.php'
|
|
51
|
+
"""书籍数据接口"""
|
|
52
|
+
|
|
53
|
+
DOWNLOAD: str = '/dl/{book_id}/{volume_id}/1/2/{is_vip}/'
|
|
54
|
+
"""下载接口"""
|
|
55
|
+
|
|
56
|
+
GETDOWNURL: str = '/getdownurl.php?b={book_id}&v={volume_id}&mobi=2&vip={is_vip}&json=1'
|
|
57
|
+
"""获取下载链接接口"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LoginResponse(Enum):
|
|
61
|
+
m100 = "登录成功。"
|
|
62
|
+
|
|
63
|
+
e400 = "帳號或密碼錯誤。"
|
|
64
|
+
e401 = "非法訪問,請使用瀏覽器正常打開本站。"
|
|
65
|
+
e402 = "帳號已經註銷。不會解釋原因,無需提問。"
|
|
66
|
+
e403 = "驗證失效,請刷新頁面重新操作。"
|
|
67
|
+
|
|
68
|
+
unknown = "未知响应代码。"
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_code(cls, code: str) -> 'LoginResponse':
|
|
72
|
+
return cls.__members__.get(code, cls.unknown)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
API_ROUTE = _ApiRoute()
|
|
76
|
+
"""API 路由常量实例"""
|
|
77
|
+
|
|
78
|
+
BASE_URL = _BaseUrl()
|
|
79
|
+
"""基础 URL 实例"""
|
kmdr/core/context.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from aiohttp import ClientSession
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
from .defaults import Configurer as InnerConfigurer, UserProfile, session_var, progress, console, base_url_var
|
|
4
5
|
|
|
5
6
|
class TerminalContext:
|
|
6
7
|
|
|
@@ -9,12 +10,6 @@ class TerminalContext:
|
|
|
9
10
|
self._progress = progress
|
|
10
11
|
self._console = console
|
|
11
12
|
|
|
12
|
-
class SessionContext:
|
|
13
|
-
|
|
14
|
-
def __init__(self, *args, **kwargs):
|
|
15
|
-
super().__init__()
|
|
16
|
-
self._session: ClientSession = session_var.get()
|
|
17
|
-
|
|
18
13
|
class UserProfileContext:
|
|
19
14
|
|
|
20
15
|
def __init__(self, *args, **kwargs):
|
|
@@ -26,3 +21,20 @@ class ConfigContext:
|
|
|
26
21
|
def __init__(self, *args, **kwargs):
|
|
27
22
|
super().__init__()
|
|
28
23
|
self._configurer = InnerConfigurer()
|
|
24
|
+
|
|
25
|
+
class SessionContext:
|
|
26
|
+
|
|
27
|
+
def __init__(self, *args, **kwargs):
|
|
28
|
+
super().__init__()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def _session(self) -> ClientSession:
|
|
32
|
+
return session_var.get()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def _base_url(self) -> str:
|
|
36
|
+
return base_url_var.get()
|
|
37
|
+
|
|
38
|
+
@_base_url.setter
|
|
39
|
+
def _base_url(self, value: str):
|
|
40
|
+
base_url_var.set(value)
|
kmdr/core/defaults.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import json
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Optional, Any
|
|
4
4
|
import argparse
|
|
5
5
|
from contextvars import ContextVar
|
|
6
6
|
from rich.console import Console
|
|
@@ -15,6 +15,7 @@ from rich.progress import (
|
|
|
15
15
|
|
|
16
16
|
from .utils import singleton
|
|
17
17
|
from .structure import Config
|
|
18
|
+
from .constants import BASE_URL
|
|
18
19
|
|
|
19
20
|
HEADERS = {
|
|
20
21
|
'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
|
|
@@ -75,6 +76,7 @@ def argument_parser():
|
|
|
75
76
|
config_parser = subparsers.add_parser('config', help='配置下载器')
|
|
76
77
|
config_parser.add_argument('-l', '--list-option', action='store_true', help='列出所有配置')
|
|
77
78
|
config_parser.add_argument('-s', '--set', nargs='+', type=str, help='设置一个或多个配置项,格式为 `key=value`,例如: `num_workers=8`')
|
|
79
|
+
config_parser.add_argument('-b', '--base-url', type=str, help='设置镜像站点的基础 URL, 例如: `https://kxx.moe`')
|
|
78
80
|
config_parser.add_argument('-c', '--clear', type=str, help='清除指定配置,可选值为 `all`, `cookie`, `option`')
|
|
79
81
|
config_parser.add_argument('-d', '--delete', '--unset', dest='unset', type=str, help='删除特定的配置选项')
|
|
80
82
|
|
|
@@ -137,6 +139,9 @@ class Configurer:
|
|
|
137
139
|
cookie = config.get('cookie', None)
|
|
138
140
|
if cookie is not None and isinstance(cookie, dict):
|
|
139
141
|
self._config.cookie = cookie
|
|
142
|
+
base_url = config.get('base_url', None)
|
|
143
|
+
if base_url is not None and isinstance(base_url, str):
|
|
144
|
+
self._config.base_url = base_url
|
|
140
145
|
|
|
141
146
|
@property
|
|
142
147
|
def config(self) -> 'Config':
|
|
@@ -162,12 +167,24 @@ class Configurer:
|
|
|
162
167
|
return self._config.option
|
|
163
168
|
|
|
164
169
|
@option.setter
|
|
165
|
-
def option(self, value: Optional[dict[str,
|
|
170
|
+
def option(self, value: Optional[dict[str, Any]]):
|
|
166
171
|
if self._config is None:
|
|
167
172
|
self._config = Config()
|
|
168
173
|
self._config.option = value
|
|
169
174
|
self.update()
|
|
170
175
|
|
|
176
|
+
@property
|
|
177
|
+
def base_url(self) -> str:
|
|
178
|
+
if self._config is None or self._config.base_url is None:
|
|
179
|
+
return BASE_URL.DEFAULT
|
|
180
|
+
return self._config.base_url
|
|
181
|
+
|
|
182
|
+
def set_base_url(self, value: str):
|
|
183
|
+
if self._config is None:
|
|
184
|
+
self._config = Config()
|
|
185
|
+
self._config.base_url = value
|
|
186
|
+
self.update()
|
|
187
|
+
|
|
171
188
|
def update(self):
|
|
172
189
|
with open(os.path.join(os.path.expanduser("~"), self.__filename), 'w') as f:
|
|
173
190
|
json.dump(self._config.__dict__, f, indent=4, ensure_ascii=False)
|
|
@@ -184,7 +201,7 @@ class Configurer:
|
|
|
184
201
|
|
|
185
202
|
self.update()
|
|
186
203
|
|
|
187
|
-
def set_option(self, key: str, value:
|
|
204
|
+
def set_option(self, key: str, value: Any):
|
|
188
205
|
if self._config.option is None:
|
|
189
206
|
self._config.option = {}
|
|
190
207
|
|
|
@@ -209,5 +226,11 @@ def __combine_args(dest: argparse.Namespace, option: dict) -> argparse.Namespace
|
|
|
209
226
|
|
|
210
227
|
def combine_args(dest: argparse.Namespace) -> argparse.Namespace:
|
|
211
228
|
assert isinstance(dest, argparse.Namespace), "dest must be an argparse.Namespace instance"
|
|
212
|
-
option = Configurer().
|
|
213
|
-
|
|
229
|
+
option = Configurer().option
|
|
230
|
+
|
|
231
|
+
if option is None:
|
|
232
|
+
return dest
|
|
233
|
+
|
|
234
|
+
return __combine_args(dest, option)
|
|
235
|
+
|
|
236
|
+
base_url_var = ContextVar('base_url', default=Configurer().base_url)
|
kmdr/core/error.py
CHANGED
|
@@ -12,4 +12,12 @@ class LoginError(KmdrError):
|
|
|
12
12
|
super().__init__(message, solution)
|
|
13
13
|
|
|
14
14
|
def __str__(self):
|
|
15
|
-
return f"{self.message}\n{self._solution}"
|
|
15
|
+
return f"{self.message}\n{self._solution}"
|
|
16
|
+
|
|
17
|
+
class RedirectError(KmdrError):
|
|
18
|
+
def __init__(self, message, new_base_url: str):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.new_base_url = new_base_url
|
|
21
|
+
|
|
22
|
+
def __str__(self):
|
|
23
|
+
return f"{self.message} 新的地址: {self.new_base_url}"
|
kmdr/core/structure.py
CHANGED
kmdr/core/utils.py
CHANGED
|
@@ -4,10 +4,10 @@ import asyncio
|
|
|
4
4
|
|
|
5
5
|
import aiohttp
|
|
6
6
|
|
|
7
|
-
from deprecation import deprecated
|
|
8
7
|
import subprocess
|
|
9
8
|
|
|
10
9
|
from .structure import BookInfo, VolInfo
|
|
10
|
+
from .error import RedirectError
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def singleton(cls):
|
|
@@ -43,7 +43,8 @@ def async_retry(
|
|
|
43
43
|
attempts: int = 3,
|
|
44
44
|
delay: float = 1.0,
|
|
45
45
|
backoff: float = 2.0,
|
|
46
|
-
retry_on_status: set[int] = {500, 502, 503, 504, 429, 408}
|
|
46
|
+
retry_on_status: set[int] = {500, 502, 503, 504, 429, 408},
|
|
47
|
+
base_url_setter: Optional[Callable[[str], None]] = None,
|
|
47
48
|
):
|
|
48
49
|
def decorator(func):
|
|
49
50
|
@functools.wraps(func)
|
|
@@ -62,9 +63,16 @@ def async_retry(
|
|
|
62
63
|
# 对于所有其他 aiohttp 客户端异常和超时,进行重试
|
|
63
64
|
if attempt == attempts - 1:
|
|
64
65
|
raise
|
|
66
|
+
except RedirectError as e:
|
|
67
|
+
if base_url_setter:
|
|
68
|
+
base_url_setter(e.new_base_url)
|
|
69
|
+
print(f"检测到重定向,已自动更新 base url 为: {e.new_base_url}。立即重试...")
|
|
70
|
+
continue
|
|
71
|
+
else:
|
|
72
|
+
raise
|
|
65
73
|
|
|
66
74
|
await asyncio.sleep(current_delay)
|
|
67
75
|
|
|
68
76
|
current_delay *= backoff
|
|
69
77
|
return wrapper
|
|
70
|
-
return decorator
|
|
78
|
+
return decorator
|
kmdr/module/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
from .authenticator import
|
|
2
|
-
from .lister import
|
|
3
|
-
from .picker import
|
|
4
|
-
from .downloader import
|
|
5
|
-
from .configurer import
|
|
1
|
+
from .authenticator import *
|
|
2
|
+
from .lister import *
|
|
3
|
+
from .picker import *
|
|
4
|
+
from .downloader import *
|
|
5
|
+
from .configurer import *
|
|
@@ -4,12 +4,11 @@ from yarl import URL
|
|
|
4
4
|
|
|
5
5
|
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
6
6
|
|
|
7
|
-
from .utils import check_status
|
|
8
|
-
from .utils import PROFILE_URL
|
|
7
|
+
from .utils import check_status, extract_base_url
|
|
9
8
|
|
|
10
9
|
@AUTHENTICATOR.register()
|
|
11
10
|
class CookieAuthenticator(Authenticator):
|
|
12
|
-
def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
|
|
11
|
+
def __init__(self, proxy: Optional[str] = None, book_url: Optional[str] = None, *args, **kwargs):
|
|
13
12
|
super().__init__(proxy, *args, **kwargs)
|
|
14
13
|
|
|
15
14
|
if 'command' in kwargs and kwargs['command'] == 'status':
|
|
@@ -17,17 +16,21 @@ class CookieAuthenticator(Authenticator):
|
|
|
17
16
|
else:
|
|
18
17
|
self._show_quota = False
|
|
19
18
|
|
|
19
|
+
# 根据用户提供的 book_url 来决定访问的镜像站
|
|
20
|
+
self._inner_base_url = extract_base_url(book_url, default=self._base_url)
|
|
21
|
+
|
|
20
22
|
async def _authenticate(self) -> bool:
|
|
21
23
|
cookie = self._configurer.cookie
|
|
22
24
|
|
|
23
25
|
if not cookie:
|
|
24
26
|
raise LoginError("无法找到 Cookie,请先完成登录。", ['kmdr login -u <username>'])
|
|
25
27
|
|
|
26
|
-
self._session.cookie_jar.update_cookies(cookie, response_url=URL(
|
|
28
|
+
self._session.cookie_jar.update_cookies(cookie, response_url=URL(self.base_url))
|
|
27
29
|
return await check_status(
|
|
28
30
|
self._session,
|
|
29
31
|
self._console,
|
|
32
|
+
base_url=self.base_url,
|
|
30
33
|
show_quota=self._show_quota,
|
|
31
34
|
is_vip_setter=lambda value: setattr(self._profile, 'is_vip', value),
|
|
32
35
|
level_setter=lambda value: setattr(self._profile, 'user_level', value),
|
|
33
|
-
)
|
|
36
|
+
)
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
import re
|
|
3
|
+
from yarl import URL
|
|
4
|
+
from urllib.parse import urljoin
|
|
3
5
|
|
|
4
6
|
from rich.prompt import Prompt
|
|
5
7
|
|
|
6
8
|
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
9
|
+
from kmdr.core.constants import API_ROUTE
|
|
10
|
+
from kmdr.core.error import RedirectError
|
|
7
11
|
|
|
8
|
-
from .utils import check_status
|
|
12
|
+
from .utils import check_status, extract_base_url
|
|
9
13
|
|
|
10
14
|
CODE_OK = 'm100'
|
|
11
15
|
|
|
@@ -33,15 +37,21 @@ class LoginAuthenticator(Authenticator):
|
|
|
33
37
|
async def _authenticate(self) -> bool:
|
|
34
38
|
|
|
35
39
|
async with self._session.post(
|
|
36
|
-
url =
|
|
40
|
+
url = urljoin(self._base_url, API_ROUTE.LOGIN_DO),
|
|
37
41
|
data = {
|
|
38
42
|
'email': self._username,
|
|
39
43
|
'passwd': self._password,
|
|
40
44
|
'keepalive': 'on'
|
|
41
45
|
},
|
|
46
|
+
allow_redirects = False
|
|
42
47
|
) as response:
|
|
43
48
|
|
|
44
49
|
response.raise_for_status()
|
|
50
|
+
|
|
51
|
+
if response.status in (301, 302, 307, 308) and 'Location' in response.headers:
|
|
52
|
+
new_location = response.headers['Location']
|
|
53
|
+
raise RedirectError("检测到重定向", new_base_url=extract_base_url(new_location) or self._base_url)
|
|
54
|
+
|
|
45
55
|
match = re.search(r'"\w+"', await response.text())
|
|
46
56
|
|
|
47
57
|
if not match:
|
|
@@ -51,8 +61,8 @@ class LoginAuthenticator(Authenticator):
|
|
|
51
61
|
if code != CODE_OK:
|
|
52
62
|
raise LoginError(f"认证失败,错误代码:{code} " + CODE_MAPPING.get(code, "未知错误。"))
|
|
53
63
|
|
|
54
|
-
if await check_status(self._session, self._console, show_quota=self._show_quota):
|
|
55
|
-
cookie = self._session.cookie_jar.filter_cookies(
|
|
64
|
+
if await check_status(self._session, self._console, base_url=self.base_url, show_quota=self._show_quota):
|
|
65
|
+
cookie = self._session.cookie_jar.filter_cookies(URL(self._base_url))
|
|
56
66
|
self._configurer.cookie = {key: morsel.value for key, morsel in cookie.items()}
|
|
57
67
|
|
|
58
68
|
return True
|
|
@@ -2,12 +2,11 @@ from typing import Optional, Callable
|
|
|
2
2
|
|
|
3
3
|
from aiohttp import ClientSession
|
|
4
4
|
from rich.console import Console
|
|
5
|
+
from urllib.parse import urljoin, urlsplit
|
|
5
6
|
|
|
6
7
|
from kmdr.core.error import LoginError
|
|
7
8
|
from kmdr.core.utils import async_retry
|
|
8
|
-
|
|
9
|
-
PROFILE_URL = 'https://kox.moe/my.php'
|
|
10
|
-
LOGIN_URL = 'https://kox.moe/login.php'
|
|
9
|
+
from kmdr.core.constants import API_ROUTE, BASE_URL
|
|
11
10
|
|
|
12
11
|
NICKNAME_ID = 'div_nickname_display'
|
|
13
12
|
|
|
@@ -19,11 +18,12 @@ LV1_ID = 'div_user_lv1'
|
|
|
19
18
|
async def check_status(
|
|
20
19
|
session: ClientSession,
|
|
21
20
|
console: Console,
|
|
21
|
+
base_url: str = BASE_URL.KXO,
|
|
22
22
|
show_quota: bool = False,
|
|
23
23
|
is_vip_setter: Optional[Callable[[int], None]] = None,
|
|
24
24
|
level_setter: Optional[Callable[[int], None]] = None,
|
|
25
25
|
) -> bool:
|
|
26
|
-
async with session.get(url =
|
|
26
|
+
async with session.get(url = urljoin(base_url, API_ROUTE.PROFILE)) as response:
|
|
27
27
|
try:
|
|
28
28
|
response.raise_for_status()
|
|
29
29
|
except Exception as e:
|
|
@@ -31,7 +31,7 @@ async def check_status(
|
|
|
31
31
|
return False
|
|
32
32
|
|
|
33
33
|
if response.history and any(resp.status in (301, 302, 307) for resp in response.history) \
|
|
34
|
-
and str(response.url) ==
|
|
34
|
+
and str(response.url) == urljoin(base_url, API_ROUTE.LOGIN):
|
|
35
35
|
raise LoginError("凭证已失效,请重新登录。", ['kmdr config -c cookie', 'kmdr login -u <username>'])
|
|
36
36
|
|
|
37
37
|
if not is_vip_setter and not level_setter and not show_quota:
|
|
@@ -81,3 +81,13 @@ def __resolve_quota_id(is_vip: Optional[int] = None, user_level: Optional[int] =
|
|
|
81
81
|
return LV1_ID
|
|
82
82
|
|
|
83
83
|
return NOR_ID
|
|
84
|
+
|
|
85
|
+
def extract_base_url(url: Optional[str], default: Optional[str] = None) -> Optional[str]:
|
|
86
|
+
if not url:
|
|
87
|
+
return default
|
|
88
|
+
|
|
89
|
+
parsed = urlsplit(url)
|
|
90
|
+
if parsed.scheme and parsed.netloc:
|
|
91
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
92
|
+
|
|
93
|
+
return default
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from kmdr.core import Configurer, CONFIGURER
|
|
2
|
+
|
|
3
|
+
@CONFIGURER.register()
|
|
4
|
+
class BaseUrlUpdator(Configurer):
|
|
5
|
+
def __init__(self, base_url: str, *args, **kwargs):
|
|
6
|
+
super().__init__(*args, **kwargs)
|
|
7
|
+
self._base_url = base_url
|
|
8
|
+
|
|
9
|
+
def operate(self) -> None:
|
|
10
|
+
try:
|
|
11
|
+
self._configurer.set_base_url(self._base_url)
|
|
12
|
+
except KeyError as e:
|
|
13
|
+
self._console.print(e.args[0])
|
|
14
|
+
exit(1)
|
|
15
|
+
|
|
16
|
+
self._console.print(f"已设置基础 URL: {self._base_url}")
|
|
@@ -13,21 +13,26 @@ class OptionLister(Configurer):
|
|
|
13
13
|
super().__init__(*args, **kwargs)
|
|
14
14
|
|
|
15
15
|
def operate(self) -> None:
|
|
16
|
-
if self._configurer.option is None:
|
|
16
|
+
if self._configurer.option is None and self._configurer.base_url is None:
|
|
17
17
|
self._console.print("[blue]当前没有任何配置项。[/blue]")
|
|
18
18
|
return
|
|
19
19
|
|
|
20
20
|
table = Table(title="[green]当前 Kmdr 配置项[/green]", show_header=False, header_style="blue")
|
|
21
21
|
|
|
22
|
+
table.add_column("配置类型 (Type)", style="magenta", no_wrap=True, min_width=10)
|
|
22
23
|
table.add_column("配置项 (Key)", style="cyan", no_wrap=True, min_width=10)
|
|
23
|
-
table.add_column("值 (Value)",
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
table.add_column("值 (Value)", no_wrap=False, min_width=20)
|
|
25
|
+
|
|
26
|
+
if self._configurer.option is not None:
|
|
27
|
+
for idx, (key, value) in enumerate(self._configurer.option.items()):
|
|
28
|
+
value_to_display = value
|
|
29
|
+
if isinstance(value, (dict, list, set, tuple)):
|
|
30
|
+
value_to_display = Pretty(value)
|
|
31
|
+
|
|
32
|
+
table.add_row('下载配置' if idx == 0 else '', key, value_to_display)
|
|
33
|
+
table.add_section()
|
|
34
|
+
|
|
35
|
+
if self._configurer.base_url is not None:
|
|
36
|
+
table.add_row('应用配置', '镜像地址', self._configurer.base_url or '未设置')
|
|
37
|
+
|
|
33
38
|
self._console.print(table)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
from urllib.parse import urljoin
|
|
2
|
+
|
|
1
3
|
from kmdr.core import Downloader, BookInfo, VolInfo, DOWNLOADER
|
|
4
|
+
from kmdr.core.constants import API_ROUTE
|
|
2
5
|
|
|
3
|
-
from .
|
|
6
|
+
from .download_utils import safe_filename, download_file_multipart
|
|
4
7
|
|
|
5
8
|
@DOWNLOADER.register(
|
|
6
9
|
hasvalues={
|
|
@@ -27,4 +30,11 @@ class DirectDownloader(Downloader):
|
|
|
27
30
|
)
|
|
28
31
|
|
|
29
32
|
def construct_download_url(self, book: BookInfo, volume: VolInfo) -> str:
|
|
30
|
-
return
|
|
33
|
+
return urljoin(
|
|
34
|
+
self._base_url,
|
|
35
|
+
API_ROUTE.DOWNLOAD.format(
|
|
36
|
+
book_id=book.id,
|
|
37
|
+
volume_id=volume.id,
|
|
38
|
+
is_vip=self._profile.is_vip
|
|
39
|
+
)
|
|
40
|
+
)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from functools import partial
|
|
2
|
+
from urllib.parse import urljoin
|
|
2
3
|
|
|
3
4
|
import json
|
|
4
5
|
from async_lru import alru_cache
|
|
5
6
|
|
|
6
7
|
from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
|
|
8
|
+
from kmdr.core.constants import API_ROUTE
|
|
7
9
|
|
|
8
|
-
from .
|
|
10
|
+
from .download_utils import safe_filename, download_file_multipart
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
@DOWNLOADER.register(order=10)
|
|
@@ -35,7 +37,14 @@ class ReferViaDownloader(Downloader):
|
|
|
35
37
|
@alru_cache(maxsize=128)
|
|
36
38
|
async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
|
|
37
39
|
|
|
38
|
-
url =
|
|
40
|
+
url = urljoin(
|
|
41
|
+
self._base_url,
|
|
42
|
+
API_ROUTE.GETDOWNURL.format(
|
|
43
|
+
book_id=book_id,
|
|
44
|
+
volume_id=volume_id,
|
|
45
|
+
is_vip=self._profile.is_vip
|
|
46
|
+
)
|
|
47
|
+
)
|
|
39
48
|
|
|
40
49
|
async with self._session.get(url) as response:
|
|
41
50
|
response.raise_for_status()
|
|
@@ -3,27 +3,22 @@ import os
|
|
|
3
3
|
import re
|
|
4
4
|
import math
|
|
5
5
|
from typing import Callable, Optional, Union, Awaitable
|
|
6
|
-
from enum import Enum
|
|
7
6
|
|
|
8
|
-
from
|
|
7
|
+
from typing_extensions import deprecated
|
|
8
|
+
|
|
9
9
|
import aiohttp
|
|
10
10
|
import aiofiles
|
|
11
11
|
import aiofiles.os as aio_os
|
|
12
12
|
from rich.progress import Progress
|
|
13
13
|
from aiohttp.client_exceptions import ClientPayloadError
|
|
14
14
|
|
|
15
|
+
from .misc import STATUS, StateManager
|
|
16
|
+
|
|
15
17
|
BLOCK_SIZE_REDUCTION_FACTOR = 0.75
|
|
16
18
|
MIN_BLOCK_SIZE = 2048
|
|
17
19
|
|
|
18
|
-
class STATUS(Enum):
|
|
19
|
-
WAITING='[blue]等待中[/blue]'
|
|
20
|
-
DOWNLOADING='[cyan]下载中[/cyan]'
|
|
21
|
-
RETRYING='[yellow]重试中[/yellow]'
|
|
22
|
-
MERGING='[magenta]合并中[/magenta]'
|
|
23
|
-
COMPLETED='[green]完成[/green]'
|
|
24
|
-
FAILED='[red]失败[/red]'
|
|
25
20
|
|
|
26
|
-
@deprecated(
|
|
21
|
+
@deprecated("请使用 'download_file_multipart'")
|
|
27
22
|
async def download_file(
|
|
28
23
|
session: aiohttp.ClientSession,
|
|
29
24
|
semaphore: asyncio.Semaphore,
|
|
@@ -163,6 +158,7 @@ async def download_file_multipart(
|
|
|
163
158
|
return
|
|
164
159
|
|
|
165
160
|
part_paths = []
|
|
161
|
+
part_expected_sizes = []
|
|
166
162
|
task_id = None
|
|
167
163
|
try:
|
|
168
164
|
current_url = await fetch_url(url)
|
|
@@ -184,9 +180,11 @@ async def download_file_multipart(
|
|
|
184
180
|
resumed_size += (await aio_os.stat(part_path)).st_size
|
|
185
181
|
|
|
186
182
|
task_id = progress.add_task("download", filename=filename, status=STATUS.WAITING.value, total=total_size, completed=resumed_size)
|
|
183
|
+
state_manager = StateManager(progress=progress, task_id=task_id)
|
|
187
184
|
|
|
188
185
|
for i, start in enumerate(range(0, total_size, chunk_size)):
|
|
189
186
|
end = min(start + chunk_size - 1, total_size - 1)
|
|
187
|
+
part_expected_sizes.append(end - start + 1)
|
|
190
188
|
|
|
191
189
|
task = _download_part(
|
|
192
190
|
session=session,
|
|
@@ -195,8 +193,7 @@ async def download_file_multipart(
|
|
|
195
193
|
start=start,
|
|
196
194
|
end=end,
|
|
197
195
|
part_path=part_paths[i],
|
|
198
|
-
|
|
199
|
-
task_id=task_id,
|
|
196
|
+
state_manager=state_manager,
|
|
200
197
|
headers=headers,
|
|
201
198
|
retry_times=retry_times
|
|
202
199
|
)
|
|
@@ -204,24 +201,29 @@ async def download_file_multipart(
|
|
|
204
201
|
|
|
205
202
|
await asyncio.gather(*tasks)
|
|
206
203
|
|
|
207
|
-
|
|
208
|
-
await
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
204
|
+
assert len(part_paths) == len(part_expected_sizes)
|
|
205
|
+
results = await asyncio.gather(*[_validate_part(part_paths[i], part_expected_sizes[i]) for i in range(num_chunks)])
|
|
206
|
+
if all(results):
|
|
207
|
+
await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.MERGING)
|
|
208
|
+
await _merge_parts(part_paths, filename_downloading)
|
|
209
|
+
os.rename(filename_downloading, file_path)
|
|
210
|
+
else:
|
|
211
|
+
# 如果有任何一个分片校验失败,则视为下载失败
|
|
212
|
+
await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.FAILED)
|
|
214
213
|
|
|
215
214
|
finally:
|
|
216
215
|
if await aio_os.path.exists(file_path):
|
|
217
216
|
if task_id is not None:
|
|
218
|
-
|
|
217
|
+
await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.COMPLETED)
|
|
219
218
|
|
|
220
219
|
cleanup_tasks = [aio_os.remove(p) for p in part_paths if await aio_os.path.exists(p)]
|
|
221
220
|
if cleanup_tasks:
|
|
222
221
|
await asyncio.gather(*cleanup_tasks)
|
|
223
222
|
if callback:
|
|
224
223
|
callback()
|
|
224
|
+
else:
|
|
225
|
+
if task_id is not None:
|
|
226
|
+
await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.FAILED)
|
|
225
227
|
|
|
226
228
|
async def _download_part(
|
|
227
229
|
session: aiohttp.ClientSession,
|
|
@@ -230,8 +232,7 @@ async def _download_part(
|
|
|
230
232
|
start: int,
|
|
231
233
|
end: int,
|
|
232
234
|
part_path: str,
|
|
233
|
-
|
|
234
|
-
task_id,
|
|
235
|
+
state_manager: StateManager,
|
|
235
236
|
headers: Optional[dict] = None,
|
|
236
237
|
retry_times: int = 3
|
|
237
238
|
):
|
|
@@ -258,21 +259,27 @@ async def _download_part(
|
|
|
258
259
|
async with session.get(url, headers=local_headers) as response:
|
|
259
260
|
response.raise_for_status()
|
|
260
261
|
|
|
261
|
-
|
|
262
|
-
progress.update(task_id, status=STATUS.DOWNLOADING.value, refresh=True)
|
|
262
|
+
await state_manager.request_status_update(part_id=start, status=STATUS.DOWNLOADING)
|
|
263
263
|
|
|
264
264
|
async with aiofiles.open(part_path, 'ab') as f:
|
|
265
265
|
async for chunk in response.content.iter_chunked(block_size):
|
|
266
266
|
if chunk:
|
|
267
267
|
await f.write(chunk)
|
|
268
|
-
|
|
268
|
+
state_manager.advance(len(chunk))
|
|
269
269
|
return
|
|
270
270
|
except Exception as e:
|
|
271
271
|
if attempts_left > 0:
|
|
272
272
|
await asyncio.sleep(3)
|
|
273
|
+
await state_manager.request_status_update(part_id=start, status=STATUS.WAITING)
|
|
273
274
|
else:
|
|
274
275
|
# console.print(f"[red]分片 {os.path.basename(part_path)} 下载失败: {e}[/red]")
|
|
275
|
-
|
|
276
|
+
await state_manager.request_status_update(part_id=start, status=STATUS.PARTIALLY_FAILED)
|
|
277
|
+
|
|
278
|
+
async def _validate_part(part_path: str, expected_size: int) -> bool:
|
|
279
|
+
if not await aio_os.path.exists(part_path):
|
|
280
|
+
return False
|
|
281
|
+
actual_size = await aio_os.path.getsize(part_path)
|
|
282
|
+
return actual_size == expected_size
|
|
276
283
|
|
|
277
284
|
async def _merge_parts(part_paths: list[str], final_path: str):
|
|
278
285
|
async with aiofiles.open(final_path, 'wb') as final_file:
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from rich.progress import Progress, TaskID
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class STATUS(Enum):
|
|
8
|
+
WAITING='[blue]等待中[/blue]'
|
|
9
|
+
RETRYING='[yellow]重试中[/yellow]'
|
|
10
|
+
DOWNLOADING='[cyan]下载中[/cyan]'
|
|
11
|
+
MERGING='[magenta]合并中[/magenta]'
|
|
12
|
+
COMPLETED='[green]完成[/green]'
|
|
13
|
+
PARTIALLY_FAILED='[red]分片失败[/red]'
|
|
14
|
+
FAILED='[red]失败[/red]'
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def order(self) -> int:
|
|
18
|
+
order_mapping = {
|
|
19
|
+
STATUS.WAITING: 1,
|
|
20
|
+
STATUS.RETRYING: 2,
|
|
21
|
+
STATUS.DOWNLOADING: 3,
|
|
22
|
+
STATUS.MERGING: 4,
|
|
23
|
+
STATUS.COMPLETED: 5,
|
|
24
|
+
STATUS.PARTIALLY_FAILED: 6,
|
|
25
|
+
STATUS.FAILED: 7,
|
|
26
|
+
}
|
|
27
|
+
return order_mapping[self]
|
|
28
|
+
|
|
29
|
+
def __lt__(self, other):
|
|
30
|
+
if not isinstance(other, STATUS):
|
|
31
|
+
return NotImplemented
|
|
32
|
+
return self.order < other.order
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StateManager:
|
|
36
|
+
|
|
37
|
+
def __init__(self, progress: Progress, task_id: TaskID):
|
|
38
|
+
self._part_states: dict[int, STATUS] = {}
|
|
39
|
+
self._progress = progress
|
|
40
|
+
self._task_id = task_id
|
|
41
|
+
self._current_status = STATUS.WAITING
|
|
42
|
+
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
|
|
45
|
+
PARENT_ID: int = -1
|
|
46
|
+
|
|
47
|
+
def advance(self, advance: int):
|
|
48
|
+
self._progress.update(self._task_id, advance=advance)
|
|
49
|
+
|
|
50
|
+
def _update_status(self):
|
|
51
|
+
if not self._part_states:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
highest_status = max(self._part_states.values())
|
|
55
|
+
if highest_status != self._current_status:
|
|
56
|
+
self._current_status = highest_status
|
|
57
|
+
self._progress.update(self._task_id, status=highest_status.value, refresh=True)
|
|
58
|
+
|
|
59
|
+
async def request_status_update(self, part_id: int, status: STATUS):
|
|
60
|
+
async with self._lock:
|
|
61
|
+
self._part_states[part_id] = status
|
|
62
|
+
self._update_status()
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
from urllib.parse import urljoin
|
|
3
|
+
|
|
2
4
|
from bs4 import BeautifulSoup
|
|
3
5
|
from rich.table import Table
|
|
4
6
|
from rich.prompt import IntPrompt
|
|
5
7
|
|
|
6
8
|
from kmdr.core import Lister, LISTERS, BookInfo, VolInfo
|
|
7
9
|
from kmdr.core.utils import async_retry
|
|
10
|
+
from kmdr.core.constants import API_ROUTE
|
|
8
11
|
|
|
9
12
|
from .utils import extract_book_info_and_volumes
|
|
10
13
|
|
|
11
|
-
MY_FOLLOW_URL = 'https://kox.moe/myfollow.php'
|
|
12
|
-
|
|
13
14
|
@LISTERS.register()
|
|
14
15
|
class FollowedBookLister(Lister):
|
|
15
16
|
|
|
@@ -62,7 +63,7 @@ class FollowedBookLister(Lister):
|
|
|
62
63
|
|
|
63
64
|
@async_retry()
|
|
64
65
|
async def _list_followed_books(self) -> 'list[BookInfo]':
|
|
65
|
-
async with self._session.get(
|
|
66
|
+
async with self._session.get(urljoin(self._base_url, API_ROUTE.MY_FOLLOW)) as response:
|
|
66
67
|
response.raise_for_status()
|
|
67
68
|
html_text = await response.text()
|
|
68
69
|
|
kmdr/module/lister/utils.py
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from bs4 import BeautifulSoup
|
|
2
2
|
import re
|
|
3
3
|
from typing import Optional
|
|
4
|
+
from urllib.parse import urljoin
|
|
4
5
|
|
|
6
|
+
from yarl import URL
|
|
5
7
|
from aiohttp import ClientSession as Session
|
|
6
8
|
|
|
7
9
|
from kmdr.core import BookInfo, VolInfo, VolumeType
|
|
@@ -16,14 +18,21 @@ async def extract_book_info_and_volumes(session: Session, url: str, book_info: O
|
|
|
16
18
|
:param url: 书籍页面的 URL。
|
|
17
19
|
:return: 包含书籍信息和卷信息的元组。
|
|
18
20
|
"""
|
|
19
|
-
|
|
21
|
+
structured_url = URL(url)
|
|
22
|
+
|
|
23
|
+
if structured_url.path.startswith('/m/'):
|
|
24
|
+
# 移除移动端路径部分,统一为桌面端路径
|
|
25
|
+
# 因为移动端页面的结构与桌面端不同,可能会影响解析
|
|
26
|
+
structured_url = structured_url.with_path(structured_url.path.replace('/m/', '', 1))
|
|
27
|
+
|
|
28
|
+
async with session.get(structured_url) as response:
|
|
20
29
|
response.raise_for_status()
|
|
21
30
|
|
|
22
31
|
# 如果后续有性能问题,可以先考虑使用 lxml 进行解析
|
|
23
32
|
book_page = BeautifulSoup(await response.text(), 'html.parser')
|
|
24
33
|
|
|
25
34
|
book_info = __extract_book_info(url, book_page, book_info)
|
|
26
|
-
volumes = await __extract_volumes(session, book_page)
|
|
35
|
+
volumes = await __extract_volumes(session, url, book_page)
|
|
27
36
|
|
|
28
37
|
return book_info, volumes
|
|
29
38
|
|
|
@@ -42,13 +51,13 @@ def __extract_book_info(url: str, book_page: BeautifulSoup, book_info: Optional[
|
|
|
42
51
|
)
|
|
43
52
|
|
|
44
53
|
|
|
45
|
-
async def __extract_volumes(session: Session, book_page: BeautifulSoup) -> list[VolInfo]:
|
|
54
|
+
async def __extract_volumes(session: Session, url: str, book_page: BeautifulSoup) -> list[VolInfo]:
|
|
46
55
|
script = book_page.find_all('script', language="javascript")[-1].text
|
|
47
56
|
|
|
48
57
|
pattern = re.compile(r'/book_data.php\?h=\w+')
|
|
49
58
|
book_data_url = pattern.search(script).group(0)
|
|
50
59
|
|
|
51
|
-
async with session.get(url =
|
|
60
|
+
async with session.get(url = urljoin(url, book_data_url)) as response:
|
|
52
61
|
response.raise_for_status()
|
|
53
62
|
|
|
54
63
|
book_data = (await response.text()).split('\n')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kmoe-manga-downloader
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.1
|
|
4
4
|
Summary: A CLI-downloader for site @kox.moe.
|
|
5
5
|
Author-email: Chris Zheng <chrisis58@outlook.com>
|
|
6
6
|
License: MIT License
|
|
@@ -35,19 +35,20 @@ Classifier: Operating System :: OS Independent
|
|
|
35
35
|
Requires-Python: >=3.9
|
|
36
36
|
Description-Content-Type: text/markdown
|
|
37
37
|
License-File: LICENSE
|
|
38
|
-
Requires-Dist: deprecation~=2.1.0
|
|
39
38
|
Requires-Dist: beautifulsoup4~=4.13.4
|
|
40
39
|
Requires-Dist: aiohttp~=3.12.15
|
|
41
40
|
Requires-Dist: aiofiles~=24.1.0
|
|
42
41
|
Requires-Dist: async-lru~=2.0.5
|
|
43
42
|
Requires-Dist: rich~=13.9.4
|
|
43
|
+
Requires-Dist: yarl~=1.20.1
|
|
44
|
+
Requires-Dist: typing-extensions~=4.15.0
|
|
44
45
|
Dynamic: license-file
|
|
45
46
|
|
|
46
47
|
# Kmoe Manga Downloader
|
|
47
48
|
|
|
48
49
|
[](https://pepy.tech/projects/kmoe-manga-downloader) [](https://pypi.org/project/kmoe-manga-downloader/) [](https://github.com/chrisis58/kmdr/actions/workflows/unit-test.yml) [](https://www.python.org/) [](https://github.com/chrisis58/kmdr/blob/main/LICENSE)
|
|
49
50
|
|
|
50
|
-
`kmdr (Kmoe Manga Downloader)` 是一个 Python
|
|
51
|
+
`kmdr (Kmoe Manga Downloader)` 是一个 Python 终端应用,用于从 [Kmoe](https://kxx.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
|
|
51
52
|
|
|
52
53
|
<p align="center">
|
|
53
54
|
<img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
|
|
@@ -55,11 +56,17 @@ Dynamic: license-file
|
|
|
55
56
|
|
|
56
57
|
## ✨功能特性
|
|
57
58
|
|
|
58
|
-
- **现代化终端界面**:
|
|
59
|
+
- **现代化终端界面**: 基于 [rich](https://github.com/Textualize/rich) 构建的终端用户界面(TUI),提供进度条和菜单等现代化、美观的交互式终端界面。
|
|
59
60
|
- **凭证和配置管理**: 应用自动维护登录凭证和下载设置,实现一次配置、持久有效,提升使用效率。
|
|
60
|
-
- **高效下载的性能**: 采用 `asyncio`
|
|
61
|
-
- **强大的高可用性**:
|
|
62
|
-
- **灵活的自动化接口**:
|
|
61
|
+
- **高效下载的性能**: 采用 `asyncio` 并发分片下载方式,充分利用网络带宽,显著加速单个大文件的下载速度。
|
|
62
|
+
- **强大的高可用性**: 内置自动重试与断点续传机制,无惧网络中断,确保下载任务在不稳定环境下依然能够成功。
|
|
63
|
+
- **灵活的自动化接口**: 支持在每个文件下载成功后自动执行自定义回调脚本,轻松集成到您的自动化工作流。
|
|
64
|
+
|
|
65
|
+
## 🖼️ 使用场景
|
|
66
|
+
|
|
67
|
+
- **通用的加速体验**: 采用并发分片下载方式,充分地利用不同类型用户的网络带宽,提升数据传输效率,从而有效缩短下载的等待时间。
|
|
68
|
+
- **灵活部署与远程控制**: 支持在远端服务器或 NAS 上运行,可以在其他设备(PC、平板)上浏览,而通过简单的命令触发远程下载任务,实现浏览与存储的分离。
|
|
69
|
+
- **智能化自动追新**: 应用支持识别重复内容,可配合定时任务实等现无人值守下载最新的内容,轻松打造时刻保持同步的个人资料库。
|
|
63
70
|
|
|
64
71
|
## 🛠️安装应用
|
|
65
72
|
|
|
@@ -73,7 +80,7 @@ pip install kmoe-manga-downloader
|
|
|
73
80
|
|
|
74
81
|
### 1. 登录 `kmoe`
|
|
75
82
|
|
|
76
|
-
首先需要登录 `
|
|
83
|
+
首先需要登录 `kmoe` 并保存登录状态(Cookie)。
|
|
77
84
|
|
|
78
85
|
```bash
|
|
79
86
|
kmdr login -u <your_username> -p <your_password>
|
|
@@ -91,26 +98,28 @@ kmdr login -u <your_username>
|
|
|
91
98
|
|
|
92
99
|
```bash
|
|
93
100
|
# 在当前目录下载第一、二、三卷
|
|
94
|
-
kmdr download --dest . --book-url https://
|
|
95
|
-
|
|
101
|
+
kmdr download --dest . --book-url https://kxx.moe/c/50076.htm --volume 1,2,3
|
|
102
|
+
# 下面命令的功能与上面相同
|
|
103
|
+
kmdr download -l https://kxx.moe/c/50076.htm -v 1-3
|
|
96
104
|
```
|
|
97
105
|
|
|
98
106
|
```bash
|
|
99
107
|
# 在目标目录下载全部番外篇
|
|
100
|
-
kmdr download --dest path/to/destination --book-url https://
|
|
101
|
-
|
|
108
|
+
kmdr download --dest path/to/destination --book-url https://kxx.moe/c/50076.htm --vol-type extra -v all
|
|
109
|
+
# 下面命令的功能与上面相同
|
|
110
|
+
kmdr download -d path/to/destination -l https://kxx.moe/c/50076.htm -t extra -v all
|
|
102
111
|
```
|
|
103
112
|
|
|
104
113
|
#### 常用参数说明:
|
|
105
114
|
|
|
106
|
-
- `-d`, `--dest`:
|
|
107
|
-
- `-l`, `--book-url`:
|
|
108
|
-
- `-v`, `--volume`:
|
|
115
|
+
- `-d`, `--dest`: 下载的目标目录(默认为当前目录),在此基础上会额外添加一个为漫画名称的子目录
|
|
116
|
+
- `-l`, `--book-url`: 指定漫画的主页地址
|
|
117
|
+
- `-v`, `--volume`: 指定下载的卷,多个用逗号分隔,例如 `1,2,3` 或 `1-5,8`,`all` 表示全部
|
|
109
118
|
- `-t`, `--vol-type`: 卷类型,`vol`: 单行本(默认);`extra`: 番外;`seri`: 连载话;`all`: 全部
|
|
110
119
|
- `-p`, `--proxy`: 代理服务器地址
|
|
111
|
-
- `-r`, `--retry`:
|
|
120
|
+
- `-r`, `--retry`: 下载失败时的重试次数,默认为 3
|
|
112
121
|
- `-c`, `--callback`: 下载完成后的回调脚本(使用方式详见 [4. 回调函数](https://github.com/chrisis58/kmoe-manga-downlaoder?tab=readme-ov-file#4-%E5%9B%9E%E8%B0%83%E5%87%BD%E6%95%B0))
|
|
113
|
-
- `--num-workers`:
|
|
122
|
+
- `--num-workers`: 最大下载并发数量,默认为 8
|
|
114
123
|
|
|
115
124
|
> 完整的参数说明可以从 `help` 指令中获取。
|
|
116
125
|
|
|
@@ -127,7 +136,7 @@ kmdr status
|
|
|
127
136
|
你可以设置一个回调函数,下载完成后执行。回调可以是任何你想要的命令:
|
|
128
137
|
|
|
129
138
|
```bash
|
|
130
|
-
kmdr download -d path/to/destination --book-url https://
|
|
139
|
+
kmdr download -d path/to/destination --book-url https://kxx.moe/c/50076.htm -v 1-3 \
|
|
131
140
|
--callback "echo '{b.name} {v.name} downloaded!' >> ~/kmdr.log"
|
|
132
141
|
```
|
|
133
142
|
|
|
@@ -143,7 +152,7 @@ kmdr download -d path/to/destination --book-url https://kox.moe/c/50076.htm -v 1
|
|
|
143
152
|
| b.name | 对应漫画的名字 |
|
|
144
153
|
| b.author | 对应漫画的作者 |
|
|
145
154
|
|
|
146
|
-
> 完整的可用参数请参考 [structure.py](https://github.com/chrisis58/
|
|
155
|
+
> 完整的可用参数请参考 [structure.py](https://github.com/chrisis58/kmoe-manga-downloader/blob/main/src/kmdr/core/structure.py#L11) 中关于 `VolInfo` 的定义。
|
|
147
156
|
|
|
148
157
|
### 5. 持久化配置
|
|
149
158
|
|
|
@@ -167,6 +176,20 @@ kmdr config -s num_workers=5 "callback=echo '{b.name} {v.name} downloaded!' >> ~
|
|
|
167
176
|
|
|
168
177
|
> 当前仅支持部分下载参数的持久化:`num_workers`, `dest`, `retry`, `callback`, `proxy`
|
|
169
178
|
|
|
179
|
+
### 6. 镜像源切换
|
|
180
|
+
|
|
181
|
+
为了保证服务的长期可用性,并让用户能根据自己的网络环境选择最快的服务器,应用支持灵活地切换镜像源。
|
|
182
|
+
|
|
183
|
+
当您发现默认源(当前为 `kxx.moe`)访问缓慢或失效时,可以通过 `config` 命令轻松切换到其他备用镜像源:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
kmdr config --base-url https://mox.moe
|
|
187
|
+
# 或者
|
|
188
|
+
kmdr config -b https://mox.moe
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
你可以参考 [镜像目录](./mirror/mirrors.json) 来选择合适的镜像源,如果你发现部分镜像源过时或者有缺失,欢迎贡献你的内容!
|
|
192
|
+
|
|
170
193
|
## ⚠️ 声明
|
|
171
194
|
|
|
172
195
|
- 本工具仅作学习、研究、交流使用,使用本工具的用户应自行承担风险
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
kmdr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
kmdr/main.py,sha256=EubhpLIrGHl9XjHXdmhROg3RBKyvHPuPaZPTaSYDsxQ,1355
|
|
3
|
+
kmdr/core/__init__.py,sha256=mTBPcw-mEKfHPV1bgwDx-JoO2G6dQ9QcOnQiEMmi7DE,342
|
|
4
|
+
kmdr/core/bases.py,sha256=muUjcuEm-n51YTHiU7LTwfQm_AwkcNJ5TzIdPZNpK0o,3929
|
|
5
|
+
kmdr/core/constants.py,sha256=_jY56Tpsg7U7Xw4Ht4Q46W4fNu8kyNCEicoTjGgKVUI,1802
|
|
6
|
+
kmdr/core/context.py,sha256=cy4_Iar-dpsWQiLGIJaitNEVw6uwRkzdvF6iYDjLWl0,930
|
|
7
|
+
kmdr/core/defaults.py,sha256=6A3nECLdbb3zVngdFKn_U1GrtcJRMor8Ju4XebCutU8,8459
|
|
8
|
+
kmdr/core/error.py,sha256=1UR16-5E-51hV8Rtg1Ua0Ro7jGfmLpFsTBFkoD6hmto,884
|
|
9
|
+
kmdr/core/registry.py,sha256=KYjvww5WRuUg5SeIeWZb96803-kgrTIKYvFTj_7bPfo,5560
|
|
10
|
+
kmdr/core/session.py,sha256=lFH4lnriwqvw-tlB3MEgRedX7h_LdtaR21Rfo-HAhRs,498
|
|
11
|
+
kmdr/core/structure.py,sha256=EQG1-8kHQ22Not2S7Q_jGxE0nb9XUbLh3JcNTb_nkvk,1199
|
|
12
|
+
kmdr/core/utils.py,sha256=MbJQM5943IEVr4J4j7zdPKuepKh3iJmI5fy6QYnIVr8,2424
|
|
13
|
+
kmdr/module/__init__.py,sha256=hY6lMBpxSn93t7OKnEbXUwTok2XqQoQbuyq1HLeC4Kc,125
|
|
14
|
+
kmdr/module/authenticator/CookieAuthenticator.py,sha256=ur_MtaeNmcrLQYpFplTvVGyd1EocR-_jocPj6K5z1uY,1353
|
|
15
|
+
kmdr/module/authenticator/LoginAuthenticator.py,sha256=8xtCIwR_GkHniJc2JMYwRYLdL4KfQ8d5phEz_NfzMpU,2529
|
|
16
|
+
kmdr/module/authenticator/__init__.py,sha256=iSWhq-suAM1BrABRuTWiCKvZ1fkmf5HQ2GmbZ341GK0,103
|
|
17
|
+
kmdr/module/authenticator/utils.py,sha256=7_oHc-9Ab48T-cTNMWEke6_OI0Z3rg-eb5LyzRzCE9M,3179
|
|
18
|
+
kmdr/module/configurer/BaseUrlUpdator.py,sha256=cTOadDJI38rIVsCb1UvOqODY_UnxUY-1TzR-tMwKcME,501
|
|
19
|
+
kmdr/module/configurer/ConfigClearer.py,sha256=2Ra-EV4fuDilWwoUS8NBSL1__-50D0YD0P6kHurH62c,480
|
|
20
|
+
kmdr/module/configurer/ConfigUnsetter.py,sha256=WVCETKg80vGCDEoaN5XWG7MYDJo-eGit-n53rBk46ZE,597
|
|
21
|
+
kmdr/module/configurer/OptionLister.py,sha256=wHcx8XGjjUL6Nj4K909aqikVK06UE7CXPo6XkZc3q3c,1466
|
|
22
|
+
kmdr/module/configurer/OptionSetter.py,sha256=g3Nid0cG0VFhEguAcHwsOpfOluf-8lKpwZLemrUfP-A,893
|
|
23
|
+
kmdr/module/configurer/__init__.py,sha256=nSWGwUCcwOkvg28zxRd0S3b4jeag_W0IhzP74xq0ewE,204
|
|
24
|
+
kmdr/module/configurer/option_validate.py,sha256=Mn91UmpTI8zVV9Em8rL2mjNV21h0iYlzT8xoWDLEI68,3387
|
|
25
|
+
kmdr/module/downloader/DirectDownloader.py,sha256=IedLaw6cGH-7Rwsvvs_27kqCDbzfHnNT5GP6ERHEo9o,1370
|
|
26
|
+
kmdr/module/downloader/ReferViaDownloader.py,sha256=oM9S8BURmMQL3KPSkb0XJw-SvSIzCiJsWse4XIahKCY,1887
|
|
27
|
+
kmdr/module/downloader/__init__.py,sha256=PajPBQ4ZJwz_6Ok6k-HV7-YSibxAW5m5voYyP-U-_f4,97
|
|
28
|
+
kmdr/module/downloader/download_utils.py,sha256=CXpsMujgAxCQbokJ9RiQdKFozr_AgbhkJ9n-V_JWr4U,11882
|
|
29
|
+
kmdr/module/downloader/misc.py,sha256=-gKmqqENQ2ukTVou9cDan0K2MDfwX43W8LyTw3o-oVM,1816
|
|
30
|
+
kmdr/module/lister/BookUrlLister.py,sha256=R1sak_GQG8HKfdSzHF-Dy1iby6TgFlxKJLf2dwBr0Bw,550
|
|
31
|
+
kmdr/module/lister/FollowedBookLister.py,sha256=WeMm7c81hbLr5TkrgQiBx8pQ1odOxWXn_xvQ9LxYZYs,2933
|
|
32
|
+
kmdr/module/lister/__init__.py,sha256=VVRMRGXASdIagzlmOuhy9gKjJzf8Rj3000BPzeWpHHE,91
|
|
33
|
+
kmdr/module/lister/utils.py,sha256=iUA0zu8JzKUouT3rwM_fr8knlHSPqApqeGSYOFI2D7I,3575
|
|
34
|
+
kmdr/module/picker/ArgsFilterPicker.py,sha256=wBCDa6KsSSOLLQk8D4ftZ9ZnwPLiHIirxf6gGBq3I08,1782
|
|
35
|
+
kmdr/module/picker/DefaultVolPicker.py,sha256=XD5mo48bwpa6T69AIxWJVvrX1daPsxyG8AFsJ4RLTBc,1760
|
|
36
|
+
kmdr/module/picker/__init__.py,sha256=qYELEkv0nFT8DadItB6fdUTED2CHrC43wuPKO7QrCCQ,93
|
|
37
|
+
kmdr/module/picker/utils.py,sha256=lpxM7q9BJeupFQy8glBrHu1o4E38dk7iLexzKytAE6g,1222
|
|
38
|
+
kmoe_manga_downloader-1.2.1.dist-info/licenses/LICENSE,sha256=bKQlsXu8mAYKRZyoZKOEqMcCc8YjT5Q3Hgr21e0yU4E,1068
|
|
39
|
+
kmoe_manga_downloader-1.2.1.dist-info/METADATA,sha256=0YGgNLfT2bFWscpO-N3QWsDbu19hMEYcXlb5yFbo3Wc,9693
|
|
40
|
+
kmoe_manga_downloader-1.2.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
41
|
+
kmoe_manga_downloader-1.2.1.dist-info/entry_points.txt,sha256=DGMytQAhx4uNuKQL7BPkiWESHLXkH-2KSEqwHdygNPA,47
|
|
42
|
+
kmoe_manga_downloader-1.2.1.dist-info/top_level.txt,sha256=e0qxOgWp0tl3GLpmXGjZv3--q_TLoJ7GztM48Ov27wM,5
|
|
43
|
+
kmoe_manga_downloader-1.2.1.dist-info/RECORD,,
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
kmdr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
kmdr/main.py,sha256=EubhpLIrGHl9XjHXdmhROg3RBKyvHPuPaZPTaSYDsxQ,1355
|
|
3
|
-
kmdr/core/__init__.py,sha256=mTBPcw-mEKfHPV1bgwDx-JoO2G6dQ9QcOnQiEMmi7DE,342
|
|
4
|
-
kmdr/core/bases.py,sha256=JbwFlYdwQf4APCqanbJaCYtNzfiHOyvRkiIP5CKIvcc,3397
|
|
5
|
-
kmdr/core/context.py,sha256=fNxgQxmS9kX7ORURGjt_REtuTa1UGl796DeWaszf9pI,710
|
|
6
|
-
kmdr/core/defaults.py,sha256=dafYcqWcz8OveV-mRI92ScRgx7y6n3pdFUpnHYUqBKk,7649
|
|
7
|
-
kmdr/core/error.py,sha256=NBsa3eXiL8IPaKsYhZ1KXfVjdfmaM91UhtyH9tr7JCU,633
|
|
8
|
-
kmdr/core/registry.py,sha256=KYjvww5WRuUg5SeIeWZb96803-kgrTIKYvFTj_7bPfo,5560
|
|
9
|
-
kmdr/core/session.py,sha256=lFH4lnriwqvw-tlB3MEgRedX7h_LdtaR21Rfo-HAhRs,498
|
|
10
|
-
kmdr/core/structure.py,sha256=JHTuEHuRJLx1dEgg59WDJCZKORvwPJWuAb6nT1cSFNA,1163
|
|
11
|
-
kmdr/core/utils.py,sha256=mqlsHRm7LnymcBr-BKKf1Uq6tF6W4101wY45A-mzg3Q,2015
|
|
12
|
-
kmdr/module/__init__.py,sha256=aSuTcHhuBHUi1xNj5HTsXxhvegGBTRPNKj4JAPLC7LE,319
|
|
13
|
-
kmdr/module/authenticator/CookieAuthenticator.py,sha256=4upqvHSWZk_EyTACkEdDsBK0VO4pdUTYq5ilImAQPNE,1143
|
|
14
|
-
kmdr/module/authenticator/LoginAuthenticator.py,sha256=kEBGFCO-hXMmIVJ7-F-7Me_a2IqNGy9j9kaUlNWHU_M,2025
|
|
15
|
-
kmdr/module/authenticator/utils.py,sha256=MytwJ7jAUKJHPiQ7nCOVJN4fv1aQMyqLDTLQXvcs28k,2806
|
|
16
|
-
kmdr/module/configurer/ConfigClearer.py,sha256=2Ra-EV4fuDilWwoUS8NBSL1__-50D0YD0P6kHurH62c,480
|
|
17
|
-
kmdr/module/configurer/ConfigUnsetter.py,sha256=WVCETKg80vGCDEoaN5XWG7MYDJo-eGit-n53rBk46ZE,597
|
|
18
|
-
kmdr/module/configurer/OptionLister.py,sha256=iP8AmYLh885axpSdTexnWtgGx1r93Ko3IC-uKmCh8zE,1093
|
|
19
|
-
kmdr/module/configurer/OptionSetter.py,sha256=g3Nid0cG0VFhEguAcHwsOpfOluf-8lKpwZLemrUfP-A,893
|
|
20
|
-
kmdr/module/configurer/option_validate.py,sha256=Mn91UmpTI8zVV9Em8rL2mjNV21h0iYlzT8xoWDLEI68,3387
|
|
21
|
-
kmdr/module/downloader/DirectDownloader.py,sha256=BKxU3_Wky9HyFJ62SihUyvrPI2LM1jMDjrcbHRntAEg,1158
|
|
22
|
-
kmdr/module/downloader/ReferViaDownloader.py,sha256=7ElAe_vXOjQJZ3ICtcMWIwVUdvY5_iBn1CX9zomeRgA,1703
|
|
23
|
-
kmdr/module/downloader/utils.py,sha256=r6AdBRihLAsRkmH563czgwGe2NXyvyRWWIQcE0yen8c,11206
|
|
24
|
-
kmdr/module/lister/BookUrlLister.py,sha256=R1sak_GQG8HKfdSzHF-Dy1iby6TgFlxKJLf2dwBr0Bw,550
|
|
25
|
-
kmdr/module/lister/FollowedBookLister.py,sha256=9ZL6pC5nnxZos9ymbdKIuUNwjiCwcfakiZRNcbLrYzI,2874
|
|
26
|
-
kmdr/module/lister/utils.py,sha256=gVIRgEf41wBSji9TGWvxdp6XE4RLhQGy6xDKJsO8gE4,3182
|
|
27
|
-
kmdr/module/picker/ArgsFilterPicker.py,sha256=wBCDa6KsSSOLLQk8D4ftZ9ZnwPLiHIirxf6gGBq3I08,1782
|
|
28
|
-
kmdr/module/picker/DefaultVolPicker.py,sha256=XD5mo48bwpa6T69AIxWJVvrX1daPsxyG8AFsJ4RLTBc,1760
|
|
29
|
-
kmdr/module/picker/utils.py,sha256=lpxM7q9BJeupFQy8glBrHu1o4E38dk7iLexzKytAE6g,1222
|
|
30
|
-
kmoe_manga_downloader-1.2.0.dist-info/licenses/LICENSE,sha256=bKQlsXu8mAYKRZyoZKOEqMcCc8YjT5Q3Hgr21e0yU4E,1068
|
|
31
|
-
kmoe_manga_downloader-1.2.0.dist-info/METADATA,sha256=IJ-UGffCDmAt8ox28Mx1NOAo_MdC3hWxsdxxXrw5rQQ,8317
|
|
32
|
-
kmoe_manga_downloader-1.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
33
|
-
kmoe_manga_downloader-1.2.0.dist-info/entry_points.txt,sha256=DGMytQAhx4uNuKQL7BPkiWESHLXkH-2KSEqwHdygNPA,47
|
|
34
|
-
kmoe_manga_downloader-1.2.0.dist-info/top_level.txt,sha256=e0qxOgWp0tl3GLpmXGjZv3--q_TLoJ7GztM48Ov27wM,5
|
|
35
|
-
kmoe_manga_downloader-1.2.0.dist-info/RECORD,,
|
|
File without changes
|
{kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/top_level.txt
RENAMED
|
File without changes
|