kmoe-manga-downloader 1.2.3b0__tar.gz → 1.2.4__tar.gz

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 (62) hide show
  1. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/PKG-INFO +21 -4
  2. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/README.md +20 -3
  3. kmoe_manga_downloader-1.2.4/assets/kmdr-log-demo.gif +0 -0
  4. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/_version.py +3 -3
  5. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/bases.py +6 -7
  6. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/console.py +8 -4
  7. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/context.py +1 -1
  8. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/defaults.py +4 -8
  9. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/error.py +15 -0
  10. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/protocol.py +4 -0
  11. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/registry.py +2 -0
  12. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/session.py +27 -3
  13. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/utils.py +24 -2
  14. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/main.py +3 -7
  15. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/authenticator/utils.py +4 -1
  16. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/configurer/BaseUrlUpdator.py +1 -1
  17. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/configurer/ConfigClearer.py +2 -2
  18. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/configurer/OptionLister.py +1 -1
  19. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/configurer/option_validate.py +7 -5
  20. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/downloader/download_utils.py +51 -9
  21. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/downloader/misc.py +15 -1
  22. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/lister/FollowedBookLister.py +2 -2
  23. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/lister/utils.py +7 -0
  24. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmoe_manga_downloader.egg-info/PKG-INFO +21 -4
  25. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmoe_manga_downloader.egg-info/SOURCES.txt +1 -0
  26. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/tests/test_kmdr_download.py +1 -1
  27. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/.github/workflows/release-package.yml +0 -0
  28. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/.github/workflows/unit-test.yml +0 -0
  29. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/.gitignore +0 -0
  30. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/LICENSE +0 -0
  31. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/assets/kmdr-demo.gif +0 -0
  32. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/mirror/mirrors.json +0 -0
  33. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/pyproject.toml +0 -0
  34. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/setup.cfg +0 -0
  35. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/__init__.py +0 -0
  36. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/__init__.py +0 -0
  37. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/constants.py +0 -0
  38. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/core/structure.py +0 -0
  39. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/__init__.py +0 -0
  40. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/authenticator/CookieAuthenticator.py +0 -0
  41. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/authenticator/LoginAuthenticator.py +0 -0
  42. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/authenticator/__init__.py +0 -0
  43. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/configurer/ConfigUnsetter.py +0 -0
  44. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/configurer/OptionSetter.py +0 -0
  45. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/configurer/__init__.py +0 -0
  46. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/downloader/DirectDownloader.py +0 -0
  47. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/downloader/ReferViaDownloader.py +0 -0
  48. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/downloader/__init__.py +0 -0
  49. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/lister/BookUrlLister.py +0 -0
  50. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/lister/__init__.py +0 -0
  51. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/picker/ArgsFilterPicker.py +0 -0
  52. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/picker/DefaultVolPicker.py +0 -0
  53. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/picker/__init__.py +0 -0
  54. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmdr/module/picker/utils.py +0 -0
  55. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmoe_manga_downloader.egg-info/dependency_links.txt +0 -0
  56. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmoe_manga_downloader.egg-info/entry_points.txt +0 -0
  57. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmoe_manga_downloader.egg-info/requires.txt +0 -0
  58. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/src/kmoe_manga_downloader.egg-info/top_level.txt +0 -0
  59. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/tests/__init__.py +0 -0
  60. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/tests/test_async_retry_decorator.py +0 -0
  61. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/tests/test_kmdr_config_option.py +0 -0
  62. {kmoe_manga_downloader-1.2.3b0 → kmoe_manga_downloader-1.2.4}/tests/test_utils_resolve_volme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.2.3b0
3
+ Version: 1.2.4
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -50,9 +50,26 @@ Dynamic: license-file
50
50
 
