kmoe-manga-downloader 1.2.1__tar.gz → 1.2.2__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 (54) hide show
  1. {kmoe_manga_downloader-1.2.1/src/kmoe_manga_downloader.egg-info → kmoe_manga_downloader-1.2.2}/PKG-INFO +1 -1
  2. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/pyproject.toml +1 -1
  3. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/__init__.py +4 -4
  4. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/bases.py +12 -16
  5. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/constants.py +17 -22
  6. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/context.py +4 -0
  7. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/defaults.py +4 -1
  8. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/error.py +16 -1
  9. kmoe_manga_downloader-1.2.2/src/kmdr/core/protocol.py +10 -0
  10. kmoe_manga_downloader-1.2.2/src/kmdr/core/session.py +115 -0
  11. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/utils.py +51 -2
  12. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/main.py +5 -3
  13. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/authenticator/CookieAuthenticator.py +4 -8
  14. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/authenticator/LoginAuthenticator.py +8 -21
  15. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/authenticator/utils.py +5 -15
  16. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/downloader/DirectDownloader.py +9 -12
  17. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/downloader/ReferViaDownloader.py +9 -12
  18. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/downloader/download_utils.py +30 -0
  19. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/downloader/misc.py +2 -0
  20. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/lister/FollowedBookLister.py +1 -2
  21. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/lister/utils.py +7 -9
  22. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2/src/kmoe_manga_downloader.egg-info}/PKG-INFO +1 -1
  23. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmoe_manga_downloader.egg-info/SOURCES.txt +1 -0
  24. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/tests/test_kmdr_download.py +9 -6
  25. kmoe_manga_downloader-1.2.1/src/kmdr/core/session.py +0 -16
  26. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/LICENSE +0 -0
  27. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/README.md +0 -0
  28. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/setup.cfg +0 -0
  29. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/__init__.py +0 -0
  30. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/registry.py +0 -0
  31. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/core/structure.py +0 -0
  32. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/__init__.py +0 -0
  33. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/authenticator/__init__.py +0 -0
  34. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/configurer/BaseUrlUpdator.py +0 -0
  35. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/configurer/ConfigClearer.py +0 -0
  36. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/configurer/ConfigUnsetter.py +0 -0
  37. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/configurer/OptionLister.py +0 -0
  38. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/configurer/OptionSetter.py +0 -0
  39. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/configurer/__init__.py +0 -0
  40. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/configurer/option_validate.py +0 -0
  41. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/downloader/__init__.py +0 -0
  42. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/lister/BookUrlLister.py +0 -0
  43. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/lister/__init__.py +0 -0
  44. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/picker/ArgsFilterPicker.py +0 -0
  45. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/picker/DefaultVolPicker.py +0 -0
  46. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/picker/__init__.py +0 -0
  47. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmdr/module/picker/utils.py +0 -0
  48. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmoe_manga_downloader.egg-info/dependency_links.txt +0 -0
  49. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmoe_manga_downloader.egg-info/entry_points.txt +0 -0
  50. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmoe_manga_downloader.egg-info/requires.txt +0 -0
  51. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/src/kmoe_manga_downloader.egg-info/top_level.txt +0 -0
  52. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/tests/test_async_retry_decorator.py +0 -0
  53. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/tests/test_kmdr_config_option.py +0 -0
  54. {kmoe_manga_downloader-1.2.1 → kmoe_manga_downloader-1.2.2}/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.1
3
+ Version: 1.2.2
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kmoe-manga-downloader"
3
- version = "1.2.1"
3
+ version = "1.2.2"
4
4
  authors = [
5
5
  { name="Chris Zheng", email="chrisis58@outlook.com" },
6
6
  ]
@@ -1,9 +1,9 @@
1
- from .bases import Authenticator, Lister, Picker, Downloader, Configurer
1
+ from .bases import Authenticator, Lister, Picker, Downloader, Configurer, SessionManager
2
2
  from .structure import VolInfo, BookInfo, VolumeType
3
- from .bases import AUTHENTICATOR, LISTERS, PICKERS, DOWNLOADER, CONFIGURER, KMDR_SESSION
3
+ from .bases import AUTHENTICATOR, LISTERS, PICKERS, DOWNLOADER, CONFIGURER, SESSION_MANAGER
4
4
 
5
- from .defaults import argument_parser, session_var
5
+ from .defaults import argument_parser, console
6
6
 
7
7
  from .error import KmdrError, LoginError
8
8
 
9
- from .session import KmdrSession
9
+ from .session import KmdrSessionManager
@@ -19,12 +19,18 @@ class Configurer(ConfigContext, TerminalContext):
19
19
  @abstractmethod
20
20
  def operate(self) -> None: ...
21
21
 
22
+ class SessionManager(SessionContext, ConfigContext, TerminalContext):
23
+
24
+ def __init__(self, *args, **kwargs):
25
+ super().__init__(*args, **kwargs)
26
+
27
+ @abstractmethod
28
+ async def session(self) -> ClientSession: ...
29
+
22
30
  class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalContext):
23
31
 
24
32
  def __init__(self, *args, **kwargs):
25
33
  super().__init__(*args, **kwargs)
26
- # 这里的 base url 可能会在认证过程中被更新
27
- self._inner_base_url: Optional[str] = None
28
34
 
