kmoe-manga-downloader 1.1.2__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/__init__.py +5 -3
- kmdr/core/bases.py +61 -87
- kmdr/core/constants.py +79 -0
- kmdr/core/context.py +40 -0
- kmdr/core/defaults.py +92 -33
- kmdr/core/error.py +10 -2
- kmdr/core/session.py +16 -0
- kmdr/core/structure.py +2 -0
- kmdr/core/utils.py +41 -40
- kmdr/main.py +24 -15
- kmdr/module/__init__.py +5 -5
- kmdr/module/authenticator/CookieAuthenticator.py +14 -7
- kmdr/module/authenticator/LoginAuthenticator.py +37 -23
- kmdr/module/authenticator/__init__.py +2 -0
- kmdr/module/authenticator/utils.py +60 -46
- kmdr/module/configurer/BaseUrlUpdator.py +16 -0
- kmdr/module/configurer/ConfigClearer.py +7 -2
- kmdr/module/configurer/ConfigUnsetter.py +2 -2
- kmdr/module/configurer/OptionLister.py +24 -5
- kmdr/module/configurer/OptionSetter.py +2 -2
- kmdr/module/configurer/__init__.py +5 -0
- kmdr/module/configurer/option_validate.py +14 -12
- kmdr/module/downloader/DirectDownloader.py +18 -6
- kmdr/module/downloader/ReferViaDownloader.py +36 -24
- kmdr/module/downloader/__init__.py +2 -0
- kmdr/module/downloader/download_utils.py +322 -0
- kmdr/module/downloader/misc.py +62 -0
- kmdr/module/lister/BookUrlLister.py +4 -3
- kmdr/module/lister/FollowedBookLister.py +62 -24
- kmdr/module/lister/__init__.py +2 -0
- kmdr/module/lister/utils.py +49 -29
- kmdr/module/picker/ArgsFilterPicker.py +1 -1
- kmdr/module/picker/DefaultVolPicker.py +34 -5
- kmdr/module/picker/__init__.py +2 -0
- {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/METADATA +48 -23
- kmoe_manga_downloader-1.2.1.dist-info/RECORD +43 -0
- kmdr/module/downloader/utils.py +0 -157
- kmoe_manga_downloader-1.1.2.dist-info/RECORD +0 -33
- {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/WHEEL +0 -0
- {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/entry_points.txt +0 -0
- {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/top_level.txt +0 -0
kmdr/core/__init__.py
CHANGED
|
@@ -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
|
kmdr/core/bases.py
CHANGED
|
@@ -1,133 +1,107 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
1
|
from typing import Callable, Optional
|
|
2
|
+
from abc import abstractmethod
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from aiohttp import ClientSession
|
|
4
6
|
|
|
5
7
|
from .error import LoginError
|
|
6
8
|
from .registry import Registry
|
|
7
9
|
from .structure import VolInfo, BookInfo
|
|
8
|
-
from .utils import
|
|
9
|
-
from .defaults import Configurer as InnerConfigurer, UserProfile
|
|
10
|
-
|
|
11
|
-
class SessionContext:
|
|
12
|
-
|
|
13
|
-
def __init__(self, *args, **kwargs):
|
|
14
|
-
super().__init__()
|
|
15
|
-
self._session = get_singleton_session()
|
|
16
|
-
|
|
17
|
-
class UserProfileContext:
|
|
10
|
+
from .utils import construct_callback, async_retry
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
super().__init__()
|
|
21
|
-
self._profile = UserProfile()
|
|
22
|
-
|
|
23
|
-
class ConfigContext:
|
|
24
|
-
|
|
25
|
-
def __init__(self, *args, **kwargs):
|
|
26
|
-
super().__init__()
|
|
27
|
-
self._configurer = InnerConfigurer()
|
|
12
|
+
from .context import TerminalContext, SessionContext, UserProfileContext, ConfigContext
|
|
28
13
|
|
|
29
|
-
class Configurer(ConfigContext):
|
|
14
|
+
class Configurer(ConfigContext, TerminalContext):
|
|
30
15
|
|
|
31
16
|
def __init__(self, *args, **kwargs):
|
|
32
17
|
super().__init__(*args, **kwargs)
|
|
33
18
|
|
|
19
|
+
@abstractmethod
|
|
34
20
|
def operate(self) -> None: ...
|
|
35
21
|
|
|
36
|
-
class Authenticator(SessionContext, ConfigContext, UserProfileContext):
|
|
22
|
+
class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalContext):
|
|
37
23
|
|
|
38
|
-
def __init__(self,
|
|
24
|
+
def __init__(self, *args, **kwargs):
|
|
39
25
|
super().__init__(*args, **kwargs)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
self._session.proxies.update({
|
|
43
|
-
'https': proxy,
|
|
44
|
-
'http': proxy,
|
|
45
|
-
})
|
|
26
|
+
# 这里的 base url 可能会在认证过程中被更新
|
|
27
|
+
self._inner_base_url: Optional[str] = None
|
|
46
28
|
|
|
47
29
|
# 在使用代理登录时,可能会出现问题,但是现在还不清楚是不是代理的问题。
|
|
48
30
|
# 主站正常情况下不使用代理也能登录成功。但是不排除特殊的网络环境下需要代理。
|
|
49
31
|
# 所以暂时保留代理登录的功能,如果后续确认是代理的问题,可以考虑启用 @no_proxy 装饰器。
|
|
50
32
|
# @no_proxy
|
|
51
|
-
def authenticate(self) -> None:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
33
|
+
async def authenticate(self) -> None:
|
|
34
|
+
with self._console.status("认证中..."):
|
|
35
|
+
try:
|
|
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
|
|
43
|
+
except LoginError as e:
|
|
44
|
+
self._console.print("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
|
|
45
|
+
self._console.print(f"[yellow]详细信息:{e}[/yellow]")
|
|
46
|
+
exit(1)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def base_url(self) -> str:
|
|
50
|
+
return self._inner_base_url or self._configurer.base_url
|
|
58
51
|
|
|
59
|
-
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def _authenticate(self) -> bool: ...
|
|
60
54
|
|
|
61
|
-
class Lister(SessionContext):
|
|
55
|
+
class Lister(SessionContext, TerminalContext):
|
|
62
56
|
|
|
63
57
|
def __init__(self, *args, **kwargs):
|
|
64
58
|
super().__init__(*args, **kwargs)
|
|
65
59
|
|
|
66
|
-
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def list(self) -> tuple[BookInfo, list[VolInfo]]: ...
|
|
67
62
|
|
|
68
|
-
class Picker(
|
|
63
|
+
class Picker(TerminalContext):
|
|
69
64
|
|
|
70
65
|
def __init__(self, *args, **kwargs):
|
|
71
66
|
super().__init__(*args, **kwargs)
|
|
72
67
|
|
|
68
|
+
@abstractmethod
|
|
73
69
|
def pick(self, volumes: list[VolInfo]) -> list[VolInfo]: ...
|
|
74
70
|
|
|
75
|
-
class Downloader(SessionContext, UserProfileContext):
|
|
71
|
+
class Downloader(SessionContext, UserProfileContext, TerminalContext):
|
|
76
72
|
|
|
77
|
-
def __init__(self,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*args, **kwargs
|
|
73
|
+
def __init__(self,
|
|
74
|
+
dest: str = '.',
|
|
75
|
+
callback: Optional[str] = None,
|
|
76
|
+
retry: int = 3,
|
|
77
|
+
num_workers: int = 8,
|
|
78
|
+
*args, **kwargs
|
|
84
79
|
):
|
|
85
80
|
super().__init__(*args, **kwargs)
|
|
81
|
+
|
|
86
82
|
self._dest: str = dest
|
|
87
|
-
self._callback: Optional[Callable
|
|
83
|
+
self._callback: Optional[Callable] = construct_callback(callback)
|
|
88
84
|
self._retry: int = retry
|
|
89
|
-
self.
|
|
90
|
-
|
|
91
|
-
if proxy:
|
|
92
|
-
self._session.proxies.update({
|
|
93
|
-
'https': proxy,
|
|
94
|
-
'http': proxy,
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
def download(self, book: BookInfo, volumes: list[VolInfo]):
|
|
98
|
-
if volumes is None or not volumes:
|
|
99
|
-
raise ValueError("No volumes to download")
|
|
85
|
+
self._semaphore = asyncio.Semaphore(num_workers)
|
|
100
86
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
self._download_with_multiple_workers(book, volumes, self._retry)
|
|
106
|
-
|
|
107
|
-
def _download(self, book: BookInfo, volume: VolInfo, retry: int): ...
|
|
108
|
-
|
|
109
|
-
def _download_with_multiple_workers(self, book: BookInfo, volumes: list[VolInfo], retry: int):
|
|
110
|
-
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION
|
|
87
|
+
async def download(self, book: BookInfo, volumes: list[VolInfo]):
|
|
88
|
+
if not volumes:
|
|
89
|
+
self._console.print("No volumes to download.")
|
|
90
|
+
exit(0)
|
|
111
91
|
|
|
112
92
|
try:
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
for volume in volumes
|
|
118
|
-
]
|
|
119
|
-
wait(futures, return_when=FIRST_EXCEPTION)
|
|
120
|
-
for future in futures:
|
|
121
|
-
future.result()
|
|
93
|
+
with self._progress:
|
|
94
|
+
tasks = [self._download(book, volume) for volume in volumes]
|
|
95
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
96
|
+
|
|
122
97
|
except KeyboardInterrupt:
|
|
123
|
-
print("\n操作已取消(KeyboardInterrupt)")
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
finally:
|
|
129
|
-
exit(130)
|
|
98
|
+
self._console.print("\n操作已取消(KeyboardInterrupt)")
|
|
99
|
+
exit(130)
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
async def _download(self, book: BookInfo, volume: VolInfo): ...
|
|
130
103
|
|
|
104
|
+
KMDR_SESSION = Registry[ClientSession]('KmdrSession', True)
|
|
131
105
|
AUTHENTICATOR = Registry[Authenticator]('Authenticator')
|
|
132
106
|
LISTERS = Registry[Lister]('Lister')
|
|
133
107
|
PICKERS = Registry[Picker]('Picker')
|
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
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from aiohttp import ClientSession
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from .defaults import Configurer as InnerConfigurer, UserProfile, session_var, progress, console, base_url_var
|
|
5
|
+
|
|
6
|
+
class TerminalContext:
|
|
7
|
+
|
|
8
|
+
def __init__(self, *args, **kwargs):
|
|
9
|
+
super().__init__()
|
|
10
|
+
self._progress = progress
|
|
11
|
+
self._console = console
|
|
12
|
+
|
|
13
|
+
class UserProfileContext:
|
|
14
|
+
|
|
15
|
+
def __init__(self, *args, **kwargs):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._profile = UserProfile()
|
|
18
|
+
|
|
19
|
+
class ConfigContext:
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args, **kwargs):
|
|
22
|
+
super().__init__()
|
|
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,10 +1,47 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import json
|
|
3
|
-
from typing import Optional
|
|
3
|
+
from typing import Optional, Any
|
|
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
|
|
18
|
+
from .constants import BASE_URL
|
|
19
|
+
|
|
20
|
+
HEADERS = {
|
|
21
|
+
'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
def console_print(*args, **kwargs):
|
|
27
|
+
console.print(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
progress = Progress(
|
|
30
|
+
TextColumn("[blue]{task.fields[filename]}", justify="left"),
|
|
31
|
+
TextColumn("{task.fields[status]}", justify="right"),
|
|
32
|
+
TextColumn("{task.percentage:>3.1f}%"),
|
|
33
|
+
BarColumn(bar_width=None),
|
|
34
|
+
"[progress.percentage]",
|
|
35
|
+
DownloadColumn(),
|
|
36
|
+
"[",
|
|
37
|
+
TransferSpeedColumn(),
|
|
38
|
+
",",
|
|
39
|
+
TimeRemainingColumn(),
|
|
40
|
+
"]",
|
|
41
|
+
console=console,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
session_var = ContextVar('session')
|
|
8
45
|
|
|
9
46
|
parser: Optional[argparse.ArgumentParser] = None
|
|
10
47
|
args: Optional[argparse.Namespace] = None
|
|
@@ -14,33 +51,34 @@ def argument_parser():
|
|
|
14
51
|
if parser is not None:
|
|
15
52
|
return parser
|
|
16
53
|
|
|
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('-
|
|
43
|
-
config_parser.add_argument('-
|
|
54
|
+
parser = argparse.ArgumentParser(description='Kmoe 漫画下载器')
|
|
55
|
+
subparsers = parser.add_subparsers(title='可用的子命令', dest='command')
|
|
56
|
+
|
|
57
|
+
download_parser = subparsers.add_parser('download', help='下载指定的漫画')
|
|
58
|
+
download_parser.add_argument('-d', '--dest', type=str, help='指定下载文件的保存路径,默认为当前目录', required=False)
|
|
59
|
+
download_parser.add_argument('-l', '--book-url', type=str, help='漫画详情页面的 URL', required=False)
|
|
60
|
+
download_parser.add_argument('-v', '--volume', type=str, help='指定下载的卷,多个用逗号分隔,例如 `1,2,3` 或 `1-5,8`,`all` 表示全部', required=False)
|
|
61
|
+
download_parser.add_argument('-t', '--vol-type', type=str, help='指定下载的卷类型,`vol` 为单行本, `extra` 为番外, `seri` 为连载', required=False, choices=['vol', 'extra', 'seri', 'all'], default='vol')
|
|
62
|
+
download_parser.add_argument('--max-size', type=float, help='限制下载卷的最大体积 (单位: MB)', required=False)
|
|
63
|
+
download_parser.add_argument('--limit', type=int, help='限制下载卷的总数量', required=False)
|
|
64
|
+
download_parser.add_argument('--num-workers', type=int, help='下载时使用的并发任务数', required=False)
|
|
65
|
+
download_parser.add_argument('-p', '--proxy', type=str, help='设置下载使用的代理服务器', required=False)
|
|
66
|
+
download_parser.add_argument('-r', '--retry', type=int, help='网络请求失败时的重试次数', required=False)
|
|
67
|
+
download_parser.add_argument('-c', '--callback', type=str, help='每个卷下载完成后执行的回调脚本,例如: `echo {v.name} downloaded!`', required=False)
|
|
68
|
+
|
|
69
|
+
login_parser = subparsers.add_parser('login', help='登录到 Kmoe')
|
|
70
|
+
login_parser.add_argument('-u', '--username', type=str, help='用户名', required=True)
|
|
71
|
+
login_parser.add_argument('-p', '--password', type=str, help='密码 (如果留空,应用将提示您输入)', required=False)
|
|
72
|
+
|
|
73
|
+
status_parser = subparsers.add_parser('status', help='显示账户信息以及配额')
|
|
74
|
+
status_parser.add_argument('-p', '--proxy', type=str, help='代理服务器', required=False)
|
|
75
|
+
|
|
76
|
+
config_parser = subparsers.add_parser('config', help='配置下载器')
|
|
77
|
+
config_parser.add_argument('-l', '--list-option', action='store_true', help='列出所有配置')
|
|
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`')
|
|
80
|
+
config_parser.add_argument('-c', '--clear', type=str, help='清除指定配置,可选值为 `all`, `cookie`, `option`')
|
|
81
|
+
config_parser.add_argument('-d', '--delete', '--unset', dest='unset', type=str, help='删除特定的配置选项')
|
|
44
82
|
|
|
45
83
|
return parser
|
|
46
84
|
|
|
@@ -101,6 +139,9 @@ class Configurer:
|
|
|
101
139
|
cookie = config.get('cookie', None)
|
|
102
140
|
if cookie is not None and isinstance(cookie, dict):
|
|
103
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
|
|
104
145
|
|
|
105
146
|
@property
|
|
106
147
|
def config(self) -> 'Config':
|
|
@@ -126,12 +167,24 @@ class Configurer:
|
|
|
126
167
|
return self._config.option
|
|
127
168
|
|
|
128
169
|
@option.setter
|
|
129
|
-
def option(self, value: Optional[dict[str,
|
|
170
|
+
def option(self, value: Optional[dict[str, Any]]):
|
|
130
171
|
if self._config is None:
|
|
131
172
|
self._config = Config()
|
|
132
173
|
self._config.option = value
|
|
133
174
|
self.update()
|
|
134
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
|
+
|
|
135
188
|
def update(self):
|
|
136
189
|
with open(os.path.join(os.path.expanduser("~"), self.__filename), 'w') as f:
|
|
137
190
|
json.dump(self._config.__dict__, f, indent=4, ensure_ascii=False)
|
|
@@ -144,11 +197,11 @@ class Configurer:
|
|
|
144
197
|
elif key == 'option':
|
|
145
198
|
self._config.option = None
|
|
146
199
|
else:
|
|
147
|
-
raise
|
|
200
|
+
raise KeyError(f"[red]对应配置不存在: {key}。可用配置项:all, cookie, option[/red]")
|
|
148
201
|
|
|
149
202
|
self.update()
|
|
150
203
|
|
|
151
|
-
def set_option(self, key: str, value:
|
|
204
|
+
def set_option(self, key: str, value: Any):
|
|
152
205
|
if self._config.option is None:
|
|
153
206
|
self._config.option = {}
|
|
154
207
|
|
|
@@ -173,5 +226,11 @@ def __combine_args(dest: argparse.Namespace, option: dict) -> argparse.Namespace
|
|
|
173
226
|
|
|
174
227
|
def combine_args(dest: argparse.Namespace) -> argparse.Namespace:
|
|
175
228
|
assert isinstance(dest, argparse.Namespace), "dest must be an argparse.Namespace instance"
|
|
176
|
-
option = Configurer().
|
|
177
|
-
|
|
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
|
@@ -5,11 +5,19 @@ 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):
|
|
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/session.py
ADDED
|
@@ -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)
|
kmdr/core/structure.py
CHANGED
kmdr/core/utils.py
CHANGED
|
@@ -1,37 +1,14 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
from typing import Optional, Callable
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
3
6
|
|
|
4
|
-
from requests import Session
|
|
5
|
-
import threading
|
|
6
7
|
import subprocess
|
|
7
8
|
|
|
8
9
|
from .structure import BookInfo, VolInfo
|
|
10
|
+
from .error import RedirectError
|
|
9
11
|
|
|
10
|
-
_session_instance: Optional[Session] = None
|
|
11
|
-
|
|
12
|
-
_session_lock = threading.Lock()
|
|
13
|
-
|
|
14
|
-
HEADERS = {
|
|
15
|
-
'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
def get_singleton_session() -> Session:
|
|
19
|
-
global _session_instance
|
|
20
|
-
|
|
21
|
-
if _session_instance is None:
|
|
22
|
-
with _session_lock:
|
|
23
|
-
if _session_instance is None:
|
|
24
|
-
_session_instance = Session()
|
|
25
|
-
_session_instance.headers.update(HEADERS)
|
|
26
|
-
|
|
27
|
-
return _session_instance
|
|
28
|
-
|
|
29
|
-
def clear_session_context():
|
|
30
|
-
session = get_singleton_session()
|
|
31
|
-
session.proxies.clear()
|
|
32
|
-
session.headers.clear()
|
|
33
|
-
session.cookies.clear()
|
|
34
|
-
session.headers.update(HEADERS)
|
|
35
12
|
|
|
36
13
|
def singleton(cls):
|
|
37
14
|
"""
|
|
@@ -61,17 +38,41 @@ def construct_callback(callback: Optional[str]) -> Optional[Callable]:
|
|
|
61
38
|
|
|
62
39
|
return _callback
|
|
63
40
|
|
|
64
|
-
def no_proxy(func):
|
|
65
|
-
@functools.wraps(func)
|
|
66
|
-
def wrapper(*args, **kwargs):
|
|
67
|
-
session = get_singleton_session()
|
|
68
|
-
|
|
69
|
-
cached_proxies = session.proxies.copy()
|
|
70
|
-
session.proxies.clear()
|
|
71
|
-
|
|
72
|
-
try:
|
|
73
|
-
return func(*args, **kwargs)
|
|
74
|
-
finally:
|
|
75
|
-
session.proxies = cached_proxies
|
|
76
41
|
|
|
77
|
-
|
|
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
|
+
base_url_setter: Optional[Callable[[str], None]] = None,
|
|
48
|
+
):
|
|
49
|
+
def decorator(func):
|
|
50
|
+
@functools.wraps(func)
|
|
51
|
+
async def wrapper(*args, **kwargs):
|
|
52
|
+
current_delay = delay
|
|
53
|
+
for attempt in range(attempts):
|
|
54
|
+
try:
|
|
55
|
+
return await func(*args, **kwargs)
|
|
56
|
+
except aiohttp.ClientResponseError as e:
|
|
57
|
+
if e.status in retry_on_status:
|
|
58
|
+
if attempt == attempts - 1:
|
|
59
|
+
raise
|
|
60
|
+
else:
|
|
61
|
+
raise
|
|
62
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
63
|
+
# 对于所有其他 aiohttp 客户端异常和超时,进行重试
|
|
64
|
+
if attempt == attempts - 1:
|
|
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
|
|
73
|
+
|
|
74
|
+
await asyncio.sleep(current_delay)
|
|
75
|
+
|
|
76
|
+
current_delay *= backoff
|
|
77
|
+
return wrapper
|
|
78
|
+
return decorator
|