kmoe-manga-downloader 1.2.1__py3-none-any.whl → 1.2.3b0__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 +6 -4
- kmdr/core/bases.py +24 -19
- kmdr/core/console.py +67 -0
- kmdr/core/constants.py +17 -22
- kmdr/core/context.py +18 -3
- kmdr/core/defaults.py +22 -7
- kmdr/core/error.py +23 -1
- kmdr/core/protocol.py +10 -0
- kmdr/core/session.py +111 -8
- kmdr/core/utils.py +51 -2
- kmdr/main.py +31 -10
- kmdr/module/authenticator/CookieAuthenticator.py +4 -8
- kmdr/module/authenticator/LoginAuthenticator.py +8 -21
- kmdr/module/authenticator/utils.py +16 -19
- 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 +10 -13
- kmdr/module/downloader/ReferViaDownloader.py +11 -12
- kmdr/module/downloader/download_utils.py +53 -7
- kmdr/module/downloader/misc.py +2 -0
- kmdr/module/lister/FollowedBookLister.py +4 -4
- kmdr/module/lister/utils.py +10 -8
- kmdr/module/picker/DefaultVolPicker.py +2 -1
- kmdr/module/picker/utils.py +14 -6
- {kmoe_manga_downloader-1.2.1.dist-info → kmoe_manga_downloader-1.2.3b0.dist-info}/METADATA +1 -1
- kmoe_manga_downloader-1.2.3b0.dist-info/RECORD +46 -0
- kmoe_manga_downloader-1.2.1.dist-info/RECORD +0 -43
- {kmoe_manga_downloader-1.2.1.dist-info → kmoe_manga_downloader-1.2.3b0.dist-info}/WHEEL +0 -0
- {kmoe_manga_downloader-1.2.1.dist-info → kmoe_manga_downloader-1.2.3b0.dist-info}/entry_points.txt +0 -0
- {kmoe_manga_downloader-1.2.1.dist-info → kmoe_manga_downloader-1.2.3b0.dist-info}/licenses/LICENSE +0 -0
- {kmoe_manga_downloader-1.2.1.dist-info → kmoe_manga_downloader-1.2.3b0.dist-info}/top_level.txt +0 -0
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,29 +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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
post_init(args)
|
|
13
|
+
log('[Lifecycle:Start] 启动 kmdr 版本:', __version__)
|
|
14
|
+
debug('[bold green]以调试模式启动[/bold green]')
|
|
15
|
+
debug('接收到的参数:', args)
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
if args.command == 'version':
|
|
18
|
+
info(f"[green]{__version__}[/green]")
|
|
19
|
+
|
|
20
|
+
elif args.command == 'config':
|
|
21
|
+
CONFIGURER.get(args).operate()
|
|
15
22
|
|
|
16
|
-
|
|
23
|
+
elif args.command == 'login':
|
|
24
|
+
async with (await SESSION_MANAGER.get(args).session()):
|
|
17
25
|
await AUTHENTICATOR.get(args).authenticate()
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
elif args.command == 'status':
|
|
28
|
+
async with (await SESSION_MANAGER.get(args).session()):
|
|
20
29
|
await AUTHENTICATOR.get(args).authenticate()
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
elif args.command == 'download':
|
|
32
|
+
async with (await SESSION_MANAGER.get(args).session()):
|
|
23
33
|
await AUTHENTICATOR.get(args).authenticate()
|
|
24
34
|
|
|
25
35
|
book, volumes = await LISTERS.get(args).list()
|
|
36
|
+
debug("获取到书籍《", book.name, "》及其", len(volumes), "个章节信息。")
|
|
26
37
|
|
|
27
38
|
volumes = PICKERS.get(args).pick(volumes)
|
|
39
|
+
debug("选择了", len(volumes), "个章节进行下载:", ', '.join(volume.name for volume in volumes))
|
|
28
40
|
|
|
29
41
|
await DOWNLOADER.get(args).download(book, volumes)
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
else:
|
|
44
|
+
fallback()
|
|
33
45
|
|
|
34
46
|
def main_sync(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
35
47
|
asyncio.run(main(args, fallback))
|
|
@@ -41,9 +53,18 @@ def entry_point():
|
|
|
41
53
|
|
|
42
54
|
main_coro = main(args, lambda: parser.print_help())
|
|
43
55
|
asyncio.run(main_coro)
|
|
56
|
+
except KmdrError as e:
|
|
57
|
+
info(f"[red]错误: {e}[/red]")
|
|
58
|
+
exit(1)
|
|
44
59
|
except KeyboardInterrupt:
|
|
45
|
-
|
|
60
|
+
info("\n操作已取消(KeyboardInterrupt)", style="yellow")
|
|
46
61
|
exit(130)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
exception(e)
|
|
64
|
+
exit(1)
|
|
65
|
+
finally:
|
|
66
|
+
log('[Lifecycle:End] 运行结束,kmdr 已退出')
|
|
67
|
+
|
|
47
68
|
|
|
48
69
|
if __name__ == '__main__':
|
|
49
70
|
entry_point()
|
|
@@ -4,11 +4,11 @@ from yarl import URL
|
|
|
4
4
|
|
|
5
5
|
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
6
6
|
|
|
7
|
-
from .utils import check_status
|
|
7
|
+
from .utils import check_status
|
|
8
8
|
|
|
9
9
|
@AUTHENTICATOR.register()
|
|
10
10
|
class CookieAuthenticator(Authenticator):
|
|
11
|
-
def __init__(self, proxy: Optional[str] = None,
|
|
11
|
+
def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
|
|
12
12
|
super().__init__(proxy, *args, **kwargs)
|
|
13
13
|
|
|
14
14
|
if 'command' in kwargs and kwargs['command'] == 'status':
|
|
@@ -16,20 +16,16 @@ class CookieAuthenticator(Authenticator):
|
|
|
16
16
|
else:
|
|
17
17
|
self._show_quota = False
|
|
18
18
|
|
|
19
|
-
# 根据用户提供的 book_url 来决定访问的镜像站
|
|
20
|
-
self._inner_base_url = extract_base_url(book_url, default=self._base_url)
|
|
21
|
-
|
|
22
19
|
async def _authenticate(self) -> bool:
|
|
23
20
|
cookie = self._configurer.cookie
|
|
24
21
|
|
|
25
22
|
if not cookie:
|
|
26
23
|
raise LoginError("无法找到 Cookie,请先完成登录。", ['kmdr login -u <username>'])
|
|
27
|
-
|
|
28
|
-
self._session.cookie_jar.update_cookies(cookie, response_url=URL(self.
|
|
24
|
+
|
|
25
|
+
self._session.cookie_jar.update_cookies(cookie, response_url=URL(self._base_url))
|
|
29
26
|
return await check_status(
|
|
30
27
|
self._session,
|
|
31
28
|
self._console,
|
|
32
|
-
base_url=self.base_url,
|
|
33
29
|
show_quota=self._show_quota,
|
|
34
30
|
is_vip_setter=lambda value: setattr(self._profile, 'is_vip', value),
|
|
35
31
|
level_setter=lambda value: setattr(self._profile, 'user_level', value),
|
|
@@ -1,24 +1,14 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
import re
|
|
3
3
|
from yarl import URL
|
|
4
|
-
from urllib.parse import urljoin
|
|
5
4
|
|
|
6
5
|
from rich.prompt import Prompt
|
|
7
6
|
|
|
8
7
|
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
9
|
-
from kmdr.core.constants import API_ROUTE
|
|
10
|
-
from kmdr.core.error import RedirectError
|
|
8
|
+
from kmdr.core.constants import API_ROUTE, LoginResponse
|
|
11
9
|
|
|
12
|
-
from .utils import check_status
|
|
10
|
+
from .utils import check_status
|
|
13
11
|
|
|
14
|
-
CODE_OK = 'm100'
|
|
15
|
-
|
|
16
|
-
CODE_MAPPING = {
|
|
17
|
-
'e400': "帳號或密碼錯誤。",
|
|
18
|
-
'e401': "非法訪問,請使用瀏覽器正常打開本站",
|
|
19
|
-
'e402': "帳號已經註銷。不會解釋原因,無需提問。",
|
|
20
|
-
'e403': "驗證失效,請刷新頁面重新操作。",
|
|
21
|
-
}
|
|
22
12
|
|
|
23
13
|
@AUTHENTICATOR.register(
|
|
24
14
|
hasvalues = {'command': 'login'}
|
|
@@ -37,31 +27,28 @@ class LoginAuthenticator(Authenticator):
|
|
|
37
27
|
async def _authenticate(self) -> bool:
|
|
38
28
|
|
|
39
29
|
async with self._session.post(
|
|
40
|
-
url =
|
|
30
|
+
url = API_ROUTE.LOGIN_DO,
|
|
41
31
|
data = {
|
|
42
32
|
'email': self._username,
|
|
43
33
|
'passwd': self._password,
|
|
44
34
|
'keepalive': 'on'
|
|
45
35
|
},
|
|
46
|
-
allow_redirects = False
|
|
47
36
|
) as response:
|
|
48
37
|
|
|
49
38
|
response.raise_for_status()
|
|
50
39
|
|
|
51
|
-
if response.status in (301, 302, 307, 308) and 'Location' in response.headers:
|
|
52
|
-
new_location = response.headers['Location']
|
|
53
|
-
raise RedirectError("检测到重定向", new_base_url=extract_base_url(new_location) or self._base_url)
|
|
54
|
-
|
|
55
40
|
match = re.search(r'"\w+"', await response.text())
|
|
56
41
|
|
|
57
42
|
if not match:
|
|
58
43
|
raise LoginError("无法解析登录响应。")
|
|
59
44
|
|
|
60
45
|
code = match.group(0).split('"')[1]
|
|
61
|
-
if code != CODE_OK:
|
|
62
|
-
raise LoginError(f"认证失败,错误代码:{code} " + CODE_MAPPING.get(code, "未知错误。"))
|
|
63
46
|
|
|
64
|
-
|
|
47
|
+
login_response = LoginResponse.from_code(code)
|
|
48
|
+
if not LoginResponse.ok(login_response):
|
|
49
|
+
raise LoginError(f"认证失败,错误代码:{login_response.name} {login_response.value}" )
|
|
50
|
+
|
|
51
|
+
if await check_status(self._session, self._console, show_quota=self._show_quota):
|
|
65
52
|
cookie = self._session.cookie_jar.filter_cookies(URL(self._base_url))
|
|
66
53
|
self._configurer.cookie = {key: morsel.value for key, morsel in cookie.items()}
|
|
67
54
|
|
|
@@ -2,11 +2,13 @@ from typing import Optional, Callable
|
|
|
2
2
|
|
|
3
3
|
from aiohttp import ClientSession
|
|
4
4
|
from rich.console import Console
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
from yarl import URL
|
|
6
7
|
|
|
7
8
|
from kmdr.core.error import LoginError
|
|
8
9
|
from kmdr.core.utils import async_retry
|
|
9
|
-
from kmdr.core.constants import API_ROUTE
|
|
10
|
+
from kmdr.core.constants import API_ROUTE
|
|
11
|
+
from kmdr.core.console import *
|
|
10
12
|
|
|
11
13
|
NICKNAME_ID = 'div_nickname_display'
|
|
12
14
|
|
|
@@ -18,20 +20,19 @@ LV1_ID = 'div_user_lv1'
|
|
|
18
20
|
async def check_status(
|
|
19
21
|
session: ClientSession,
|
|
20
22
|
console: Console,
|
|
21
|
-
base_url: str = BASE_URL.KXO,
|
|
22
23
|
show_quota: bool = False,
|
|
23
24
|
is_vip_setter: Optional[Callable[[int], None]] = None,
|
|
24
25
|
level_setter: Optional[Callable[[int], None]] = None,
|
|
25
26
|
) -> bool:
|
|
26
|
-
async with session.get(url =
|
|
27
|
+
async with session.get(url = API_ROUTE.PROFILE) as response:
|
|
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) \
|
|
34
|
-
and
|
|
35
|
+
and URL(response.url).path == API_ROUTE.LOGIN:
|
|
35
36
|
raise LoginError("凭证已失效,请重新登录。", ['kmdr config -c cookie', 'kmdr login -u <username>'])
|
|
36
37
|
|
|
37
38
|
if not is_vip_setter and not level_setter and not show_quota:
|
|
@@ -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]:
|
|
@@ -81,13 +88,3 @@ def __resolve_quota_id(is_vip: Optional[int] = None, user_level: Optional[int] =
|
|
|
81
88
|
return LV1_ID
|
|
82
89
|
|
|
83
90
|
return NOR_ID
|
|
84
|
-
|
|
85
|
-
def extract_base_url(url: Optional[str], default: Optional[str] = None) -> Optional[str]:
|
|
86
|
-
if not url:
|
|
87
|
-
return default
|
|
88
|
-
|
|
89
|
-
parsed = urlsplit(url)
|
|
90
|
-
if parsed.scheme and parsed.netloc:
|
|
91
|
-
return f"{parsed.scheme}://{parsed.netloc}"
|
|
92
|
-
|
|
93
|
-
return default
|
|
@@ -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
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
from
|
|
1
|
+
from functools import partial
|
|
2
2
|
|
|
3
3
|
from kmdr.core import Downloader, BookInfo, VolInfo, DOWNLOADER
|
|
4
4
|
from kmdr.core.constants import API_ROUTE
|
|
5
5
|
|
|
6
|
-
from .download_utils import
|
|
6
|
+
from .download_utils import download_file_multipart, readable_safe_filename
|
|
7
7
|
|
|
8
8
|
@DOWNLOADER.register(
|
|
9
9
|
hasvalues={
|
|
10
|
-
'method':
|
|
10
|
+
'method': 2
|
|
11
11
|
}
|
|
12
12
|
)
|
|
13
13
|
class DirectDownloader(Downloader):
|
|
@@ -15,26 +15,23 @@ class DirectDownloader(Downloader):
|
|
|
15
15
|
super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
|
|
16
16
|
|
|
17
17
|
async def _download(self, book: BookInfo, volume: VolInfo):
|
|
18
|
-
sub_dir =
|
|
18
|
+
sub_dir = readable_safe_filename(book.name)
|
|
19
19
|
download_path = f'{self._dest}/{sub_dir}'
|
|
20
20
|
|
|
21
21
|
await download_file_multipart(
|
|
22
22
|
self._session,
|
|
23
23
|
self._semaphore,
|
|
24
24
|
self._progress,
|
|
25
|
-
|
|
25
|
+
partial(self.construct_download_url, book, volume),
|
|
26
26
|
download_path,
|
|
27
|
-
|
|
27
|
+
readable_safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
|
|
28
28
|
self._retry,
|
|
29
29
|
callback=lambda: self._callback(book, volume) if self._callback else None
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
def construct_download_url(self, book: BookInfo, volume: VolInfo) -> str:
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
volume_id=volume.id,
|
|
38
|
-
is_vip=self._profile.is_vip
|
|
39
|
-
)
|
|
33
|
+
return API_ROUTE.DOWNLOAD.format(
|
|
34
|
+
book_id=book.id,
|
|
35
|
+
volume_id=volume.id,
|
|
36
|
+
is_vip=self._profile.is_vip
|
|
40
37
|
)
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
from functools import partial
|
|
2
|
-
from urllib.parse import urljoin
|
|
3
2
|
|
|
4
3
|
import json
|
|
5
4
|
from async_lru import alru_cache
|
|
6
5
|
|
|
7
6
|
from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
|
|
8
7
|
from kmdr.core.constants import API_ROUTE
|
|
8
|
+
from kmdr.core.error import ResponseError
|
|
9
|
+
from kmdr.core.console import debug
|
|
9
10
|
|
|
10
|
-
from .download_utils import
|
|
11
|
+
from .download_utils import download_file_multipart, readable_safe_filename
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
@DOWNLOADER.register(order=10)
|
|
@@ -17,7 +18,7 @@ class ReferViaDownloader(Downloader):
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
async def _download(self, book: BookInfo, volume: VolInfo):
|
|
20
|
-
sub_dir =
|
|
21
|
+
sub_dir = readable_safe_filename(book.name)
|
|
21
22
|
download_path = f'{self._dest}/{sub_dir}'
|
|
22
23
|
|
|
23
24
|
await download_file_multipart(
|
|
@@ -26,7 +27,7 @@ class ReferViaDownloader(Downloader):
|
|
|
26
27
|
self._progress,
|
|
27
28
|
partial(self.fetch_download_url, book.id, volume.id),
|
|
28
29
|
download_path,
|
|
29
|
-
|
|
30
|
+
readable_safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
|
|
30
31
|
self._retry,
|
|
31
32
|
headers={
|
|
32
33
|
"X-Km-From": "kb_http_down"
|
|
@@ -37,20 +38,18 @@ class ReferViaDownloader(Downloader):
|
|
|
37
38
|
@alru_cache(maxsize=128)
|
|
38
39
|
async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
self._base_url,
|
|
41
|
+
async with self._session.get(
|
|
42
42
|
API_ROUTE.GETDOWNURL.format(
|
|
43
43
|
book_id=book_id,
|
|
44
44
|
volume_id=volume_id,
|
|
45
45
|
is_vip=self._profile.is_vip
|
|
46
46
|
)
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
async with self._session.get(url) as response:
|
|
47
|
+
) as response:
|
|
50
48
|
response.raise_for_status()
|
|
51
49
|
data = await response.text()
|
|
52
50
|
data = json.loads(data)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
debug("获取下载链接响应数据:", data)
|
|
52
|
+
if (code := data.get('code')) != 200:
|
|
53
|
+
raise ResponseError(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}", code)
|
|
54
|
+
|
|
56
55
|
return data['url']
|