kmoe-manga-downloader 1.2.0__py3-none-any.whl → 1.2.2__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 (35) hide show
  1. kmdr/core/__init__.py +4 -4
  2. kmdr/core/bases.py +13 -5
  3. kmdr/core/constants.py +74 -0
  4. kmdr/core/context.py +23 -7
  5. kmdr/core/defaults.py +31 -5
  6. kmdr/core/error.py +24 -1
  7. kmdr/core/protocol.py +10 -0
  8. kmdr/core/session.py +107 -8
  9. kmdr/core/structure.py +2 -0
  10. kmdr/core/utils.py +61 -4
  11. kmdr/main.py +5 -3
  12. kmdr/module/__init__.py +5 -5
  13. kmdr/module/authenticator/CookieAuthenticator.py +3 -4
  14. kmdr/module/authenticator/LoginAuthenticator.py +9 -12
  15. kmdr/module/authenticator/__init__.py +2 -0
  16. kmdr/module/authenticator/utils.py +5 -5
  17. kmdr/module/configurer/BaseUrlUpdator.py +16 -0
  18. kmdr/module/configurer/OptionLister.py +16 -11
  19. kmdr/module/configurer/__init__.py +5 -0
  20. kmdr/module/downloader/DirectDownloader.py +12 -5
  21. kmdr/module/downloader/ReferViaDownloader.py +15 -9
  22. kmdr/module/downloader/__init__.py +2 -0
  23. kmdr/module/downloader/{utils.py → download_utils.py} +63 -26
  24. kmdr/module/downloader/misc.py +64 -0
  25. kmdr/module/lister/FollowedBookLister.py +3 -3
  26. kmdr/module/lister/__init__.py +2 -0
  27. kmdr/module/lister/utils.py +9 -2
  28. kmdr/module/picker/__init__.py +2 -0
  29. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/METADATA +42 -19
  30. kmoe_manga_downloader-1.2.2.dist-info/RECORD +44 -0
  31. kmoe_manga_downloader-1.2.0.dist-info/RECORD +0 -35
  32. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/WHEEL +0 -0
  33. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/entry_points.txt +0 -0
  34. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/licenses/LICENSE +0 -0
  35. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/top_level.txt +0 -0
kmdr/core/__init__.py CHANGED
@@ -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
kmdr/core/bases.py CHANGED
@@ -7,7 +7,7 @@ from aiohttp import ClientSession
7
7
  from .error import LoginError
8
8
  from .registry import Registry
9
9
  from .structure import VolInfo, BookInfo
10
- from .utils import construct_callback
10
+ from .utils import construct_callback, async_retry
11
11
 
12
12
  from .context import TerminalContext, SessionContext, UserProfileContext, ConfigContext
13
13
 
