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.
Files changed (42) hide show
  1. kmdr/core/__init__.py +5 -3
  2. kmdr/core/bases.py +61 -87
  3. kmdr/core/constants.py +79 -0
  4. kmdr/core/context.py +40 -0
  5. kmdr/core/defaults.py +92 -33
  6. kmdr/core/error.py +10 -2
  7. kmdr/core/session.py +16 -0
  8. kmdr/core/structure.py +2 -0
  9. kmdr/core/utils.py +41 -40
  10. kmdr/main.py +24 -15
  11. kmdr/module/__init__.py +5 -5
  12. kmdr/module/authenticator/CookieAuthenticator.py +14 -7
  13. kmdr/module/authenticator/LoginAuthenticator.py +37 -23
  14. kmdr/module/authenticator/__init__.py +2 -0
  15. kmdr/module/authenticator/utils.py +60 -46
  16. kmdr/module/configurer/BaseUrlUpdator.py +16 -0
  17. kmdr/module/configurer/ConfigClearer.py +7 -2
  18. kmdr/module/configurer/ConfigUnsetter.py +2 -2
  19. kmdr/module/configurer/OptionLister.py +24 -5
  20. kmdr/module/configurer/OptionSetter.py +2 -2
  21. kmdr/module/configurer/__init__.py +5 -0
  22. kmdr/module/configurer/option_validate.py +14 -12
  23. kmdr/module/downloader/DirectDownloader.py +18 -6
  24. kmdr/module/downloader/ReferViaDownloader.py +36 -24
  25. kmdr/module/downloader/__init__.py +2 -0
  26. kmdr/module/downloader/download_utils.py +322 -0
  27. kmdr/module/downloader/misc.py +62 -0
  28. kmdr/module/lister/BookUrlLister.py +4 -3
  29. kmdr/module/lister/FollowedBookLister.py +62 -24
  30. kmdr/module/lister/__init__.py +2 -0
  31. kmdr/module/lister/utils.py +49 -29
  32. kmdr/module/picker/ArgsFilterPicker.py +1 -1
  33. kmdr/module/picker/DefaultVolPicker.py +34 -5
  34. kmdr/module/picker/__init__.py +2 -0
  35. {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/METADATA +48 -23
  36. kmoe_manga_downloader-1.2.1.dist-info/RECORD +43 -0
  37. kmdr/module/downloader/utils.py +0 -157
  38. kmoe_manga_downloader-1.1.2.dist-info/RECORD +0 -33
  39. {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/WHEEL +0 -0
  40. {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/entry_points.txt +0 -0
  41. {kmoe_manga_downloader-1.1.2.dist-info → kmoe_manga_downloader-1.2.1.dist-info}/licenses/LICENSE +0 -0
  42. {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 == 'login':
10
- AUTHENTICATOR.get(args).authenticate()
10
+ if args.command == 'config':
11
+ CONFIGURER.get(args).operate()
12
+ return
11
13
 
12
- elif args.command == 'status':
13
- AUTHENTICATOR.get(args).authenticate()
14
+ async with KMDR_SESSION.get(args):
14
15
 
15
- elif args.command == 'download':
16
- AUTHENTICATOR.get(args).authenticate()
16
+ if args.command == 'login':
17
+ await AUTHENTICATOR.get(args).authenticate()
17
18
 
18
- book, volumes = LISTERS.get(args).list()
19
+ elif args.command == 'status':
20
+ await AUTHENTICATOR.get(args).authenticate()
19
21
 
20
- volumes = PICKERS.get(args).pick(volumes)
22
+ elif args.command == 'download':
23
+ await AUTHENTICATOR.get(args).authenticate()
21
24
 
22
- DOWNLOADER.get(args).download(book, volumes)
25
+ book, volumes = await LISTERS.get(args).list()
23
26
 
24
- elif args.command == 'config':
25
- CONFIGURER.get(args).operate()
27
+ volumes = PICKERS.get(args).pick(volumes)
28
+
29
+ await DOWNLOADER.get(args).download(book, volumes)
26
30
 
27
- else:
28
- fallback()
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
- main(args, lambda: parser.print_help())
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 CookieAuthenticator, LoginAuthenticator
2
- from .lister import BookUrlLister, FollowedBookLister
3
- from .picker import ArgsFilterPicker, DefaultVolPicker
4
- from .downloader import DirectDownloader, ReferViaDownloader
5
- from .configurer import OptionLister, OptionSetter, ConfigClearer, ConfigUnsetter
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
- def _authenticate(self) -> bool:
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("No cookie found, please login first.", ['kmdr login -u <username>'])
26
+ raise LoginError("无法找到 Cookie,请先完成登录。", ['kmdr login -u <username>'])
22
27
 
23
- self._session.cookies.update(cookie)
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 getpass import getpass
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 = getpass("please input your 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
- response = self._session.post(
35
- url = 'https://kox.moe/login_do.php',
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.raise_for_status()
43
- match = re.search(r'"\w+"', response.text)
44
-
45
- if not match:
46
- raise LoginError("Failed to extract authentication code from response.")
47
-
48
- code = match.group(0).split('"')[1]
49
- if code != CODE_OK:
50
- raise LoginError(f"Authentication failed with error code: {code} " + CODE_MAPPING.get(code, "Unknown error."))
51
-
52
- if check_status(self._session, show_quota=self._show_quota):
53
- self._configurer.cookie = self._session.cookies.get_dict()
54
- return True
55
-
56
- return False
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
@@ -0,0 +1,2 @@
1
+ from .CookieAuthenticator import CookieAuthenticator
2
+ from .LoginAuthenticator import LoginAuthenticator
@@ -1,11 +1,12 @@
1
1
  from typing import Optional, Callable
2
2
 
3
- from requests import Session
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
- PROFILE_URL = 'https://kox.moe/my.php'
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
- def check_status(
17
- session: Session,
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
- response = session.get(url = PROFILE_URL)
23
-
24
- try:
25
- response.raise_for_status()
26
- except Exception as e:
27
- print(f"Error: {type(e).__name__}: {e}")
28
- return False
29
-
30
- if response.history and any(resp.status_code in (301, 302, 307) for resp in response.history) \
31
- and response.url == LOGIN_URL:
32
- raise LoginError("Invalid credentials, please login again.", ['kmdr config -c cookie', 'kmdr login -u <username>'])
33
-
34
- if not is_vip_setter and not level_setter and not show_quota:
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
- self._configurer.clear(self._clear)
11
- print(f"Cleared configuration: {self._clear}")
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("No option specified to unset.")
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"Unset configuration: {self._unset}")
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("No configurations found.")
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
- print("Current configurations:")
18
- for key, value in self._configurer.option.items():
19
- print(f"\t{key} = {value}")
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"Invalid option format: `{option}`. Expected format is key=value.")
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"Set configuration: {key} = {validated_value}")
26
+ self._console.print(f"[green]已设置配置: {key} = {validated_value}[/green]")
27
27
 
28
28
 
29
29
 
@@ -0,0 +1,5 @@
1
+ from .BaseUrlUpdator import BaseUrlUpdator
2
+ from .ConfigClearer import ConfigClearer
3
+ from .OptionLister import OptionLister
4
+ from .OptionSetter import OptionSetter
5
+ from .ConfigUnsetter import ConfigUnsetter
@@ -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"Unsupported option: {key}. Supported options are: {', '.join(__OPTIONS_VALIDATOR.keys())}")
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"Unknown option: {key}. Supported options are: {', '.join(__OPTIONS_VALIDATOR.keys())}")
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("Number of workers must be a positive integer.")
65
+ raise ValueError("必须是正值。")
64
66
  return num_workers
65
67
  except ValueError as e:
66
- print(f"Invalid value for num_workers: {value}. {str(e)}")
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("Destination cannot be empty.")
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"Destination directory does not exist or is not a directory: {value}")
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"Destination directory is not writable: {value}")
81
+ print(f"[red]目标目录不可写: {value}[/red]")
80
82
  return None
