kmoe-manga-downloader 1.1.1__tar.gz → 1.2.0__tar.gz
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.
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/PKG-INFO +17 -11
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/README.md +10 -6
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/pyproject.toml +7 -5
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/core/__init__.py +5 -3
- kmoe_manga_downloader-1.2.0/src/kmdr/core/bases.py +97 -0
- kmoe_manga_downloader-1.2.0/src/kmdr/core/context.py +28 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/core/defaults.py +64 -28
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/core/error.py +1 -1
- kmoe_manga_downloader-1.2.0/src/kmdr/core/session.py +16 -0
- kmoe_manga_downloader-1.2.0/src/kmdr/core/utils.py +70 -0
- kmoe_manga_downloader-1.2.0/src/kmdr/main.py +49 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/authenticator/CookieAuthenticator.py +8 -4
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/authenticator/LoginAuthenticator.py +25 -21
- kmoe_manga_downloader-1.2.0/src/kmdr/module/authenticator/utils.py +83 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/configurer/ConfigClearer.py +7 -2
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/configurer/ConfigUnsetter.py +2 -2
- kmoe_manga_downloader-1.2.0/src/kmdr/module/configurer/OptionLister.py +33 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/configurer/OptionSetter.py +2 -2
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/configurer/option_validate.py +14 -12
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/downloader/DirectDownloader.py +7 -5
- kmoe_manga_downloader-1.2.0/src/kmdr/module/downloader/ReferViaDownloader.py +47 -0
- kmoe_manga_downloader-1.2.0/src/kmdr/module/downloader/utils.py +315 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/lister/BookUrlLister.py +4 -3
- kmoe_manga_downloader-1.2.0/src/kmdr/module/lister/FollowedBookLister.py +75 -0
- kmoe_manga_downloader-1.2.0/src/kmdr/module/lister/utils.py +90 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/picker/ArgsFilterPicker.py +1 -1
- kmoe_manga_downloader-1.2.0/src/kmdr/module/picker/DefaultVolPicker.py +50 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmoe_manga_downloader.egg-info/PKG-INFO +17 -11
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmoe_manga_downloader.egg-info/SOURCES.txt +3 -1
- kmoe_manga_downloader-1.2.0/src/kmoe_manga_downloader.egg-info/requires.txt +6 -0
- kmoe_manga_downloader-1.2.0/tests/test_async_retry_decorator.py +91 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/tests/test_kmdr_config_option.py +1 -1
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/tests/test_kmdr_download.py +1 -3
- kmoe_manga_downloader-1.1.1/src/kmdr/core/bases.py +0 -125
- kmoe_manga_downloader-1.1.1/src/kmdr/core/utils.py +0 -77
- kmoe_manga_downloader-1.1.1/src/kmdr/main.py +0 -36
- kmoe_manga_downloader-1.1.1/src/kmdr/module/authenticator/utils.py +0 -79
- kmoe_manga_downloader-1.1.1/src/kmdr/module/configurer/OptionLister.py +0 -19
- kmoe_manga_downloader-1.1.1/src/kmdr/module/downloader/ReferViaDownloader.py +0 -44
- kmoe_manga_downloader-1.1.1/src/kmdr/module/downloader/utils.py +0 -142
- kmoe_manga_downloader-1.1.1/src/kmdr/module/lister/FollowedBookLister.py +0 -38
- kmoe_manga_downloader-1.1.1/src/kmdr/module/lister/utils.py +0 -79
- kmoe_manga_downloader-1.1.1/src/kmdr/module/picker/DefaultVolPicker.py +0 -21
- kmoe_manga_downloader-1.1.1/src/kmoe_manga_downloader.egg-info/requires.txt +0 -4
- kmoe_manga_downloader-1.1.1/tests/test_cache_by_kwargs.py +0 -87
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/LICENSE +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/setup.cfg +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/__init__.py +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/core/registry.py +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/core/structure.py +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/__init__.py +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmdr/module/picker/utils.py +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmoe_manga_downloader.egg-info/dependency_links.txt +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmoe_manga_downloader.egg-info/entry_points.txt +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/src/kmoe_manga_downloader.egg-info/top_level.txt +0 -0
- {kmoe_manga_downloader-1.1.1 → kmoe_manga_downloader-1.2.0}/tests/test_utils_resolve_volme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kmoe-manga-downloader
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
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,25 +35,31 @@ 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:
|
|
39
|
-
Requires-Dist:
|
|
40
|
-
Requires-Dist:
|
|
41
|
-
Requires-Dist:
|
|
38
|
+
Requires-Dist: deprecation~=2.1.0
|
|
39
|
+
Requires-Dist: beautifulsoup4~=4.13.4
|
|
40
|
+
Requires-Dist: aiohttp~=3.12.15
|
|
41
|
+
Requires-Dist: aiofiles~=24.1.0
|
|
42
|
+
Requires-Dist: async-lru~=2.0.5
|
|
43
|
+
Requires-Dist: rich~=13.9.4
|
|
42
44
|
Dynamic: license-file
|
|
43
45
|
|
|
44
46
|
# Kmoe Manga Downloader
|
|
45
47
|
|
|
46
48
|
[](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)
|
|
47
49
|
|
|
48
|
-
`kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/)
|
|
50
|
+
`kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
|
|
51
|
+
|
|
52
|
+
<p align="center">
|
|
53
|
+
<img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
|
|
54
|
+
</p>
|
|
49
55
|
|
|
50
56
|
## ✨功能特性
|
|
51
57
|
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
58
|
+
- **现代化终端界面**: 使用 [rich](https://github.com/Textualize/rich) 构建的终端用户界面(TUI),提供进度条和菜单等现代化、美观的交互式终端界面。
|
|
59
|
+
- **凭证和配置管理**: 应用自动维护登录凭证和下载设置,实现一次配置、持久有效,提升使用效率。
|
|
60
|
+
- **高效下载的性能**: 采用 `asyncio` 并发分片下载技术,充分利用网络带宽,极大加速单个大文件的下载速度。
|
|
61
|
+
- **强大的高可用性**: 内置强大的自动重试与断点续传机制,无惧网络中断,确保下载任务最终成功。
|
|
62
|
+
- **灵活的自动化接口**: 支持下载完成后自动执行自定义回调脚本,轻松集成到您的自动化流程。
|
|
57
63
|
|
|
58
64
|
## 🛠️安装应用
|
|
59
65
|
|
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
[](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)
|
|
4
4
|
|
|
5
|
-
`kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/)
|
|
5
|
+
`kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
|
|
9
|
+
</p>
|
|
6
10
|
|
|
7
11
|
## ✨功能特性
|
|
8
12
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
13
|
+
- **现代化终端界面**: 使用 [rich](https://github.com/Textualize/rich) 构建的终端用户界面(TUI),提供进度条和菜单等现代化、美观的交互式终端界面。
|
|
14
|
+
- **凭证和配置管理**: 应用自动维护登录凭证和下载设置,实现一次配置、持久有效,提升使用效率。
|
|
15
|
+
- **高效下载的性能**: 采用 `asyncio` 并发分片下载技术,充分利用网络带宽,极大加速单个大文件的下载速度。
|
|
16
|
+
- **强大的高可用性**: 内置强大的自动重试与断点续传机制,无惧网络中断,确保下载任务最终成功。
|
|
17
|
+
- **灵活的自动化接口**: 支持下载完成后自动执行自定义回调脚本,轻松集成到您的自动化流程。
|
|
14
18
|
|
|
15
19
|
## 🛠️安装应用
|
|
16
20
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kmoe-manga-downloader"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
authors = [
|
|
5
5
|
{ name="Chris Zheng", email="chrisis58@outlook.com" },
|
|
6
6
|
]
|
|
@@ -17,10 +17,12 @@ classifiers = [
|
|
|
17
17
|
]
|
|
18
18
|
|
|
19
19
|
dependencies = [
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
20
|
+
"deprecation~=2.1.0",
|
|
21
|
+
"beautifulsoup4~=4.13.4",
|
|
22
|
+
"aiohttp~=3.12.15",
|
|
23
|
+
"aiofiles~=24.1.0",
|
|
24
|
+
"async-lru~=2.0.5",
|
|
25
|
+
"rich~=13.9.4"
|
|
24
26
|
]
|
|
25
27
|
|
|
26
28
|
[project.urls]
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
from .bases import Authenticator, Lister, Picker, Downloader, Configurer
|
|
2
2
|
from .structure import VolInfo, BookInfo, VolumeType
|
|
3
|
-
from .bases import AUTHENTICATOR, LISTERS, PICKERS, DOWNLOADER, CONFIGURER
|
|
3
|
+
from .bases import AUTHENTICATOR, LISTERS, PICKERS, DOWNLOADER, CONFIGURER, KMDR_SESSION
|
|
4
4
|
|
|
5
|
-
from .defaults import argument_parser
|
|
5
|
+
from .defaults import argument_parser, session_var
|
|
6
6
|
|
|
7
|
-
from .error import KmdrError, LoginError
|
|
7
|
+
from .error import KmdrError, LoginError
|
|
8
|
+
|
|
9
|
+
from .session import KmdrSession
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Callable, Optional
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from aiohttp import ClientSession
|
|
6
|
+
|
|
7
|
+
from .error import LoginError
|
|
8
|
+
from .registry import Registry
|
|
9
|
+
from .structure import VolInfo, BookInfo
|
|
10
|
+
from .utils import construct_callback
|
|
11
|
+
|
|
12
|
+
from .context import TerminalContext, SessionContext, UserProfileContext, ConfigContext
|
|
13
|
+
|
|
14
|
+
class Configurer(ConfigContext, TerminalContext):
|
|
15
|
+
|
|
16
|
+
def __init__(self, *args, **kwargs):
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def operate(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalContext):
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args, **kwargs):
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
# 在使用代理登录时,可能会出现问题,但是现在还不清楚是不是代理的问题。
|
|
28
|
+
# 主站正常情况下不使用代理也能登录成功。但是不排除特殊的网络环境下需要代理。
|
|
29
|
+
# 所以暂时保留代理登录的功能,如果后续确认是代理的问题,可以考虑启用 @no_proxy 装饰器。
|
|
30
|
+
# @no_proxy
|
|
31
|
+
async def authenticate(self) -> None:
|
|
32
|
+
with self._console.status("认证中..."):
|
|
33
|
+
try:
|
|
34
|
+
assert await self._authenticate()
|
|
35
|
+
except LoginError as e:
|
|
36
|
+
self._console.print("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
|
|
37
|
+
self._console.print(f"[yellow]详细信息:{e}[/yellow]")
|
|
38
|
+
exit(1)
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def _authenticate(self) -> bool: ...
|
|
42
|
+
|
|
43
|
+
class Lister(SessionContext, TerminalContext):
|
|
44
|
+
|
|
45
|
+
def __init__(self, *args, **kwargs):
|
|
46
|
+
super().__init__(*args, **kwargs)
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def list(self) -> tuple[BookInfo, list[VolInfo]]: ...
|
|
50
|
+
|
|
51
|
+
class Picker(TerminalContext):
|
|
52
|
+
|
|
53
|
+
def __init__(self, *args, **kwargs):
|
|
54
|
+
super().__init__(*args, **kwargs)
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def pick(self, volumes: list[VolInfo]) -> list[VolInfo]: ...
|
|
58
|
+
|
|
59
|
+
class Downloader(SessionContext, UserProfileContext, TerminalContext):
|
|
60
|
+
|
|
61
|
+
def __init__(self,
|
|
62
|
+
dest: str = '.',
|
|
63
|
+
callback: Optional[str] = None,
|
|
64
|
+
retry: int = 3,
|
|
65
|
+
num_workers: int = 8,
|
|
66
|
+
*args, **kwargs
|
|
67
|
+
):
|
|
68
|
+
super().__init__(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
self._dest: str = dest
|
|
71
|
+
self._callback: Optional[Callable] = construct_callback(callback)
|
|
72
|
+
self._retry: int = retry
|
|
73
|
+
self._semaphore = asyncio.Semaphore(num_workers)
|
|
74
|
+
|
|
75
|
+
async def download(self, book: BookInfo, volumes: list[VolInfo]):
|
|
76
|
+
if not volumes:
|
|
77
|
+
self._console.print("No volumes to download.")
|
|
78
|
+
exit(0)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
with self._progress:
|
|
82
|
+
tasks = [self._download(book, volume) for volume in volumes]
|
|
83
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
84
|
+
|
|
85
|
+
except KeyboardInterrupt:
|
|
86
|
+
self._console.print("\n操作已取消(KeyboardInterrupt)")
|
|
87
|
+
exit(130)
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
async def _download(self, book: BookInfo, volume: VolInfo): ...
|
|
91
|
+
|
|
92
|
+
KMDR_SESSION = Registry[ClientSession]('KmdrSession', True)
|
|
93
|
+
AUTHENTICATOR = Registry[Authenticator]('Authenticator')
|
|
94
|
+
LISTERS = Registry[Lister]('Lister')
|
|
95
|
+
PICKERS = Registry[Picker]('Picker')
|
|
96
|
+
DOWNLOADER = Registry[Downloader]('Downloader', True)
|
|
97
|
+
CONFIGURER = Registry[Configurer]('Configurer')
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from aiohttp import ClientSession
|
|
2
|
+
|
|
3
|
+
from .defaults import Configurer as InnerConfigurer, UserProfile, session_var, progress, console
|
|
4
|
+
|
|
5
|
+
class TerminalContext:
|
|
6
|
+
|
|
7
|
+
def __init__(self, *args, **kwargs):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self._progress = progress
|
|
10
|
+
self._console = console
|
|
11
|
+
|
|
12
|
+
class SessionContext:
|
|
13
|
+
|
|
14
|
+
def __init__(self, *args, **kwargs):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self._session: ClientSession = session_var.get()
|
|
17
|
+
|
|
18
|
+
class UserProfileContext:
|
|
19
|
+
|
|
20
|
+
def __init__(self, *args, **kwargs):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self._profile = UserProfile()
|
|
23
|
+
|
|
24
|
+
class ConfigContext:
|
|
25
|
+
|
|
26
|
+
def __init__(self, *args, **kwargs):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._configurer = InnerConfigurer()
|
|
@@ -2,10 +2,46 @@ import os
|
|
|
2
2
|
import json
|
|
3
3
|
from typing import Optional
|
|
4
4
|
import argparse
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.progress import (
|
|
8
|
+
Progress,
|
|
9
|
+
BarColumn,
|
|
10
|
+
DownloadColumn,
|
|
11
|
+
TextColumn,
|
|
12
|
+
TransferSpeedColumn,
|
|
13
|
+
TimeRemainingColumn,
|
|
14
|
+
)
|
|
5
15
|
|
|
6
16
|
from .utils import singleton
|
|
7
17
|
from .structure import Config
|
|
8
18
|
|
|
19
|
+
HEADERS = {
|
|
20
|
+
'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
def console_print(*args, **kwargs):
|
|
26
|
+
console.print(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
progress = Progress(
|
|
29
|
+
TextColumn("[blue]{task.fields[filename]}", justify="left"),
|
|
30
|
+
TextColumn("{task.fields[status]}", justify="right"),
|
|
31
|
+
TextColumn("{task.percentage:>3.1f}%"),
|
|
32
|
+
BarColumn(bar_width=None),
|
|
33
|
+
"[progress.percentage]",
|
|
34
|
+
DownloadColumn(),
|
|
35
|
+
"[",
|
|
36
|
+
TransferSpeedColumn(),
|
|
37
|
+
",",
|
|
38
|
+
TimeRemainingColumn(),
|
|
39
|
+
"]",
|
|
40
|
+
console=console,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
session_var = ContextVar('session')
|
|
44
|
+
|
|
9
45
|
parser: Optional[argparse.ArgumentParser] = None
|
|
10
46
|
args: Optional[argparse.Namespace] = None
|
|
11
47
|
|
|
@@ -14,33 +50,33 @@ def argument_parser():
|
|
|
14
50
|
if parser is not None:
|
|
15
51
|
return parser
|
|
16
52
|
|
|
17
|
-
parser = argparse.ArgumentParser(description='
|
|
18
|
-
subparsers = parser.add_subparsers(title='
|
|
19
|
-
|
|
20
|
-
download_parser = subparsers.add_parser('download', help='
|
|
21
|
-
download_parser.add_argument('-d', '--dest', type=str, help='
|
|
22
|
-
download_parser.add_argument('-l', '--book-url', type=str, help='
|
|
23
|
-
download_parser.add_argument('-v', '--volume', type=str, help='
|
|
24
|
-
download_parser.add_argument('-t', '--vol-type', type=str, help='
|
|
25
|
-
download_parser.add_argument('--max-size', type=float, help='
|
|
26
|
-
download_parser.add_argument('--limit', type=int, help='
|
|
27
|
-
download_parser.add_argument('--num-workers', type=int, help='
|
|
28
|
-
download_parser.add_argument('-p', '--proxy', type=str, help='
|
|
29
|
-
download_parser.add_argument('-r', '--retry', type=int, help='
|
|
30
|
-
download_parser.add_argument('-c', '--callback', type=str, help='
|
|
31
|
-
|
|
32
|
-
login_parser = subparsers.add_parser('login', help='
|
|
33
|
-
login_parser.add_argument('-u', '--username', type=str, help='
|
|
34
|
-
login_parser.add_argument('-p', '--password', type=str, help='
|
|
35
|
-
|
|
36
|
-
status_parser = subparsers.add_parser('status', help='
|
|
37
|
-
status_parser.add_argument('-p', '--proxy', type=str, help='
|
|
38
|
-
|
|
39
|
-
config_parser = subparsers.add_parser('config', help='
|
|
40
|
-
config_parser.add_argument('-l', '--list-option', action='store_true', help='
|
|
41
|
-
config_parser.add_argument('-s', '--set', nargs='+', type=str, help='
|
|
42
|
-
config_parser.add_argument('-c', '--clear', type=str, help='
|
|
43
|
-
config_parser.add_argument('-d', '--delete', '--unset', dest='unset', type=str, help='
|
|
53
|
+
parser = argparse.ArgumentParser(description='Kmoe 漫画下载器')
|
|
54
|
+
subparsers = parser.add_subparsers(title='可用的子命令', dest='command')
|
|
55
|
+
|
|
56
|
+
download_parser = subparsers.add_parser('download', help='下载指定的漫画')
|
|
57
|
+
download_parser.add_argument('-d', '--dest', type=str, help='指定下载文件的保存路径,默认为当前目录', required=False)
|
|
58
|
+
download_parser.add_argument('-l', '--book-url', type=str, help='漫画详情页面的 URL', required=False)
|
|
59
|
+
download_parser.add_argument('-v', '--volume', type=str, help='指定下载的卷,多个用逗号分隔,例如 `1,2,3` 或 `1-5,8`,`all` 表示全部', required=False)
|
|
60
|
+
download_parser.add_argument('-t', '--vol-type', type=str, help='指定下载的卷类型,`vol` 为单行本, `extra` 为番外, `seri` 为连载', required=False, choices=['vol', 'extra', 'seri', 'all'], default='vol')
|
|
61
|
+
download_parser.add_argument('--max-size', type=float, help='限制下载卷的最大体积 (单位: MB)', required=False)
|
|
62
|
+
download_parser.add_argument('--limit', type=int, help='限制下载卷的总数量', required=False)
|
|
63
|
+
download_parser.add_argument('--num-workers', type=int, help='下载时使用的并发任务数', required=False)
|
|
64
|
+
download_parser.add_argument('-p', '--proxy', type=str, help='设置下载使用的代理服务器', required=False)
|
|
65
|
+
download_parser.add_argument('-r', '--retry', type=int, help='网络请求失败时的重试次数', required=False)
|
|
66
|
+
download_parser.add_argument('-c', '--callback', type=str, help='每个卷下载完成后执行的回调脚本,例如: `echo {v.name} downloaded!`', required=False)
|
|
67
|
+
|
|
68
|
+
login_parser = subparsers.add_parser('login', help='登录到 Kmoe')
|
|
69
|
+
login_parser.add_argument('-u', '--username', type=str, help='用户名', required=True)
|
|
70
|
+
login_parser.add_argument('-p', '--password', type=str, help='密码 (如果留空,应用将提示您输入)', required=False)
|
|
71
|
+
|
|
72
|
+
status_parser = subparsers.add_parser('status', help='显示账户信息以及配额')
|
|
73
|
+
status_parser.add_argument('-p', '--proxy', type=str, help='代理服务器', required=False)
|
|
74
|
+
|
|
75
|
+
config_parser = subparsers.add_parser('config', help='配置下载器')
|
|
76
|
+
config_parser.add_argument('-l', '--list-option', action='store_true', help='列出所有配置')
|
|
77
|
+
config_parser.add_argument('-s', '--set', nargs='+', type=str, help='设置一个或多个配置项,格式为 `key=value`,例如: `num_workers=8`')
|
|
78
|
+
config_parser.add_argument('-c', '--clear', type=str, help='清除指定配置,可选值为 `all`, `cookie`, `option`')
|
|
79
|
+
config_parser.add_argument('-d', '--delete', '--unset', dest='unset', type=str, help='删除特定的配置选项')
|
|
44
80
|
|
|
45
81
|
return parser
|
|
46
82
|
|
|
@@ -144,7 +180,7 @@ class Configurer:
|
|
|
144
180
|
elif key == 'option':
|
|
145
181
|
self._config.option = None
|
|
146
182
|
else:
|
|
147
|
-
raise
|
|
183
|
+
raise KeyError(f"[red]对应配置不存在: {key}。可用配置项:all, cookie, option[/red]")
|
|
148
184
|
|
|
149
185
|
self.update()
|
|
150
186
|
|
|
@@ -5,7 +5,7 @@ class KmdrError(RuntimeError):
|
|
|
5
5
|
super().__init__(message, *args, **kwargs)
|
|
6
6
|
self.message = message
|
|
7
7
|
|
|
8
|
-
self._solution = "" if solution is None else "\
|
|
8
|
+
self._solution = "" if solution is None else "\n[bold cyan]推荐解决方法:[/bold cyan] \n" + "\n".join(f"[cyan]>>> {sol}[/cyan]" for sol in solution)
|
|
9
9
|
|
|
10
10
|
class LoginError(KmdrError):
|
|
11
11
|
def __init__(self, message, solution: Optional[list[str]] = None):
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from aiohttp import ClientSession
|
|
4
|
+
|
|
5
|
+
from .bases import KMDR_SESSION
|
|
6
|
+
from .defaults import session_var, HEADERS
|
|
7
|
+
|
|
8
|
+
@KMDR_SESSION.register()
|
|
9
|
+
class KmdrSession(ClientSession):
|
|
10
|
+
"""
|
|
11
|
+
Kmdr 的 HTTP 会话管理类,支持从参数中初始化。简化 ClientSession 的使用。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
|
|
15
|
+
ClientSession.__init__(self, proxy=proxy, trust_env=True, headers=HEADERS)
|
|
16
|
+
session_var.set(self)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Optional, Callable
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
|
|
7
|
+
from deprecation import deprecated
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from .structure import BookInfo, VolInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def singleton(cls):
|
|
14
|
+
"""
|
|
15
|
+
**非线程安全**的单例装饰器
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
instances = {}
|
|
19
|
+
|
|
20
|
+
def get_instance(*args, **kwargs):
|
|
21
|
+
if cls not in instances:
|
|
22
|
+
instances[cls] = cls(*args, **kwargs)
|
|
23
|
+
return instances[cls]
|
|
24
|
+
|
|
25
|
+
return get_instance
|
|
26
|
+
|
|
27
|
+
def construct_callback(callback: Optional[str]) -> Optional[Callable]:
|
|
28
|
+
if callback is None or not isinstance(callback, str) or not callback.strip():
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def _callback(book: BookInfo, volume: VolInfo) -> int:
|
|
32
|
+
nonlocal callback
|
|
33
|
+
|
|
34
|
+
assert callback, "Callback script cannot be empty"
|
|
35
|
+
formatted_callback = callback.strip().format(b=book, v=volume)
|
|
36
|
+
|
|
37
|
+
return subprocess.run(formatted_callback, shell=True, check=True).returncode
|
|
38
|
+
|
|
39
|
+
return _callback
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def async_retry(
|
|
43
|
+
attempts: int = 3,
|
|
44
|
+
delay: float = 1.0,
|
|
45
|
+
backoff: float = 2.0,
|
|
46
|
+
retry_on_status: set[int] = {500, 502, 503, 504, 429, 408}
|
|
47
|
+
):
|
|
48
|
+
def decorator(func):
|
|
49
|
+
@functools.wraps(func)
|
|
50
|
+
async def wrapper(*args, **kwargs):
|
|
51
|
+
current_delay = delay
|
|
52
|
+
for attempt in range(attempts):
|
|
53
|
+
try:
|
|
54
|
+
return await func(*args, **kwargs)
|
|
55
|
+
except aiohttp.ClientResponseError as e:
|
|
56
|
+
if e.status in retry_on_status:
|
|
57
|
+
if attempt == attempts - 1:
|
|
58
|
+
raise
|
|
59
|
+
else:
|
|
60
|
+
raise
|
|
61
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
62
|
+
# 对于所有其他 aiohttp 客户端异常和超时,进行重试
|
|
63
|
+
if attempt == attempts - 1:
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
await asyncio.sleep(current_delay)
|
|
67
|
+
|
|
68
|
+
current_delay *= backoff
|
|
69
|
+
return wrapper
|
|
70
|
+
return decorator
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
from argparse import Namespace
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from kmdr.core import *
|
|
6
|
+
from kmdr.module import *
|
|
7
|
+
|
|
8
|
+
async def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
9
|
+
|
|
10
|
+
if args.command == 'config':
|
|
11
|
+
CONFIGURER.get(args).operate()
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
async with KMDR_SESSION.get(args):
|
|
15
|
+
|
|
16
|
+
if args.command == 'login':
|
|
17
|
+
await AUTHENTICATOR.get(args).authenticate()
|
|
18
|
+
|
|
19
|
+
elif args.command == 'status':
|
|
20
|
+
await AUTHENTICATOR.get(args).authenticate()
|
|
21
|
+
|
|
22
|
+
elif args.command == 'download':
|
|
23
|
+
await AUTHENTICATOR.get(args).authenticate()
|
|
24
|
+
|
|
25
|
+
book, volumes = await LISTERS.get(args).list()
|
|
26
|
+
|
|
27
|
+
volumes = PICKERS.get(args).pick(volumes)
|
|
28
|
+
|
|
29
|
+
await DOWNLOADER.get(args).download(book, volumes)
|
|
30
|
+
|
|
31
|
+
else:
|
|
32
|
+
fallback()
|
|
33
|
+
|
|
34
|
+
def main_sync(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
35
|
+
asyncio.run(main(args, fallback))
|
|
36
|
+
|
|
37
|
+
def entry_point():
|
|
38
|
+
try:
|
|
39
|
+
parser = argument_parser()
|
|
40
|
+
args = parser.parse_args()
|
|
41
|
+
|
|
42
|
+
main_coro = main(args, lambda: parser.print_help())
|
|
43
|
+
asyncio.run(main_coro)
|
|
44
|
+
except KeyboardInterrupt:
|
|
45
|
+
print("\n操作已取消(KeyboardInterrupt)")
|
|
46
|
+
exit(130)
|
|
47
|
+
|
|
48
|
+
if __name__ == '__main__':
|
|
49
|
+
entry_point()
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
|
+
from yarl import URL
|
|
4
|
+
|
|
3
5
|
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
4
6
|
|
|
5
7
|
from .utils import check_status
|
|
8
|
+
from .utils import PROFILE_URL
|
|
6
9
|
|
|
7
10
|
@AUTHENTICATOR.register()
|
|
8
11
|
class CookieAuthenticator(Authenticator):
|
|
@@ -14,15 +17,16 @@ class CookieAuthenticator(Authenticator):
|
|
|
14
17
|
else:
|
|
15
18
|
self._show_quota = False
|
|
16
19
|
|
|
17
|
-
def _authenticate(self) -> bool:
|
|
20
|
+
async def _authenticate(self) -> bool:
|
|
18
21
|
cookie = self._configurer.cookie
|
|
19
22
|
|
|
20
23
|
if not cookie:
|
|
21
|
-
raise LoginError("
|
|
24
|
+
raise LoginError("无法找到 Cookie,请先完成登录。", ['kmdr login -u <username>'])
|
|
22
25
|
|
|
23
|
-
self._session.
|
|
24
|
-
return check_status(
|
|
26
|
+
self._session.cookie_jar.update_cookies(cookie, response_url=URL(PROFILE_URL))
|
|
27
|
+
return await check_status(
|
|
25
28
|
self._session,
|
|
29
|
+
self._console,
|
|
26
30
|
show_quota=self._show_quota,
|
|
27
31
|
is_vip_setter=lambda value: setattr(self._profile, 'is_vip', value),
|
|
28
32
|
level_setter=lambda value: setattr(self._profile, 'user_level', value),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
import re
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
from rich.prompt import Prompt
|
|
4
5
|
|
|
5
6
|
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
6
7
|
|
|
@@ -25,32 +26,35 @@ class LoginAuthenticator(Authenticator):
|
|
|
25
26
|
self._show_quota = show_quota
|
|
26
27
|
|
|
27
28
|
if password is None:
|
|
28
|
-
password =
|
|
29
|
+
password = Prompt.ask("请输入密码", password=True, console=self._console)
|
|
29
30
|
|
|
30
31
|
self._password = password
|
|
31
32
|
|
|
32
|
-
def _authenticate(self) -> bool:
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
async def _authenticate(self) -> bool:
|
|
34
|
+
|
|
35
|
+
async with self._session.post(
|
|
35
36
|
url = 'https://kox.moe/login_do.php',
|
|
36
37
|
data = {
|
|
37
38
|
'email': self._username,
|
|
38
39
|
'passwd': self._password,
|
|
39
40
|
'keepalive': 'on'
|
|
40
41
|
},
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
self.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
) as response:
|
|
43
|
+
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
match = re.search(r'"\w+"', await response.text())
|
|
46
|
+
|
|
47
|
+
if not match:
|
|
48
|
+
raise LoginError("无法解析登录响应。")
|
|
49
|
+
|
|
50
|
+
code = match.group(0).split('"')[1]
|
|
51
|
+
if code != CODE_OK:
|
|
52
|
+
raise LoginError(f"认证失败,错误代码:{code} " + CODE_MAPPING.get(code, "未知错误。"))
|
|
53
|
+
|
|
54
|
+
if await check_status(self._session, self._console, show_quota=self._show_quota):
|
|
55
|
+
cookie = self._session.cookie_jar.filter_cookies('https://kox.moe')
|
|
56
|
+
self._configurer.cookie = {key: morsel.value for key, morsel in cookie.items()}
|
|
57
|
+
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
return False
|