@@ -19,6 +19,14 @@ 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):
@@ -31,10 +39,10 @@ class Authenticator(SessionContext, ConfigContext, UserProfileContext, TerminalC
31
39
  async def authenticate(self) -> None:
32
40
  with self._console.status("认证中..."):
33
41
  try:
34
- assert await self._authenticate()
42
+ assert await async_retry()(self._authenticate)()
35
43
  except LoginError as e:
36
- self._console.print("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
37
44
  self._console.print(f"[yellow]详细信息:{e}[/yellow]")
45
+ self._console.print("[red]认证失败。请检查您的登录凭据或会话 cookie。[/red]")
38
46
  exit(1)
39
47
 
40
48
  @abstractmethod
@@ -74,7 +82,7 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
74
82
 
75
83
  async def download(self, book: BookInfo, volumes: list[VolInfo]):
76
84
  if not volumes:
77
- self._console.print("No volumes to download.")
85
+ self._console.print("没有可下载的卷。", style="blue")
78
86
  exit(0)
79
87
 
80
88
  try:
@@ -89,7 +97,7 @@ class Downloader(SessionContext, UserProfileContext, TerminalContext):
89
97
  @abstractmethod
90
98
  async def _download(self, book: BookInfo, volume: VolInfo): ...
91
99
 
92
- KMDR_SESSION = Registry[ClientSession]('KmdrSession', True)
100
+ SESSION_MANAGER = Registry[SessionManager]('SessionManager', True)
93
101
  AUTHENTICATOR = Registry[Authenticator]('Authenticator')
94
102
  LISTERS = Registry[Lister]('Lister')
95
103
  PICKERS = Registry[Picker]('Picker')
kmdr/core/constants.py ADDED
@@ -0,0 +1,74 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import Union
4
+
5
+ from typing_extensions import deprecated
6
+
7
+ class BASE_URL(Enum):
8
+
9
+ @property
10
+ @deprecated("KOX 已过时,请使用 KXO 或 KOZ。")
11
+ def KOX(self) -> str:
12
+ return 'https://kox.moe'
13
+
14
+ KXX = 'https://kxx.moe'
15
+
16
+ KXO = 'https://kxo.moe'
17
+
18
+ KOZ = 'https://koz.moe'
19
+
20
+ MOX = 'https://mox.moe'
21
+
22
+ @classmethod
23
+ def alternatives(cls) -> set[str]:
24
+ """返回备用的基础 URL 列表"""
25
+ return {cls.KXO.value, cls.KOZ.value, cls.MOX.value}
26
+
27
+ DEFAULT = KXX
28
+
29
+ @dataclass(frozen=True)
30
+ class _ApiRoute():
31
+ PROFILE: str = '/my.php'
32
+ """用户信息页面"""
33
+
34
+ LOGIN: str = '/login.php'
35
+ """登录页面"""
36
+
37
+ LOGIN_DO: str = '/login_do.php'
38
+ """登录接口"""
39
+
40
+ MY_FOLLOW: str = '/myfollow.php'
41
+ """关注列表页面"""
42
+
43
+ BOOK_DATA: str = '/book_data.php'
44
+ """书籍数据接口"""
45
+
46
+ DOWNLOAD: str = '/dl/{book_id}/{volume_id}/1/2/{is_vip}/'
47
+ """下载接口"""
48
+
49
+ GETDOWNURL: str = '/getdownurl.php?b={book_id}&v={volume_id}&mobi=2&vip={is_vip}&json=1'
50
+ """获取下载链接接口"""
51
+
52
+
53
+ class LoginResponse(Enum):
54
+ m100 = "登录成功。"
55
+
56
+ e400 = "帳號或密碼錯誤。"
57
+ e401 = "非法訪問,請使用瀏覽器正常打開本站。"
58
+ e402 = "帳號已經註銷。不會解釋原因,無需提問。"
59
+ e403 = "驗證失效,請刷新頁面重新操作。"
60
+
61
+ unknown = "未知响应代码。"
62
+
63
+ @classmethod
64
+ def from_code(cls, code: str) -> 'LoginResponse':
65
+ return cls.__members__.get(code, cls.unknown)
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
72
+
73
+ API_ROUTE = _ApiRoute()
74
+ """API 路由常量实例"""
kmdr/core/context.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from aiohttp import ClientSession
2
2
 
3
- from .defaults import Configurer as InnerConfigurer, UserProfile, session_var, progress, console
3
+
4
+ from .defaults import Configurer as InnerConfigurer, UserProfile, session_var, progress, console, base_url_var
4
5
 
5
6
  class TerminalContext:
6
7
 
@@ -9,12 +10,6 @@ class TerminalContext:
9
10
  self._progress = progress
10
11
  self._console = console
11
12
 
12
- class SessionContext:
13
-
14
- def __init__(self, *args, **kwargs):
15
- super().__init__()
16
- self._session: ClientSession = session_var.get()
17
-
18
13
  class UserProfileContext:
19
14
 
20
15
  def __init__(self, *args, **kwargs):
@@ -26,3 +21,24 @@ class ConfigContext:
26
21
  def __init__(self, *args, **kwargs):
27
22
  super().__init__()
28
23
  self._configurer = InnerConfigurer()
24
+
25
+ class SessionContext:
26
+
27
+ def __init__(self, *args, **kwargs):
28
+ super().__init__()
29
+
30
+ @property
31
+ def _session(self) -> ClientSession:
32
+ return session_var.get()
33
+
34
+ @_session.setter
35
+ def _session(self, value: ClientSession):
36
+ session_var.set(value)
37
+
38
+ @property
39
+ def _base_url(self) -> str:
40
+ return base_url_var.get()
41
+
42
+ @_base_url.setter
43
+ def _base_url(self, value: str):
44
+ base_url_var.set(value)
kmdr/core/defaults.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import os
2
2
  import json
3
- from typing import Optional
3
+ from typing import Optional, Any
4
4
  import argparse
5
5
  from contextvars import ContextVar
6
6
  from rich.console import Console
@@ -15,6 +15,7 @@ from rich.progress import (
15
15
 
16
16
  from .utils import singleton
17
17
  from .structure import Config
18
+ from .constants import BASE_URL
18
19
 
19
20
  HEADERS = {
20
21
  'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
@@ -75,6 +76,7 @@ def argument_parser():
75
76
  config_parser = subparsers.add_parser('config', help='配置下载器')
76
77
  config_parser.add_argument('-l', '--list-option', action='store_true', help='列出所有配置')
77
78
  config_parser.add_argument('-s', '--set', nargs='+', type=str, help='设置一个或多个配置项,格式为 `key=value`,例如: `num_workers=8`')
79
+ config_parser.add_argument('-b', '--base-url', type=str, help='设置镜像站点的基础 URL, 例如: `https://kxx.moe`')
78
80
  config_parser.add_argument('-c', '--clear', type=str, help='清除指定配置,可选值为 `all`, `cookie`, `option`')
79
81
  config_parser.add_argument('-d', '--delete', '--unset', dest='unset', type=str, help='删除特定的配置选项')
80
82
 
@@ -137,6 +139,9 @@ class Configurer:
137
139
  cookie = config.get('cookie', None)
138
140
  if cookie is not None and isinstance(cookie, dict):
139
141
  self._config.cookie = cookie
142
+ base_url = config.get('base_url', None)
143
+ if base_url is not None and isinstance(base_url, str):
144
+ self._config.base_url = base_url
140
145
 
141
146
  @property
142
147
  def config(self) -> 'Config':
@@ -162,12 +167,27 @@ class Configurer:
162
167
  return self._config.option
163
168
 
164
169
  @option.setter
165
- def option(self, value: Optional[dict[str, any]]):
170
+ def option(self, value: Optional[dict[str, Any]]):
166
171
  if self._config is None:
167
172
  self._config = Config()
168
173
  self._config.option = value
169
174
  self.update()
170
175
 
176
+ @property
177
+ def base_url(self) -> str:
178
+ if self._config is None or self._config.base_url is None:
179
+ return BASE_URL.DEFAULT.value
180
+ return self._config.base_url
181
+
182
+ def set_base_url(self, value: str):
183
+ if self._config is None:
184
+ self._config = Config()
185
+ self._config.base_url = value
186
+ self.update()
187
+
188
+ def get_base_url(self) -> str:
189
+ return self._config.base_url
190
+
171
191
  def update(self):
172
192
  with open(os.path.join(os.path.expanduser("~"), self.__filename), 'w') as f:
173
193
  json.dump(self._config.__dict__, f, indent=4, ensure_ascii=False)
@@ -184,7 +204,7 @@ class Configurer:
184
204
 
185
205
  self.update()
186
206
 
187
- def set_option(self, key: str, value: any):
207
+ def set_option(self, key: str, value: Any):
188
208
  if self._config.option is None:
189
209
  self._config.option = {}
190
210
 
@@ -209,5 +229,11 @@ def __combine_args(dest: argparse.Namespace, option: dict) -> argparse.Namespace
209
229
 
210
230
  def combine_args(dest: argparse.Namespace) -> argparse.Namespace:
211
231
  assert isinstance(dest, argparse.Namespace), "dest must be an argparse.Namespace instance"
212
- option = Configurer().config.option
213
- return __combine_args(dest, option)
232
+ option = Configurer().option
233
+
234
+ if option is None:
235
+ return dest
236
+
237
+ return __combine_args(dest, option)
238
+
239
+ base_url_var = ContextVar('base_url', default=Configurer().base_url)
kmdr/core/error.py CHANGED
@@ -7,9 +7,32 @@ 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)
13
20
 
14
21
  def __str__(self):
15
- return f"{self.message}\n{self._solution}"
22
+ return f"{self.message}\n{self._solution}"
23
+
24
+ class RedirectError(KmdrError):
25
+ def __init__(self, message, new_base_url: str):
26
+ super().__init__(message)
27
+ self.new_base_url = new_base_url
28
+
29
+ def __str__(self):
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})"
kmdr/core/protocol.py ADDED
@@ -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: ...
kmdr/core/session.py CHANGED
@@ -1,16 +1,115 @@
1
1
  from typing import Optional
2
+ from urllib.parse import urlsplit, urljoin
2
3
 
3
4
  from aiohttp import ClientSession
4
5
 
5
- from .bases import KMDR_SESSION
6
- from .defaults import session_var, HEADERS
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
7
12
 
8
- @KMDR_SESSION.register()
9
- class KmdrSession(ClientSession):
13
+
14
+
15
+ # 通常只会有一个 SessionManager 的实现
16
+ # 因此这里直接注册为默认实现
17
+ @SESSION_MANAGER.register()
18
+ class KmdrSessionManager(SessionManager):
10
19
  """
11
- Kmdr 的 HTTP 会话管理类,支持从参数中初始化。简化 ClientSession 的使用。
20
+ Kmdr 的 HTTP 会话管理类,支持从参数中初始化 ClientSession 的实例。
12
21
  """
13
22
 
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)
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
+
kmdr/core/structure.py CHANGED
@@ -66,3 +66,5 @@ class Config:
66
66
  """
67
67
 
68
68
  cookie: Optional[dict[str, str]] = None
69
+
70
+ base_url: Optional[str] = None
kmdr/core/utils.py CHANGED
@@ -1,13 +1,14 @@
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
6
6
 
7
- from deprecation import deprecated
8
7
  import subprocess
9
8
 
10
9
  from .structure import BookInfo, VolInfo
10
+ from .error import RedirectError
11
+ from .protocol import Consumer
11
12
 
12
13
 
13
14
  def singleton(cls):
@@ -43,7 +44,9 @@ def async_retry(
43
44
  attempts: int = 3,
44
45
  delay: float = 1.0,
45
46
  backoff: float = 2.0,
46
- retry_on_status: set[int] = {500, 502, 503, 504, 429, 408}
47
+ retry_on_status: set[int] = {500, 502, 503, 504, 429, 408},
48
+ base_url_setter: Optional[Consumer[str]] = None,
49
+ on_failure: Optional[Callable[[Exception], None]] = None
47
50
  ):
48
51
  def decorator(func):
49
52
  @functools.wraps(func)
@@ -62,9 +65,63 @@ def async_retry(
62
65
  # 对于所有其他 aiohttp 客户端异常和超时,进行重试
63
66
  if attempt == attempts - 1:
64
67
  raise
68
+ except RedirectError as e:
69
+ if base_url_setter:
70
+ base_url_setter(e.new_base_url)
71
+ print(f"检测到重定向,已自动更新 base url 为: {e.new_base_url}。立即重试...")
72
+ continue
73
+ else:
74
+ raise
75
+ except Exception as e:
76
+ if on_failure:
77
+ on_failure(e)
78
+ break
79
+ else:
80
+ raise
65
81
 
66
82
  await asyncio.sleep(current_delay)
67
83
 
68
84
  current_delay *= backoff
69
85
  return wrapper
70
- return decorator
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)]
kmdr/main.py CHANGED
@@ -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__':
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 *
@@ -5,7 +5,6 @@ from yarl import URL
5
5
  from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
6
6
 
7
7
  from .utils import check_status
8
- from .utils import PROFILE_URL
9
8
 
10
9
  @AUTHENTICATOR.register()
11
10
  class CookieAuthenticator(Authenticator):
@@ -22,12 +21,12 @@ class CookieAuthenticator(Authenticator):
22
21
 
23
22
  if not cookie:
24
23
  raise LoginError("无法找到 Cookie,请先完成登录。", ['kmdr login -u <username>'])
25
-
26
- self._session.cookie_jar.update_cookies(cookie, response_url=URL(PROFILE_URL))
24
+
25
+ self._session.cookie_jar.update_cookies(cookie, response_url=URL(self._base_url))
27
26
  return await check_status(
28
27
  self._session,
29
28
  self._console,
30
29
  show_quota=self._show_quota,
31
30
  is_vip_setter=lambda value: setattr(self._profile, 'is_vip', value),
32
31
  level_setter=lambda value: setattr(self._profile, 'user_level', value),
33
- )
32
+ )
@@ -1,20 +1,14 @@
1
1
  from typing import Optional
2
2
  import re
3
+ from yarl import URL
3
4
 
4
5
  from rich.prompt import Prompt
5
6
 
6
7
  from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
8
+ from kmdr.core.constants import API_ROUTE, LoginResponse
7
9
 
8
10
  from .utils import check_status
9
11
 
10
- CODE_OK = 'm100'
11
-
12
- CODE_MAPPING = {
13
- 'e400': "帳號或密碼錯誤。",
14
- 'e401': "非法訪問,請使用瀏覽器正常打開本站",
15
- 'e402': "帳號已經註銷。不會解釋原因,無需提問。",
16
- 'e403': "驗證失效,請刷新頁面重新操作。",
17
- }
18
12
 
19
13
  @AUTHENTICATOR.register(
20
14
  hasvalues = {'command': 'login'}
@@ -33,7 +27,7 @@ class LoginAuthenticator(Authenticator):
33
27
  async def _authenticate(self) -> bool:
34
28
 
35
29
  async with self._session.post(
36
- url = 'https://kox.moe/login_do.php',
30
+ url = API_ROUTE.LOGIN_DO,
37
31
  data = {
38
32
  'email': self._username,
39
33
  'passwd': self._password,
@@ -42,17 +36,20 @@ class LoginAuthenticator(Authenticator):
42
36
  ) as response:
43
37
 
44
38
  response.raise_for_status()
39
+
45
40
  match = re.search(r'"\w+"', await response.text())
46
41
 
47
42
  if not match:
48
43
  raise LoginError("无法解析登录响应。")
49
44
 
50
45
  code = match.group(0).split('"')[1]
51
- if code != CODE_OK:
52
- raise LoginError(f"认证失败,错误代码:{code} " + CODE_MAPPING.get(code, "未知错误。"))
46
+
47
+ login_response = LoginResponse.from_code(code)
48
+ if not LoginResponse.ok(login_response):
49
+ raise LoginError(f"认证失败,错误代码:{login_response.name} {login_response.value}" )
53
50
 
54
51
  if await check_status(self._session, self._console, show_quota=self._show_quota):
55
- cookie = self._session.cookie_jar.filter_cookies('https://kox.moe')
52
+ cookie = self._session.cookie_jar.filter_cookies(URL(self._base_url))
56
53
  self._configurer.cookie = {key: morsel.value for key, morsel in cookie.items()}
57
54
 
58
55
  return True
@@ -0,0 +1,2 @@
1
+ from .CookieAuthenticator import CookieAuthenticator
2
+ from .LoginAuthenticator import LoginAuthenticator