29
35
  # 在使用代理登录时,可能会出现问题,但是现在还不清楚是不是代理的问题。
30
36
  # 主站正常情况下不使用代理也能登录成功。但是不排除特殊的网络环境下需要代理。
@@ -33,21 +39,11 @@ class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalC
33
39
  async def authenticate(self) -> None:
34
40
  with self._console.status("认证中..."):
35
41
  try:
36
- # 这里添加了 base_url_setter,以便在重定向时更新 base_url
37
- assert await async_retry(
38
- base_url_setter=self._configurer.set_base_url
39
- )(self._authenticate)()
40
-
41
- # 登录成功后,更新 base_url
42
- self._base_url = self.base_url
42
+ assert await async_retry()(self._authenticate)()
43
43
  except LoginError as e:
44
- self._console.print("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
45
44
  self._console.print(f"[yellow]详细信息:{e}[/yellow]")
45
+ self._console.print("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
46
46
  exit(1)
47
-
48
- @property
49
- def base_url(self) -> str:
50
- return self._inner_base_url or self._configurer.base_url
51
47
 
52
48
  @abstractmethod
53
49
  async def _authenticate(self) -> bool: ...
@@ -86,7 +82,7 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
86
82
 
87
83
  async def download(self, book: BookInfo, volumes: list[VolInfo]):
88
84
  if not volumes:
89
- self._console.print("No volumes to download.")
85
+ self._console.print("没有可下载的卷。", style="blue")
90
86
  exit(0)
91
87
 
92
88
  try:
@@ -101,7 +97,7 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
101
97
  @abstractmethod
102
98
  async def _download(self, book: BookInfo, volume: VolInfo): ...
103
99
 
104
- KMDR_SESSION = Registry[ClientSession]('KmdrSession', True)
100
+ SESSION_MANAGER = Registry[SessionManager]('SessionManager', True)
105
101
  AUTHENTICATOR = Registry[Authenticator]('Authenticator')
106
102
  LISTERS = Registry[Lister]('Lister')
107
103
  PICKERS = Registry[Picker]('Picker')
@@ -1,37 +1,30 @@
1
1
  from dataclasses import dataclass
2
2
  from enum import Enum
3
- from typing_extensions import deprecated
3
+ from typing import Union
4
4
 
5
+ from typing_extensions import deprecated
5
6
 
6
- @dataclass(frozen=True)
7
- class _BaseUrl:
7
+ class BASE_URL(Enum):
8
8
 
9
9
  @property
10
10
  @deprecated("KOX 已过时,请使用 KXO 或 KOZ。")
11
11
  def KOX(self) -> str:
12
12
  return 'https://kox.moe'
13
13
 
14
- @property
15
- def KXX(self) -> str:
16
- return 'https://kxx.moe'
14
+ KXX = 'https://kxx.moe'
17
15
 
18
- @property
19
- def KXO(self) -> str:
20
- return 'https://kxo.moe'
16
+ KXO = 'https://kxo.moe'
21
17
 
22
- @property
23
- def KOZ(self) -> str:
24
- return 'https://koz.moe'
18
+ KOZ = 'https://koz.moe'
25
19
 
26
- @property
27
- def MOX(self) -> str:
28
- return 'https://mox.moe'
20
+ MOX = 'https://mox.moe'
29
21
 
30
- @property
31
- def DEFAULT(self) -> str:
32
- """默认基础 URL"""
33
- return self.KXX
22
+ @classmethod
23
+ def alternatives(cls) -> set[str]:
24
+ """返回备用的基础 URL 列表"""
25
+ return {cls.KXO.value, cls.KOZ.value, cls.MOX.value}
34
26
 
27
+ DEFAULT = KXX
35
28
 
36
29
  @dataclass(frozen=True)
37
30
  class _ApiRoute():
@@ -71,9 +64,11 @@ class LoginResponse(Enum):
71
64
  def from_code(cls, code: str) -> 'LoginResponse':
72
65
  return cls.__members__.get(code, cls.unknown)
73
66
 
67
+ @classmethod
68
+ def ok(cls, code: Union[str, 'LoginResponse']) -> bool:
69
+ if isinstance(code, LoginResponse):
70
+ return code == cls.m100
71
+ return cls.from_code(code) == cls.m100
74
72
 
75
73
  API_ROUTE = _ApiRoute()
76
74
  """API 路由常量实例"""
77
-
78
- BASE_URL = _BaseUrl()
79
- """基础 URL 实例"""
@@ -30,6 +30,10 @@ class SessionContext:
30
30
  @property
31
31
  def _session(self) -> ClientSession:
32
32
  return session_var.get()
33
+
34
+ @_session.setter
35
+ def _session(self, value: ClientSession):
36
+ session_var.set(value)
33
37
 
34
38
  @property
35
39
  def _base_url(self) -> str:
@@ -176,7 +176,7 @@ class Configurer:
176
176
  @property
177
177
  def base_url(self) -> str:
178
178
  if self._config is None or self._config.base_url is None:
179
- return BASE_URL.DEFAULT
179
+ return BASE_URL.DEFAULT.value
180
180
  return self._config.base_url
181
181
 
182
182
  def set_base_url(self, value: str):
@@ -185,6 +185,9 @@ class Configurer:
185
185
  self._config.base_url = value
186
186
  self.update()
187
187
 
188
+ def get_base_url(self) -> str:
189
+ return self._config.base_url
190
+
188
191
  def update(self):
189
192
  with open(os.path.join(os.path.expanduser("~"), self.__filename), 'w') as f:
190
193
  json.dump(self._config.__dict__, f, indent=4, ensure_ascii=False)
@@ -7,6 +7,13 @@ 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
+ class InitializationError(KmdrError):
11
+ def __init__(self, message, solution: Optional[list[str]] = None):
12
+ super().__init__(message, solution)
13
+
14
+ def __str__(self):
15
+ return f"{self.message}\n{self._solution}"
16
+
10
17
  class LoginError(KmdrError):
11
18
  def __init__(self, message, solution: Optional[list[str]] = None):
12
19
  super().__init__(message, solution)
@@ -20,4 +27,12 @@ class RedirectError(KmdrError):
20
27
  self.new_base_url = new_base_url
21
28
 
22
29
  def __str__(self):
23
- return f"{self.message} 新的地址: {self.new_base_url}"
30
+ return f"{self.message} 新的地址: {self.new_base_url}"
31
+
32
+ class ResponseError(KmdrError):
33
+ def __init__(self, message, status_code: int):
34
+ super().__init__(message)
35
+ self.status_code = status_code
36
+
37
+ def __str__(self):
38
+ return f"{self.message} (状态码: {self.status_code})"
@@ -0,0 +1,10 @@
1
+ from typing import Protocol, TypeVar
2
+
3
+ S = TypeVar('S', covariant=True)
4
+ T = TypeVar('T', contravariant=True)
5
+
6
+ class Supplier(Protocol[S]):
7
+ def __call__(self) -> S: ...
8
+
9
+ class Consumer(Protocol[T]):
10
+ def __call__(self, value: T) -> None: ...
@@ -0,0 +1,115 @@
1
+ from typing import Optional
2
+ from urllib.parse import urlsplit, urljoin
3
+
4
+ from aiohttp import ClientSession
5
+
6
+ from .constants import BASE_URL, API_ROUTE
7
+ from .utils import async_retry, PrioritySorter
8
+ from .bases import SESSION_MANAGER, SessionManager
9
+ from .defaults import HEADERS
10
+ from .error import InitializationError, RedirectError
11
+ from .protocol import Supplier
12
+
13
+
14
+
15
+ # 通常只会有一个 SessionManager 的实现
16
+ # 因此这里直接注册为默认实现
17
+ @SESSION_MANAGER.register()
18
+ class KmdrSessionManager(SessionManager):
19
+ """
20
+ Kmdr 的 HTTP 会话管理类,支持从参数中初始化 ClientSession 的实例。
21
+ """
22
+
23
+ def __init__(self, proxy: Optional[str] = None, book_url: Optional[str] = None, *args, **kwargs):
24
+ super().__init__(*args, **kwargs)
25
+ self._proxy = proxy
26
+
27
+ self._sorter = PrioritySorter[str]()
28
+ [self._sorter.set(alt) for alt in BASE_URL.alternatives()]
29
+ self._sorter.incr(BASE_URL.DEFAULT.value, 2)
30
+ self._sorter.incr(self._base_url, 5)
31
+
32
+ if book_url is not None and book_url.strip() != "" :
33
+ splited = urlsplit(book_url)
34
+ primary_base_url = f"{splited.scheme}://{splited.netloc}"
35
+
36
+ self._sorter.incr(primary_base_url, 10)
37
+
38
+ async def session(self) -> ClientSession:
39
+ try:
40
+ if self._session is not None and not self._session.closed:
41
+ # 幂等性检查:如果 session 已经存在且未关闭,直接返回
42
+ return self._session
43
+ except LookupError:
44
+ # session_var 尚未设置
45
+ pass
46
+
47
+ with self._console.status("初始化中..."):
48
+
49
+ self._base_url = await self._probing_base_url()
50
+ # 持久化配置
51
+ self._configurer.set_base_url(self._base_url)
52
+
53
+ self._session = ClientSession(
54
+ base_url=self._base_url,
55
+ proxy=self._proxy,
56
+ trust_env=True,
57
+ headers=HEADERS,
58
+ )
59
+
60
+ return self._session
61
+
62
+ async def validate_url(self, session: ClientSession, url_supplier: Supplier[str]) -> bool:
63
+ try:
64
+ async with session.head(
65
+ # 这里只请求登录页面的头信息保证快速响应
66
+ # 选择登录页面,一个是因为登录页面对所有用户都开放
67
+ # 另外是因为不同网站的登录页面通常是不同的,可以有效区分不同的网站
68
+ # 如果后续发现有更合适的探测方式,可以考虑替换
69
+ urljoin(url_supplier(), API_ROUTE.LOGIN),
70
+ allow_redirects=False
71
+ ) as response:
72
+ if response.status in (301, 302, 307, 308) and 'Location' in response.headers:
73
+ new_location = urlsplit(response.headers['Location'])
74
+ raise RedirectError("检测到重定向", new_base_url=f"{new_location.scheme}://{new_location.netloc}")
75
+
76
+ return response.status == 200
77
+ except Exception as e:
78
+ self._console.print(f"[yellow]无法连接到镜像: {url_supplier()},错误信息: {e}[/yellow]")
79
+ return False
80
+
81
+ async def _probing_base_url(self) -> str:
82
+ """
83
+ 探测可用的镜像地址。
84
+ 顺序为:首选地址 -> 备用地址
85
+ 当前首选地址不可用时,尝试备用地址,直到找到可用的地址或耗尽所有选项。
86
+ 如果所有地址均不可用,则抛出 InitializationError 异常。
87
+
88
+ :raises InitializationError: 如果所有镜像地址均不可用。
89
+ :return: 可用的镜像地址。
90
+ """
91
+
92
+ ret_base_url: str
93
+
94
+ def get_base_url() -> str:
95
+ nonlocal ret_base_url
96
+ return ret_base_url
97
+
98
+ def set_base_url(value: str) -> None:
99
+ nonlocal ret_base_url
100
+ ret_base_url = value
101
+
102
+ async with ClientSession(proxy=self._proxy, trust_env=True, headers=HEADERS) as probe_session:
103
+ # TODO: 请求远程仓库中的镜像列表,并添加到 sorter 中
104
+
105
+ for bu in self._sorter.sort():
106
+ set_base_url(bu)
107
+
108
+ if await async_retry(
109
+ base_url_setter=set_base_url,
110
+ on_failure=lambda e: self._console.print(f"[yellow]无法连接到镜像: {get_base_url()},错误信息: {e}[/yellow]"),
111
+ )(self.validate_url)(probe_session, get_base_url):
112
+ return get_base_url()
113
+
114
+ raise InitializationError(f"所有镜像均不可用,请检查您的网络连接或使用其他镜像。\n详情参考:https://github.com/chrisis58/kmoe-manga-downloader/blob/main/mirror/mirrors.json")
115
+
@@ -1,5 +1,5 @@
1
1
  import functools
2
- from typing import Optional, Callable
2
+ from typing import Optional, Callable, TypeVar, Hashable, Generic
3
3
  import asyncio
4
4
 
5
5
  import aiohttp
@@ -8,6 +8,7 @@ import subprocess
8
8
 
9
9
  from .structure import BookInfo, VolInfo
10
10
  from .error import RedirectError
11
+ from .protocol import Consumer
11
12
 
12
13
 
13
14
  def singleton(cls):
@@ -44,7 +45,8 @@ def async_retry(
44
45
  delay: float = 1.0,
45
46
  backoff: float = 2.0,
46
47
  retry_on_status: set[int] = {500, 502, 503, 504, 429, 408},
47
- base_url_setter: Optional[Callable[[str], None]] = None,
48
+ base_url_setter: Optional[Consumer[str]] = None,
49
+ on_failure: Optional[Callable[[Exception], None]] = None
48
50
  ):
49
51
  def decorator(func):
50
52
  @functools.wraps(func)
@@ -70,9 +72,56 @@ def async_retry(
70
72
  continue
71
73
  else:
72
74
  raise
75
+ except Exception as e:
76
+ if on_failure:
77
+ on_failure(e)
78
+ break
79
+ else:
80
+ raise
73
81
 
74
82
  await asyncio.sleep(current_delay)
75
83
 
76
84
  current_delay *= backoff
77
85
  return wrapper
78
86
  return decorator
87
+
88
+
89
+ H = TypeVar('H', bound=Hashable)
90
+ class PrioritySorter(Generic[H]):
91
+ """
92
+ 根据优先级对元素进行排序的工具类
93
+ """
94
+
95
+ DEFAULT_ORDER = 10
96
+
97
+ def __init__(self):
98
+ self._items: dict[H, int] = {}
99
+
100
+ def __repr__(self) -> str:
101
+ return f"PrioritySorter({self._items})"
102
+
103
+ def get(self, key: H) -> Optional[int]:
104
+ """获取对应元素的优先级"""
105
+ return self._items.get(key)
106
+
107
+ def set(self, key: H, value: int = DEFAULT_ORDER) -> None:
108
+ """设置对应元素的优先级"""
109
+ self._items[key] = value
110
+
111
+ def remove(self, key: H) -> None:
112
+ """移除对应元素"""
113
+ self._items.pop(key, None)
114
+
115
+ def incr(self, key: H, offset: int = 1) -> None:
116
+ """提升对应元素的优先级"""
117
+ current_value = self._items.get(key, self.DEFAULT_ORDER)
118
+ self._items[key] = current_value + offset
119
+
120
+ def decr(self, key: H, offset: int = 1) -> None:
121
+ """降低对应元素的优先级"""
122
+ current_value = self._items.get(key, self.DEFAULT_ORDER)
123
+ self._items[key] = current_value - offset
124
+
125
+ def sort(self) -> list[H]:
126
+ """返回根据优先级排序后的元素列表,优先级高的元素排在前面"""
127
+ return [k for k, v in sorted(self._items.items(), key=lambda item: item[1], reverse=True)]
@@ -11,8 +11,7 @@ async def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NO
11
11
  CONFIGURER.get(args).operate()
12
12
  return
13
13
 
14
- async with KMDR_SESSION.get(args):
15
-
14
+ async with (await SESSION_MANAGER.get(args).session()):
16
15
  if args.command == 'login':
17
16
  await AUTHENTICATOR.get(args).authenticate()
18
17
 
@@ -41,8 +40,11 @@ def entry_point():
41
40
 
42
41
  main_coro = main(args, lambda: parser.print_help())
43
42
  asyncio.run(main_coro)
43
+ except KmdrError as e:
44
+ console.print(f"[red]错误: {e}[/red]")
45
+ exit(1)
44
46
  except KeyboardInterrupt:
45
- print("\n操作已取消(KeyboardInterrupt)")
47
+ console.print("\n操作已取消(KeyboardInterrupt)", style="yellow")
46
48
  exit(130)
47
49
 
48
50
  if __name__ == '__main__':
@@ -4,11 +4,11 @@ from yarl import URL
4
4
 
5
5
  from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
6
6
 
7
- from .utils import check_status, extract_base_url
7
+ from .utils import check_status
8
8
 
9
9
  @AUTHENTICATOR.register()
10
10
  class CookieAuthenticator(Authenticator):
11
- def __init__(self, proxy: Optional[str] = None, book_url: Optional[str] = None, *args, **kwargs):
11
+ def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
12
12
  super().__init__(proxy, *args, **kwargs)
13
13
 
14
14
  if 'command' in kwargs and kwargs['command'] == 'status':
@@ -16,20 +16,16 @@ class CookieAuthenticator(Authenticator):
16
16
  else:
17
17
  self._show_quota = False
18
18
 
19
- # 根据用户提供的 book_url 来决定访问的镜像站
20
- self._inner_base_url = extract_base_url(book_url, default=self._base_url)
21
-
22
19
  async def _authenticate(self) -> bool:
23
20
  cookie = self._configurer.cookie
24
21
 
25
22
  if not cookie:
26
23
  raise LoginError("无法找到 Cookie,请先完成登录。", ['kmdr login -u <username>'])
27
-
28
- self._session.cookie_jar.update_cookies(cookie, response_url=URL(self.base_url))
24
+
25
+ self._session.cookie_jar.update_cookies(cookie, response_url=URL(self._base_url))
29
26
  return await check_status(
30
27
  self._session,
31
28
  self._console,
32
- base_url=self.base_url,
33
29
  show_quota=self._show_quota,
34
30
  is_vip_setter=lambda value: setattr(self._profile, 'is_vip', value),
35
31
  level_setter=lambda value: setattr(self._profile, 'user_level', value),
@@ -1,24 +1,14 @@
1
1
  from typing import Optional
2
2
  import re
3
3
  from yarl import URL
4
- from urllib.parse import urljoin
5
4
 
6
5
  from rich.prompt import Prompt
7
6
 
8
7
  from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
9
- from kmdr.core.constants import API_ROUTE
10
- from kmdr.core.error import RedirectError
8
+ from kmdr.core.constants import API_ROUTE, LoginResponse
11
9
 
12
- from .utils import check_status, extract_base_url
10
+ from .utils import check_status
13
11
 
14
- CODE_OK = 'm100'
15
-
16
- CODE_MAPPING = {
17
- 'e400': "帳號或密碼錯誤。",
18
- 'e401': "非法訪問,請使用瀏覽器正常打開本站",
19
- 'e402': "帳號已經註銷。不會解釋原因,無需提問。",
20
- 'e403': "驗證失效,請刷新頁面重新操作。",
21
- }
22
12
 
23
13
  @AUTHENTICATOR.register(
24
14
  hasvalues = {'command': 'login'}
@@ -37,31 +27,28 @@ class LoginAuthenticator(Authenticator):
37
27
  async def _authenticate(self) -> bool:
38
28
 
39
29
  async with self._session.post(
40
- url = urljoin(self._base_url, API_ROUTE.LOGIN_DO),
30
+ url = API_ROUTE.LOGIN_DO,
41
31
  data = {
42
32
  'email': self._username,
43
33
  'passwd': self._password,
44
34
  'keepalive': 'on'
45
35
  },
46
- allow_redirects = False
47
36
  ) as response:
48
37
 
49
38
  response.raise_for_status()
50
39
 
51
- if response.status in (301, 302, 307, 308) and 'Location' in response.headers:
52
- new_location = response.headers['Location']
53
- raise RedirectError("检测到重定向", new_base_url=extract_base_url(new_location) or self._base_url)
54
-
55
40
  match = re.search(r'"\w+"', await response.text())
56
41
 
57
42
  if not match:
58
43
  raise LoginError("无法解析登录响应。")
59
44
 
60
45
  code = match.group(0).split('"')[1]
61
- if code != CODE_OK:
62
- raise LoginError(f"认证失败,错误代码:{code} " + CODE_MAPPING.get(code, "未知错误。"))
63
46
 
64
- if await check_status(self._session, self._console, base_url=self.base_url, show_quota=self._show_quota):
47
+ login_response = LoginResponse.from_code(code)
48
+ if not LoginResponse.ok(login_response):
49
+ raise LoginError(f"认证失败,错误代码:{login_response.name} {login_response.value}" )
50
+
51
+ if await check_status(self._session, self._console, show_quota=self._show_quota):
65
52
  cookie = self._session.cookie_jar.filter_cookies(URL(self._base_url))
66
53
  self._configurer.cookie = {key: morsel.value for key, morsel in cookie.items()}
67
54
 
@@ -2,11 +2,12 @@ from typing import Optional, Callable
2
2
 
3
3
  from aiohttp import ClientSession
4
4
  from rich.console import Console
5
- from urllib.parse import urljoin, urlsplit
5
+
6
+ from yarl import URL
6
7
 
7
8
  from kmdr.core.error import LoginError
8
9
  from kmdr.core.utils import async_retry
9
- from kmdr.core.constants import API_ROUTE, BASE_URL
10
+ from kmdr.core.constants import API_ROUTE
10
11
 
11
12
  NICKNAME_ID = 'div_nickname_display'
12
13
 
@@ -18,12 +19,11 @@ LV1_ID = 'div_user_lv1'
18
19
  async def check_status(
19
20
  session: ClientSession,
20
21
  console: Console,
21
- base_url: str = BASE_URL.KXO,
22
22
  show_quota: bool = False,
23
23
  is_vip_setter: Optional[Callable[[int], None]] = None,
24
24
  level_setter: Optional[Callable[[int], None]] = None,
25
25
  ) -> bool:
26
- async with session.get(url = urljoin(base_url, API_ROUTE.PROFILE)) as response:
26
+ async with session.get(url = API_ROUTE.PROFILE) as response:
27
27
  try:
28
28
  response.raise_for_status()
29
29
  except Exception as e:
@@ -31,7 +31,7 @@ async def check_status(
31
31
  return False
32
32
 
33
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):
34
+ and URL(response.url).path == API_ROUTE.LOGIN:
35
35
  raise LoginError("凭证已失效,请重新登录。", ['kmdr config -c cookie', 'kmdr login -u <username>'])
36
36
 
37
37
  if not is_vip_setter and not level_setter and not show_quota:
@@ -81,13 +81,3 @@ def __resolve_quota_id(is_vip: Optional[int] = None, user_level: Optional[int] =
81
81
  return LV1_ID
82
82
 
83
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
@@ -1,9 +1,9 @@
1
- from urllib.parse import urljoin
1
+ from functools import partial
2
2
 
3
3
  from kmdr.core import Downloader, BookInfo, VolInfo, DOWNLOADER
4
4
  from kmdr.core.constants import API_ROUTE
5
5
 
6
- from .download_utils import safe_filename, download_file_multipart
6
+ from .download_utils import download_file_multipart, readable_safe_filename
7
7
 
8
8
  @DOWNLOADER.register(
9
9
  hasvalues={
@@ -15,26 +15,23 @@ class DirectDownloader(Downloader):
15
15
  super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
16
16
 
17
17
  async def _download(self, book: BookInfo, volume: VolInfo):
18
- sub_dir = safe_filename(book.name)
18
+ sub_dir = readable_safe_filename(book.name)
19
19
  download_path = f'{self._dest}/{sub_dir}'
20
20
 
21
21
  await download_file_multipart(
22
22
  self._session,
23
23
  self._semaphore,
24
24
  self._progress,
25
- lambda: self.construct_download_url(book, volume),
25
+ partial(self.construct_download_url, book, volume),
26
26
  download_path,
27
- safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
27
+ readable_safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
28
28
  self._retry,
29
29
  callback=lambda: self._callback(book, volume) if self._callback else None
30
30
  )
31
31
 
32
32
  def construct_download_url(self, book: BookInfo, volume: VolInfo) -> str:
33
- return 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
- )
33
+ return API_ROUTE.DOWNLOAD.format(
34
+ book_id=book.id,
35
+ volume_id=volume.id,
36
+ is_vip=self._profile.is_vip
40
37
  )
@@ -1,13 +1,13 @@
1
1
  from functools import partial
2
- from urllib.parse import urljoin
3
2
 
4
3
  import json
5
4
  from async_lru import alru_cache
6
5
 
7
6
  from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
8
7
  from kmdr.core.constants import API_ROUTE
8
+ from kmdr.core.error import ResponseError
9
9
 
10
- from .download_utils import safe_filename, download_file_multipart
10
+ from .download_utils import download_file_multipart, readable_safe_filename
11
11
 
12
12
 
13
13
  @DOWNLOADER.register(order=10)
@@ -17,7 +17,7 @@ class ReferViaDownloader(Downloader):
17
17
 
18
18
 
19
19
  async def _download(self, book: BookInfo, volume: VolInfo):
20
- sub_dir = safe_filename(book.name)
20
+ sub_dir = readable_safe_filename(book.name)
21
21
  download_path = f'{self._dest}/{sub_dir}'
22
22
 
23
23
  await download_file_multipart(
@@ -26,7 +26,7 @@ class ReferViaDownloader(Downloader):
26
26
  self._progress,
27
27
  partial(self.fetch_download_url, book.id, volume.id),
28
28
  download_path,
29
- safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
29
+ readable_safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
30
30
  self._retry,
31
31
  headers={
32
32
  "X-Km-From": "kb_http_down"
@@ -37,20 +37,17 @@ class ReferViaDownloader(Downloader):
37
37
  @alru_cache(maxsize=128)
38
38
  async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
39
39
 
40
- url = urljoin(
41
- self._base_url,
40
+ async with self._session.get(
42
41
  API_ROUTE.GETDOWNURL.format(
43
42
  book_id=book_id,
44
43
  volume_id=volume_id,
45
44
  is_vip=self._profile.is_vip
46
45
  )
47
- )
48
-
49
- async with self._session.get(url) as response:
46
+ ) as response:
50
47
  response.raise_for_status()
51
48
  data = await response.text()
52
49
  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
-
50
+ if (code := data.get('code')) != 200:
51
+ raise ResponseError(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}", code)
52
+
56
53
  return data['url']
@@ -267,6 +267,12 @@ async def _download_part(
267
267
  await f.write(chunk)
268
268
  state_manager.advance(len(chunk))
269
269
  return
270
+
271
+ except asyncio.CancelledError:
272
+ # 如果任务被取消,更新状态为已取消
273
+ await state_manager.request_status_update(part_id=start, status=STATUS.CANCELLED)
274
+ raise
275
+
270
276
  except Exception as e:
271
277
  if attempts_left > 0:
272
278
  await asyncio.sleep(3)
@@ -297,7 +303,31 @@ async def _merge_parts(part_paths: list[str], final_path: str):
297
303
  raise e
298
304
 
299
305
 
306
+ CHAR_MAPPING = {
307
+ '\\': '\',
308
+ '/': '/',
309
+ ':': ':',
310
+ '*': '*',
311
+ '?': '?',
312
+ '"': '"',
313
+ '<': '<',
314
+ '>': '>',
315
+ '|': '|',
316
+ }
317
+ DEFAULT_ILLEGAL_CHARS_REPLACEMENT = '_'
318
+ ILLEGAL_CHARS_RE = re.compile(r'[\\/:*?"<>|]')
319
+
320
+ def readable_safe_filename(name: str) -> str:
321
+ """
322
+ 将字符串转换为安全的文件名,替换掉非法字符。
323
+ """
324
+ def replace_char(match):
325
+ char = match.group(0)
326
+ return CHAR_MAPPING.get(char, DEFAULT_ILLEGAL_CHARS_REPLACEMENT)
327
+
328
+ return ILLEGAL_CHARS_RE.sub(replace_char, name).strip()
300
329
 
330
+ @deprecated("请使用 'readable_safe_filename'")
301
331
  def safe_filename(name: str) -> str:
302
332
  """
303
333
  替换非法文件名字符为下划线
@@ -12,6 +12,7 @@ class STATUS(Enum):
12
12
  COMPLETED='[green]完成[/green]'
13
13
  PARTIALLY_FAILED='[red]分片失败[/red]'
14
14
  FAILED='[red]失败[/red]'
15
+ CANCELLED='[yellow]已取消[/yellow]'
15
16
 
16
17
  @property
17
18
  def order(self) -> int:
@@ -23,6 +24,7 @@ class STATUS(Enum):
23
24
  STATUS.COMPLETED: 5,
24
25
  STATUS.PARTIALLY_FAILED: 6,
25
26
  STATUS.FAILED: 7,
27
+ STATUS.CANCELLED: 8,
26
28
  }
27
29
  return order_mapping[self]
28
30
 
@@ -1,5 +1,4 @@
1
1
  import asyncio
2
- from urllib.parse import urljoin
3
2
 
4
3
  from bs4 import BeautifulSoup
5
4
  from rich.table import Table
@@ -63,7 +62,7 @@ class FollowedBookLister(Lister):
63
62
 
64
63
  @async_retry()
65
64
  async def _list_followed_books(self) -> 'list[BookInfo]':
66
- async with self._session.get(urljoin(self._base_url, API_ROUTE.MY_FOLLOW)) as response:
65
+ async with self._session.get(API_ROUTE.MY_FOLLOW) as response:
67
66
  response.raise_for_status()
68
67
  html_text = await response.text()
69
68
 
@@ -1,7 +1,6 @@
1
1
  from bs4 import BeautifulSoup
2
2
  import re
3
3
  from typing import Optional
4
- from urllib.parse import urljoin
5
4
 
6
5
  from yarl import URL
7
6
  from aiohttp import ClientSession as Session
@@ -20,19 +19,18 @@ async def extract_book_info_and_volumes(session: Session, url: str, book_info: O
20
19
  """
21
20
  structured_url = URL(url)
22
21
 
23
- if structured_url.path.startswith('/m/'):
24
- # 移除移动端路径部分,统一为桌面端路径
25
- # 因为移动端页面的结构与桌面端不同,可能会影响解析
26
- structured_url = structured_url.with_path(structured_url.path.replace('/m/', '', 1))
22
+ # 移除移动端路径部分,统一为桌面端路径
23
+ # 因为移动端页面的结构与桌面端不同,可能会影响解析
24
+ route = structured_url.path[2:] if structured_url.path.startswith('/m/') else structured_url.path
27
25
 
28
- async with session.get(structured_url) as response:
26
+ async with session.get(route) as response:
29
27
  response.raise_for_status()
30
28
 
31
29
  # 如果后续有性能问题,可以先考虑使用 lxml 进行解析
32
30
  book_page = BeautifulSoup(await response.text(), 'html.parser')
33
31
 
34
32
  book_info = __extract_book_info(url, book_page, book_info)
35
- volumes = await __extract_volumes(session, url, book_page)
33
+ volumes = await __extract_volumes(session, book_page)
36
34
 
37
35
  return book_info, volumes
38
36
 
@@ -51,13 +49,13 @@ def __extract_book_info(url: str, book_page: BeautifulSoup, book_info: Optional[
51
49
  )
52
50
 
53
51
 
54
- async def __extract_volumes(session: Session, url: str, book_page: BeautifulSoup) -> list[VolInfo]:
52
+ async def __extract_volumes(session: Session, book_page: BeautifulSoup) -> list[VolInfo]:
55
53
  script = book_page.find_all('script', language="javascript")[-1].text
56
54
 
57
55
  pattern = re.compile(r'/book_data.php\?h=\w+')
58
56
  book_data_url = pattern.search(script).group(0)
59
57
 
60
- async with session.get(url = urljoin(url, book_data_url)) as response:
58
+ async with session.get(url = book_data_url) as response:
61
59
  response.raise_for_status()
62
60
 
63
61
  book_data = (await response.text()).split('\n')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -9,6 +9,7 @@ src/kmdr/core/constants.py
9
9
  src/kmdr/core/context.py
10
10
  src/kmdr/core/defaults.py
11
11
  src/kmdr/core/error.py
12
+ src/kmdr/core/protocol.py
12
13
  src/kmdr/core/registry.py
13
14
  src/kmdr/core/session.py
14
15
  src/kmdr/core/structure.py
@@ -11,6 +11,9 @@ BASE_DIR = os.environ.get('KMDR_TEST_DIR', './tests')
11
11
  KMOE_USERNAME = os.environ.get('KMOE_USERNAME')
12
12
  KMOE_PASSWORD = os.environ.get('KMOE_PASSWORD')
13
13
 
14
+ DEFAULT_BASE_URL = BASE_URL.DEFAULT.value
15
+ ALTERNATIVE_BASE_URL = BASE_URL.MOX.value
16
+
14
17
  @unittest.skipUnless(KMOE_USERNAME and KMOE_PASSWORD, "KMOE_USERNAME and KMOE_PASSWORD must be set in environment variables")
15
18
  class TestKmdrDownload(unittest.TestCase):
16
19
 
@@ -55,7 +58,7 @@ class TestKmdrDownload(unittest.TestCase):
55
58
  Namespace(
56
59
  command='download',
57
60
  dest=dest,
58
- book_url=f'{BASE_URL.DEFAULT}/c/51044.htm',
61
+ book_url=f'{DEFAULT_BASE_URL}/c/51044.htm',
59
62
  vol_type='extra',
60
63
  volume='all',
61
64
  max_size=0.6,
@@ -84,7 +87,7 @@ class TestKmdrDownload(unittest.TestCase):
84
87
  # 这个 URL 可能会在未来失效,因为 MOX 站点不保证长期可用
85
88
  # 这里使用 MOX 站点是为了测试多源下载功能
86
89
  # 如果确认是镜像站失效,请替换为其他可用的镜像站
87
- book_url=f'{BASE_URL.MOX}/c/51044.htm',
90
+ book_url=f'{ALTERNATIVE_BASE_URL}/c/51044.htm',
88
91
  vol_type='extra',
89
92
  volume='all',
90
93
  max_size=0.6,
@@ -109,7 +112,7 @@ class TestKmdrDownload(unittest.TestCase):
109
112
  Namespace(
110
113
  command='download',
111
114
  dest=dest,
112
- book_url=f'{BASE_URL.DEFAULT}/m/c/51044.htm',
115
+ book_url=f'{DEFAULT_BASE_URL}/m/c/51044.htm',
113
116
  vol_type='extra',
114
117
  volume='all',
115
118
  max_size=0.6,
@@ -134,7 +137,7 @@ class TestKmdrDownload(unittest.TestCase):
134
137
  Namespace(
135
138
  command='download',
136
139
  dest=dest,
137
- book_url=f'{BASE_URL.DEFAULT}/c/51044.htm',
140
+ book_url=f'{DEFAULT_BASE_URL}/c/51044.htm',
138
141
  vol_type='extra',
139
142
  volume='all',
140
143
  max_size=0.6,
@@ -160,7 +163,7 @@ class TestKmdrDownload(unittest.TestCase):
160
163
  Namespace(
161
164
  command='download',
162
165
  dest=dest,
163
- book_url=f'{BASE_URL.DEFAULT}/c/51044.htm',
166
+ book_url=f'{DEFAULT_BASE_URL}/c/51044.htm',
164
167
  vol_type='extra',
165
168
  volume='all',
166
169
  max_size=0.4,
@@ -186,7 +189,7 @@ class TestKmdrDownload(unittest.TestCase):
186
189
  Namespace(
187
190
  command='download',
188
191
  dest=dest,
189
- book_url=f'{BASE_URL.DEFAULT}/c/51043.htm',
192
+ book_url=f'{DEFAULT_BASE_URL}/c/51043.htm',
190
193
  vol_type='extra',
191
194
  volume='all',
192
195
  max_size=0.4,
@@ -1,16 +0,0 @@
1
- from typing import Optional
2
-
3
- from aiohttp import ClientSession
4
-
5
- from .bases import KMDR_SESSION
6
- from .defaults import session_var, HEADERS
7
-
8
- @KMDR_SESSION.register()
9
- class KmdrSession(ClientSession):
10
- """
11
- Kmdr 的 HTTP 会话管理类,支持从参数中初始化。简化 ClientSession 的使用。
12
- """
13
-
14
- def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
15
- ClientSession.__init__(self, proxy=proxy, trust_env=True, headers=HEADERS)
16
- session_var.set(self)