kmoe-manga-downloader 1.2.2__py3-none-any.whl → 1.2.3__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/__init__.py +4 -0
- kmdr/_version.py +34 -0
- kmdr/core/__init__.py +4 -2
- kmdr/core/bases.py +14 -5
- kmdr/core/console.py +67 -0
- kmdr/core/context.py +14 -3
- kmdr/core/defaults.py +19 -7
- kmdr/core/error.py +7 -0
- kmdr/core/session.py +6 -2
- kmdr/main.py +29 -10
- kmdr/module/authenticator/utils.py +11 -4
- kmdr/module/configurer/BaseUrlUpdator.py +3 -2
- kmdr/module/configurer/ConfigClearer.py +3 -2
- kmdr/module/configurer/ConfigUnsetter.py +3 -2
- kmdr/module/configurer/OptionLister.py +3 -2
- kmdr/module/configurer/OptionSetter.py +3 -2
- kmdr/module/configurer/option_validate.py +11 -11
- kmdr/module/downloader/DirectDownloader.py +1 -1
- kmdr/module/downloader/ReferViaDownloader.py +2 -0
- kmdr/module/downloader/download_utils.py +22 -7
- kmdr/module/lister/FollowedBookLister.py +3 -2
- kmdr/module/lister/utils.py +5 -1
- kmdr/module/picker/DefaultVolPicker.py +2 -1
- kmdr/module/picker/utils.py +14 -6
- {kmoe_manga_downloader-1.2.2.dist-info → kmoe_manga_downloader-1.2.3.dist-info}/METADATA +21 -4
- kmoe_manga_downloader-1.2.3.dist-info/RECORD +46 -0
- kmoe_manga_downloader-1.2.2.dist-info/RECORD +0 -44
- {kmoe_manga_downloader-1.2.2.dist-info → kmoe_manga_downloader-1.2.3.dist-info}/WHEEL +0 -0
- {kmoe_manga_downloader-1.2.2.dist-info → kmoe_manga_downloader-1.2.3.dist-info}/entry_points.txt +0 -0
- {kmoe_manga_downloader-1.2.2.dist-info → kmoe_manga_downloader-1.2.3.dist-info}/licenses/LICENSE +0 -0
- {kmoe_manga_downloader-1.2.2.dist-info → kmoe_manga_downloader-1.2.3.dist-info}/top_level.txt +0 -0
kmdr/__init__.py
CHANGED
kmdr/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '1.2.3'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 2, 3)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
kmdr/core/__init__.py
CHANGED
|
@@ -2,8 +2,10 @@ from .bases import Authenticator, Lister, Picker, Downloader, Configurer, Sessio
|
|
|
2
2
|
from .structure import VolInfo, BookInfo, VolumeType
|
|
3
3
|
from .bases import AUTHENTICATOR, LISTERS, PICKERS, DOWNLOADER, CONFIGURER, SESSION_MANAGER
|
|
4
4
|
|
|
5
|
-
from .defaults import argument_parser,
|
|
5
|
+
from .defaults import argument_parser, post_init
|
|
6
6
|
|
|
7
7
|
from .error import KmdrError, LoginError
|
|
8
8
|
|
|
9
|
-
from .session import KmdrSessionManager
|
|
9
|
+
from .session import KmdrSessionManager
|
|
10
|
+
|
|
11
|
+
from .console import info, debug, exception, log
|
kmdr/core/bases.py
CHANGED
|
@@ -4,6 +4,7 @@ from abc import abstractmethod
|
|
|
4
4
|
import asyncio
|
|
5
5
|
from aiohttp import ClientSession
|
|
6
6
|
|
|
7
|
+
from .console import *
|
|
7
8
|
from .error import LoginError
|
|
8
9
|
from .registry import Registry
|
|
9
10
|
from .structure import VolInfo, BookInfo
|
|
@@ -41,8 +42,8 @@ class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalC
|
|
|
41
42
|
try:
|
|
42
43
|
assert await async_retry()(self._authenticate)()
|
|
43
44
|
except LoginError as e:
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
info(f"[yellow]详细信息:{e}[/yellow]")
|
|
46
|
+
info("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
|
|
46
47
|
exit(1)
|
|
47
48
|
|
|
48
49
|
@abstractmethod
|
|
@@ -82,16 +83,24 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
|
|
|
82
83
|
|
|
83
84
|
async def download(self, book: BookInfo, volumes: list[VolInfo]):
|
|
84
85
|
if not volumes:
|
|
85
|
-
|
|
86
|
+
info("没有可下载的卷。", style="blue")
|
|
86
87
|
exit(0)
|
|
87
88
|
|
|
88
89
|
try:
|
|
89
90
|
with self._progress:
|
|
90
91
|
tasks = [self._download(book, volume) for volume in volumes]
|
|
91
|
-
await asyncio.gather(*tasks, return_exceptions=True)
|
|
92
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
93
|
+
|
|
94
|
+
exceptions = [res for res in results if isinstance(res, Exception)]
|
|
95
|
+
if exceptions:
|
|
96
|
+
info(f"[red]下载过程中出现 {len(exceptions)} 个错误:[/red]")
|
|
97
|
+
for exc in exceptions:
|
|
98
|
+
info(f"[red]- {exc}[/red]")
|
|
99
|
+
exception(exc)
|
|
100
|
+
exit(1)
|
|
92
101
|
|
|
93
102
|
except KeyboardInterrupt:
|
|
94
|
-
|
|
103
|
+
info("\n操作已取消(KeyboardInterrupt)")
|
|
95
104
|
exit(130)
|
|
96
105
|
|
|
97
106
|
@abstractmethod
|
kmdr/core/console.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KMDR 用于管理控制台输出的模块。
|
|
3
|
+
|
|
4
|
+
提供信息、调试和日志记录功能,确保在交互式和非交互式环境中均能正确输出。
|
|
5
|
+
"""
|
|
6
|
+
from typing import Any
|
|
7
|
+
import sys
|
|
8
|
+
import io
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.traceback import Traceback
|
|
12
|
+
|
|
13
|
+
from kmdr.core.defaults import is_verbose
|
|
14
|
+
|
|
15
|
+
_console_config = dict[str, Any](
|
|
16
|
+
log_time_format="[%Y-%m-%d %H:%M:%S]",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
utf8_stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='backslashreplace')
|
|
21
|
+
_console_config['file'] = utf8_stdout
|
|
22
|
+
except io.UnsupportedOperation:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
_console = Console(**_console_config)
|
|
26
|
+
|
|
27
|
+
def info(*args, **kwargs):
|
|
28
|
+
"""
|
|
29
|
+
在终端中输出信息
|
|
30
|
+
|
|
31
|
+
会根据终端是否为交互式选择合适的输出方式。
|
|
32
|
+
"""
|
|
33
|
+
if _console.is_interactive:
|
|
34
|
+
_console.print(*args, **kwargs)
|
|
35
|
+
else:
|
|
36
|
+
_console.log(*args, **kwargs, _stack_offset=2)
|
|
37
|
+
|
|
38
|
+
def debug(*args, **kwargs):
|
|
39
|
+
"""
|
|
40
|
+
在终端中输出调试信息
|
|
41
|
+
|
|
42
|
+
`info` 的条件版本,仅当启用详细模式时才会输出。
|
|
43
|
+
"""
|
|
44
|
+
if is_verbose():
|
|
45
|
+
if _console.is_interactive:
|
|
46
|
+
_console.print("[dim]DEBUG:[/]", *args, **kwargs)
|
|
47
|
+
else:
|
|
48
|
+
_console.log("DEBUG:", *args, **kwargs, _stack_offset=2)
|
|
49
|
+
|
|
50
|
+
def log(*args, debug=False, **kwargs):
|
|
51
|
+
"""
|
|
52
|
+
仅在非交互式终端中记录日志信息
|
|
53
|
+
|
|
54
|
+
:warning: 仅在非交互式终端中输出日志信息,避免干扰交互式用户界面。
|
|
55
|
+
"""
|
|
56
|
+
if _console.is_interactive:
|
|
57
|
+
# 如果是交互式终端,则不记录日志
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if debug and is_verbose():
|
|
61
|
+
# 仅在调试模式和启用详细模式时记录调试日志
|
|
62
|
+
_console.log("DEBUG:", *args, **kwargs, _stack_offset=2)
|
|
63
|
+
else:
|
|
64
|
+
_console.log(*args, **kwargs, _stack_offset=2)
|
|
65
|
+
|
|
66
|
+
def exception(exception: Exception):
|
|
67
|
+
_console.print((Traceback.from_exception(type(exception), exception, exception.__traceback__)))
|
kmdr/core/context.py
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
1
3
|
from aiohttp import ClientSession
|
|
4
|
+
from rich.progress import Progress
|
|
2
5
|
|
|
6
|
+
from .defaults import Configurer as InnerConfigurer, UserProfile, session_var, base_url_var, progress_definition
|
|
7
|
+
from .console import _console
|
|
3
8
|
|
|
4
|
-
|
|
9
|
+
_lazy_progress: Optional[Progress] = None
|
|
5
10
|
|
|
6
11
|
class TerminalContext:
|
|
7
12
|
|
|
8
13
|
def __init__(self, *args, **kwargs):
|
|
9
14
|
super().__init__()
|
|
10
|
-
self.
|
|
11
|
-
|
|
15
|
+
self._console = _console
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def _progress(self) -> Progress:
|
|
19
|
+
global _lazy_progress
|
|
20
|
+
if _lazy_progress is None:
|
|
21
|
+
_lazy_progress = Progress(*progress_definition, console=self._console)
|
|
22
|
+
return _lazy_progress
|
|
12
23
|
|
|
13
24
|
class UserProfileContext:
|
|
14
25
|
|
kmdr/core/defaults.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import sys
|
|
1
3
|
import os
|
|
2
4
|
import json
|
|
3
5
|
from typing import Optional, Any
|
|
@@ -21,12 +23,8 @@ HEADERS = {
|
|
|
21
23
|
'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
console = Console()
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
console.print(*args, **kwargs)
|
|
28
|
-
|
|
29
|
-
progress = Progress(
|
|
27
|
+
progress_definition = (
|
|
30
28
|
TextColumn("[blue]{task.fields[filename]}", justify="left"),
|
|
31
29
|
TextColumn("{task.fields[status]}", justify="right"),
|
|
32
30
|
TextColumn("{task.percentage:>3.1f}%"),
|
|
@@ -38,7 +36,6 @@ progress = Progress(
|
|
|
38
36
|
",",
|
|
39
37
|
TimeRemainingColumn(),
|
|
40
38
|
"]",
|
|
41
|
-
console=console,
|
|
42
39
|
)
|
|
43
40
|
|
|
44
41
|
session_var = ContextVar('session')
|
|
@@ -52,8 +49,13 @@ def argument_parser():
|
|
|
52
49
|
return parser
|
|
53
50
|
|
|
54
51
|
parser = argparse.ArgumentParser(description='Kmoe 漫画下载器')
|
|
52
|
+
|
|
53
|
+
parser.add_argument('-v', '--verbose', action='store_true', help='启用详细输出')
|
|
54
|
+
|
|
55
55
|
subparsers = parser.add_subparsers(title='可用的子命令', dest='command')
|
|
56
56
|
|
|
57
|
+
version_parser = subparsers.add_parser('version', help='显示当前版本信息')
|
|
58
|
+
|
|
57
59
|
download_parser = subparsers.add_parser('download', help='下载指定的漫画')
|
|
58
60
|
download_parser.add_argument('-d', '--dest', type=str, help='指定下载文件的保存路径,默认为当前目录', required=False)
|
|
59
61
|
download_parser.add_argument('-l', '--book-url', type=str, help='漫画详情页面的 URL', required=False)
|
|
@@ -65,6 +67,7 @@ def argument_parser():
|
|
|
65
67
|
download_parser.add_argument('-p', '--proxy', type=str, help='设置下载使用的代理服务器', required=False)
|
|
66
68
|
download_parser.add_argument('-r', '--retry', type=int, help='网络请求失败时的重试次数', required=False)
|
|
67
69
|
download_parser.add_argument('-c', '--callback', type=str, help='每个卷下载完成后执行的回调脚本,例如: `echo {v.name} downloaded!`', required=False)
|
|
70
|
+
download_parser.add_argument('-m', '--method', type=int, help='下载方法,对应网站上的不同下载方式', required=False, choices=[1, 2], default=1)
|
|
68
71
|
|
|
69
72
|
login_parser = subparsers.add_parser('login', help='登录到 Kmoe')
|
|
70
73
|
login_parser.add_argument('-u', '--username', type=str, help='用户名', required=True)
|
|
@@ -185,7 +188,7 @@ class Configurer:
|
|
|
185
188
|
self._config.base_url = value
|
|
186
189
|
self.update()
|
|
187
190
|
|
|
188
|
-
def get_base_url(self) -> str:
|
|
191
|
+
def get_base_url(self) -> Optional[str]:
|
|
189
192
|
return self._config.base_url
|
|
190
193
|
|
|
191
194
|
def update(self):
|
|
@@ -237,3 +240,12 @@ def combine_args(dest: argparse.Namespace) -> argparse.Namespace:
|
|
|
237
240
|
return __combine_args(dest, option)
|
|
238
241
|
|
|
239
242
|
base_url_var = ContextVar('base_url', default=Configurer().base_url)
|
|
243
|
+
|
|
244
|
+
_verbose = False
|
|
245
|
+
|
|
246
|
+
def is_verbose() -> bool:
|
|
247
|
+
return _verbose
|
|
248
|
+
|
|
249
|
+
def post_init(args) -> None:
|
|
250
|
+
global _verbose
|
|
251
|
+
_verbose = getattr(args, 'verbose', False)
|
kmdr/core/error.py
CHANGED
|
@@ -14,6 +14,13 @@ class InitializationError(KmdrError):
|
|
|
14
14
|
def __str__(self):
|
|
15
15
|
return f"{self.message}\n{self._solution}"
|
|
16
16
|
|
|
17
|
+
class ArgsResolveError(KmdrError):
|
|
18
|
+
def __init__(self, message, solution: Optional[list[str]] = None):
|
|
19
|
+
super().__init__(message, solution)
|
|
20
|
+
|
|
21
|
+
def __str__(self):
|
|
22
|
+
return f"{self.message}\n{self._solution}"
|
|
23
|
+
|
|
17
24
|
class LoginError(KmdrError):
|
|
18
25
|
def __init__(self, message, solution: Optional[list[str]] = None):
|
|
19
26
|
super().__init__(message, solution)
|
kmdr/core/session.py
CHANGED
|
@@ -9,6 +9,7 @@ from .bases import SESSION_MANAGER, SessionManager
|
|
|
9
9
|
from .defaults import HEADERS
|
|
10
10
|
from .error import InitializationError, RedirectError
|
|
11
11
|
from .protocol import Supplier
|
|
12
|
+
from .console import *
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
|
|
@@ -32,8 +33,10 @@ class KmdrSessionManager(SessionManager):
|
|
|
32
33
|
if book_url is not None and book_url.strip() != "" :
|
|
33
34
|
splited = urlsplit(book_url)
|
|
34
35
|
primary_base_url = f"{splited.scheme}://{splited.netloc}"
|
|
36
|
+
debug("提升书籍链接所在镜像地址优先级:", primary_base_url)
|
|
35
37
|
|
|
36
38
|
self._sorter.incr(primary_base_url, 10)
|
|
39
|
+
debug("镜像地址优先级排序:", self._sorter)
|
|
37
40
|
|
|
38
41
|
async def session(self) -> ClientSession:
|
|
39
42
|
try:
|
|
@@ -49,6 +52,7 @@ class KmdrSessionManager(SessionManager):
|
|
|
49
52
|
self._base_url = await self._probing_base_url()
|
|
50
53
|
# 持久化配置
|
|
51
54
|
self._configurer.set_base_url(self._base_url)
|
|
55
|
+
debug("使用的基础 URL:", self._base_url)
|
|
52
56
|
|
|
53
57
|
self._session = ClientSession(
|
|
54
58
|
base_url=self._base_url,
|
|
@@ -75,7 +79,7 @@ class KmdrSessionManager(SessionManager):
|
|
|
75
79
|
|
|
76
80
|
return response.status == 200
|
|
77
81
|
except Exception as e:
|
|
78
|
-
|
|
82
|
+
info(f"[yellow]无法连接到镜像: {url_supplier()},错误信息: {e}[/yellow]")
|
|
79
83
|
return False
|
|
80
84
|
|
|
81
85
|
async def _probing_base_url(self) -> str:
|
|
@@ -107,7 +111,7 @@ class KmdrSessionManager(SessionManager):
|
|
|
107
111
|
|
|
108
112
|
if await async_retry(
|
|
109
113
|
base_url_setter=set_base_url,
|
|
110
|
-
on_failure=lambda e:
|
|
114
|
+
on_failure=lambda e: info(f"[yellow]无法连接到镜像: {get_base_url()},错误信息: {e}[/yellow]"),
|
|
111
115
|
)(self.validate_url)(probe_session, get_base_url):
|
|
112
116
|
return get_base_url()
|
|
113
117
|
|
kmdr/main.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from kmdr import __version__
|
|
2
|
+
|
|
1
3
|
from typing import Callable
|
|
2
4
|
from argparse import Namespace
|
|
3
5
|
import asyncio
|
|
@@ -7,28 +9,39 @@ from kmdr.module import *
|
|
|
7
9
|
|
|
8
10
|
async def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
post_init(args)
|
|
13
|
+
log('[Lifecycle:Start] 启动 kmdr, 版本', __version__)
|
|
14
|
+
debug('[bold green]以调试模式启动[/bold green]')
|
|
15
|
+
debug('接收到的参数:', args)
|
|
16
|
+
|
|
17
|
+
if args.command == 'version':
|
|
18
|
+
info(f"[green]{__version__}[/green]")
|
|
19
|
+
|
|
20
|
+
elif args.command == 'config':
|
|
11
21
|
CONFIGURER.get(args).operate()
|
|
12
|
-
return
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
elif args.command == 'login':
|
|
24
|
+
async with (await SESSION_MANAGER.get(args).session()):
|
|
16
25
|
await AUTHENTICATOR.get(args).authenticate()
|
|
17
26
|
|
|
18
|
-
|
|
27
|
+
elif args.command == 'status':
|
|
28
|
+
async with (await SESSION_MANAGER.get(args).session()):
|
|
19
29
|
await AUTHENTICATOR.get(args).authenticate()
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
elif args.command == 'download':
|
|
32
|
+
async with (await SESSION_MANAGER.get(args).session()):
|
|
22
33
|
await AUTHENTICATOR.get(args).authenticate()
|
|
23
34
|
|
|
24
35
|
book, volumes = await LISTERS.get(args).list()
|
|
36
|
+
debug("获取到书籍《", book.name, "》及其", len(volumes), "个章节信息。")
|
|
25
37
|
|
|
26
38
|
volumes = PICKERS.get(args).pick(volumes)
|
|
39
|
+
debug("选择了", len(volumes), "个章节进行下载:", ', '.join(volume.name for volume in volumes))
|
|
27
40
|
|
|
28
41
|
await DOWNLOADER.get(args).download(book, volumes)
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
else:
|
|
44
|
+
fallback()
|
|
32
45
|
|
|
33
46
|
def main_sync(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
34
47
|
asyncio.run(main(args, fallback))
|
|
@@ -41,11 +54,17 @@ def entry_point():
|
|
|
41
54
|
main_coro = main(args, lambda: parser.print_help())
|
|
42
55
|
asyncio.run(main_coro)
|
|
43
56
|
except KmdrError as e:
|
|
44
|
-
|
|
57
|
+
info(f"[red]错误: {e}[/red]")
|
|
45
58
|
exit(1)
|
|
46
59
|
except KeyboardInterrupt:
|
|
47
|
-
|
|
60
|
+
info("\n操作已取消(KeyboardInterrupt)", style="yellow")
|
|
48
61
|
exit(130)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
exception(e)
|
|
64
|
+
exit(1)
|
|
65
|
+
finally:
|
|
66
|
+
log('[Lifecycle:End] 运行结束,kmdr 已退出')
|
|
67
|
+
|
|
49
68
|
|
|
50
69
|
if __name__ == '__main__':
|
|
51
70
|
entry_point()
|
|
@@ -8,6 +8,7 @@ from yarl import URL
|
|
|
8
8
|
from kmdr.core.error import LoginError
|
|
9
9
|
from kmdr.core.utils import async_retry
|
|
10
10
|
from kmdr.core.constants import API_ROUTE
|
|
11
|
+
from kmdr.core.console import *
|
|
11
12
|
|
|
12
13
|
NICKNAME_ID = 'div_nickname_display'
|
|
13
14
|
|
|
@@ -27,7 +28,7 @@ async def check_status(
|
|
|
27
28
|
try:
|
|
28
29
|
response.raise_for_status()
|
|
29
30
|
except Exception as e:
|
|
30
|
-
|
|
31
|
+
info(f"Error: {type(e).__name__}: {e}")
|
|
31
32
|
return False
|
|
32
33
|
|
|
33
34
|
if response.history and any(resp.status in (301, 302, 307) for resp in response.history) \
|
|
@@ -50,6 +51,8 @@ async def check_status(
|
|
|
50
51
|
is_vip = int(var_define.get('is_vip', '0'))
|
|
51
52
|
user_level = int(var_define.get('user_level', '0'))
|
|
52
53
|
|
|
54
|
+
debug("解析到用户状态: is_vip=", is_vip, ", user_level=", user_level)
|
|
55
|
+
|
|
53
56
|
if is_vip_setter:
|
|
54
57
|
is_vip_setter(is_vip)
|
|
55
58
|
if level_setter:
|
|
@@ -58,10 +61,14 @@ async def check_status(
|
|
|
58
61
|
if not show_quota:
|
|
59
62
|
return True
|
|
60
63
|
|
|
61
|
-
nickname = soup.find('div', id=NICKNAME_ID).text.strip().split(' ')[0]
|
|
62
|
-
quota = soup.find('div', id=__resolve_quota_id(is_vip, user_level)).text.strip()
|
|
64
|
+
nickname = soup.find('div', id=NICKNAME_ID).text.strip().split(' ')[0].replace('\xa0', '')
|
|
65
|
+
quota = soup.find('div', id=__resolve_quota_id(is_vip, user_level)).text.strip().replace('\xa0', '')
|
|
66
|
+
|
|
67
|
+
if console.is_interactive:
|
|
68
|
+
info(f"\n当前登录为 [bold cyan]{nickname}[/bold cyan]\n\n{quota}")
|
|
69
|
+
else:
|
|
70
|
+
info(f"当前登录为 {nickname}")
|
|
63
71
|
|
|
64
|
-
console.print(f"\n当前登录为 [bold cyan]{nickname}[/bold cyan]\n\n{quota}")
|
|
65
72
|
return True
|
|
66
73
|
|
|
67
74
|
def extract_var_define(script_text) -> dict[str, str]:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from kmdr.core import Configurer, CONFIGURER
|
|
2
|
+
from kmdr.core.console import info
|
|
2
3
|
|
|
3
4
|
@CONFIGURER.register()
|
|
4
5
|
class BaseUrlUpdator(Configurer):
|
|
@@ -10,7 +11,7 @@ class BaseUrlUpdator(Configurer):
|
|
|
10
11
|
try:
|
|
11
12
|
self._configurer.set_base_url(self._base_url)
|
|
12
13
|
except KeyError as e:
|
|
13
|
-
|
|
14
|
+
info(f"[red]{e.args[0]}[/red]")
|
|
14
15
|
exit(1)
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
info(f"已设置基础 URL: {self._base_url}")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from kmdr.core import Configurer, CONFIGURER
|
|
2
|
+
from kmdr.core.console import info
|
|
2
3
|
|
|
3
4
|
@CONFIGURER.register()
|
|
4
5
|
class ConfigClearer(Configurer):
|
|
@@ -10,7 +11,7 @@ class ConfigClearer(Configurer):
|
|
|
10
11
|
try:
|
|
11
12
|
self._configurer.clear(self._clear)
|
|
12
13
|
except KeyError as e:
|
|
13
|
-
|
|
14
|
+
info(f"[red]{e.args[0]}[/red]")
|
|
14
15
|
exit(1)
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
info(f"Cleared configuration: {self._clear}")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from kmdr.core import Configurer, CONFIGURER
|
|
2
|
+
from kmdr.core.console import info
|
|
2
3
|
|
|
3
4
|
from .option_validate import check_key
|
|
4
5
|
|
|
@@ -10,9 +11,9 @@ class ConfigUnsetter(Configurer):
|
|
|
10
11
|
|
|
11
12
|
def operate(self) -> None:
|
|
12
13
|
if not self._unset:
|
|
13
|
-
|
|
14
|
+
info("[yellow]请提供要取消设置的配置项。[/yellow]")
|
|
14
15
|
return
|
|
15
16
|
|
|
16
17
|
check_key(self._unset)
|
|
17
18
|
self._configurer.unset_option(self._unset)
|
|
18
|
-
|
|
19
|
+
info(f"[green]取消配置项: {self._unset}[/green]")
|
|
@@ -2,6 +2,7 @@ from rich.table import Table
|
|
|
2
2
|
from rich.pretty import Pretty
|
|
3
3
|
|
|
4
4
|
from kmdr.core import CONFIGURER, Configurer
|
|
5
|
+
from kmdr.core.console import info
|
|
5
6
|
|
|
6
7
|
@CONFIGURER.register(
|
|
7
8
|
hasvalues={
|
|
@@ -14,7 +15,7 @@ class OptionLister(Configurer):
|
|
|
14
15
|
|
|
15
16
|
def operate(self) -> None:
|
|
16
17
|
if self._configurer.option is None and self._configurer.base_url is None:
|
|
17
|
-
|
|
18
|
+
info("[blue]当前没有任何配置项。[/blue]")
|
|
18
19
|
return
|
|
19
20
|
|
|
20
21
|
table = Table(title="[green]当前 Kmdr 配置项[/green]", show_header=False, header_style="blue")
|
|
@@ -35,4 +36,4 @@ class OptionLister(Configurer):
|
|
|
35
36
|
if self._configurer.base_url is not None:
|
|
36
37
|
table.add_row('应用配置', '镜像地址', self._configurer.base_url or '未设置')
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
info(table)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from kmdr.core import Configurer, CONFIGURER
|
|
2
|
+
from kmdr.core.console import info
|
|
2
3
|
|
|
3
4
|
from .option_validate import validate
|
|
4
5
|
|
|
@@ -11,7 +12,7 @@ class OptionSetter(Configurer):
|
|
|
11
12
|
def operate(self) -> None:
|
|
12
13
|
for option in self._set:
|
|
13
14
|
if '=' not in option:
|
|
14
|
-
|
|
15
|
+
info(f"[red]无效的选项格式: `{option}`。[/red] 应为 key=value 格式。")
|
|
15
16
|
continue
|
|
16
17
|
|
|
17
18
|
key, value = option.split('=', 1)
|
|
@@ -23,7 +24,7 @@ class OptionSetter(Configurer):
|
|
|
23
24
|
continue
|
|
24
25
|
|
|
25
26
|
self._configurer.set_option(key, validated_value)
|
|
26
|
-
|
|
27
|
+
info(f"[green]已设置配置: {key} = {validated_value}[/green]")
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
|
|
@@ -2,7 +2,7 @@ from typing import Optional
|
|
|
2
2
|
from functools import wraps
|
|
3
3
|
import os
|
|
4
4
|
|
|
5
|
-
from kmdr.core.
|
|
5
|
+
from kmdr.core.console import info
|
|
6
6
|
|
|
7
7
|
__OPTIONS_VALIDATOR = {}
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ def validate(key: str, value: str) -> Optional[object]:
|
|
|
17
17
|
if key in __OPTIONS_VALIDATOR:
|
|
18
18
|
return __OPTIONS_VALIDATOR[key](value)
|
|
19
19
|
else:
|
|
20
|
-
|
|
20
|
+
info(f"[red]不支持的配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
|
|
21
21
|
return None
|
|
22
22
|
|
|
23
23
|
def check_key(key: str, exit_if_invalid: bool = True) -> None:
|
|
@@ -29,7 +29,7 @@ def check_key(key: str, exit_if_invalid: bool = True) -> None:
|
|
|
29
29
|
:param exit_if_invalid: 如果键名无效,是否退出程序
|
|
30
30
|
"""
|
|
31
31
|
if key not in __OPTIONS_VALIDATOR:
|
|
32
|
-
|
|
32
|
+
info(f"[red]未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
|
|
33
33
|
if exit_if_invalid:
|
|
34
34
|
exit(1)
|
|
35
35
|
|
|
@@ -65,24 +65,24 @@ def validate_num_workers(value: str) -> Optional[int]:
|
|
|
65
65
|
raise ValueError("必须是正值。")
|
|
66
66
|
return num_workers
|
|
67
67
|
except ValueError as e:
|
|
68
|
-
|
|
68
|
+
info(f"[red]无效的 num_workers 值: {value}。{str(e)}[/red]")
|
|
69
69
|
return None
|
|
70
70
|
|
|
71
71
|
@register_validator('dest')
|
|
72
72
|
def validate_dest(value: str) -> Optional[str]:
|
|
73
73
|
if not value:
|
|
74
|
-
|
|
74
|
+
info("[red]目标目录不能为空。[/red]")
|
|
75
75
|
return None
|
|
76
76
|
if not os.path.exists(value) or not os.path.isdir(value):
|
|
77
|
-
|
|
77
|
+
info(f"[red]目标目录不存在或不是目录: {value}[/red]")
|
|
78
78
|
return None
|
|
79
79
|
|
|
80
80
|
if not os.access(value, os.W_OK):
|
|
81
|
-
|
|
81
|
+
info(f"[red]目标目录不可写: {value}[/red]")
|
|
82
82
|
return None
|
|
83
83
|
|
|
84
84
|
if not os.path.isabs(value):
|
|
85
|
-
|
|
85
|
+
info(f"[yellow]目标目录最好是绝对路径: {value}[/yellow]")
|
|
86
86
|
|
|
87
87
|
return value
|
|
88
88
|
|
|
@@ -94,19 +94,19 @@ def validate_retry(value: str) -> Optional[int]:
|
|
|
94
94
|
raise ValueError("必须是正值。")
|
|
95
95
|
return retry
|
|
96
96
|
except ValueError as e:
|
|
97
|
-
|
|
97
|
+
info(f"[red]无效的 retry 值: {value}。{str(e)}[/red]")
|
|
98
98
|
return None
|
|
99
99
|
|
|
100
100
|
@register_validator('callback')
|
|
101
101
|
def validate_callback(value: str) -> Optional[str]:
|
|
102
102
|
if not value:
|
|
103
|
-
|
|
103
|
+
info("[red]回调不能为空。[/red]")
|
|
104
104
|
return None
|
|
105
105
|
return value
|
|
106
106
|
|
|
107
107
|
@register_validator('proxy')
|
|
108
108
|
def validate_proxy(value: str) -> Optional[str]:
|
|
109
109
|
if not value:
|
|
110
|
-
|
|
110
|
+
info("[red]代理不能为空。[/red]")
|
|
111
111
|
return None
|
|
112
112
|
return value
|
|
@@ -6,6 +6,7 @@ from async_lru import alru_cache
|
|
|
6
6
|
from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
|
|
7
7
|
from kmdr.core.constants import API_ROUTE
|
|
8
8
|
from kmdr.core.error import ResponseError
|
|
9
|
+
from kmdr.core.console import debug
|
|
9
10
|
|
|
10
11
|
from .download_utils import download_file_multipart, readable_safe_filename
|
|
11
12
|
|
|
@@ -47,6 +48,7 @@ class ReferViaDownloader(Downloader):
|
|
|
47
48
|
response.raise_for_status()
|
|
48
49
|
data = await response.text()
|
|
49
50
|
data = json.loads(data)
|
|
51
|
+
debug("获取下载链接响应数据:", data)
|
|
50
52
|
if (code := data.get('code')) != 200:
|
|
51
53
|
raise ResponseError(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}", code)
|
|
52
54
|
|
|
@@ -12,13 +12,15 @@ 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 kmdr.core.console import info, log, debug
|
|
16
|
+
|
|
15
17
|
from .misc import STATUS, StateManager
|
|
16
18
|
|
|
17
19
|
BLOCK_SIZE_REDUCTION_FACTOR = 0.75
|
|
18
20
|
MIN_BLOCK_SIZE = 2048
|
|
19
21
|
|
|
20
22
|
|
|
21
|
-
@deprecated("
|
|
23
|
+
@deprecated("本函数可能不会积极维护,请改用 'download_file_multipart'")
|
|
22
24
|
async def download_file(
|
|
23
25
|
session: aiohttp.ClientSession,
|
|
24
26
|
semaphore: asyncio.Semaphore,
|
|
@@ -53,9 +55,11 @@ async def download_file(
|
|
|
53
55
|
await aio_os.makedirs(dest_path, exist_ok=True)
|
|
54
56
|
|
|
55
57
|
if await aio_os.path.exists(file_path):
|
|
56
|
-
|
|
58
|
+
info(f"[yellow]{filename} 已经存在[/yellow]")
|
|
57
59
|
return
|
|
58
60
|
|
|
61
|
+
log("开始下载文件:", filename, "到路径:", dest_path)
|
|
62
|
+
|
|
59
63
|
block_size = 8192
|
|
60
64
|
attempts_left = retry_times + 1
|
|
61
65
|
task_id = None
|
|
@@ -105,7 +109,7 @@ async def download_file(
|
|
|
105
109
|
else:
|
|
106
110
|
raise IOError(f"Failed to download {filename} after {retry_times} retries.")
|
|
107
111
|
|
|
108
|
-
|
|
112
|
+
await aio_os.rename(filename_downloading, file_path)
|
|
109
113
|
|
|
110
114
|
except Exception as e:
|
|
111
115
|
if task_id is not None:
|
|
@@ -154,16 +158,22 @@ async def download_file_multipart(
|
|
|
154
158
|
await aio_os.makedirs(dest_path, exist_ok=True)
|
|
155
159
|
|
|
156
160
|
if await aio_os.path.exists(file_path):
|
|
157
|
-
|
|
161
|
+
info(f"[blue]{filename} 已经存在[/blue]")
|
|
158
162
|
return
|
|
159
163
|
|
|
164
|
+
log("开始下载文件:", filename)
|
|
160
165
|
part_paths = []
|
|
161
166
|
part_expected_sizes = []
|
|
162
167
|
task_id = None
|
|
168
|
+
|
|
169
|
+
state_manager: Optional[StateManager] = None
|
|
163
170
|
try:
|
|
164
171
|
current_url = await fetch_url(url)
|
|
165
172
|
|
|
166
173
|
async with session.head(current_url, headers=headers, allow_redirects=True) as response:
|
|
174
|
+
# 注意:这个请求完成后,服务器就会记录这次下载,并消耗对应的流量配额,详细的规则请参考网站说明:
|
|
175
|
+
# 注 1 : 訂閱連載中的漫畫,有更新時自動推送的卷(冊),暫不計算在使用額度中,不扣減使用額度。
|
|
176
|
+
# 注 2 : 對同一卷(冊)書在 12 小時內重複*下載*,不會重複扣減額度。但重復推送是會扣減的。
|
|
167
177
|
response.raise_for_status()
|
|
168
178
|
total_size = int(response.headers['Content-Length'])
|
|
169
179
|
|
|
@@ -206,14 +216,14 @@ async def download_file_multipart(
|
|
|
206
216
|
if all(results):
|
|
207
217
|
await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.MERGING)
|
|
208
218
|
await _merge_parts(part_paths, filename_downloading)
|
|
209
|
-
|
|
219
|
+
await aio_os.rename(filename_downloading, file_path)
|
|
210
220
|
else:
|
|
211
221
|
# 如果有任何一个分片校验失败,则视为下载失败
|
|
212
222
|
await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.FAILED)
|
|
213
223
|
|
|
214
224
|
finally:
|
|
215
225
|
if await aio_os.path.exists(file_path):
|
|
216
|
-
if task_id is not None:
|
|
226
|
+
if task_id is not None and state_manager is not None:
|
|
217
227
|
await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.COMPLETED)
|
|
218
228
|
|
|
219
229
|
cleanup_tasks = [aio_os.remove(p) for p in part_paths if await aio_os.path.exists(p)]
|
|
@@ -222,7 +232,7 @@ async def download_file_multipart(
|
|
|
222
232
|
if callback:
|
|
223
233
|
callback()
|
|
224
234
|
else:
|
|
225
|
-
if task_id is not None:
|
|
235
|
+
if task_id is not None and state_manager is not None:
|
|
226
236
|
await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.FAILED)
|
|
227
237
|
|
|
228
238
|
async def _download_part(
|
|
@@ -256,6 +266,7 @@ async def _download_part(
|
|
|
256
266
|
local_headers['Range'] = f'bytes={current_start}-{end}'
|
|
257
267
|
|
|
258
268
|
async with semaphore:
|
|
269
|
+
debug("开始下载分片:", os.path.basename(part_path), "范围:", current_start, "-", end)
|
|
259
270
|
async with session.get(url, headers=local_headers) as response:
|
|
260
271
|
response.raise_for_status()
|
|
261
272
|
|
|
@@ -266,6 +277,7 @@ async def _download_part(
|
|
|
266
277
|
if chunk:
|
|
267
278
|
await f.write(chunk)
|
|
268
279
|
state_manager.advance(len(chunk))
|
|
280
|
+
log("分片", os.path.basename(part_path), "下载完成。")
|
|
269
281
|
return
|
|
270
282
|
|
|
271
283
|
except asyncio.CancelledError:
|
|
@@ -275,10 +287,12 @@ async def _download_part(
|
|
|
275
287
|
|
|
276
288
|
except Exception as e:
|
|
277
289
|
if attempts_left > 0:
|
|
290
|
+
debug("分片", os.path.basename(part_path), "下载出错:", e, ",正在重试... 剩余重试次数:", attempts_left)
|
|
278
291
|
await asyncio.sleep(3)
|
|
279
292
|
await state_manager.request_status_update(part_id=start, status=STATUS.WAITING)
|
|
280
293
|
else:
|
|
281
294
|
# console.print(f"[red]分片 {os.path.basename(part_path)} 下载失败: {e}[/red]")
|
|
295
|
+
debug("分片", os.path.basename(part_path), "下载失败:", e)
|
|
282
296
|
await state_manager.request_status_update(part_id=start, status=STATUS.PARTIALLY_FAILED)
|
|
283
297
|
|
|
284
298
|
async def _validate_part(part_path: str, expected_size: int) -> bool:
|
|
@@ -288,6 +302,7 @@ async def _validate_part(part_path: str, expected_size: int) -> bool:
|
|
|
288
302
|
return actual_size == expected_size
|
|
289
303
|
|
|
290
304
|
async def _merge_parts(part_paths: list[str], final_path: str):
|
|
305
|
+
debug("合并分片到最终文件:", final_path)
|
|
291
306
|
async with aiofiles.open(final_path, 'wb') as final_file:
|
|
292
307
|
try:
|
|
293
308
|
for part_path in part_paths:
|
|
@@ -7,6 +7,7 @@ from rich.prompt import IntPrompt
|
|
|
7
7
|
from kmdr.core import Lister, LISTERS, BookInfo, VolInfo
|
|
8
8
|
from kmdr.core.utils import async_retry
|
|
9
9
|
from kmdr.core.constants import API_ROUTE
|
|
10
|
+
from kmdr.core.console import info
|
|
10
11
|
|
|
11
12
|
from .utils import extract_book_info_and_volumes
|
|
12
13
|
|
|
@@ -23,7 +24,7 @@ class FollowedBookLister(Lister):
|
|
|
23
24
|
books = await self._list_followed_books()
|
|
24
25
|
|
|
25
26
|
if not books:
|
|
26
|
-
|
|
27
|
+
info("[yellow]关注列表为空。[/yellow]")
|
|
27
28
|
exit(0)
|
|
28
29
|
|
|
29
30
|
table = Table(title="关注的书籍列表", show_header=True, header_style="bold blue")
|
|
@@ -42,7 +43,7 @@ class FollowedBookLister(Lister):
|
|
|
42
43
|
book.status
|
|
43
44
|
)
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
info(table)
|
|
46
47
|
|
|
47
48
|
valid_choices = [str(i) for i in range(1, len(books) + 1)]
|
|
48
49
|
|
kmdr/module/lister/utils.py
CHANGED
|
@@ -7,6 +7,7 @@ from aiohttp import ClientSession as Session
|
|
|
7
7
|
|
|
8
8
|
from kmdr.core import BookInfo, VolInfo, VolumeType
|
|
9
9
|
from kmdr.core.utils import async_retry
|
|
10
|
+
from kmdr.core.console import debug
|
|
10
11
|
|
|
11
12
|
@async_retry()
|
|
12
13
|
async def extract_book_info_and_volumes(session: Session, url: str, book_info: Optional[BookInfo] = None) -> tuple[BookInfo, list[VolInfo]]:
|
|
@@ -21,7 +22,10 @@ async def extract_book_info_and_volumes(session: Session, url: str, book_info: O
|
|
|
21
22
|
|
|
22
23
|
# 移除移动端路径部分,统一为桌面端路径
|
|
23
24
|
# 因为移动端页面的结构与桌面端不同,可能会影响解析
|
|
24
|
-
route = structured_url.path
|
|
25
|
+
route = structured_url.path
|
|
26
|
+
if structured_url.path.startswith('/m/'):
|
|
27
|
+
debug("检测到移动端链接,转换为桌面端链接进行处理。")
|
|
28
|
+
route = structured_url.path[2:]
|
|
25
29
|
|
|
26
30
|
async with session.get(route) as response:
|
|
27
31
|
response.raise_for_status()
|
|
@@ -2,6 +2,7 @@ from rich.table import Table
|
|
|
2
2
|
from rich.prompt import Prompt
|
|
3
3
|
|
|
4
4
|
from kmdr.core import Picker, PICKERS, VolInfo
|
|
5
|
+
from kmdr.core.console import info
|
|
5
6
|
|
|
6
7
|
from .utils import resolve_volume
|
|
7
8
|
|
|
@@ -35,7 +36,7 @@ class DefaultVolPicker(Picker):
|
|
|
35
36
|
f"{volume.size:.2f}"
|
|
36
37
|
)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
info(table)
|
|
39
40
|
|
|
40
41
|
choice_str = Prompt.ask(
|
|
41
42
|
"[green]请选择要下载的卷序号 (例如 'all', '1,2,3', '1-3,4-6')[/green]",
|
kmdr/module/picker/utils.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
|
+
from kmdr.core.error import ArgsResolveError
|
|
4
|
+
|
|
3
5
|
def resolve_volume(volume: str) -> Optional[set[int]]:
|
|
4
6
|
if volume == 'all':
|
|
5
7
|
return None
|
|
@@ -18,20 +20,26 @@ def resolve_volume(volume: str) -> Optional[set[int]]:
|
|
|
18
20
|
|
|
19
21
|
if (volume := volume.strip()).isdigit():
|
|
20
22
|
# 只有一个数字
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
if (volume_digit := int(volume)) <= 0:
|
|
24
|
+
raise ArgsResolveError(f"卷号必须大于 0,当前值为 {volume_digit}。")
|
|
25
|
+
return {volume_digit}
|
|
23
26
|
elif '-' in volume and volume.count('-') == 1 and ',' not in volume:
|
|
24
27
|
# 使用了范围符号
|
|
25
28
|
start, end = volume.split('-')
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
if not start.strip().isdigit() or not end.strip().isdigit():
|
|
31
|
+
raise ArgsResolveError(f"无效的范围格式: {volume}。请使用 'start-end' 或 'start, end'。")
|
|
28
32
|
|
|
29
33
|
start = int(start.strip())
|
|
30
34
|
end = int(end.strip())
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
if start <= 0:
|
|
37
|
+
raise ArgsResolveError(f"卷号必须大于 0,当前值为 {start}。")
|
|
38
|
+
if end <= 0:
|
|
39
|
+
raise ArgsResolveError(f"卷号必须大于 0,当前值为 {end}。")
|
|
40
|
+
if start > end:
|
|
41
|
+
raise ArgsResolveError(f"起始卷号必须小于或等于结束卷号,当前值为 {start} - {end}。")
|
|
34
42
|
|
|
35
43
|
return set(range(start, end + 1))
|
|
36
44
|
|
|
37
|
-
raise
|
|
45
|
+
raise ArgsResolveError(f"无效的卷号格式: {volume}。请使用 'all', '1,2,3', '1-3', 或 '1-3,4-6'。")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kmoe-manga-downloader
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.3
|
|
4
4
|
Summary: A CLI-downloader for site @kox.moe.
|
|
5
5
|
Author-email: Chris Zheng <chrisis58@outlook.com>
|
|
6
6
|
License: MIT License
|
|
@@ -50,9 +50,26 @@ Dynamic: license-file
|
|
|
50
50
|
|
|
51
51
|
`kmdr (Kmoe Manga Downloader)` 是一个 Python 终端应用,用于从 [Kmoe](https://kxx.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
|
|
52
52
|
|
|
53
|
-
<
|
|
54
|
-
<
|
|
55
|
-
|
|
53
|
+
<table style="min-width: 600px;">
|
|
54
|
+
<tbody>
|
|
55
|
+
<tr>
|
|
56
|
+
<td style="text-align: center;" width="100">
|
|
57
|
+
交互模式
|
|
58
|
+
</td>
|
|
59
|
+
<td style="text-align: center;">
|
|
60
|
+
<img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" />
|
|
61
|
+
</td>
|
|
62
|
+
</tr>
|
|
63
|
+
<tr>
|
|
64
|
+
<td style="text-align: center;" width="100">
|
|
65
|
+
日志模式
|
|
66
|
+
</td>
|
|
67
|
+
<td style="text-align: center;">
|
|
68
|
+
<img src="assets/kmdr-log-demo.gif" alt="kmdr 日志使用演示" />
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
</tbody>
|
|
72
|
+
</table>
|
|
56
73
|
|
|
57
74
|
## ✨功能特性
|
|
58
75
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
kmdr/__init__.py,sha256=iLmy2rOkHS_4KZWMD8BgT7R3tLMKeaTCDVf3B4FyYxM,91
|
|
2
|
+
kmdr/_version.py,sha256=7owcFdDdCCS8dpZXbj3NSPOQKDAjQMZUPIbI0lbVczA,704
|
|
3
|
+
kmdr/main.py,sha256=CSYLADdJ5bJMxT3wn0Nkq5_bmRBpXxzqpLmLCbzi9Q0,2227
|
|
4
|
+
kmdr/core/__init__.py,sha256=aR3mhP1A81oTmTnBOonVQm_mFvFeNCOo7kalZVfszAc,416
|
|
5
|
+
kmdr/core/bases.py,sha256=br3HJC4mWL2jN4Y5QeXB5SwA0xnOfi2ougr5pEx7j8s,3989
|
|
6
|
+
kmdr/core/console.py,sha256=VziwGJQygF-zCrzTApPF-I9BgZKFSqCTiZ_pc5n8C-4,1950
|
|
7
|
+
kmdr/core/constants.py,sha256=JFj8FHO9ab6AeIzHz3LaC8l3ziLLFi75vil8wMPfZoA,1869
|
|
8
|
+
kmdr/core/context.py,sha256=IU5zHALsl4s9FeQJukhs703RAUe57Qf5P-nUAqGcW2A,1368
|
|
9
|
+
kmdr/core/defaults.py,sha256=tF41i145znhhDGVNL5irQpdF4lV_1tkWtvToRfm2R4s,8968
|
|
10
|
+
kmdr/core/error.py,sha256=qdbMEU6YhRcdbxlfZKKydoMFYXOCJQ473EYzl3mO33Q,1585
|
|
11
|
+
kmdr/core/protocol.py,sha256=ntHmnrvg_nZXh2-It0Wu9g2e8f3YpOLfpoRsWniG8as,247
|
|
12
|
+
kmdr/core/registry.py,sha256=KYjvww5WRuUg5SeIeWZb96803-kgrTIKYvFTj_7bPfo,5560
|
|
13
|
+
kmdr/core/session.py,sha256=ZrnoxZWUwLSMiLIOZDGEQZcH7RuBLcc5e2E0m17qX-s,4937
|
|
14
|
+
kmdr/core/structure.py,sha256=EQG1-8kHQ22Not2S7Q_jGxE0nb9XUbLh3JcNTb_nkvk,1199
|
|
15
|
+
kmdr/core/utils.py,sha256=_tRLggprXTGFvbRzyt3dgapQr2wI5NQ8Hc4_BHVj7lU,4069
|
|
16
|
+
kmdr/module/__init__.py,sha256=hY6lMBpxSn93t7OKnEbXUwTok2XqQoQbuyq1HLeC4Kc,125
|
|
17
|
+
kmdr/module/authenticator/CookieAuthenticator.py,sha256=lv_monQ7QUMfgPPgxoV1xr_KyUtwCKZhqJ8umv3W6xo,1108
|
|
18
|
+
kmdr/module/authenticator/LoginAuthenticator.py,sha256=5nyrzxYEGiRQ9ulnhjpuUWYeog-ALZlCCSAYkbwlKMw,1890
|
|
19
|
+
kmdr/module/authenticator/__init__.py,sha256=iSWhq-suAM1BrABRuTWiCKvZ1fkmf5HQ2GmbZ341GK0,103
|
|
20
|
+
kmdr/module/authenticator/utils.py,sha256=b49cwKjHVqOIHei0SUU5kMtWAgkYU0NH3YvDvdPhkxM,3053
|
|
21
|
+
kmdr/module/configurer/BaseUrlUpdator.py,sha256=OsdMUuyGujfqE6vEPt96sCJkJTfsPxAf5z2hbjcNZsQ,522
|
|
22
|
+
kmdr/module/configurer/ConfigClearer.py,sha256=irGBsck1EhTWl4IIOPF5b4o4TyrGvDAzibg9reQsI9A,501
|
|
23
|
+
kmdr/module/configurer/ConfigUnsetter.py,sha256=tbrOGKnTSeKhdMukIBPFqtdctEiQhu7GRThLthFYZew,602
|
|
24
|
+
kmdr/module/configurer/OptionLister.py,sha256=hqOtXfPcunzqkrq6Qx1KyZwEivp_vbpTVZ8PP3Q0MbY,1471
|
|
25
|
+
kmdr/module/configurer/OptionSetter.py,sha256=BQXIgyLoMDSNua2x96FtX-9JmJhVpPJ5o0tWHj_-7B0,898
|
|
26
|
+
kmdr/module/configurer/__init__.py,sha256=nSWGwUCcwOkvg28zxRd0S3b4jeag_W0IhzP74xq0ewE,204
|
|
27
|
+
kmdr/module/configurer/option_validate.py,sha256=tb6fvj01cDiPTQa6tC4-dYcTfUJUX3DBmkHKAKeuW3g,3358
|
|
28
|
+
kmdr/module/downloader/DirectDownloader.py,sha256=SPnzKDpVPg3utyrPrFr4pYiMI5w3eRCMemcYevH9VzY,1320
|
|
29
|
+
kmdr/module/downloader/ReferViaDownloader.py,sha256=o565u6W4G_4UFoqsLkXGRFIlRSMueZB_ZMdiAfhUVCc,1970
|
|
30
|
+
kmdr/module/downloader/__init__.py,sha256=PajPBQ4ZJwz_6Ok6k-HV7-YSibxAW5m5voYyP-U-_f4,97
|
|
31
|
+
kmdr/module/downloader/download_utils.py,sha256=DQ46ZQys0RFkXQLkp0w6PMjnBCUT1PQR19sxmAoJ2jM,13918
|
|
32
|
+
kmdr/module/downloader/misc.py,sha256=dS4GDHYxzKcqdhFNVJQG-4bS4IXMDnDrwi6eSWubl20,1892
|
|
33
|
+
kmdr/module/lister/BookUrlLister.py,sha256=R1sak_GQG8HKfdSzHF-Dy1iby6TgFlxKJLf2dwBr0Bw,550
|
|
34
|
+
kmdr/module/lister/FollowedBookLister.py,sha256=5r44sW_ypnRM5nBBd-4AL_WcNYCRklMewvMj1O2xCGc,2880
|
|
35
|
+
kmdr/module/lister/__init__.py,sha256=VVRMRGXASdIagzlmOuhy9gKjJzf8Rj3000BPzeWpHHE,91
|
|
36
|
+
kmdr/module/lister/utils.py,sha256=phAdtGI3F5kT6x4JFOiN39Ei_yY5JdwQG-tfRZ6MVvs,3595
|
|
37
|
+
kmdr/module/picker/ArgsFilterPicker.py,sha256=wBCDa6KsSSOLLQk8D4ftZ9ZnwPLiHIirxf6gGBq3I08,1782
|
|
38
|
+
kmdr/module/picker/DefaultVolPicker.py,sha256=qScmRtwpe1u3fY7HMIo15YXtwB4LYAIJaTu_EkrAJbs,1780
|
|
39
|
+
kmdr/module/picker/__init__.py,sha256=qYELEkv0nFT8DadItB6fdUTED2CHrC43wuPKO7QrCCQ,93
|
|
40
|
+
kmdr/module/picker/utils.py,sha256=baJzpQSSSBdTPZ2scrEeNOssGYOL0nNwxN1gTsOAqnc,1604
|
|
41
|
+
kmoe_manga_downloader-1.2.3.dist-info/licenses/LICENSE,sha256=bKQlsXu8mAYKRZyoZKOEqMcCc8YjT5Q3Hgr21e0yU4E,1068
|
|
42
|
+
kmoe_manga_downloader-1.2.3.dist-info/METADATA,sha256=q8ZJcM0_cZInyViBwcW3ofIa4qsPCmrXFiO9U6Yp3L8,10114
|
|
43
|
+
kmoe_manga_downloader-1.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
44
|
+
kmoe_manga_downloader-1.2.3.dist-info/entry_points.txt,sha256=DGMytQAhx4uNuKQL7BPkiWESHLXkH-2KSEqwHdygNPA,47
|
|
45
|
+
kmoe_manga_downloader-1.2.3.dist-info/top_level.txt,sha256=e0qxOgWp0tl3GLpmXGjZv3--q_TLoJ7GztM48Ov27wM,5
|
|
46
|
+
kmoe_manga_downloader-1.2.3.dist-info/RECORD,,
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
kmdr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
kmdr/main.py,sha256=LUb3ccAEd8F2Hg4kBlfD85q0gI5wzsZnCfYQQFGy1hU,1491
|
|
3
|
-
kmdr/core/__init__.py,sha256=yE96S0ShaNObncCatA9rnGoJI50Atp4vRVmdladnPlY,364
|
|
4
|
-
kmdr/core/bases.py,sha256=aTvCePKDvPLtQW3Eq8isobHhncxxdVvIvZLX5jsZqKk,3673
|
|
5
|
-
kmdr/core/constants.py,sha256=JFj8FHO9ab6AeIzHz3LaC8l3ziLLFi75vil8wMPfZoA,1869
|
|
6
|
-
kmdr/core/context.py,sha256=AEh6A8f1ZMf3mhrA0-BR00Vd_wEK6pcI2-8VZnSeMaI,1033
|
|
7
|
-
kmdr/core/defaults.py,sha256=L7KdJKisZnrkE25t_ncnYFjimMutIHSeMDpeg0o7VA0,8541
|
|
8
|
-
kmdr/core/error.py,sha256=i2X175KJUvUWdmFWJnRWojIp07lTJnZWSo6IJnYqLRo,1359
|
|
9
|
-
kmdr/core/protocol.py,sha256=ntHmnrvg_nZXh2-It0Wu9g2e8f3YpOLfpoRsWniG8as,247
|
|
10
|
-
kmdr/core/registry.py,sha256=KYjvww5WRuUg5SeIeWZb96803-kgrTIKYvFTj_7bPfo,5560
|
|
11
|
-
kmdr/core/session.py,sha256=lcC8mRn3G54kdYM00sRYIOrs2cHLnrN9qSk_axdhE0w,4740
|
|
12
|
-
kmdr/core/structure.py,sha256=EQG1-8kHQ22Not2S7Q_jGxE0nb9XUbLh3JcNTb_nkvk,1199
|
|
13
|
-
kmdr/core/utils.py,sha256=_tRLggprXTGFvbRzyt3dgapQr2wI5NQ8Hc4_BHVj7lU,4069
|
|
14
|
-
kmdr/module/__init__.py,sha256=hY6lMBpxSn93t7OKnEbXUwTok2XqQoQbuyq1HLeC4Kc,125
|
|
15
|
-
kmdr/module/authenticator/CookieAuthenticator.py,sha256=lv_monQ7QUMfgPPgxoV1xr_KyUtwCKZhqJ8umv3W6xo,1108
|
|
16
|
-
kmdr/module/authenticator/LoginAuthenticator.py,sha256=5nyrzxYEGiRQ9ulnhjpuUWYeog-ALZlCCSAYkbwlKMw,1890
|
|
17
|
-
kmdr/module/authenticator/__init__.py,sha256=iSWhq-suAM1BrABRuTWiCKvZ1fkmf5HQ2GmbZ341GK0,103
|
|
18
|
-
kmdr/module/authenticator/utils.py,sha256=htMhBWlCo9T6aa2iMDTP_6Rjang5wRxlTTAPOMTO9Wc,2807
|
|
19
|
-
kmdr/module/configurer/BaseUrlUpdator.py,sha256=cTOadDJI38rIVsCb1UvOqODY_UnxUY-1TzR-tMwKcME,501
|
|
20
|
-
kmdr/module/configurer/ConfigClearer.py,sha256=2Ra-EV4fuDilWwoUS8NBSL1__-50D0YD0P6kHurH62c,480
|
|
21
|
-
kmdr/module/configurer/ConfigUnsetter.py,sha256=WVCETKg80vGCDEoaN5XWG7MYDJo-eGit-n53rBk46ZE,597
|
|
22
|
-
kmdr/module/configurer/OptionLister.py,sha256=wHcx8XGjjUL6Nj4K909aqikVK06UE7CXPo6XkZc3q3c,1466
|
|
23
|
-
kmdr/module/configurer/OptionSetter.py,sha256=g3Nid0cG0VFhEguAcHwsOpfOluf-8lKpwZLemrUfP-A,893
|
|
24
|
-
kmdr/module/configurer/__init__.py,sha256=nSWGwUCcwOkvg28zxRd0S3b4jeag_W0IhzP74xq0ewE,204
|
|
25
|
-
kmdr/module/configurer/option_validate.py,sha256=Mn91UmpTI8zVV9Em8rL2mjNV21h0iYlzT8xoWDLEI68,3387
|
|
26
|
-
kmdr/module/downloader/DirectDownloader.py,sha256=STJW5xkf6qV96ZufEOSLggZ0g19KVpy1VDGp578jZBo,1320
|
|
27
|
-
kmdr/module/downloader/ReferViaDownloader.py,sha256=z58UEpZvzbDEjIWdHoaKEqM7a1DXD5MMuEZWmhW0LyQ,1875
|
|
28
|
-
kmdr/module/downloader/__init__.py,sha256=PajPBQ4ZJwz_6Ok6k-HV7-YSibxAW5m5voYyP-U-_f4,97
|
|
29
|
-
kmdr/module/downloader/download_utils.py,sha256=Oc5CbOqNOkbeQWWqbW1sr_7T0AQcv0ZPZLxsf3iCkcY,12730
|
|
30
|
-
kmdr/module/downloader/misc.py,sha256=dS4GDHYxzKcqdhFNVJQG-4bS4IXMDnDrwi6eSWubl20,1892
|
|
31
|
-
kmdr/module/lister/BookUrlLister.py,sha256=R1sak_GQG8HKfdSzHF-Dy1iby6TgFlxKJLf2dwBr0Bw,550
|
|
32
|
-
kmdr/module/lister/FollowedBookLister.py,sha256=oen4te0H_GYEDkGiiZeWVnIPo0UVp_btIWIWxbXI4_o,2875
|
|
33
|
-
kmdr/module/lister/__init__.py,sha256=VVRMRGXASdIagzlmOuhy9gKjJzf8Rj3000BPzeWpHHE,91
|
|
34
|
-
kmdr/module/lister/utils.py,sha256=Q1EwGOYH4uJ2Xzsi57k_f5u8tp8Q3546GxTBxvZmZIA,3459
|
|
35
|
-
kmdr/module/picker/ArgsFilterPicker.py,sha256=wBCDa6KsSSOLLQk8D4ftZ9ZnwPLiHIirxf6gGBq3I08,1782
|
|
36
|
-
kmdr/module/picker/DefaultVolPicker.py,sha256=XD5mo48bwpa6T69AIxWJVvrX1daPsxyG8AFsJ4RLTBc,1760
|
|
37
|
-
kmdr/module/picker/__init__.py,sha256=qYELEkv0nFT8DadItB6fdUTED2CHrC43wuPKO7QrCCQ,93
|
|
38
|
-
kmdr/module/picker/utils.py,sha256=lpxM7q9BJeupFQy8glBrHu1o4E38dk7iLexzKytAE6g,1222
|
|
39
|
-
kmoe_manga_downloader-1.2.2.dist-info/licenses/LICENSE,sha256=bKQlsXu8mAYKRZyoZKOEqMcCc8YjT5Q3Hgr21e0yU4E,1068
|
|
40
|
-
kmoe_manga_downloader-1.2.2.dist-info/METADATA,sha256=ImooJYbR8FLJOGhQfQNwlIvccOxBw5VegHof8ijjwxM,9693
|
|
41
|
-
kmoe_manga_downloader-1.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
42
|
-
kmoe_manga_downloader-1.2.2.dist-info/entry_points.txt,sha256=DGMytQAhx4uNuKQL7BPkiWESHLXkH-2KSEqwHdygNPA,47
|
|
43
|
-
kmoe_manga_downloader-1.2.2.dist-info/top_level.txt,sha256=e0qxOgWp0tl3GLpmXGjZv3--q_TLoJ7GztM48Ov27wM,5
|
|
44
|
-
kmoe_manga_downloader-1.2.2.dist-info/RECORD,,
|
|
File without changes
|
{kmoe_manga_downloader-1.2.2.dist-info → kmoe_manga_downloader-1.2.3.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.2.2.dist-info → kmoe_manga_downloader-1.2.3.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.2.2.dist-info → kmoe_manga_downloader-1.2.3.dist-info}/top_level.txt
RENAMED
|
File without changes
|