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/main.py
CHANGED
|
@@ -1,37 +1,46 @@
|
|
|
1
1
|
from typing import Callable
|
|
2
2
|
from argparse import Namespace
|
|
3
|
+
import asyncio
|
|
3
4
|
|
|
4
5
|
from kmdr.core import *
|
|
5
6
|
from kmdr.module import *
|
|
6
7
|
|
|
7
|
-
def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
8
|
+
async def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
8
9
|
|
|
9
|
-
if args.command == '
|
|
10
|
-
|
|
10
|
+
if args.command == 'config':
|
|
11
|
+
CONFIGURER.get(args).operate()
|
|
12
|
+
return
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
AUTHENTICATOR.get(args).authenticate()
|
|
14
|
+
async with KMDR_SESSION.get(args):
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
if args.command == 'login':
|
|
17
|
+
await AUTHENTICATOR.get(args).authenticate()
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
elif args.command == 'status':
|
|
20
|
+
await AUTHENTICATOR.get(args).authenticate()
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
elif args.command == 'download':
|
|
23
|
+
await AUTHENTICATOR.get(args).authenticate()
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
book, volumes = await LISTERS.get(args).list()
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
volumes = PICKERS.get(args).pick(volumes)
|
|
28
|
+
|
|
29
|
+
await DOWNLOADER.get(args).download(book, volumes)
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
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))
|
|
29
36
|
|
|
30
37
|
def entry_point():
|
|
31
38
|
try:
|
|
32
39
|
parser = argument_parser()
|
|
33
40
|
args = parser.parse_args()
|
|
34
|
-
|
|
41
|
+
|
|
42
|
+
main_coro = main(args, lambda: parser.print_help())
|
|
43
|
+
asyncio.run(main_coro)
|
|
35
44
|
except KeyboardInterrupt:
|
|
36
45
|
print("\n操作已取消(KeyboardInterrupt)")
|
|
37
46
|
exit(130)
|
kmdr/module/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
from .authenticator import
|
|
2
|
-
from .lister import
|
|
3
|
-
from .picker import
|
|
4
|
-
from .downloader import
|
|
5
|
-
from .configurer import
|
|
1
|
+
from .authenticator import *
|
|
2
|
+
from .lister import *
|
|
3
|
+
from .picker import *
|
|
4
|
+
from .downloader import *
|
|
5
|
+
from .configurer import *
|
|
@@ -1,12 +1,14 @@
|
|
|
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
|
-
from .utils import check_status
|
|
7
|
+
from .utils import check_status, extract_base_url
|
|
6
8
|
|
|
7
9
|
@AUTHENTICATOR.register()
|
|
8
10
|
class CookieAuthenticator(Authenticator):
|
|
9
|
-
def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
|
|
11
|
+
def __init__(self, proxy: Optional[str] = None, book_url: Optional[str] = None, *args, **kwargs):
|
|
10
12
|
super().__init__(proxy, *args, **kwargs)
|
|
11
13
|
|
|
12
14
|
if 'command' in kwargs and kwargs['command'] == 'status':
|
|
@@ -14,16 +16,21 @@ class CookieAuthenticator(Authenticator):
|
|
|
14
16
|
else:
|
|
15
17
|
self._show_quota = False
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
# 根据用户提供的 book_url 来决定访问的镜像站
|
|
20
|
+
self._inner_base_url = extract_base_url(book_url, default=self._base_url)
|
|
21
|
+
|
|
22
|
+
async def _authenticate(self) -> bool:
|
|
18
23
|
cookie = self._configurer.cookie
|
|
19
24
|
|
|
20
25
|
if not cookie:
|
|
21
|
-
raise LoginError("
|
|
26
|
+
raise LoginError("无法找到 Cookie,请先完成登录。", ['kmdr login -u <username>'])
|
|
22
27
|
|
|
23
|
-
self._session.
|
|
24
|
-
return check_status(
|
|
28
|
+
self._session.cookie_jar.update_cookies(cookie, response_url=URL(self.base_url))
|
|
29
|
+
return await check_status(
|
|
25
30
|
self._session,
|
|
31
|
+
self._console,
|
|
32
|
+
base_url=self.base_url,
|
|
26
33
|
show_quota=self._show_quota,
|
|
27
34
|
is_vip_setter=lambda value: setattr(self._profile, 'is_vip', value),
|
|
28
35
|
level_setter=lambda value: setattr(self._profile, 'user_level', value),
|
|
29
|
-
)
|
|
36
|
+
)
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
import re
|
|
3
|
-
from
|
|
3
|
+
from yarl import URL
|
|
4
|
+
from urllib.parse import urljoin
|
|
5
|
+
|
|
6
|
+
from rich.prompt import Prompt
|
|
4
7
|
|
|
5
8
|
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
9
|
+
from kmdr.core.constants import API_ROUTE
|
|
10
|
+
from kmdr.core.error import RedirectError
|
|
6
11
|
|
|
7
|
-
from .utils import check_status
|
|
12
|
+
from .utils import check_status, extract_base_url
|
|
8
13
|
|
|
9
14
|
CODE_OK = 'm100'
|
|
10
15
|
|
|
@@ -25,32 +30,41 @@ class LoginAuthenticator(Authenticator):
|
|
|
25
30
|
self._show_quota = show_quota
|
|
26
31
|
|
|
27
32
|
if password is None:
|
|
28
|
-
password =
|
|
33
|
+
password = Prompt.ask("请输入密码", password=True, console=self._console)
|
|
29
34
|
|
|
30
35
|
self._password = password
|
|
31
36
|
|
|
32
|
-
def _authenticate(self) -> bool:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
url =
|
|
37
|
+
async def _authenticate(self) -> bool:
|
|
38
|
+
|
|
39
|
+
async with self._session.post(
|
|
40
|
+
url = urljoin(self._base_url, API_ROUTE.LOGIN_DO),
|
|
36
41
|
data = {
|
|
37
42
|
'email': self._username,
|
|
38
43
|
'passwd': self._password,
|
|
39
44
|
'keepalive': 'on'
|
|
40
45
|
},
|
|
41
|
-
|
|
42
|
-
response
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
allow_redirects = False
|
|
47
|
+
) as response:
|
|
48
|
+
|
|
49
|
+
response.raise_for_status()
|
|
50
|
+
|
|
51
|
+
if response.status in (301, 302, 307, 308) and 'Location' in response.headers:
|
|
52
|
+
new_location = response.headers['Location']
|
|
53
|
+
raise RedirectError("检测到重定向", new_base_url=extract_base_url(new_location) or self._base_url)
|
|
54
|
+
|
|
55
|
+
match = re.search(r'"\w+"', await response.text())
|
|
56
|
+
|
|
57
|
+
if not match:
|
|
58
|
+
raise LoginError("无法解析登录响应。")
|
|
59
|
+
|
|
60
|
+
code = match.group(0).split('"')[1]
|
|
61
|
+
if code != CODE_OK:
|
|
62
|
+
raise LoginError(f"认证失败,错误代码:{code} " + CODE_MAPPING.get(code, "未知错误。"))
|
|
63
|
+
|
|
64
|
+
if await check_status(self._session, self._console, base_url=self.base_url, show_quota=self._show_quota):
|
|
65
|
+
cookie = self._session.cookie_jar.filter_cookies(URL(self._base_url))
|
|
66
|
+
self._configurer.cookie = {key: morsel.value for key, morsel in cookie.items()}
|
|
67
|
+
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
return False
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from typing import Optional, Callable
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from aiohttp import ClientSession
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from urllib.parse import urljoin, urlsplit
|
|
4
6
|
|
|
5
7
|
from kmdr.core.error import LoginError
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
LOGIN_URL = 'https://kox.moe/login.php'
|
|
8
|
+
from kmdr.core.utils import async_retry
|
|
9
|
+
from kmdr.core.constants import API_ROUTE, BASE_URL
|
|
9
10
|
|
|
10
11
|
NICKNAME_ID = 'div_nickname_display'
|
|
11
12
|
|
|
@@ -13,52 +14,55 @@ VIP_ID = 'div_user_vip'
|
|
|
13
14
|
NOR_ID = 'div_user_nor'
|
|
14
15
|
LV1_ID = 'div_user_lv1'
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
@async_retry()
|
|
18
|
+
async def check_status(
|
|
19
|
+
session: ClientSession,
|
|
20
|
+
console: Console,
|
|
21
|
+
base_url: str = BASE_URL.KXO,
|
|
18
22
|
show_quota: bool = False,
|
|
19
23
|
is_vip_setter: Optional[Callable[[int], None]] = None,
|
|
20
|
-
level_setter: Optional[Callable[[int], None]] = None
|
|
24
|
+
level_setter: Optional[Callable[[int], None]] = None,
|
|
21
25
|
) -> bool:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
async with session.get(url = urljoin(base_url, API_ROUTE.PROFILE)) as response:
|
|
27
|
+
try:
|
|
28
|
+
response.raise_for_status()
|
|
29
|
+
except Exception as e:
|
|
30
|
+
console.print(f"Error: {type(e).__name__}: {e}")
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
if response.history and any(resp.status in (301, 302, 307) for resp in response.history) \
|
|
34
|
+
and str(response.url) == urljoin(base_url, API_ROUTE.LOGIN):
|
|
35
|
+
raise LoginError("凭证已失效,请重新登录。", ['kmdr config -c cookie', 'kmdr login -u <username>'])
|
|
36
|
+
|
|
37
|
+
if not is_vip_setter and not level_setter and not show_quota:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
from bs4 import BeautifulSoup
|
|
41
|
+
|
|
42
|
+
# 如果后续有性能问题,可以先考虑使用 lxml 进行解析
|
|
43
|
+
soup = BeautifulSoup(await response.text(), 'html.parser')
|
|
44
|
+
|
|
45
|
+
script = soup.find('script', language="javascript")
|
|
46
|
+
|
|
47
|
+
if script:
|
|
48
|
+
var_define = extract_var_define(script.text[:100])
|
|
49
|
+
|
|
50
|
+
is_vip = int(var_define.get('is_vip', '0'))
|
|
51
|
+
user_level = int(var_define.get('user_level', '0'))
|
|
52
|
+
|
|
53
|
+
if is_vip_setter:
|
|
54
|
+
is_vip_setter(is_vip)
|
|
55
|
+
if level_setter:
|
|
56
|
+
level_setter(user_level)
|
|
57
|
+
|
|
58
|
+
if not show_quota:
|
|
59
|
+
return True
|
|
60
|
+
|
|
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()
|
|
63
|
+
|
|
64
|
+
console.print(f"\n当前登录为 [bold cyan]{nickname}[/bold cyan]\n\n{quota}")
|
|
35
65
|
return True
|
|
36
|
-
|
|
37
|
-
from bs4 import BeautifulSoup
|
|
38
|
-
|
|
39
|
-
soup = BeautifulSoup(response.text, 'html.parser')
|
|
40
|
-
|
|
41
|
-
script = soup.find('script', language="javascript")
|
|
42
|
-
|
|
43
|
-
if script:
|
|
44
|
-
var_define = extract_var_define(script.text[:100])
|
|
45
|
-
|
|
46
|
-
is_vip = int(var_define.get('is_vip', '0'))
|
|
47
|
-
user_level = int(var_define.get('user_level', '0'))
|
|
48
|
-
|
|
49
|
-
if is_vip_setter:
|
|
50
|
-
is_vip_setter(is_vip)
|
|
51
|
-
if level_setter:
|
|
52
|
-
level_setter(user_level)
|
|
53
|
-
|
|
54
|
-
if not show_quota:
|
|
55
|
-
return True
|
|
56
|
-
|
|
57
|
-
nickname = soup.find('div', id=NICKNAME_ID).text.strip().split(' ')[0]
|
|
58
|
-
quota = soup.find('div', id=__resolve_quota_id(is_vip, user_level)).text.strip()
|
|
59
|
-
|
|
60
|
-
print(f"\n当前登录为 {nickname}\n\n{quota}")
|
|
61
|
-
return True
|
|
62
66
|
|
|
63
67
|
def extract_var_define(script_text) -> dict[str, str]:
|
|
64
68
|
var_define = {}
|
|
@@ -77,3 +81,13 @@ def __resolve_quota_id(is_vip: Optional[int] = None, user_level: Optional[int] =
|
|
|
77
81
|
return LV1_ID
|
|
78
82
|
|
|
79
83
|
return NOR_ID
|
|
84
|
+
|
|
85
|
+
def extract_base_url(url: Optional[str], default: Optional[str] = None) -> Optional[str]:
|
|
86
|
+
if not url:
|
|
87
|
+
return default
|
|
88
|
+
|
|
89
|
+
parsed = urlsplit(url)
|
|
90
|
+
if parsed.scheme and parsed.netloc:
|
|
91
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
92
|
+
|
|
93
|
+
return default
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from kmdr.core import Configurer, CONFIGURER
|
|
2
|
+
|
|
3
|
+
@CONFIGURER.register()
|
|
4
|
+
class BaseUrlUpdator(Configurer):
|
|
5
|
+
def __init__(self, base_url: str, *args, **kwargs):
|
|
6
|
+
super().__init__(*args, **kwargs)
|
|
7
|
+
self._base_url = base_url
|
|
8
|
+
|
|
9
|
+
def operate(self) -> None:
|
|
10
|
+
try:
|
|
11
|
+
self._configurer.set_base_url(self._base_url)
|
|
12
|
+
except KeyError as e:
|
|
13
|
+
self._console.print(e.args[0])
|
|
14
|
+
exit(1)
|
|
15
|
+
|
|
16
|
+
self._console.print(f"已设置基础 URL: {self._base_url}")
|
|
@@ -7,5 +7,10 @@ class ConfigClearer(Configurer):
|
|
|
7
7
|
self._clear = clear
|
|
8
8
|
|
|
9
9
|
def operate(self) -> None:
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
try:
|
|
11
|
+
self._configurer.clear(self._clear)
|
|
12
|
+
except KeyError as e:
|
|
13
|
+
self._console.print(e.args[0])
|
|
14
|
+
exit(1)
|
|
15
|
+
|
|
16
|
+
self._console.print(f"Cleared configuration: {self._clear}")
|
|
@@ -10,9 +10,9 @@ class ConfigUnsetter(Configurer):
|
|
|
10
10
|
|
|
11
11
|
def operate(self) -> None:
|
|
12
12
|
if not self._unset:
|
|
13
|
-
print("
|
|
13
|
+
self._console.print("[yellow]请提供要取消设置的配置项。[/yellow]")
|
|
14
14
|
return
|
|
15
15
|
|
|
16
16
|
check_key(self._unset)
|
|
17
17
|
self._configurer.unset_option(self._unset)
|
|
18
|
-
print(f"
|
|
18
|
+
self._console.print(f"[green]取消配置项: {self._unset}[/green]")
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
from rich.table import Table
|
|
2
|
+
from rich.pretty import Pretty
|
|
3
|
+
|
|
1
4
|
from kmdr.core import CONFIGURER, Configurer
|
|
2
5
|
|
|
3
6
|
@CONFIGURER.register(
|
|
@@ -10,10 +13,26 @@ class OptionLister(Configurer):
|
|
|
10
13
|
super().__init__(*args, **kwargs)
|
|
11
14
|
|
|
12
15
|
def operate(self) -> None:
|
|
13
|
-
if self._configurer.option is None:
|
|
14
|
-
print("
|
|
16
|
+
if self._configurer.option is None and self._configurer.base_url is None:
|
|
17
|
+
self._console.print("[blue]当前没有任何配置项。[/blue]")
|
|
15
18
|
return
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
table = Table(title="[green]当前 Kmdr 配置项[/green]", show_header=False, header_style="blue")
|
|
21
|
+
|
|
22
|
+
table.add_column("配置类型 (Type)", style="magenta", no_wrap=True, min_width=10)
|
|
23
|
+
table.add_column("配置项 (Key)", style="cyan", no_wrap=True, min_width=10)
|
|
24
|
+
table.add_column("值 (Value)", no_wrap=False, min_width=20)
|
|
25
|
+
|
|
26
|
+
if self._configurer.option is not None:
|
|
27
|
+
for idx, (key, value) in enumerate(self._configurer.option.items()):
|
|
28
|
+
value_to_display = value
|
|
29
|
+
if isinstance(value, (dict, list, set, tuple)):
|
|
30
|
+
value_to_display = Pretty(value)
|
|
31
|
+
|
|
32
|
+
table.add_row('下载配置' if idx == 0 else '', key, value_to_display)
|
|
33
|
+
table.add_section()
|
|
34
|
+
|
|
35
|
+
if self._configurer.base_url is not None:
|
|
36
|
+
table.add_row('应用配置', '镜像地址', self._configurer.base_url or '未设置')
|
|
37
|
+
|
|
38
|
+
self._console.print(table)
|
|
@@ -11,7 +11,7 @@ class OptionSetter(Configurer):
|
|
|
11
11
|
def operate(self) -> None:
|
|
12
12
|
for option in self._set:
|
|
13
13
|
if '=' not in option:
|
|
14
|
-
print(f"
|
|
14
|
+
self._console.print(f"[red]无效的选项格式: `{option}`。[/red] 应为 key=value 格式。")
|
|
15
15
|
continue
|
|
16
16
|
|
|
17
17
|
key, value = option.split('=', 1)
|
|
@@ -23,7 +23,7 @@ class OptionSetter(Configurer):
|
|
|
23
23
|
continue
|
|
24
24
|
|
|
25
25
|
self._configurer.set_option(key, validated_value)
|
|
26
|
-
print(f"
|
|
26
|
+
self._console.print(f"[green]已设置配置: {key} = {validated_value}[/green]")
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
|
|
@@ -2,6 +2,8 @@ from typing import Optional
|
|
|
2
2
|
from functools import wraps
|
|
3
3
|
import os
|
|
4
4
|
|
|
5
|
+
from kmdr.core.defaults import console_print as print
|
|
6
|
+
|
|
5
7
|
__OPTIONS_VALIDATOR = {}
|
|
6
8
|
|
|
7
9
|
def validate(key: str, value: str) -> Optional[object]:
|
|
@@ -15,7 +17,7 @@ def validate(key: str, value: str) -> Optional[object]:
|
|
|
15
17
|
if key in __OPTIONS_VALIDATOR:
|
|
16
18
|
return __OPTIONS_VALIDATOR[key](value)
|
|
17
19
|
else:
|
|
18
|
-
print(f"
|
|
20
|
+
print(f"[red]不支持的配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
|
|
19
21
|
return None
|
|
20
22
|
|
|
21
23
|
def check_key(key: str, exit_if_invalid: bool = True) -> None:
|
|
@@ -27,7 +29,7 @@ def check_key(key: str, exit_if_invalid: bool = True) -> None:
|
|
|
27
29
|
:param exit_if_invalid: 如果键名无效,是否退出程序
|
|
28
30
|
"""
|
|
29
31
|
if key not in __OPTIONS_VALIDATOR:
|
|
30
|
-
print(f"
|
|
32
|
+
print(f"[red]未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
|
|
31
33
|
if exit_if_invalid:
|
|
32
34
|
exit(1)
|
|
33
35
|
|
|
@@ -60,27 +62,27 @@ def validate_num_workers(value: str) -> Optional[int]:
|
|
|
60
62
|
try:
|
|
61
63
|
num_workers = int(value)
|
|
62
64
|
if num_workers <= 0:
|
|
63
|
-
raise ValueError("
|
|
65
|
+
raise ValueError("必须是正值。")
|
|
64
66
|
return num_workers
|
|
65
67
|
except ValueError as e:
|
|
66
|
-
print(f"
|
|
68
|
+
print(f"[red]无效的 num_workers 值: {value}。{str(e)}[/red]")
|
|
67
69
|
return None
|
|
68
70
|
|
|
69
71
|
@register_validator('dest')
|
|
70
72
|
def validate_dest(value: str) -> Optional[str]:
|
|
71
73
|
if not value:
|
|
72
|
-
print("
|
|
74
|
+
print("[red]目标目录不能为空。[/red]")
|
|
73
75
|
return None
|
|
74
76
|
if not os.path.exists(value) or not os.path.isdir(value):
|
|
75
|
-
print(f"
|
|
77
|
+
print(f"[red]目标目录不存在或不是目录: {value}[/red]")
|
|
76
78
|
return None
|
|
77
79
|
|
|
78
80
|
if not os.access(value, os.W_OK):
|
|
79
|
-
print(f"
|
|
81
|
+
print(f"[red]目标目录不可写: {value}[/red]")
|
|
80
82
|
return None
|
|
81
83
|
|
|
82
84
|
if not os.path.isabs(value):
|
|
83
|
-
print(f"
|
|
85
|
+
print(f"[yellow]目标目录最好是绝对路径: {value}[/yellow]")
|
|
84
86
|
|
|
85
87
|
return value
|
|
86
88
|
|
|
@@ -89,22 +91,22 @@ def validate_retry(value: str) -> Optional[int]:
|
|
|
89
91
|
try:
|
|
90
92
|
retry = int(value)
|
|
91
93
|
if retry < 0:
|
|
92
|
-
raise ValueError("
|
|
94
|
+
raise ValueError("必须是正值。")
|
|
93
95
|
return retry
|
|
94
96
|
except ValueError as e:
|
|
95
|
-
print(f"
|
|
97
|
+
print(f"[red]无效的 retry 值: {value}。{str(e)}[/red]")
|
|
96
98
|
return None
|
|
97
99
|
|
|
98
100
|
@register_validator('callback')
|
|
99
101
|
def validate_callback(value: str) -> Optional[str]:
|
|
100
102
|
if not value:
|
|
101
|
-
print("
|
|
103
|
+
print("[red]回调不能为空。[/red]")
|
|
102
104
|
return None
|
|
103
105
|
return value
|
|
104
106
|
|
|
105
107
|
@register_validator('proxy')
|
|
106
108
|
def validate_proxy(value: str) -> Optional[str]:
|
|
107
109
|
if not value:
|
|
108
|
-
print("
|
|
110
|
+
print("[red]代理不能为空。[/red]")
|
|
109
111
|
return None
|
|
110
112
|
return value
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
from urllib.parse import urljoin
|
|
2
|
+
|
|
1
3
|
from kmdr.core import Downloader, BookInfo, VolInfo, DOWNLOADER
|
|
4
|
+
from kmdr.core.constants import API_ROUTE
|
|
2
5
|
|
|
3
|
-
from .
|
|
6
|
+
from .download_utils import safe_filename, download_file_multipart
|
|
4
7
|
|
|
5
8
|
@DOWNLOADER.register(
|
|
6
9
|
hasvalues={
|
|
@@ -8,21 +11,30 @@ from .utils import download_file, safe_filename
|
|
|
8
11
|
}
|
|
9
12
|
)
|
|
10
13
|
class DirectDownloader(Downloader):
|
|
11
|
-
def __init__(self, dest='.', callback=None, retry=3, num_workers=
|
|
14
|
+
def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
|
|
12
15
|
super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
|
|
13
16
|
|
|
14
|
-
def _download(self, book: BookInfo, volume: VolInfo
|
|
17
|
+
async def _download(self, book: BookInfo, volume: VolInfo):
|
|
15
18
|
sub_dir = safe_filename(book.name)
|
|
16
19
|
download_path = f'{self._dest}/{sub_dir}'
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
await download_file_multipart(
|
|
19
22
|
self._session,
|
|
23
|
+
self._semaphore,
|
|
24
|
+
self._progress,
|
|
20
25
|
lambda: self.construct_download_url(book, volume),
|
|
21
26
|
download_path,
|
|
22
27
|
safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
|
|
23
|
-
|
|
28
|
+
self._retry,
|
|
24
29
|
callback=lambda: self._callback(book, volume) if self._callback else None
|
|
25
30
|
)
|
|
26
31
|
|
|
27
32
|
def construct_download_url(self, book: BookInfo, volume: VolInfo) -> str:
|
|
28
|
-
return
|
|
33
|
+
return urljoin(
|
|
34
|
+
self._base_url,
|
|
35
|
+
API_ROUTE.DOWNLOAD.format(
|
|
36
|
+
book_id=book.id,
|
|
37
|
+
volume_id=volume.id,
|
|
38
|
+
is_vip=self._profile.is_vip
|
|
39
|
+
)
|
|
40
|
+
)
|
|
@@ -1,44 +1,56 @@
|
|
|
1
|
+
from functools import partial
|
|
2
|
+
from urllib.parse import urljoin
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from async_lru import alru_cache
|
|
6
|
+
|
|
1
7
|
from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
|
|
8
|
+
from kmdr.core.constants import API_ROUTE
|
|
2
9
|
|
|
3
|
-
from .
|
|
10
|
+
from .download_utils import safe_filename, download_file_multipart
|
|
4
11
|
|
|
5
|
-
try:
|
|
6
|
-
import cloudscraper
|
|
7
|
-
except ImportError:
|
|
8
|
-
cloudscraper = None
|
|
9
12
|
|
|
10
13
|
@DOWNLOADER.register(order=10)
|
|
11
14
|
class ReferViaDownloader(Downloader):
|
|
12
|
-
def __init__(self, dest='.', callback=None, retry=3, num_workers=
|
|
15
|
+
def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
|
|
13
16
|
super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
|
|
14
17
|
|
|
15
|
-
if cloudscraper:
|
|
16
|
-
self._scraper = cloudscraper.create_scraper()
|
|
17
|
-
else:
|
|
18
|
-
self._scraper = None
|
|
19
18
|
|
|
20
|
-
def _download(self, book: BookInfo, volume: VolInfo
|
|
19
|
+
async def _download(self, book: BookInfo, volume: VolInfo):
|
|
21
20
|
sub_dir = safe_filename(book.name)
|
|
22
21
|
download_path = f'{self._dest}/{sub_dir}'
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
self._session
|
|
26
|
-
|
|
23
|
+
await download_file_multipart(
|
|
24
|
+
self._session,
|
|
25
|
+
self._semaphore,
|
|
26
|
+
self._progress,
|
|
27
|
+
partial(self.fetch_download_url, book.id, volume.id),
|
|
27
28
|
download_path,
|
|
28
29
|
safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
|
|
29
|
-
|
|
30
|
+
self._retry,
|
|
30
31
|
headers={
|
|
31
32
|
"X-Km-From": "kb_http_down"
|
|
32
33
|
},
|
|
33
34
|
callback=lambda: self._callback(book, volume) if self._callback else None
|
|
34
35
|
)
|
|
35
36
|
|
|
36
|
-
@
|
|
37
|
-
def fetch_download_url(self, book_id: str, volume_id: str) -> str:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
@alru_cache(maxsize=128)
|
|
38
|
+
async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
|
|
39
|
+
|
|
40
|
+
url = urljoin(
|
|
41
|
+
self._base_url,
|
|
42
|
+
API_ROUTE.GETDOWNURL.format(
|
|
43
|
+
book_id=book_id,
|
|
44
|
+
volume_id=volume_id,
|
|
45
|
+
is_vip=self._profile.is_vip
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async with self._session.get(url) as response:
|
|
50
|
+
response.raise_for_status()
|
|
51
|
+
data = await response.text()
|
|
52
|
+
data = json.loads(data)
|
|
53
|
+
if data.get('code') != 200:
|
|
54
|
+
raise Exception(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}")
|
|
55
|
+
|
|
56
|
+
return data['url']
|