kmoe-manga-downloader 1.1.1__py3-none-any.whl → 1.2.0__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 (31) hide show
  1. kmdr/core/__init__.py +5 -3
  2. kmdr/core/bases.py +53 -81
  3. kmdr/core/context.py +28 -0
  4. kmdr/core/defaults.py +64 -28
  5. kmdr/core/error.py +1 -1
  6. kmdr/core/session.py +16 -0
  7. kmdr/core/utils.py +33 -40
  8. kmdr/main.py +30 -17
  9. kmdr/module/authenticator/CookieAuthenticator.py +8 -4
  10. kmdr/module/authenticator/LoginAuthenticator.py +25 -21
  11. kmdr/module/authenticator/utils.py +47 -43
  12. kmdr/module/configurer/ConfigClearer.py +7 -2
  13. kmdr/module/configurer/ConfigUnsetter.py +2 -2
  14. kmdr/module/configurer/OptionLister.py +17 -3
  15. kmdr/module/configurer/OptionSetter.py +2 -2
  16. kmdr/module/configurer/option_validate.py +14 -12
  17. kmdr/module/downloader/DirectDownloader.py +7 -5
  18. kmdr/module/downloader/ReferViaDownloader.py +27 -24
  19. kmdr/module/downloader/utils.py +274 -101
  20. kmdr/module/lister/BookUrlLister.py +4 -3
  21. kmdr/module/lister/FollowedBookLister.py +59 -22
  22. kmdr/module/lister/utils.py +39 -28
  23. kmdr/module/picker/ArgsFilterPicker.py +1 -1
  24. kmdr/module/picker/DefaultVolPicker.py +34 -5
  25. {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/METADATA +17 -11
  26. kmoe_manga_downloader-1.2.0.dist-info/RECORD +35 -0
  27. kmoe_manga_downloader-1.1.1.dist-info/RECORD +0 -33
  28. {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/WHEEL +0 -0
  29. {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/entry_points.txt +0 -0
  30. {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/licenses/LICENSE +0 -0
  31. {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,10 @@
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
4
5
 
5
6
  from kmdr.core.error import LoginError
7
+ from kmdr.core.utils import async_retry
6
8
 
7
9
  PROFILE_URL = 'https://kox.moe/my.php'
8
10
  LOGIN_URL = 'https://kox.moe/login.php'
@@ -13,52 +15,54 @@ VIP_ID = 'div_user_vip'
13
15
  NOR_ID = 'div_user_nor'
14
16
  LV1_ID = 'div_user_lv1'
15
17
 
16
- def check_status(
17
- session: Session,
18
+ @async_retry()
19
+ async def check_status(
20
+ session: ClientSession,
21
+ console: Console,
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 = PROFILE_URL) 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) == LOGIN_URL:
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 = {}
@@ -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(
@@ -11,9 +14,20 @@ class OptionLister(Configurer):
11
14
 
12
15
  def operate(self) -> None:
13
16
  if self._configurer.option is None:
14
- print("No configurations found.")
17
+ self._console.print("[blue]当前没有任何配置项。[/blue]")
15
18
  return
16
19
 
17
- print("Current configurations:")
20
+ table = Table(title="[green]当前 Kmdr 配置项[/green]", show_header=False, header_style="blue")
21
+
22
+ table.add_column("配置项 (Key)", style="cyan", no_wrap=True, min_width=10)
23
+ table.add_column("值 (Value)", style="white", no_wrap=False, min_width=20)
24
+
18
25
  for key, value in self._configurer.option.items():
19
- print(f"\t{key} = {value}")
26
+ value_to_display = value
27
+ if isinstance(value, (dict, list, set, tuple)):
28
+ value_to_display = Pretty(value)
29
+
30
+ table.add_row(key, value_to_display)
31
+ table.add_section()
32
+
33
+ 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
 
@@ -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,6 @@
1
1
  from kmdr.core import Downloader, BookInfo, VolInfo, DOWNLOADER
2
2
 
3
- from .utils import download_file, safe_filename
3
+ from .utils import download_file, safe_filename, download_file_multipart
4
4
 
5
5
  @DOWNLOADER.register(
6
6
  hasvalues={
@@ -8,19 +8,21 @@ from .utils import download_file, safe_filename
8
8
  }
9
9
  )
10
10
  class DirectDownloader(Downloader):
11
- def __init__(self, dest='.', callback=None, retry=3, num_workers=1, proxy=None, *args, **kwargs):
11
+ def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
12
12
  super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
13
13
 
14
- def _download(self, book: BookInfo, volume: VolInfo, retry: int):
14
+ async def _download(self, book: BookInfo, volume: VolInfo):
15
15
  sub_dir = safe_filename(book.name)
16
16
  download_path = f'{self._dest}/{sub_dir}'
17
17
 
18
- download_file(
18
+ await download_file_multipart(
19
19
  self._session,
20
+ self._semaphore,
21
+ self._progress,
20
22
  lambda: self.construct_download_url(book, volume),
21
23
  download_path,
22
24
  safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
23
- retry,
25
+ self._retry,
24
26
  callback=lambda: self._callback(book, volume) if self._callback else None
25
27
  )
26
28
 
@@ -1,44 +1,47 @@
1
+ from functools import partial
2
+
3
+ import json
4
+ from async_lru import alru_cache
5
+
1
6
  from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
2
7
 
3
- from .utils import download_file, safe_filename, cached_by_kwargs
8
+ from .utils import download_file, safe_filename, download_file_multipart
4
9
 
5
- try:
6
- import cloudscraper
7
- except ImportError:
8
- cloudscraper = None
9
10
 
10
11
  @DOWNLOADER.register(order=10)
11
12
  class ReferViaDownloader(Downloader):
12
- def __init__(self, dest='.', callback=None, retry=3, num_workers=1, proxy=None, *args, **kwargs):
13
+ def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
13
14
  super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
14
15
 
15
- if cloudscraper:
16
- self._scraper = cloudscraper.create_scraper()
17
- else:
18
- self._scraper = None
19
16
 
20
- def _download(self, book: BookInfo, volume: VolInfo, retry: int):
17
+ async def _download(self, book: BookInfo, volume: VolInfo):
21
18
  sub_dir = safe_filename(book.name)
22
19
  download_path = f'{self._dest}/{sub_dir}'
23
20
 
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),
21
+ await download_file_multipart(
22
+ self._session,
23
+ self._semaphore,
24
+ self._progress,
25
+ partial(self.fetch_download_url, book.id, volume.id),
27
26
  download_path,
28
27
  safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
29
- retry,
28
+ self._retry,
30
29
  headers={
31
30
  "X-Km-From": "kb_http_down"
32
31
  },
33
32
  callback=lambda: self._callback(book, volume) if self._callback else None
34
33
  )
35
34
 
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']
35
+ @alru_cache(maxsize=128)
36
+ async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
37
+
38
+ url = f"https://kox.moe/getdownurl.php?b={book_id}&v={volume_id}&mobi=2&vip={self._profile.is_vip}&json=1"
39
+
40
+ async with self._session.get(url) as response:
41
+ response.raise_for_status()
42
+ data = await response.text()
43
+ data = json.loads(data)
44
+ if data.get('code') != 200:
45
+ raise Exception(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}")
46
+
47
+ return data['url']