81
83
 
82
84
  if not os.path.isabs(value):
83
- print(f"Destination better be an absolute path: {value}")
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("Retry count must be a non-negative integer.")
94
+ raise ValueError("必须是正值。")
93
95
  return retry
94
96
  except ValueError as e:
95
- print(f"Invalid value for retry: {value}. {str(e)}")
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("Callback cannot be empty.")
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("Proxy cannot be empty.")
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 .utils import download_file, safe_filename
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=1, proxy=None, *args, **kwargs):
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, retry: int):
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
- download_file(
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
- retry,
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 f'https://kox.moe/dl/{book.id}/{volume.id}/1/2/{self._profile.is_vip}/'
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 .utils import download_file, safe_filename, cached_by_kwargs
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=1, proxy=None, *args, **kwargs):
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, retry: int):
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
- download_file(
25
- self._session if not self._scraper else self._scraper,
26
- lambda: self.fetch_download_url(book_id=book.id, volume_id=volume.id),
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
- retry,
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
- @cached_by_kwargs
37
- def fetch_download_url(self, book_id: str, volume_id: str) -> str:
38
- response = self._session.get(f"https://kox.moe/getdownurl.php?b={book_id}&v={volume_id}&mobi=2&vip={self._profile.is_vip}&json=1")
39
- response.raise_for_status()
40
- data = response.json()
41
- if data.get('code') != 200:
42
- raise Exception(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}")
43
-
44
- return data['url']
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']
@@ -0,0 +1,2 @@
1
+ from .DirectDownloader import DirectDownloader
2
+ from .ReferViaDownloader import ReferViaDownloader