51
51
  `kmdr (Kmoe Manga Downloader)` 是一个 Python 终端应用,用于从 [Kmoe](https://kxx.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
52
52
 
53
- <p align="center">
54
- <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
55
- </p>
53
+ <table style="min-width: 600px;">
54
+ <tbody>
55
+ <tr>
56
+ <td style="text-align: center;" width="100">
57
+ 交互模式
58
+ </td>
59
+ <td style="text-align: center;">
60
+ <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" />
61
+ </td>
62
+ </tr>
63
+ <tr>
64
+ <td style="text-align: center;" width="100">
65
+ 日志模式
66
+ </td>
67
+ <td style="text-align: center;">
68
+ <img src="assets/kmdr-log-demo.gif" alt="kmdr 日志使用演示" />
69
+ </td>
70
+ </tr>
71
+ </tbody>
72
+ </table>
56
73
 
57
74
  ## ✨功能特性
58
75
 
@@ -4,9 +4,26 @@
4
4
 
5
5
  `kmdr (Kmoe Manga Downloader)` 是一个 Python 终端应用,用于从 [Kmoe](https://kxx.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
6
6
 
7
- <p align="center">
8
- <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
9
- </p>
7
+ <table style="min-width: 600px;">
8
+ <tbody>
9
+ <tr>
10
+ <td style="text-align: center;" width="100">
11
+ 交互模式
12
+ </td>
13
+ <td style="text-align: center;">
14
+ <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" />
15
+ </td>
16
+ </tr>
17
+ <tr>
18
+ <td style="text-align: center;" width="100">
19
+ 日志模式
20
+ </td>
21
+ <td style="text-align: center;">
22
+ <img src="assets/kmdr-log-demo.gif" alt="kmdr 日志使用演示" />
23
+ </td>
24
+ </tr>
25
+ </tbody>
26
+ </table>
10
27
 
11
28
  ## ✨功能特性
12
29
 
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.2.3b0'
32
- __version_tuple__ = version_tuple = (1, 2, 3, 'b0')
31
+ __version__ = version = '1.2.4'
32
+ __version_tuple__ = version_tuple = (1, 2, 4)
33
33
 
34
- __commit_id__ = commit_id = 'g28497cb81'
34
+ __commit_id__ = commit_id = 'gb584a1f0c'
@@ -9,6 +9,7 @@ from .error import LoginError
9
9
  from .registry import Registry
10
10
  from .structure import VolInfo, BookInfo
11
11
  from .utils import construct_callback, async_retry
12
+ from .protocol import AsyncCtxManager
12
13
 
13
14
  from .context import TerminalContext, SessionContext, UserProfileContext, ConfigContext
14
15
 
@@ -26,7 +27,7 @@ class SessionManager(SessionContext, ConfigContext, TerminalContext):
26
27
  super().__init__(*args, **kwargs)
27
28
 
28
29
  @abstractmethod
29
- async def session(self) -> ClientSession: ...
30
+ async def session(self) -> AsyncCtxManager[ClientSession]: ...
30
31
 
31
32
  class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalContext):
32
33
 
@@ -44,7 +45,6 @@ class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalC
44
45
  except LoginError as e:
45
46
  info(f"[yellow]详细信息:{e}[/yellow]")
46
47
  info("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
47
- exit(1)
48
48
 
49
49
  @abstractmethod
50
50
  async def _authenticate(self) -> bool: ...
@@ -84,7 +84,7 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
84
84
  async def download(self, book: BookInfo, volumes: list[VolInfo]):
85
85
  if not volumes:
86
86
  info("没有可下载的卷。", style="blue")
87
- exit(0)
87
+ return
88
88
 
89
89
  try:
90
90
  with self._progress:
@@ -97,11 +97,10 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
97
97
  for exc in exceptions:
98
98
  info(f"[red]- {exc}[/red]")
99
99
  exception(exc)
100
- exit(1)
101
100
 
102
- except KeyboardInterrupt:
103
- info("\n操作已取消(KeyboardInterrupt)")
104
- exit(130)
101
+ except asyncio.CancelledError:
102
+ await asyncio.sleep(0.01)
103
+ raise
105
104
 
106
105
  @abstractmethod
107
106
  async def _download(self, book: BookInfo, volume: VolInfo): ...
@@ -10,8 +10,6 @@ import io
10
10
  from rich.console import Console
11
11
  from rich.traceback import Traceback
12
12
 
13
- from kmdr.core.defaults import is_verbose
14
-
15
13
  _console_config = dict[str, Any](
16
14
  log_time_format="[%Y-%m-%d %H:%M:%S]",
17
15
  )
@@ -24,6 +22,12 @@ except io.UnsupportedOperation:
24
22
 
25
23
  _console = Console(**_console_config)
26
24
 
25
+ _is_verbose = False
26
+
27
+ def _update_verbose_setting(value: bool):
28
+ global _is_verbose
29
+ _is_verbose = value
30
+
27
31
  def info(*args, **kwargs):
28
32
  """
29
33
  在终端中输出信息
@@ -41,7 +45,7 @@ def debug(*args, **kwargs):
41
45
 
42
46
  `info` 的条件版本,仅当启用详细模式时才会输出。
43
47
  """
44
- if is_verbose():
48
+ if _is_verbose:
45
49
  if _console.is_interactive:
46
50
  _console.print("[dim]DEBUG:[/]", *args, **kwargs)
47
51
  else:
@@ -57,7 +61,7 @@ def log(*args, debug=False, **kwargs):
57
61
  # 如果是交互式终端,则不记录日志
58
62
  return
59
63
 
60
- if debug and is_verbose():
64
+ if debug and _is_verbose:
61
65
  # 仅在调试模式和启用详细模式时记录调试日志
62
66
  _console.log("DEBUG:", *args, **kwargs, _stack_offset=2)
63
67
  else:
@@ -18,7 +18,7 @@ class TerminalContext:
18
18
  def _progress(self) -> Progress:
19
19
  global _lazy_progress
20
20
  if _lazy_progress is None:
21
- _lazy_progress = Progress(*progress_definition, console=self._console)
21
+ _lazy_progress = Progress(*progress_definition, console=self._console, refresh_per_second=4)
22
22
  return _lazy_progress
23
23
 
24
24
  class UserProfileContext:
@@ -5,6 +5,7 @@ import json
5
5
  from typing import Optional, Any
6
6
  import argparse
7
7
  from contextvars import ContextVar
8
+
8
9
  from rich.console import Console
9
10
  from rich.progress import (
10
11
  Progress,
@@ -18,6 +19,7 @@ from rich.progress import (
18
19
  from .utils import singleton
19
20
  from .structure import Config
20
21
  from .constants import BASE_URL
22
+ from .console import _update_verbose_setting
21
23
 
22
24
  HEADERS = {
23
25
  'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
@@ -95,7 +97,6 @@ def parse_args():
95
97
 
96
98
  if args.command is None:
97
99
  parser.print_help()
98
- exit(1)
99
100
 
100
101
  return args
101
102
 
@@ -241,11 +242,6 @@ def combine_args(dest: argparse.Namespace) -> argparse.Namespace:
241
242
 
242
243
  base_url_var = ContextVar('base_url', default=Configurer().base_url)
243
244
 
244
- _verbose = False
245
-
246
- def is_verbose() -> bool:
247
- return _verbose
248
-
249
245
  def post_init(args) -> None:
250
- global _verbose
251
- _verbose = getattr(args, 'verbose', False)
246
+ _verbose = getattr(args, 'verbose', False)
247
+ _update_verbose_setting(_verbose)
@@ -7,6 +7,9 @@ class KmdrError(RuntimeError):
7
7
 
8
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
+ def __str__(self):
11
+ return f"{self.message}\n{self._solution}"
12
+
10
13
  class InitializationError(KmdrError):
11
14
  def __init__(self, message, solution: Optional[list[str]] = None):
12
15
  super().__init__(message, solution)
@@ -36,6 +39,18 @@ class RedirectError(KmdrError):
36
39
  def __str__(self):
37
40
  return f"{self.message} 新的地址: {self.new_base_url}"
38
41
 
42
+ class ValidationError(KmdrError):
43
+ def __init__(self, message, field: str):
44
+ super().__init__(message)
45
+ self.field = field
46
+
47
+ def __str__(self):
48
+ return f"{self.message} (字段: {self.field})"
49
+
50
+ class EmptyResultError(KmdrError):
51
+ def __init__(self, message):
52
+ super().__init__(message)
53
+
39
54
  class ResponseError(KmdrError):
40
55
  def __init__(self, message, status_code: int):
41
56
  super().__init__(message)
@@ -8,3 +8,7 @@ class Supplier(Protocol[S]):
8
8
 
9
9
  class Consumer(Protocol[T]):
10
10
  def __call__(self, value: T) -> None: ...
11
+
12
+ class AsyncCtxManager(Protocol[S]):
13
+ async def __aenter__(self) -> S: ...
14
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None: ...
@@ -3,6 +3,7 @@ from dataclasses import dataclass, field
3
3
  from argparse import Namespace
4
4
 
5
5
  from .defaults import combine_args
6
+ from .console import debug
6
7
 
7
8
  T = TypeVar('T')
8
9
 
@@ -76,6 +77,7 @@ class Registry(Generic[T]):
76
77
  def get(self, condition: Namespace) -> T:
77
78
  if self._combine_args:
78
79
  condition = combine_args(condition)
80
+ debug("合并默认参数后,条件为:", condition)
79
81
  return self._get(condition)
80
82
 
81
83
  def _get(self, condition: Namespace) -> T:
@@ -1,6 +1,9 @@
1
1
  from typing import Optional
2
2
  from urllib.parse import urlsplit, urljoin
3
+ from typing import Type
4
+ from types import TracebackType
3
5
 
6
+ import asyncio
4
7
  from aiohttp import ClientSession
5
8
 
6
9
  from .constants import BASE_URL, API_ROUTE
@@ -10,6 +13,7 @@ from .defaults import HEADERS
10
13
  from .error import InitializationError, RedirectError
11
14
  from .protocol import Supplier
12
15
  from .console import *
16
+ from .protocol import AsyncCtxManager
13
17
 
14
18
 
15
19
 
@@ -38,11 +42,11 @@ class KmdrSessionManager(SessionManager):
38
42
  self._sorter.incr(primary_base_url, 10)
39
43
  debug("镜像地址优先级排序:", self._sorter)
40
44
 
41
- async def session(self) -> ClientSession:
45
+ async def session(self) -> AsyncCtxManager[ClientSession]:
42
46
  try:
43
47
  if self._session is not None and not self._session.closed:
44
48
  # 幂等性检查:如果 session 已经存在且未关闭,直接返回
45
- return self._session
49
+ return SessionCtxManager(self._session)
46
50
  except LookupError:
47
51
  # session_var 尚未设置
48
52
  pass
@@ -53,6 +57,7 @@ class KmdrSessionManager(SessionManager):
53
57
  # 持久化配置
54
58
  self._configurer.set_base_url(self._base_url)
55
59
  debug("使用的基础 URL:", self._base_url)
60
+ debug("使用的代理:", self._proxy)
56
61
 
57
62
  self._session = ClientSession(
58
63
  base_url=self._base_url,
@@ -61,7 +66,7 @@ class KmdrSessionManager(SessionManager):
61
66
  headers=HEADERS,
62
67
  )
63
68
 
64
- return self._session
69
+ return SessionCtxManager(self._session)
65
70
 
66
71
  async def validate_url(self, session: ClientSession, url_supplier: Supplier[str]) -> bool:
67
72
  try:
@@ -117,3 +122,22 @@ class KmdrSessionManager(SessionManager):
117
122
 
118
123
  raise InitializationError(f"所有镜像均不可用,请检查您的网络连接或使用其他镜像。\n详情参考:https://github.com/chrisis58/kmoe-manga-downloader/blob/main/mirror/mirrors.json")
119
124
 
125
+ class SessionCtxManager:
126
+ def __init__(self, session: ClientSession):
127
+ self._session = session
128
+
129
+ async def __aenter__(self) -> ClientSession:
130
+ await self._session.__aenter__()
131
+ return self._session
132
+
133
+ async def __aexit__(
134
+ self,
135
+ exc_type: Optional[Type[BaseException]],
136
+ exc_value: Optional[BaseException],
137
+ traceback: Optional[TracebackType]
138
+ ):
139
+ await self._session.__aexit__(exc_type, exc_value, traceback)
140
+
141
+ if exc_type in (KeyboardInterrupt, asyncio.CancelledError):
142
+ debug("任务被取消,正在清理资源")
143
+ await asyncio.sleep(0.01)
@@ -1,6 +1,7 @@
1
1
  import functools
2
2
  from typing import Optional, Callable, TypeVar, Hashable, Generic
3
3
  import asyncio
4
+ from asyncio.proactor_events import _ProactorBasePipeTransport
4
5
 
5
6
  import aiohttp
6
7
 
@@ -9,6 +10,7 @@ import subprocess
9
10
  from .structure import BookInfo, VolInfo
10
11
  from .error import RedirectError
11
12
  from .protocol import Consumer
13
+ from .console import debug
12
14
 
13
15
 
14
16
  def singleton(cls):
@@ -68,7 +70,7 @@ def async_retry(
68
70
  except RedirectError as e:
69
71
  if base_url_setter:
70
72
  base_url_setter(e.new_base_url)
71
- print(f"检测到重定向,已自动更新 base url 为: {e.new_base_url}。立即重试...")
73
+ debug("检测到重定向,已自动更新 base url 为", e.new_base_url)
72
74
  continue
73
75
  else:
74
76
  raise
@@ -124,4 +126,24 @@ class PrioritySorter(Generic[H]):
124
126
 
125
127
  def sort(self) -> list[H]:
126
128
  """返回根据优先级排序后的元素列表,优先级高的元素排在前面"""
127
- return [k for k, v in sorted(self._items.items(), key=lambda item: item[1], reverse=True)]
129
+ return [k for k, v in sorted(self._items.items(), key=lambda item: item[1], reverse=True)]
130
+
131
+ def _silence_event_loop_closed(func):
132
+ """
133
+ 用于静默处理 'Event loop is closed' 异常的装饰器。
134
+ 该异常在某些情况下(如 Windows 平台使用 Proactor 事件循环)会在对象销毁时抛出,
135
+ 导致程序输出不必要的错误信息。此装饰器捕获该异常并忽略它。
136
+
137
+ @see https://github.com/aio-libs/aiohttp/issues/4324
138
+ """
139
+ @functools.wraps(func)
140
+ def wrapper(self, *args, **kwargs):
141
+ try:
142
+ return func(self, *args, **kwargs)
143
+ except RuntimeError as e:
144
+ if str(e) != 'Event loop is closed':
145
+ raise
146
+
147
+ return wrapper
148
+
149
+ _ProactorBasePipeTransport.__del__ = _silence_event_loop_closed(_ProactorBasePipeTransport.__del__)
@@ -1,16 +1,15 @@
1
- from kmdr import __version__
2
-
3
1
  from typing import Callable
4
2
  from argparse import Namespace
5
3
  import asyncio
6
4
 
5
+ from kmdr import __version__
7
6
  from kmdr.core import *
8
7
  from kmdr.module import *
9
8
 
10
9
  async def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
11
10
 
12
11
  post_init(args)
13
- log('[Lifecycle:Start] 启动 kmdr 版本:', __version__)
12
+ log('[Lifecycle:Start] 启动 kmdr, 版本', __version__)
14
13
  debug('[bold green]以调试模式启动[/bold green]')
15
14
  debug('接收到的参数:', args)
16
15
 
@@ -51,17 +50,14 @@ def entry_point():
51
50
  parser = argument_parser()
52
51
  args = parser.parse_args()
53
52
 
54
- main_coro = main(args, lambda: parser.print_help())
53
+ main_coro = main(args, parser.print_help)
55
54
  asyncio.run(main_coro)
56
55
  except KmdrError as e:
57
56
  info(f"[red]错误: {e}[/red]")
58
- exit(1)
59
57
  except KeyboardInterrupt:
60
58
  info("\n操作已取消(KeyboardInterrupt)", style="yellow")
61
- exit(130)
62
59
  except Exception as e:
63
60
  exception(e)
64
- exit(1)
65
61
  finally:
66
62
  log('[Lifecycle:End] 运行结束,kmdr 已退出')
67
63
 
@@ -77,7 +77,10 @@ def extract_var_define(script_text) -> dict[str, str]:
77
77
  line = line.strip()
78
78
  if line.startswith("var ") and "=" in line:
79
79
  var_name, var_value = line[4:].split("=", 1)
80
- var_define[var_name.strip()] = var_value.strip().strip(";").strip('"')
80
+ var_value = var_value.strip().strip(";").strip('"')
81
+ if var_name and var_value:
82
+ var_define[var_name.strip()] = var_value
83
+ debug("解析到变量定义: ", var_define)
81
84
  return var_define
82
85
 
83
86
  def __resolve_quota_id(is_vip: Optional[int] = None, user_level: Optional[int] = None):
@@ -12,6 +12,6 @@ class BaseUrlUpdator(Configurer):
12
12
  self._configurer.set_base_url(self._base_url)
13
13
  except KeyError as e:
14
14
  info(f"[red]{e.args[0]}[/red]")
15
- exit(1)
15
+ return
16
16
 
17
17
  info(f"已设置基础 URL: {self._base_url}")
@@ -12,6 +12,6 @@ class ConfigClearer(Configurer):
12
12
  self._configurer.clear(self._clear)
13
13
  except KeyError as e:
14
14
  info(f"[red]{e.args[0]}[/red]")
15
- exit(1)
15
+ return
16
16
 
17
- info(f"Cleared configuration: {self._clear}")
17
+ info(f"[green]已清除: {self._clear}[/green]")
@@ -26,7 +26,7 @@ class OptionLister(Configurer):
26
26
 
27
27
  if self._configurer.option is not None:
28
28
  for idx, (key, value) in enumerate(self._configurer.option.items()):
29
- value_to_display = value
29
+ value_to_display = str(value)
30
30
  if isinstance(value, (dict, list, set, tuple)):
31
31
  value_to_display = Pretty(value)
32
32
 
@@ -3,6 +3,7 @@ from functools import wraps
3
3
  import os
4
4
 
5
5
  from kmdr.core.console import info
6
+ from kmdr.core.error import ValidationError
6
7
 
7
8
  __OPTIONS_VALIDATOR = {}
8
9
 
@@ -20,18 +21,19 @@ def validate(key: str, value: str) -> Optional[object]:
20
21
  info(f"[red]不支持的配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
21
22
  return None
22
23
 
23
- def check_key(key: str, exit_if_invalid: bool = True) -> None:
24
+ def check_key(key: str, raise_if_invalid: bool = True) -> None:
24
25
  """
25
26
  供外部调用的验证函数,用于检查配置项的键名是否有效。
26
27
  如果键名无效,函数会打印错误信息并退出程序。
27
28
 
28
29
  :param key: 配置项的键名
29
- :param exit_if_invalid: 如果键名无效,是否退出程序
30
+ :param raise_if_invalid: 如果键名无效,是否抛出异常
30
31
  """
31
32
  if key not in __OPTIONS_VALIDATOR:
32
- info(f"[red]未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
33
- if exit_if_invalid:
34
- exit(1)
33
+ if raise_if_invalid:
34
+ raise ValidationError(f"未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}", field=key)
35
+ else:
36
+ info(f"[red]未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
35
37
 
36
38
  def register_validator(arg_name):
37
39
  """
@@ -13,12 +13,26 @@ from rich.progress import Progress
13
13
  from aiohttp.client_exceptions import ClientPayloadError
14
14
 
15
15
  from kmdr.core.console import info, log, debug
16
+ from kmdr.core.error import ResponseError
17
+ from kmdr.core.utils import async_retry
16
18
 
17
19
  from .misc import STATUS, StateManager
18
20
 
19
21
  BLOCK_SIZE_REDUCTION_FACTOR = 0.75
20
22
  MIN_BLOCK_SIZE = 2048
21
23
 
24
+ _HEAD_SEMAPHORE_VALUE = 3
25
+ _HEAD_SEMAPHORE: Optional[asyncio.Semaphore] = None
26
+ """定义的用于 HEAD 请求的信号量,限制并发数量以避免触发服务器限流。"""
27
+
28
+ def _get_head_request_semaphore() -> asyncio.Semaphore:
29
+ """惰性初始化 HEAD 请求信号量。"""
30
+ global _HEAD_SEMAPHORE
31
+
32
+ if _HEAD_SEMAPHORE is None:
33
+ _HEAD_SEMAPHORE = asyncio.Semaphore(_HEAD_SEMAPHORE_VALUE)
34
+ return _HEAD_SEMAPHORE
35
+
22
36
 
23
37
  @deprecated("本函数可能不会积极维护,请改用 'download_file_multipart'")
24
38
  async def download_file(
@@ -161,8 +175,7 @@ async def download_file_multipart(
161
175
  info(f"[blue]{filename} 已经存在[/blue]")
162
176
  return
163
177
 
164
- log(f"开始下载文件: {filename} 到路径: {dest_path}")
165
-
178
+ log("开始下载文件:", filename)
166
179
  part_paths = []
167
180
  part_expected_sizes = []
168
181
  task_id = None
@@ -171,12 +184,10 @@ async def download_file_multipart(
171
184
  try:
172
185
  current_url = await fetch_url(url)
173
186
 
174
- async with session.head(current_url, headers=headers, allow_redirects=True) as response:
175
- # 注意:这个请求完成后,服务器就会记录这次下载,并消耗对应的流量配额,详细的规则请参考网站说明:
176
- # 1 : 訂閱連載中的漫畫,有更新時自動推送的卷(冊),暫不計算在使用額度中,不扣減使用額度。
177
- # 注 2 : 對同一卷(冊)書在 12 小時內重複*下載*,不會重複扣減額度。但重復推送是會扣減的。
178
- response.raise_for_status()
179
- total_size = int(response.headers['Content-Length'])
187
+ async with _get_head_request_semaphore():
188
+ # 获取文件信息,请求以获取文件大小
189
+ # 控制并发,避免过多并发请求触发服务器限流
190
+ total_size = await _fetch_content_length(session, current_url, headers=headers)
180
191
 
181
192
  chunk_size = chunk_size_mb * 1024 * 1024
182
193
  num_chunks = math.ceil(total_size / chunk_size)
@@ -236,6 +247,36 @@ async def download_file_multipart(
236
247
  if task_id is not None and state_manager is not None:
237
248
  await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.FAILED)
238
249
 
250
+ @async_retry()
251
+ async def _fetch_content_length(
252
+ session: aiohttp.ClientSession,
253
+ url: str,
254
+ headers: Optional[dict] = None,
255
+ ) -> int:
256
+ """
257
+ 获取文件的内容长度(字节数)
258
+
259
+ :note: 这个请求完成后,服务器就会记录这次下载,并消耗对应的流量配额,详细的规则请参考网站说明:
260
+ - 注 1 : 訂閱連載中的漫畫,有更新時自動推送的卷(冊),暫不計算在使用額度中,不扣減使用額度。
261
+ - 注 2 : 對同一卷(冊)書在 12 小時內重複*下載*,不會重複扣減額度。但重復推送是會扣減的。
262
+
263
+ :param session: aiohttp.ClientSession 对象
264
+ :param url: 文件 URL
265
+ :param headers: 请求头
266
+ :return: 文件大小(字节数)
267
+ """
268
+ if headers is None:
269
+ headers = {}
270
+
271
+ async with session.head(url, headers=headers, allow_redirects=True) as response:
272
+ # 注意:这个请求完成后,服务器就会记录这次下载,并消耗对应的流量配额,详细的规则请参考网站说明:
273
+ # 注 1 : 訂閱連載中的漫畫,有更新時自動推送的卷(冊),暫不計算在使用額度中,不扣減使用額度。
274
+ # 注 2 : 對同一卷(冊)書在 12 小時內重複*下載*,不會重複扣減額度。但重復推送是會扣減的。
275
+ response.raise_for_status()
276
+ if 'Content-Length' not in response.headers:
277
+ raise ResponseError("无法从服务器获取文件大小,请检查网络连接或稍后重试。", status_code=response.status)
278
+ return int(response.headers['Content-Length'])
279
+
239
280
  async def _download_part(
240
281
  session: aiohttp.ClientSession,
241
282
  semaphore: asyncio.Semaphore,
@@ -278,6 +319,7 @@ async def _download_part(
278
319
  if chunk:
279
320
  await f.write(chunk)
280
321
  state_manager.advance(len(chunk))
322
+ await state_manager.pop_part(part_id=start)
281
323
  log("分片", os.path.basename(part_path), "下载完成。")
282
324
  return
283
325
 
@@ -289,8 +331,8 @@ async def _download_part(
289
331
  except Exception as e:
290
332
  if attempts_left > 0:
291
333
  debug("分片", os.path.basename(part_path), "下载出错:", e, ",正在重试... 剩余重试次数:", attempts_left)
292
- await asyncio.sleep(3)
293
334
  await state_manager.request_status_update(part_id=start, status=STATUS.WAITING)
335
+ await asyncio.sleep(3)
294
336
  else:
295
337
  # console.print(f"[red]分片 {os.path.basename(part_path)} 下载失败: {e}[/red]")
296
338
  debug("分片", os.path.basename(part_path), "下载失败:", e)
@@ -3,6 +3,8 @@ import asyncio
3
3
 
4
4
  from rich.progress import Progress, TaskID
5
5
 
6
+ from kmdr.core.console import debug
7
+
6
8
 
7
9
  class STATUS(Enum):
8
10
  WAITING='[blue]等待中[/blue]'
@@ -53,12 +55,24 @@ class StateManager:
53
55
  if not self._part_states:
54
56
  return
55
57
 
58
+ debug("当前状态:", self._part_states)
56
59
  highest_status = max(self._part_states.values())
57
60
  if highest_status != self._current_status:
58
61
  self._current_status = highest_status
59
- self._progress.update(self._task_id, status=highest_status.value, refresh=True)
62
+ self._progress.update(self._task_id, status=highest_status.value)
63
+
64
+ async def pop_part(self, part_id: int):
65
+ """
66
+ 下载完成后移除分片状态记录,不再参与状态计算
67
+
68
+ :note: 为避免状态闪烁,调用后不会更新状态
69
+ """
70
+ async with self._lock:
71
+ if part_id in self._part_states:
72
+ self._part_states.pop(part_id)
60
73
 
61
74
  async def request_status_update(self, part_id: int, status: STATUS):
62
75
  async with self._lock:
76
+ debug("分片", part_id, "请求状态更新为", status)
63
77
  self._part_states[part_id] = status
64
78
  self._update_status()
@@ -8,6 +8,7 @@ from kmdr.core import Lister, LISTERS, BookInfo, VolInfo
8
8
  from kmdr.core.utils import async_retry
9
9
  from kmdr.core.constants import API_ROUTE
10
10
  from kmdr.core.console import info
11
+ from kmdr.core.error import EmptyResultError
11
12
 
12
13
  from .utils import extract_book_info_and_volumes
13
14
 
@@ -24,8 +25,7 @@ class FollowedBookLister(Lister):
24
25
  books = await self._list_followed_books()
25
26
 
26
27
  if not books:
27
- info("[yellow]关注列表为空。[/yellow]")
28
- exit(0)
28
+ raise EmptyResultError("关注列表为空。")
29
29
 
30
30
  table = Table(title="关注的书籍列表", show_header=True, header_style="bold blue")
31
31
  table.add_column("序号", style="dim", width=4, justify="center")
@@ -8,6 +8,7 @@ from aiohttp import ClientSession as Session
8
8
  from kmdr.core import BookInfo, VolInfo, VolumeType
9
9
  from kmdr.core.utils import async_retry
10
10
  from kmdr.core.console import debug
11
+ from kmdr.core.error import KmdrError
11
12
 
12
13
  @async_retry()
13
14
  async def extract_book_info_and_volumes(session: Session, url: str, book_info: Optional[BookInfo] = None) -> tuple[BookInfo, list[VolInfo]]:
@@ -41,6 +42,12 @@ async def extract_book_info_and_volumes(session: Session, url: str, book_info: O
41
42
  def __extract_book_info(url: str, book_page: BeautifulSoup, book_info: Optional[BookInfo]) -> BookInfo:
42
43
  book_name = book_page.find('font', class_='text_bglight_big').text
43
44
 
45
+ if '為符合要求,此書內容已屏蔽' in book_name:
46
+ raise KmdrError(
47
+ "[yellow]该书籍内容已被屏蔽,请检查代理配置。[/yellow]",
48
+ solution=["kmdr config -s proxy=<your_proxy> # 设置可用的代理地址"]
49
+ )
50
+
44
51
  id = book_page.find('input', attrs={'name': 'bookid'})['value']
45
52
 
46
53
  return BookInfo(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.2.3b0
3
+ Version: 1.2.4
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -50,9 +50,26 @@ Dynamic: license-file
50
50
 
51
51
  `kmdr (Kmoe Manga Downloader)` 是一个 Python 终端应用,用于从 [Kmoe](https://kxx.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
52
52
 
53
- <p align="center">
54
- <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
55
- </p>
53
+ <table style="min-width: 600px;">
54
+ <tbody>
55
+ <tr>
56
+ <td style="text-align: center;" width="100">
57
+ 交互模式
58
+ </td>
59
+ <td style="text-align: center;">
60
+ <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" />
61
+ </td>
62
+ </tr>
63
+ <tr>
64
+ <td style="text-align: center;" width="100">
65
+ 日志模式
66
+ </td>
67
+ <td style="text-align: center;">
68
+ <img src="assets/kmdr-log-demo.gif" alt="kmdr 日志使用演示" />
69
+ </td>
70
+ </tr>
71
+ </tbody>
72
+ </table>
56
73
 
57
74
  ## ✨功能特性
58
75
 
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  .github/workflows/release-package.yml
6
6
  .github/workflows/unit-test.yml
7
7
  assets/kmdr-demo.gif
8
+ assets/kmdr-log-demo.gif
8
9
  mirror/mirrors.json
9
10
  src/kmdr/__init__.py
10
11
  src/kmdr/_version.py
@@ -175,7 +175,7 @@ class TestKmdrDownload(unittest.TestCase):
175
175
 
176
176
  assert len(files := os.listdir(dest)) == 2, "Expected one subdirectory and one callback log file in the destination"
177
177
  assert 'callback.log' in files, "Expected callback log file to be present"
178
- with open(os.path.join(dest, 'callback.log'), 'r', encoding='utf-8') as f:
178
+ with open(os.path.join(dest, 'callback.log'), 'r') as f:
179
179
  log_content = f.read()
180
180
  assert "CALLBACK:" in log_content, "Expected callback log to contain the correct message"
181
181
  files.remove('callback.log')