kmoe-manga-downloader 1.0.0__py3-none-any.whl → 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kmdr/core/__init__.py +3 -1
- kmdr/core/bases.py +17 -5
- kmdr/core/defaults.py +23 -0
- kmdr/core/error.py +15 -0
- kmdr/main.py +4 -7
- kmdr/module/authenticator/CookieAuthenticator.py +8 -4
- kmdr/module/authenticator/LoginAuthenticator.py +18 -16
- kmdr/module/authenticator/utils.py +62 -8
- kmdr/module/configurer/ConfigUnsetter.py +4 -1
- kmdr/module/configurer/option_validate.py +46 -12
- kmdr/module/downloader/DirectDownloader.py +3 -3
- kmdr/module/downloader/ReferViaDownloader.py +4 -4
- kmdr/module/downloader/utils.py +38 -14
- {kmoe_manga_downloader-1.0.0.dist-info → kmoe_manga_downloader-1.1.1.dist-info}/METADATA +30 -36
- kmoe_manga_downloader-1.1.1.dist-info/RECORD +33 -0
- kmoe_manga_downloader-1.0.0.dist-info/RECORD +0 -32
- {kmoe_manga_downloader-1.0.0.dist-info → kmoe_manga_downloader-1.1.1.dist-info}/WHEEL +0 -0
- {kmoe_manga_downloader-1.0.0.dist-info → kmoe_manga_downloader-1.1.1.dist-info}/entry_points.txt +0 -0
- {kmoe_manga_downloader-1.0.0.dist-info → kmoe_manga_downloader-1.1.1.dist-info}/licenses/LICENSE +0 -0
- {kmoe_manga_downloader-1.0.0.dist-info → kmoe_manga_downloader-1.1.1.dist-info}/top_level.txt +0 -0
kmdr/core/__init__.py
CHANGED
|
@@ -2,4 +2,6 @@ from .bases import Authenticator, Lister, Picker, Downloader, Configurer
|
|
|
2
2
|
from .structure import VolInfo, BookInfo, VolumeType
|
|
3
3
|
from .bases import AUTHENTICATOR, LISTERS, PICKERS, DOWNLOADER, CONFIGURER
|
|
4
4
|
|
|
5
|
-
from .defaults import argument_parser
|
|
5
|
+
from .defaults import argument_parser
|
|
6
|
+
|
|
7
|
+
from .error import KmdrError, LoginError
|
kmdr/core/bases.py
CHANGED
|
@@ -2,10 +2,11 @@ import os
|
|
|
2
2
|
|
|
3
3
|
from typing import Callable, Optional
|
|
4
4
|
|
|
5
|
+
from .error import LoginError
|
|
5
6
|
from .registry import Registry
|
|
6
7
|
from .structure import VolInfo, BookInfo
|
|
7
8
|
from .utils import get_singleton_session, construct_callback
|
|
8
|
-
from .defaults import Configurer as InnerConfigurer
|
|
9
|
+
from .defaults import Configurer as InnerConfigurer, UserProfile
|
|
9
10
|
|
|
10
11
|
class SessionContext:
|
|
11
12
|
|
|
@@ -13,6 +14,12 @@ class SessionContext:
|
|
|
13
14
|
super().__init__()
|
|
14
15
|
self._session = get_singleton_session()
|
|
15
16
|
|
|
17
|
+
class UserProfileContext:
|
|
18
|
+
|
|
19
|
+
def __init__(self, *args, **kwargs):
|
|
20
|
+
super().__init__()
|
|
21
|
+
self._profile = UserProfile()
|
|
22
|
+
|
|
16
23
|
class ConfigContext:
|
|
17
24
|
|
|
18
25
|
def __init__(self, *args, **kwargs):
|
|
@@ -26,7 +33,7 @@ class Configurer(ConfigContext):
|
|
|
26
33
|
|
|
27
34
|
def operate(self) -> None: ...
|
|
28
35
|
|
|
29
|
-
class Authenticator(SessionContext, ConfigContext):
|
|
36
|
+
class Authenticator(SessionContext, ConfigContext, UserProfileContext):
|
|
30
37
|
|
|
31
38
|
def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
|
|
32
39
|
super().__init__(*args, **kwargs)
|
|
@@ -41,8 +48,13 @@ class Authenticator(SessionContext, ConfigContext):
|
|
|
41
48
|
# 主站正常情况下不使用代理也能登录成功。但是不排除特殊的网络环境下需要代理。
|
|
42
49
|
# 所以暂时保留代理登录的功能,如果后续确认是代理的问题,可以考虑启用 @no_proxy 装饰器。
|
|
43
50
|
# @no_proxy
|
|
44
|
-
def authenticate(self) ->
|
|
45
|
-
|
|
51
|
+
def authenticate(self) -> None:
|
|
52
|
+
try:
|
|
53
|
+
assert self._authenticate()
|
|
54
|
+
except LoginError as e:
|
|
55
|
+
print("Authentication failed. Please check your login credentials or session cookies.")
|
|
56
|
+
print(f"Details: {e}")
|
|
57
|
+
exit(1)
|
|
46
58
|
|
|
47
59
|
def _authenticate(self) -> bool: ...
|
|
48
60
|
|
|
@@ -60,7 +72,7 @@ class Picker(SessionContext):
|
|
|
60
72
|
|
|
61
73
|
def pick(self, volumes: list[VolInfo]) -> list[VolInfo]: ...
|
|
62
74
|
|
|
63
|
-
class Downloader(SessionContext):
|
|
75
|
+
class Downloader(SessionContext, UserProfileContext):
|
|
64
76
|
|
|
65
77
|
def __init__(self,
|
|
66
78
|
dest: str = '.',
|
kmdr/core/defaults.py
CHANGED
|
@@ -58,6 +58,29 @@ def parse_args():
|
|
|
58
58
|
|
|
59
59
|
return args
|
|
60
60
|
|
|
61
|
+
@singleton
|
|
62
|
+
class UserProfile:
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self._is_vip: Optional[int] = None
|
|
66
|
+
self._user_level: Optional[int] = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def is_vip(self) -> Optional[int]:
|
|
70
|
+
return self._is_vip
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def user_level(self) -> Optional[int]:
|
|
74
|
+
return self._user_level
|
|
75
|
+
|
|
76
|
+
@is_vip.setter
|
|
77
|
+
def is_vip(self, value: Optional[int]):
|
|
78
|
+
self._is_vip = value
|
|
79
|
+
|
|
80
|
+
@user_level.setter
|
|
81
|
+
def user_level(self, value: Optional[int]):
|
|
82
|
+
self._user_level = value
|
|
83
|
+
|
|
61
84
|
@singleton
|
|
62
85
|
class Configurer:
|
|
63
86
|
|
kmdr/core/error.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
class KmdrError(RuntimeError):
|
|
4
|
+
def __init__(self, message: str, solution: Optional[list[str]] = None, *args: object, **kwargs: object):
|
|
5
|
+
super().__init__(message, *args, **kwargs)
|
|
6
|
+
self.message = message
|
|
7
|
+
|
|
8
|
+
self._solution = "" if solution is None else "\nSuggested Solution: \n" + "\n".join(f">>> {sol}" for sol in solution)
|
|
9
|
+
|
|
10
|
+
class LoginError(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}"
|
kmdr/main.py
CHANGED
|
@@ -7,16 +7,13 @@ from kmdr.module import *
|
|
|
7
7
|
def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
8
8
|
|
|
9
9
|
if args.command == 'login':
|
|
10
|
-
|
|
11
|
-
raise RuntimeError("Authentication failed. Please check your credentials.")
|
|
10
|
+
AUTHENTICATOR.get(args).authenticate()
|
|
12
11
|
|
|
13
12
|
elif args.command == 'status':
|
|
14
|
-
|
|
15
|
-
raise RuntimeError("Authentication failed. Please check your credentials.")
|
|
13
|
+
AUTHENTICATOR.get(args).authenticate()
|
|
16
14
|
|
|
17
|
-
elif args.command == 'download':
|
|
18
|
-
|
|
19
|
-
raise RuntimeError("Authentication failed. Please check your credentials.")
|
|
15
|
+
elif args.command == 'download':
|
|
16
|
+
AUTHENTICATOR.get(args).authenticate()
|
|
20
17
|
|
|
21
18
|
book, volumes = LISTERS.get(args).list()
|
|
22
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
|
-
from kmdr.core import Authenticator, AUTHENTICATOR
|
|
3
|
+
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
4
4
|
|
|
5
5
|
from .utils import check_status
|
|
6
6
|
|
|
@@ -18,8 +18,12 @@ class CookieAuthenticator(Authenticator):
|
|
|
18
18
|
cookie = self._configurer.cookie
|
|
19
19
|
|
|
20
20
|
if not cookie:
|
|
21
|
-
|
|
22
|
-
return False
|
|
21
|
+
raise LoginError("No cookie found, please login first.", ['kmdr login -u <username>'])
|
|
23
22
|
|
|
24
23
|
self._session.cookies.update(cookie)
|
|
25
|
-
return check_status(
|
|
24
|
+
return check_status(
|
|
25
|
+
self._session,
|
|
26
|
+
show_quota=self._show_quota,
|
|
27
|
+
is_vip_setter=lambda value: setattr(self._profile, 'is_vip', value),
|
|
28
|
+
level_setter=lambda value: setattr(self._profile, 'user_level', value),
|
|
29
|
+
)
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
import re
|
|
3
|
+
from getpass import getpass
|
|
3
4
|
|
|
4
|
-
from kmdr.core import Authenticator, AUTHENTICATOR
|
|
5
|
+
from kmdr.core import Authenticator, AUTHENTICATOR, LoginError
|
|
5
6
|
|
|
6
7
|
from .utils import check_status
|
|
7
8
|
|
|
9
|
+
CODE_OK = 'm100'
|
|
10
|
+
|
|
11
|
+
CODE_MAPPING = {
|
|
12
|
+
'e400': "帳號或密碼錯誤。",
|
|
13
|
+
'e401': "非法訪問,請使用瀏覽器正常打開本站",
|
|
14
|
+
'e402': "帳號已經註銷。不會解釋原因,無需提問。",
|
|
15
|
+
'e403': "驗證失效,請刷新頁面重新操作。",
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
@AUTHENTICATOR.register(
|
|
10
19
|
hasvalues = {'command': 'login'}
|
|
@@ -16,7 +25,7 @@ class LoginAuthenticator(Authenticator):
|
|
|
16
25
|
self._show_quota = show_quota
|
|
17
26
|
|
|
18
27
|
if password is None:
|
|
19
|
-
password =
|
|
28
|
+
password = getpass("please input your password: ")
|
|
20
29
|
|
|
21
30
|
self._password = password
|
|
22
31
|
|
|
@@ -31,22 +40,15 @@ class LoginAuthenticator(Authenticator):
|
|
|
31
40
|
},
|
|
32
41
|
)
|
|
33
42
|
response.raise_for_status()
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
match = re.search(r'"\w+"', response.text)
|
|
44
|
+
|
|
36
45
|
if not match:
|
|
37
|
-
raise
|
|
38
|
-
code = match.group(0).split('"')[1]
|
|
39
|
-
if code != 'm100':
|
|
40
|
-
if code == 'e400':
|
|
41
|
-
print("帳號或密碼錯誤。")
|
|
42
|
-
elif code == 'e401':
|
|
43
|
-
print("非法訪問,請使用瀏覽器正常打開本站")
|
|
44
|
-
elif code == 'e402':
|
|
45
|
-
print("帳號已經註銷。不會解釋原因,無需提問。")
|
|
46
|
-
elif code == 'e403':
|
|
47
|
-
print("驗證失效,請刷新頁面重新操作。")
|
|
48
|
-
raise RuntimeError("Authentication failed with code: " + code)
|
|
46
|
+
raise LoginError("Failed to extract authentication code from response.")
|
|
49
47
|
|
|
48
|
+
code = match.group(0).split('"')[1]
|
|
49
|
+
if code != CODE_OK:
|
|
50
|
+
raise LoginError(f"Authentication failed with error code: {code} " + CODE_MAPPING.get(code, "Unknown error."))
|
|
51
|
+
|
|
50
52
|
if check_status(self._session, show_quota=self._show_quota):
|
|
51
53
|
self._configurer.cookie = self._session.cookies.get_dict()
|
|
52
54
|
return True
|
|
@@ -1,25 +1,79 @@
|
|
|
1
|
+
from typing import Optional, Callable
|
|
2
|
+
|
|
1
3
|
from requests import Session
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
from kmdr.core.error import LoginError
|
|
6
|
+
|
|
7
|
+
PROFILE_URL = 'https://kox.moe/my.php'
|
|
8
|
+
LOGIN_URL = 'https://kox.moe/login.php'
|
|
9
|
+
|
|
10
|
+
NICKNAME_ID = 'div_nickname_display'
|
|
11
|
+
|
|
12
|
+
VIP_ID = 'div_user_vip'
|
|
13
|
+
NOR_ID = 'div_user_nor'
|
|
14
|
+
LV1_ID = 'div_user_lv1'
|
|
15
|
+
|
|
16
|
+
def check_status(
|
|
17
|
+
session: Session,
|
|
18
|
+
show_quota: bool = False,
|
|
19
|
+
is_vip_setter: Optional[Callable[[int], None]] = None,
|
|
20
|
+
level_setter: Optional[Callable[[int], None]] = None
|
|
21
|
+
) -> bool:
|
|
22
|
+
response = session.get(url = PROFILE_URL)
|
|
5
23
|
|
|
6
24
|
try:
|
|
7
25
|
response.raise_for_status()
|
|
8
26
|
except Exception as e:
|
|
9
27
|
print(f"Error: {type(e).__name__}: {e}")
|
|
10
28
|
return False
|
|
29
|
+
|
|
30
|
+
if response.history and any(resp.status_code in (301, 302, 307) for resp in response.history) \
|
|
31
|
+
and response.url == LOGIN_URL:
|
|
32
|
+
raise LoginError("Invalid credentials, please login again.", ['kmdr config -c cookie', 'kmdr login -u <username>'])
|
|
11
33
|
|
|
12
|
-
if not show_quota:
|
|
34
|
+
if not is_vip_setter and not level_setter and not show_quota:
|
|
13
35
|
return True
|
|
14
36
|
|
|
15
37
|
from bs4 import BeautifulSoup
|
|
16
38
|
|
|
17
39
|
soup = BeautifulSoup(response.text, 'html.parser')
|
|
40
|
+
|
|
41
|
+
script = soup.find('script', language="javascript")
|
|
42
|
+
|
|
43
|
+
if script:
|
|
44
|
+
var_define = extract_var_define(script.text[:100])
|
|
45
|
+
|
|
46
|
+
is_vip = int(var_define.get('is_vip', '0'))
|
|
47
|
+
user_level = int(var_define.get('user_level', '0'))
|
|
48
|
+
|
|
49
|
+
if is_vip_setter:
|
|
50
|
+
is_vip_setter(is_vip)
|
|
51
|
+
if level_setter:
|
|
52
|
+
level_setter(user_level)
|
|
18
53
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
54
|
+
if not show_quota:
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
nickname = soup.find('div', id=NICKNAME_ID).text.strip().split(' ')[0]
|
|
58
|
+
quota = soup.find('div', id=__resolve_quota_id(is_vip, user_level)).text.strip()
|
|
59
|
+
|
|
60
|
+
print(f"\n当前登录为 {nickname}\n\n{quota}")
|
|
24
61
|
return True
|
|
62
|
+
|
|
63
|
+
def extract_var_define(script_text) -> dict[str, str]:
|
|
64
|
+
var_define = {}
|
|
65
|
+
for line in script_text.splitlines():
|
|
66
|
+
line = line.strip()
|
|
67
|
+
if line.startswith("var ") and "=" in line:
|
|
68
|
+
var_name, var_value = line[4:].split("=", 1)
|
|
69
|
+
var_define[var_name.strip()] = var_value.strip().strip(";").strip('"')
|
|
70
|
+
return var_define
|
|
71
|
+
|
|
72
|
+
def __resolve_quota_id(is_vip: Optional[int] = None, user_level: Optional[int] = None):
|
|
73
|
+
if is_vip is not None and is_vip >= 1:
|
|
74
|
+
return VIP_ID
|
|
75
|
+
|
|
76
|
+
if user_level is not None and user_level <= 1:
|
|
77
|
+
return LV1_ID
|
|
25
78
|
|
|
79
|
+
return NOR_ID
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from kmdr.core import Configurer, CONFIGURER
|
|
2
2
|
|
|
3
|
+
from .option_validate import check_key
|
|
4
|
+
|
|
3
5
|
@CONFIGURER.register()
|
|
4
6
|
class ConfigUnsetter(Configurer):
|
|
5
7
|
def __init__(self, unset: str, *args, **kwargs):
|
|
@@ -10,6 +12,7 @@ class ConfigUnsetter(Configurer):
|
|
|
10
12
|
if not self._unset:
|
|
11
13
|
print("No option specified to unset.")
|
|
12
14
|
return
|
|
13
|
-
|
|
15
|
+
|
|
16
|
+
check_key(self._unset)
|
|
14
17
|
self._configurer.unset_option(self._unset)
|
|
15
18
|
print(f"Unset configuration: {self._unset}")
|
|
@@ -1,27 +1,61 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
|
+
from functools import wraps
|
|
2
3
|
import os
|
|
3
4
|
|
|
4
5
|
__OPTIONS_VALIDATOR = {}
|
|
5
6
|
|
|
6
7
|
def validate(key: str, value: str) -> Optional[object]:
|
|
8
|
+
"""
|
|
9
|
+
供外部调用的验证函数,根据键名调用相应的验证器。
|
|
10
|
+
|
|
11
|
+
:param key: 配置项的键名
|
|
12
|
+
:param value: 配置项的值
|
|
13
|
+
:return: 验证后的值或 None
|
|
14
|
+
"""
|
|
7
15
|
if key in __OPTIONS_VALIDATOR:
|
|
8
16
|
return __OPTIONS_VALIDATOR[key](value)
|
|
9
17
|
else:
|
|
10
18
|
print(f"Unsupported option: {key}. Supported options are: {', '.join(__OPTIONS_VALIDATOR.keys())}")
|
|
11
19
|
return None
|
|
12
20
|
|
|
13
|
-
def
|
|
14
|
-
|
|
21
|
+
def check_key(key: str, exit_if_invalid: bool = True) -> None:
|
|
22
|
+
"""
|
|
23
|
+
供外部调用的验证函数,用于检查配置项的键名是否有效。
|
|
24
|
+
如果键名无效,函数会打印错误信息并退出程序。
|
|
25
|
+
|
|
26
|
+
:param key: 配置项的键名
|
|
27
|
+
:param exit_if_invalid: 如果键名无效,是否退出程序
|
|
28
|
+
"""
|
|
29
|
+
if key not in __OPTIONS_VALIDATOR:
|
|
30
|
+
print(f"Unknown option: {key}. Supported options are: {', '.join(__OPTIONS_VALIDATOR.keys())}")
|
|
31
|
+
if exit_if_invalid:
|
|
32
|
+
exit(1)
|
|
33
|
+
|
|
34
|
+
def register_validator(arg_name):
|
|
35
|
+
"""
|
|
36
|
+
验证函数的注册装饰器,用于将验证函数注册到全局验证器字典中。
|
|
37
|
+
|
|
38
|
+
:param arg_name: 配置项的键名
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def wrapper(func):
|
|
42
|
+
global __OPTIONS_VALIDATOR
|
|
43
|
+
__OPTIONS_VALIDATOR[arg_name] = func
|
|
44
|
+
|
|
45
|
+
@wraps(func)
|
|
46
|
+
def inner(*args, **kwargs):
|
|
47
|
+
return func(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
return inner
|
|
15
50
|
|
|
16
|
-
|
|
51
|
+
return wrapper
|
|
17
52
|
|
|
18
|
-
assert func_name.startswith('validate_'), \
|
|
19
|
-
f"Validator function name must start with 'validate_', got '{func_name}'"
|
|
20
53
|
|
|
21
|
-
|
|
22
|
-
|
|
54
|
+
#############################################
|
|
55
|
+
## 以下为各个配置项的验证函数。
|
|
56
|
+
#############################################
|
|
23
57
|
|
|
24
|
-
@
|
|
58
|
+
@register_validator('num_workers')
|
|
25
59
|
def validate_num_workers(value: str) -> Optional[int]:
|
|
26
60
|
try:
|
|
27
61
|
num_workers = int(value)
|
|
@@ -32,7 +66,7 @@ def validate_num_workers(value: str) -> Optional[int]:
|
|
|
32
66
|
print(f"Invalid value for num_workers: {value}. {str(e)}")
|
|
33
67
|
return None
|
|
34
68
|
|
|
35
|
-
@
|
|
69
|
+
@register_validator('dest')
|
|
36
70
|
def validate_dest(value: str) -> Optional[str]:
|
|
37
71
|
if not value:
|
|
38
72
|
print("Destination cannot be empty.")
|
|
@@ -50,7 +84,7 @@ def validate_dest(value: str) -> Optional[str]:
|
|
|
50
84
|
|
|
51
85
|
return value
|
|
52
86
|
|
|
53
|
-
@
|
|
87
|
+
@register_validator('retry')
|
|
54
88
|
def validate_retry(value: str) -> Optional[int]:
|
|
55
89
|
try:
|
|
56
90
|
retry = int(value)
|
|
@@ -61,14 +95,14 @@ def validate_retry(value: str) -> Optional[int]:
|
|
|
61
95
|
print(f"Invalid value for retry: {value}. {str(e)}")
|
|
62
96
|
return None
|
|
63
97
|
|
|
64
|
-
@
|
|
98
|
+
@register_validator('callback')
|
|
65
99
|
def validate_callback(value: str) -> Optional[str]:
|
|
66
100
|
if not value:
|
|
67
101
|
print("Callback cannot be empty.")
|
|
68
102
|
return None
|
|
69
103
|
return value
|
|
70
104
|
|
|
71
|
-
@
|
|
105
|
+
@register_validator('proxy')
|
|
72
106
|
def validate_proxy(value: str) -> Optional[str]:
|
|
73
107
|
if not value:
|
|
74
108
|
print("Proxy cannot be empty.")
|
|
@@ -17,12 +17,12 @@ class DirectDownloader(Downloader):
|
|
|
17
17
|
|
|
18
18
|
download_file(
|
|
19
19
|
self._session,
|
|
20
|
-
self.construct_download_url(book, volume),
|
|
20
|
+
lambda: self.construct_download_url(book, volume),
|
|
21
21
|
download_path,
|
|
22
|
-
f'[Kmoe][{book.name}][{volume.name}].epub',
|
|
22
|
+
safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
|
|
23
23
|
retry,
|
|
24
24
|
callback=lambda: self._callback(book, volume) if self._callback else None
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
def construct_download_url(self, book: BookInfo, volume: VolInfo) -> str:
|
|
28
|
-
return f'https://kox.moe/dl/{book.id}/{volume.id}/1/2/
|
|
28
|
+
return f'https://kox.moe/dl/{book.id}/{volume.id}/1/2/{self._profile.is_vip}/'
|
|
@@ -23,9 +23,9 @@ class ReferViaDownloader(Downloader):
|
|
|
23
23
|
|
|
24
24
|
download_file(
|
|
25
25
|
self._session if not self._scraper else self._scraper,
|
|
26
|
-
self.fetch_download_url(
|
|
26
|
+
lambda: self.fetch_download_url(book_id=book.id, volume_id=volume.id),
|
|
27
27
|
download_path,
|
|
28
|
-
f'[Kmoe][{book.name}][{volume.name}].epub',
|
|
28
|
+
safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
|
|
29
29
|
retry,
|
|
30
30
|
headers={
|
|
31
31
|
"X-Km-From": "kb_http_down"
|
|
@@ -34,8 +34,8 @@ class ReferViaDownloader(Downloader):
|
|
|
34
34
|
)
|
|
35
35
|
|
|
36
36
|
@cached_by_kwargs
|
|
37
|
-
def fetch_download_url(self,
|
|
38
|
-
response = self._session.get(f"https://kox.moe/getdownurl.php?b={
|
|
37
|
+
def fetch_download_url(self, book_id: str, volume_id: str) -> str:
|
|
38
|
+
response = self._session.get(f"https://kox.moe/getdownurl.php?b={book_id}&v={volume_id}&mobi=2&vip={self._profile.is_vip}&json=1")
|
|
39
39
|
response.raise_for_status()
|
|
40
40
|
data = response.json()
|
|
41
41
|
if data.get('code') != 200:
|
kmdr/module/downloader/utils.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
from typing import Callable, Optional
|
|
1
|
+
from typing import Callable, Optional, Union
|
|
2
2
|
import os
|
|
3
3
|
import time
|
|
4
|
+
from functools import wraps
|
|
4
5
|
|
|
5
6
|
from requests import Session, HTTPError
|
|
6
7
|
from requests.exceptions import ChunkedEncodingError
|
|
@@ -12,7 +13,7 @@ MIN_BLOCK_SIZE = 2048
|
|
|
12
13
|
|
|
13
14
|
def download_file(
|
|
14
15
|
session: Session,
|
|
15
|
-
url: str,
|
|
16
|
+
url: Union[str, Callable[[], str]],
|
|
16
17
|
dest_path: str,
|
|
17
18
|
filename: str,
|
|
18
19
|
retry_times: int = 0,
|
|
@@ -20,10 +21,22 @@ def download_file(
|
|
|
20
21
|
callback: Optional[Callable] = None,
|
|
21
22
|
block_size: int = 8192
|
|
22
23
|
):
|
|
24
|
+
"""
|
|
25
|
+
下载文件
|
|
26
|
+
|
|
27
|
+
:param session: requests.Session 对象
|
|
28
|
+
:param url: 下载链接或者其 Supplier
|
|
29
|
+
:param dest_path: 目标路径
|
|
30
|
+
:param filename: 文件名
|
|
31
|
+
:param retry_times: 重试次数
|
|
32
|
+
:param headers: 请求头
|
|
33
|
+
:param callback: 下载完成后的回调函数
|
|
34
|
+
:param block_size: 块大小
|
|
35
|
+
"""
|
|
23
36
|
if headers is None:
|
|
24
37
|
headers = {}
|
|
25
38
|
filename_downloading = f'{filename}.downloading'
|
|
26
|
-
|
|
39
|
+
|
|
27
40
|
file_path = f'{dest_path}/{filename}'
|
|
28
41
|
tmp_file_path = f'{dest_path}/{filename_downloading}'
|
|
29
42
|
|
|
@@ -33,6 +46,9 @@ def download_file(
|
|
|
33
46
|
if os.path.exists(file_path):
|
|
34
47
|
tqdm.write(f"{filename} already exists.")
|
|
35
48
|
return
|
|
49
|
+
|
|
50
|
+
if callable(url):
|
|
51
|
+
url = url()
|
|
36
52
|
|
|
37
53
|
resume_from = 0
|
|
38
54
|
total_size_in_bytes = 0
|
|
@@ -64,11 +80,6 @@ def download_file(
|
|
|
64
80
|
except Exception as e:
|
|
65
81
|
prefix = f"{type(e).__name__} occurred while downloading {filename}. "
|
|
66
82
|
|
|
67
|
-
if isinstance(e, HTTPError):
|
|
68
|
-
e.request.headers['Cookie'] = '***MASKED***'
|
|
69
|
-
tqdm.write(f"Request Headers: {e.request.headers}")
|
|
70
|
-
tqdm.write(f"Response Headers: {e.response.headers}")
|
|
71
|
-
|
|
72
83
|
new_block_size = block_size
|
|
73
84
|
if isinstance(e, ChunkedEncodingError):
|
|
74
85
|
new_block_size = max(int(block_size * BLOCK_SIZE_REDUCTION_FACTOR), MIN_BLOCK_SIZE)
|
|
@@ -89,6 +100,8 @@ def safe_filename(name: str) -> str:
|
|
|
89
100
|
return re.sub(r'[\\/:*?"<>|]', '_', name)
|
|
90
101
|
|
|
91
102
|
|
|
103
|
+
function_cache = {}
|
|
104
|
+
|
|
92
105
|
def cached_by_kwargs(func):
|
|
93
106
|
"""
|
|
94
107
|
根据关键字参数缓存函数结果的装饰器。
|
|
@@ -101,18 +114,29 @@ def cached_by_kwargs(func):
|
|
|
101
114
|
>>> result2 = add(3, 2, c=3) # Uses cached result
|
|
102
115
|
>>> assert result1 == result2 # Both results are the same
|
|
103
116
|
"""
|
|
104
|
-
cache = {}
|
|
105
117
|
|
|
118
|
+
global function_cache
|
|
119
|
+
if func not in function_cache:
|
|
120
|
+
function_cache[func] = {}
|
|
121
|
+
|
|
122
|
+
@wraps(func)
|
|
106
123
|
def wrapper(*args, **kwargs):
|
|
107
124
|
if not kwargs:
|
|
108
125
|
return func(*args, **kwargs)
|
|
109
|
-
|
|
110
|
-
nonlocal cache
|
|
111
126
|
|
|
112
127
|
key = frozenset(kwargs.items())
|
|
113
128
|
|
|
114
|
-
if key not in
|
|
115
|
-
|
|
116
|
-
return
|
|
129
|
+
if key not in function_cache[func]:
|
|
130
|
+
function_cache[func][key] = func(*args, **kwargs)
|
|
131
|
+
return function_cache[func][key]
|
|
117
132
|
|
|
118
133
|
return wrapper
|
|
134
|
+
|
|
135
|
+
def clear_cache(func):
|
|
136
|
+
assert hasattr(func, "__wrapped__"), "Function is not wrapped"
|
|
137
|
+
global function_cache
|
|
138
|
+
|
|
139
|
+
wrapped = func.__wrapped__
|
|
140
|
+
|
|
141
|
+
if wrapped in function_cache:
|
|
142
|
+
function_cache[wrapped] = {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kmoe-manga-downloader
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: A CLI-downloader for site @kox.moe.
|
|
5
5
|
Author-email: Chris Zheng <chrisis58@outlook.com>
|
|
6
6
|
License: MIT License
|
|
@@ -43,28 +43,24 @@ Dynamic: license-file
|
|
|
43
43
|
|
|
44
44
|
# Kmoe Manga Downloader
|
|
45
45
|
|
|
46
|
-
[](https://github.com/chrisis58/kmdr/actions/workflows/unit-test.yml) [](https://www.python.org/) [](https://github.com/chrisis58/kmdr/blob/main/LICENSE)
|
|
46
|
+
[](https://pepy.tech/projects/kmoe-manga-downloader) [](https://pypi.org/project/kmoe-manga-downloader/) [](https://github.com/chrisis58/kmdr/actions/workflows/unit-test.yml) [](https://www.python.org/) [](https://github.com/chrisis58/kmdr/blob/main/LICENSE)
|
|
47
47
|
|
|
48
|
-
`kmdr (Kmoe Manga Downloader)` 是一个 Python
|
|
48
|
+
`kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定书籍及其卷,并支持回调脚本执行。
|
|
49
49
|
|
|
50
50
|
## ✨功能特性
|
|
51
51
|
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
57
|
-
- 提供通用配置持久化的实现
|
|
52
|
+
- **凭证管理**: 命令行登录并持久化会话
|
|
53
|
+
- **多种下载方式**: 支持通过书籍 URL 或从收藏列表下载
|
|
54
|
+
- **高效下载**: 支持多线程、失败重试及断点续传
|
|
55
|
+
- **配置持久化**: 保存常用下载目录、代理等设置
|
|
56
|
+
- **回调支持**: 下载完成后自动执行自定义脚本
|
|
58
57
|
|
|
59
|
-
##
|
|
58
|
+
## 🛠️安装应用
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
你可以通过 PyPI 使用 `pip` 进行安装:
|
|
62
61
|
|
|
63
62
|
```bash
|
|
64
|
-
|
|
65
|
-
cd kmoe-manga-downloader
|
|
66
|
-
|
|
67
|
-
pip install -r requirements.txt
|
|
63
|
+
pip install kmoe-manga-downloader
|
|
68
64
|
```
|
|
69
65
|
|
|
70
66
|
## 📋使用方法
|
|
@@ -74,35 +70,34 @@ pip install -r requirements.txt
|
|
|
74
70
|
首先需要登录 `kox.moe` 并保存登录状态(Cookie)。
|
|
75
71
|
|
|
76
72
|
```bash
|
|
77
|
-
|
|
73
|
+
kmdr login -u <your_username> -p <your_password>
|
|
74
|
+
# 或者
|
|
75
|
+
kmdr login -u <your_username>
|
|
78
76
|
```
|
|
79
77
|
|
|
80
|
-
|
|
78
|
+
第二种方式会在程序运行时获取登录密码,此时你输入的密码**不会显示**在终端中。
|
|
81
79
|
|
|
82
|
-
|
|
83
|
-
python kmdr.py login -u <your_username>
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
第二种方式会在程序运行时获取登录密码。如果登录成功,会同时显示当前登录用户及配额。
|
|
80
|
+
如果登录成功,会同时显示当前登录用户及配额。
|
|
87
81
|
|
|
88
82
|
### 2. 下载漫画书籍
|
|
89
83
|
|
|
90
84
|
你可以通过以下命令下载指定书籍或卷:
|
|
91
85
|
|
|
92
86
|
```bash
|
|
93
|
-
#
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
# 在当前目录下载第一、二、三卷
|
|
88
|
+
kmdr download --dest . --book-url https://kox.moe/c/50076.htm --volume 1,2,3
|
|
89
|
+
kmdr download -l https://kox.moe/c/50076.htm -v 1-3
|
|
96
90
|
```
|
|
97
91
|
|
|
98
92
|
```bash
|
|
99
|
-
#
|
|
100
|
-
|
|
93
|
+
# 在目标目录下载全部番外篇
|
|
94
|
+
kmdr download --dest path/to/destination --book-url https://kox.moe/c/50076.htm --vol-type extra -v all
|
|
95
|
+
kmdr download -d path/to/destination -l https://kox.moe/c/50076.htm -t extra -v all
|
|
101
96
|
```
|
|
102
97
|
|
|
103
98
|
#### 常用参数说明:
|
|
104
99
|
|
|
105
|
-
- `-d`, `--dest`:
|
|
100
|
+
- `-d`, `--dest`: 下载的目标目录(默认为当前目录),在此基础上会额外添加一个为书籍名称的子目录
|
|
106
101
|
- `-l`, `--book-url`: 指定书籍的主页地址
|
|
107
102
|
- `-v`, `--volume`: 指定卷的名称,多个名称使用逗号分隔,`all` 表示下载所有卷
|
|
108
103
|
- `-t`, `--vol-type`: 卷类型,`vol`: 单行本(默认);`extra`: 番外;`seri`: 连载话;`all`: 全部
|
|
@@ -115,10 +110,10 @@ python kmdr.py download -d path/to/destination --book-url https://kox.moe/c/5007
|
|
|
115
110
|
|
|
116
111
|
### 3. 查看账户状态
|
|
117
112
|
|
|
118
|
-
|
|
113
|
+
查看当前账户信息(账户名和配额等):
|
|
119
114
|
|
|
120
115
|
```bash
|
|
121
|
-
|
|
116
|
+
kmdr status
|
|
122
117
|
```
|
|
123
118
|
|
|
124
119
|
### 4. 回调函数
|
|
@@ -126,7 +121,7 @@ python kmdr.py status
|
|
|
126
121
|
你可以设置一个回调函数,下载完成后执行。回调可以是任何你想要的命令:
|
|
127
122
|
|
|
128
123
|
```bash
|
|
129
|
-
|
|
124
|
+
kmdr download -d path/to/destination --book-url https://kox.moe/c/50076.htm -v 1-3 \
|
|
130
125
|
--callback "echo '{b.name} {v.name} downloaded!' >> ~/kmdr.log"
|
|
131
126
|
```
|
|
132
127
|
|
|
@@ -146,11 +141,11 @@ python kmdr.py download -d path/to/destination --book-url https://kox.moe/c/5007
|
|
|
146
141
|
|
|
147
142
|
### 5. 持久化配置
|
|
148
143
|
|
|
149
|
-
|
|
144
|
+
重复设置下载的代理服务器、目标路径等参数,可能会降低应用的使用效率。所以应用也提供了通用配置的持久化命令:
|
|
150
145
|
|
|
151
146
|
```bash
|
|
152
|
-
|
|
153
|
-
|
|
147
|
+
kmdr config --set proxy=http://localhost:7890 dest=/path/to/destination
|
|
148
|
+
kmdr config -s num_workers=5 "callback=echo '{b.name} {v.name} downloaded!' >> ~/kmdr.log"
|
|
154
149
|
```
|
|
155
150
|
|
|
156
151
|
只需要配置一次即可对之后的所有的下载指令生效。
|
|
@@ -175,8 +170,7 @@ python kmdr.py config -s num_workers=5 "callback=echo '{b.name} {v.name} downloa
|
|
|
175
170
|
---
|
|
176
171
|
|
|
177
172
|
<div align=center>
|
|
178
|
-
💬任何使用中遇到的问题、希望添加的功能,都欢迎提交 issue
|
|
173
|
+
💬任何使用中遇到的问题、希望添加的功能,都欢迎提交 issue 交流!<br />
|
|
179
174
|
⭐ 如果这个项目对你有帮助,请给它一个星标!<br /> <br />
|
|
180
175
|
<img src="https://counter.seku.su/cmoe?name=kmdr&theme=mbs" />
|
|
181
176
|
</div>
|
|
182
|
-
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
kmdr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
kmdr/main.py,sha256=f3bHGfrVs_ZEydj03YOWz2ATrNH7pHQs8GLDgKea-48,891
|
|
3
|
+
kmdr/core/__init__.py,sha256=WT4MHsww1KOcIlf3z_AK5-4mXNFNBoCE1REQ0dvf9DE,281
|
|
4
|
+
kmdr/core/bases.py,sha256=nlrMNq_GsJmZtBnOSR-VP8LgJ9ZOHBWH4Sj8aqACx9E,4091
|
|
5
|
+
kmdr/core/defaults.py,sha256=9vVN0xywLOHm3tcNf1GGJ9RTXB38YLTvBMqxmp0jEAE,6582
|
|
6
|
+
kmdr/core/error.py,sha256=ZXsiD-Ihe0zo5wdNgd7Rg-ISZifsiU5C6FeHesurfy4,597
|
|
7
|
+
kmdr/core/registry.py,sha256=KYjvww5WRuUg5SeIeWZb96803-kgrTIKYvFTj_7bPfo,5560
|
|
8
|
+
kmdr/core/structure.py,sha256=JHTuEHuRJLx1dEgg59WDJCZKORvwPJWuAb6nT1cSFNA,1163
|
|
9
|
+
kmdr/core/utils.py,sha256=eBoj8O37kIQSN6S72EPpv83YRQlplBZ1FzEvehOLEKU,1953
|
|
10
|
+
kmdr/module/__init__.py,sha256=aSuTcHhuBHUi1xNj5HTsXxhvegGBTRPNKj4JAPLC7LE,319
|
|
11
|
+
kmdr/module/authenticator/CookieAuthenticator.py,sha256=5LzqtpTCfKufcjbzFeJIAUbUaTxo2W4mN1ki8CDel2Y,1002
|
|
12
|
+
kmdr/module/authenticator/LoginAuthenticator.py,sha256=PnLGpgSZCA6GvtYFAKeCIRvjtojwIeF4it50QdTXuu8,1827
|
|
13
|
+
kmdr/module/authenticator/utils.py,sha256=fgheXC2--ZQ53zap6gF4lA88CzJRN4ljgKmOs3IbiJU,2415
|
|
14
|
+
kmdr/module/configurer/ConfigClearer.py,sha256=S5VxJ5latDK2-0Xf77_fVECXah7DhbI0exWE6AwUUkc,355
|
|
15
|
+
kmdr/module/configurer/ConfigUnsetter.py,sha256=ZjySNAQj4-j9k68_tbXa2Z9G0TqGgfKPRCtIf939Hfg,531
|
|
16
|
+
kmdr/module/configurer/OptionLister.py,sha256=qc-nWPft_EtbEgJnz32h3nwGyGbe9oabKZpm6dIJi_o,516
|
|
17
|
+
kmdr/module/configurer/OptionSetter.py,sha256=9MIkWZb-aFUTqpVCVrnri3JZTE0zO3CIYuN_nz5yzzY,842
|
|
18
|
+
kmdr/module/configurer/option_validate.py,sha256=DdtUG0E-LgjjHQgPBSyRZ_ID40QRRFSS_FEQHdK94hM,3327
|
|
19
|
+
kmdr/module/downloader/DirectDownloader.py,sha256=5ny3or9fj2rb3qGxEWBmiEs5vlIfvCSGFiMoFISs_J8,1060
|
|
20
|
+
kmdr/module/downloader/ReferViaDownloader.py,sha256=_x-hniGmIqc_7OFY-kQUrOIH0TwDcf-_NCsmVql2pp0,1694
|
|
21
|
+
kmdr/module/downloader/utils.py,sha256=TdZ5ojVsw4ke-n5347pDzgulndOt-r56JD9lD7CuEW4,4323
|
|
22
|
+
kmdr/module/lister/BookUrlLister.py,sha256=Mq7EBXWSKd-6cygfkTjmOvgcUUaJI4NMQiaEIv9VDSk,470
|
|
23
|
+
kmdr/module/lister/FollowedBookLister.py,sha256=_fShmCAsZQqboeuRtLBjo6d9CkGpat8xNKC3COtKllc,1537
|
|
24
|
+
kmdr/module/lister/utils.py,sha256=0EHQIA05EZ1V0_SfkJ8demjzMRmFh9Q5qPvE6jvBqSU,2560
|
|
25
|
+
kmdr/module/picker/ArgsFilterPicker.py,sha256=f3suMPPeFEtB3u7aUY63k_sGIOP196R-VviQ9RfDBTA,1756
|
|
26
|
+
kmdr/module/picker/DefaultVolPicker.py,sha256=kpG5dvv1UKMhSA01VKGNB59zM5uszspyUVfRlL9aQA0,750
|
|
27
|
+
kmdr/module/picker/utils.py,sha256=lpxM7q9BJeupFQy8glBrHu1o4E38dk7iLexzKytAE6g,1222
|
|
28
|
+
kmoe_manga_downloader-1.1.1.dist-info/licenses/LICENSE,sha256=bKQlsXu8mAYKRZyoZKOEqMcCc8YjT5Q3Hgr21e0yU4E,1068
|
|
29
|
+
kmoe_manga_downloader-1.1.1.dist-info/METADATA,sha256=1Rj0x86vnKIJbG5C_0Q2A615ssX0b7FujbHseuSbnVs,7773
|
|
30
|
+
kmoe_manga_downloader-1.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
31
|
+
kmoe_manga_downloader-1.1.1.dist-info/entry_points.txt,sha256=DGMytQAhx4uNuKQL7BPkiWESHLXkH-2KSEqwHdygNPA,47
|
|
32
|
+
kmoe_manga_downloader-1.1.1.dist-info/top_level.txt,sha256=e0qxOgWp0tl3GLpmXGjZv3--q_TLoJ7GztM48Ov27wM,5
|
|
33
|
+
kmoe_manga_downloader-1.1.1.dist-info/RECORD,,
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
kmdr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
kmdr/main.py,sha256=WcY3gDnh3Qr2LkwkZQ5fwMdDd7OpT5w46lM3l_x6kOc,1183
|
|
3
|
-
kmdr/core/__init__.py,sha256=6oreDxzipo3S11rhuLeZHGY2uke4gV4bA7NPTD5skvI,239
|
|
4
|
-
kmdr/core/bases.py,sha256=hCk4IGPjx5FZJ1tdwFusYtZuwT75s3tn7O7-KBOXr28,3670
|
|
5
|
-
kmdr/core/defaults.py,sha256=tP82FHlW4Ah38yNhwa1K25tYlRmw6dhBZxnzB31Utno,6062
|
|
6
|
-
kmdr/core/registry.py,sha256=KYjvww5WRuUg5SeIeWZb96803-kgrTIKYvFTj_7bPfo,5560
|
|
7
|
-
kmdr/core/structure.py,sha256=JHTuEHuRJLx1dEgg59WDJCZKORvwPJWuAb6nT1cSFNA,1163
|
|
8
|
-
kmdr/core/utils.py,sha256=eBoj8O37kIQSN6S72EPpv83YRQlplBZ1FzEvehOLEKU,1953
|
|
9
|
-
kmdr/module/__init__.py,sha256=aSuTcHhuBHUi1xNj5HTsXxhvegGBTRPNKj4JAPLC7LE,319
|
|
10
|
-
kmdr/module/authenticator/CookieAuthenticator.py,sha256=vB_IvbWDelnRwCKKBP8yLB-NFa1q5QR4wNNNsi1pFmQ,774
|
|
11
|
-
kmdr/module/authenticator/LoginAuthenticator.py,sha256=XeaenbrpnCj0Fs7aLV6zhoAdL0PvksUj4tnEPEzIcYM,1879
|
|
12
|
-
kmdr/module/authenticator/utils.py,sha256=sVG2UTbQ1IdAtxUDHHjFts30WHik8ibC2zyFcGHXtP8,799
|
|
13
|
-
kmdr/module/configurer/ConfigClearer.py,sha256=S5VxJ5latDK2-0Xf77_fVECXah7DhbI0exWE6AwUUkc,355
|
|
14
|
-
kmdr/module/configurer/ConfigUnsetter.py,sha256=lik17kG7XJZ0J7AZ5z0jSUEOENbD0CML6wdHwaHbhXw,468
|
|
15
|
-
kmdr/module/configurer/OptionLister.py,sha256=qc-nWPft_EtbEgJnz32h3nwGyGbe9oabKZpm6dIJi_o,516
|
|
16
|
-
kmdr/module/configurer/OptionSetter.py,sha256=9MIkWZb-aFUTqpVCVrnri3JZTE0zO3CIYuN_nz5yzzY,842
|
|
17
|
-
kmdr/module/configurer/option_validate.py,sha256=Pn9an9oFSpV8Jkz5uW12HprgyH7KJ_fo_ENEDLYxin0,2238
|
|
18
|
-
kmdr/module/downloader/DirectDownloader.py,sha256=v13Bsr7TMspGOgxfX6F2aLO-wNUtHGa1R0zaiW9a08k,1016
|
|
19
|
-
kmdr/module/downloader/ReferViaDownloader.py,sha256=0jbH0NWhhszjJsAHu5nkPt_Axzxp3AV8NR2sYaFdtCk,1641
|
|
20
|
-
kmdr/module/downloader/utils.py,sha256=wBPmHiEHvFbdbyK4qmlkrzUFpl1LUOdZHY7Pg1s-1AY,3753
|
|
21
|
-
kmdr/module/lister/BookUrlLister.py,sha256=Mq7EBXWSKd-6cygfkTjmOvgcUUaJI4NMQiaEIv9VDSk,470
|
|
22
|
-
kmdr/module/lister/FollowedBookLister.py,sha256=_fShmCAsZQqboeuRtLBjo6d9CkGpat8xNKC3COtKllc,1537
|
|
23
|
-
kmdr/module/lister/utils.py,sha256=0EHQIA05EZ1V0_SfkJ8demjzMRmFh9Q5qPvE6jvBqSU,2560
|
|
24
|
-
kmdr/module/picker/ArgsFilterPicker.py,sha256=f3suMPPeFEtB3u7aUY63k_sGIOP196R-VviQ9RfDBTA,1756
|
|
25
|
-
kmdr/module/picker/DefaultVolPicker.py,sha256=kpG5dvv1UKMhSA01VKGNB59zM5uszspyUVfRlL9aQA0,750
|
|
26
|
-
kmdr/module/picker/utils.py,sha256=lpxM7q9BJeupFQy8glBrHu1o4E38dk7iLexzKytAE6g,1222
|
|
27
|
-
kmoe_manga_downloader-1.0.0.dist-info/licenses/LICENSE,sha256=bKQlsXu8mAYKRZyoZKOEqMcCc8YjT5Q3Hgr21e0yU4E,1068
|
|
28
|
-
kmoe_manga_downloader-1.0.0.dist-info/METADATA,sha256=DXGLAIc7JIibitm8Em96Zw5qCxiQ2_q2MfzLqm53SfU,7647
|
|
29
|
-
kmoe_manga_downloader-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
30
|
-
kmoe_manga_downloader-1.0.0.dist-info/entry_points.txt,sha256=DGMytQAhx4uNuKQL7BPkiWESHLXkH-2KSEqwHdygNPA,47
|
|
31
|
-
kmoe_manga_downloader-1.0.0.dist-info/top_level.txt,sha256=e0qxOgWp0tl3GLpmXGjZv3--q_TLoJ7GztM48Ov27wM,5
|
|
32
|
-
kmoe_manga_downloader-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
{kmoe_manga_downloader-1.0.0.dist-info → kmoe_manga_downloader-1.1.1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.0.0.dist-info → kmoe_manga_downloader-1.1.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{kmoe_manga_downloader-1.0.0.dist-info → kmoe_manga_downloader-1.1.1.dist-info}/top_level.txt
RENAMED
|
File without changes
|