kmoe-manga-downloader 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kmdr/__init__.py +0 -0
- kmdr/core/__init__.py +5 -0
- kmdr/core/bases.py +113 -0
- kmdr/core/defaults.py +154 -0
- kmdr/core/registry.py +128 -0
- kmdr/core/structure.py +68 -0
- kmdr/core/utils.py +77 -0
- kmdr/main.py +39 -0
- kmdr/module/__init__.py +5 -0
- kmdr/module/authenticator/CookieAuthenticator.py +25 -0
- kmdr/module/authenticator/LoginAuthenticator.py +54 -0
- kmdr/module/authenticator/utils.py +25 -0
- kmdr/module/configurer/ConfigClearer.py +11 -0
- kmdr/module/configurer/ConfigUnsetter.py +15 -0
- kmdr/module/configurer/OptionLister.py +19 -0
- kmdr/module/configurer/OptionSetter.py +32 -0
- kmdr/module/configurer/option_validate.py +76 -0
- kmdr/module/downloader/DirectDownloader.py +28 -0
- kmdr/module/downloader/ReferViaDownloader.py +44 -0
- kmdr/module/downloader/utils.py +118 -0
- kmdr/module/lister/BookUrlLister.py +15 -0
- kmdr/module/lister/FollowedBookLister.py +38 -0
- kmdr/module/lister/utils.py +79 -0
- kmdr/module/picker/ArgsFilterPicker.py +49 -0
- kmdr/module/picker/DefaultVolPicker.py +21 -0
- kmdr/module/picker/utils.py +37 -0
- kmoe_manga_downloader-1.0.0.dist-info/METADATA +182 -0
- kmoe_manga_downloader-1.0.0.dist-info/RECORD +32 -0
- kmoe_manga_downloader-1.0.0.dist-info/WHEEL +5 -0
- kmoe_manga_downloader-1.0.0.dist-info/entry_points.txt +2 -0
- kmoe_manga_downloader-1.0.0.dist-info/licenses/LICENSE +21 -0
- kmoe_manga_downloader-1.0.0.dist-info/top_level.txt +1 -0
kmdr/__init__.py
ADDED
|
File without changes
|
kmdr/core/__init__.py
ADDED
kmdr/core/bases.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
|
|
5
|
+
from .registry import Registry
|
|
6
|
+
from .structure import VolInfo, BookInfo
|
|
7
|
+
from .utils import get_singleton_session, construct_callback
|
|
8
|
+
from .defaults import Configurer as InnerConfigurer
|
|
9
|
+
|
|
10
|
+
class SessionContext:
|
|
11
|
+
|
|
12
|
+
def __init__(self, *args, **kwargs):
|
|
13
|
+
super().__init__()
|
|
14
|
+
self._session = get_singleton_session()
|
|
15
|
+
|
|
16
|
+
class ConfigContext:
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, **kwargs):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self._configurer = InnerConfigurer()
|
|
21
|
+
|
|
22
|
+
class Configurer(ConfigContext):
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args, **kwargs):
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
def operate(self) -> None: ...
|
|
28
|
+
|
|
29
|
+
class Authenticator(SessionContext, ConfigContext):
|
|
30
|
+
|
|
31
|
+
def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
if proxy:
|
|
35
|
+
self._session.proxies.update({
|
|
36
|
+
'https': proxy,
|
|
37
|
+
'http': proxy,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
# 在使用代理登录时,可能会出现问题,但是现在还不清楚是不是代理的问题。
|
|
41
|
+
# 主站正常情况下不使用代理也能登录成功。但是不排除特殊的网络环境下需要代理。
|
|
42
|
+
# 所以暂时保留代理登录的功能,如果后续确认是代理的问题,可以考虑启用 @no_proxy 装饰器。
|
|
43
|
+
# @no_proxy
|
|
44
|
+
def authenticate(self) -> bool:
|
|
45
|
+
return self._authenticate()
|
|
46
|
+
|
|
47
|
+
def _authenticate(self) -> bool: ...
|
|
48
|
+
|
|
49
|
+
class Lister(SessionContext):
|
|
50
|
+
|
|
51
|
+
def __init__(self, *args, **kwargs):
|
|
52
|
+
super().__init__(*args, **kwargs)
|
|
53
|
+
|
|
54
|
+
def list(self) -> tuple[BookInfo, list[VolInfo]]: ...
|
|
55
|
+
|
|
56
|
+
class Picker(SessionContext):
|
|
57
|
+
|
|
58
|
+
def __init__(self, *args, **kwargs):
|
|
59
|
+
super().__init__(*args, **kwargs)
|
|
60
|
+
|
|
61
|
+
def pick(self, volumes: list[VolInfo]) -> list[VolInfo]: ...
|
|
62
|
+
|
|
63
|
+
class Downloader(SessionContext):
|
|
64
|
+
|
|
65
|
+
def __init__(self,
|
|
66
|
+
dest: str = '.',
|
|
67
|
+
callback: Optional[str] = None,
|
|
68
|
+
retry: int = 3,
|
|
69
|
+
num_workers: int = 1,
|
|
70
|
+
proxy: Optional[str] = None,
|
|
71
|
+
*args, **kwargs
|
|
72
|
+
):
|
|
73
|
+
super().__init__(*args, **kwargs)
|
|
74
|
+
self._dest: str = dest
|
|
75
|
+
self._callback: Optional[Callable[[BookInfo, VolInfo], int]] = construct_callback(callback)
|
|
76
|
+
self._retry: int = retry
|
|
77
|
+
self._num_workers: int = num_workers
|
|
78
|
+
|
|
79
|
+
if proxy:
|
|
80
|
+
self._session.proxies.update({
|
|
81
|
+
'https': proxy,
|
|
82
|
+
'http': proxy,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
def download(self, book: BookInfo, volumes: list[VolInfo]):
|
|
86
|
+
if volumes is None or not volumes:
|
|
87
|
+
raise ValueError("No volumes to download")
|
|
88
|
+
|
|
89
|
+
if self._num_workers <= 1:
|
|
90
|
+
for volume in volumes:
|
|
91
|
+
self._download(book, volume, self._retry)
|
|
92
|
+
else:
|
|
93
|
+
self._download_with_multiple_workers(book, volumes, self._retry)
|
|
94
|
+
|
|
95
|
+
def _download(self, book: BookInfo, volume: VolInfo, retry: int): ...
|
|
96
|
+
|
|
97
|
+
def _download_with_multiple_workers(self, book: BookInfo, volumes: list[VolInfo], retry: int):
|
|
98
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
99
|
+
|
|
100
|
+
max_workers = min(self._num_workers, len(volumes))
|
|
101
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
102
|
+
futures = [
|
|
103
|
+
executor.submit(self._download, book, volume, retry)
|
|
104
|
+
for volume in volumes
|
|
105
|
+
]
|
|
106
|
+
for future in futures:
|
|
107
|
+
future.result()
|
|
108
|
+
|
|
109
|
+
AUTHENTICATOR = Registry[Authenticator]('Authenticator')
|
|
110
|
+
LISTERS = Registry[Lister]('Lister')
|
|
111
|
+
PICKERS = Registry[Picker]('Picker')
|
|
112
|
+
DOWNLOADER = Registry[Downloader]('Downloader', True)
|
|
113
|
+
CONFIGURER = Registry[Configurer]('Configurer')
|
kmdr/core/defaults.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from .utils import singleton
|
|
7
|
+
from .structure import Config
|
|
8
|
+
|
|
9
|
+
parser: Optional[argparse.ArgumentParser] = None
|
|
10
|
+
args: Optional[argparse.Namespace] = None
|
|
11
|
+
|
|
12
|
+
def argument_parser():
|
|
13
|
+
global parser
|
|
14
|
+
if parser is not None:
|
|
15
|
+
return parser
|
|
16
|
+
|
|
17
|
+
parser = argparse.ArgumentParser(description='Kox Downloader')
|
|
18
|
+
subparsers = parser.add_subparsers(title='subcommands', dest='command')
|
|
19
|
+
|
|
20
|
+
download_parser = subparsers.add_parser('download', help='Download books')
|
|
21
|
+
download_parser.add_argument('-d', '--dest', type=str, help='Download destination, default to current directory', required=False)
|
|
22
|
+
download_parser.add_argument('-l', '--book-url', type=str, help='Book page\'s url', required=False)
|
|
23
|
+
download_parser.add_argument('-v', '--volume', type=str, help='Volume(s), split using commas, `all` for all', required=False)
|
|
24
|
+
download_parser.add_argument('-t', '--vol-type', type=str, help='Volume type, `vol` for volume, `extra` for extras, `seri` for serialized', required=False, choices=['vol', 'extra', 'seri', 'all'], default='vol')
|
|
25
|
+
download_parser.add_argument('--max-size', type=float, help='Max size of volume in MB', required=False)
|
|
26
|
+
download_parser.add_argument('--limit', type=int, help='Limit number of volumes to download', required=False)
|
|
27
|
+
download_parser.add_argument('--num-workers', type=int, help='Number of workers to use for downloading', required=False)
|
|
28
|
+
download_parser.add_argument('-p', '--proxy', type=str, help='Proxy server', required=False)
|
|
29
|
+
download_parser.add_argument('-r', '--retry', type=int, help='Retry times', required=False)
|
|
30
|
+
download_parser.add_argument('-c', '--callback', type=str, help='Callback script, use as `echo {v.name} downloaded!`', required=False)
|
|
31
|
+
|
|
32
|
+
login_parser = subparsers.add_parser('login', help='Login to kox.moe')
|
|
33
|
+
login_parser.add_argument('-u', '--username', type=str, help='Your username', required=True)
|
|
34
|
+
login_parser.add_argument('-p', '--password', type=str, help='Your password', required=False)
|
|
35
|
+
|
|
36
|
+
status_parser = subparsers.add_parser('status', help='Show status of account and script')
|
|
37
|
+
status_parser.add_argument('-p', '--proxy', type=str, help='Proxy server', required=False)
|
|
38
|
+
|
|
39
|
+
config_parser = subparsers.add_parser('config', help='Configure the downloader')
|
|
40
|
+
config_parser.add_argument('-l', '--list-option', action='store_true', help='List all configurations')
|
|
41
|
+
config_parser.add_argument('-s', '--set', nargs='+', type=str, help='Configuration options to set, e.g. num_workers=3 dest=.')
|
|
42
|
+
config_parser.add_argument('-c', '--clear', type=str, help='Clear configurations, `all`, `cookie`, `option` are available')
|
|
43
|
+
config_parser.add_argument('-d', '--delete', '--unset', dest='unset', type=str, help='Delete a specific configuration option')
|
|
44
|
+
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
def parse_args():
|
|
48
|
+
global args
|
|
49
|
+
if args is not None:
|
|
50
|
+
return args
|
|
51
|
+
|
|
52
|
+
parser = argument_parser()
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
if args.command is None:
|
|
56
|
+
parser.print_help()
|
|
57
|
+
exit(1)
|
|
58
|
+
|
|
59
|
+
return args
|
|
60
|
+
|
|
61
|
+
@singleton
|
|
62
|
+
class Configurer:
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
self.__filename = '.kmdr'
|
|
66
|
+
|
|
67
|
+
if not os.path.exists(os.path.join(os.path.expanduser("~"), self.__filename)):
|
|
68
|
+
self._config = Config()
|
|
69
|
+
self.update()
|
|
70
|
+
else:
|
|
71
|
+
with open(os.path.join(os.path.expanduser("~"), self.__filename), 'r') as f:
|
|
72
|
+
config = json.load(f)
|
|
73
|
+
|
|
74
|
+
self._config = Config()
|
|
75
|
+
option = config.get('option', None)
|
|
76
|
+
if option is not None and isinstance(option, dict):
|
|
77
|
+
self._config.option = option
|
|
78
|
+
cookie = config.get('cookie', None)
|
|
79
|
+
if cookie is not None and isinstance(cookie, dict):
|
|
80
|
+
self._config.cookie = cookie
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def config(self) -> 'Config':
|
|
84
|
+
return self._config
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def cookie(self) -> Optional[dict]:
|
|
88
|
+
if self._config is None:
|
|
89
|
+
return None
|
|
90
|
+
return self._config.cookie
|
|
91
|
+
|
|
92
|
+
@cookie.setter
|
|
93
|
+
def cookie(self, value: Optional[dict[str, str]]):
|
|
94
|
+
if self._config is None:
|
|
95
|
+
self._config = Config()
|
|
96
|
+
self._config.cookie = value
|
|
97
|
+
self.update()
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def option(self) -> Optional[dict]:
|
|
101
|
+
if self._config is None:
|
|
102
|
+
return None
|
|
103
|
+
return self._config.option
|
|
104
|
+
|
|
105
|
+
@option.setter
|
|
106
|
+
def option(self, value: Optional[dict[str, any]]):
|
|
107
|
+
if self._config is None:
|
|
108
|
+
self._config = Config()
|
|
109
|
+
self._config.option = value
|
|
110
|
+
self.update()
|
|
111
|
+
|
|
112
|
+
def update(self):
|
|
113
|
+
with open(os.path.join(os.path.expanduser("~"), self.__filename), 'w') as f:
|
|
114
|
+
json.dump(self._config.__dict__, f, indent=4, ensure_ascii=False)
|
|
115
|
+
|
|
116
|
+
def clear(self, key: str):
|
|
117
|
+
if key == 'all':
|
|
118
|
+
self._config = Config()
|
|
119
|
+
elif key == 'cookie':
|
|
120
|
+
self._config.cookie = None
|
|
121
|
+
elif key == 'option':
|
|
122
|
+
self._config.option = None
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError(f"Unsupported clear option: {key}")
|
|
125
|
+
|
|
126
|
+
self.update()
|
|
127
|
+
|
|
128
|
+
def set_option(self, key: str, value: any):
|
|
129
|
+
if self._config.option is None:
|
|
130
|
+
self._config.option = {}
|
|
131
|
+
|
|
132
|
+
self._config.option[key] = value
|
|
133
|
+
self.update()
|
|
134
|
+
|
|
135
|
+
def unset_option(self, key: str):
|
|
136
|
+
if self._config.option is None or key not in self._config.option:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
del self._config.option[key]
|
|
140
|
+
self.update()
|
|
141
|
+
|
|
142
|
+
def __combine_args(dest: argparse.Namespace, option: dict) -> argparse.Namespace:
|
|
143
|
+
if option is None:
|
|
144
|
+
return dest
|
|
145
|
+
|
|
146
|
+
for key, value in option.items():
|
|
147
|
+
if hasattr(dest, key) and getattr(dest, key) is None:
|
|
148
|
+
setattr(dest, key, value)
|
|
149
|
+
return dest
|
|
150
|
+
|
|
151
|
+
def combine_args(dest: argparse.Namespace) -> argparse.Namespace:
|
|
152
|
+
assert isinstance(dest, argparse.Namespace), "dest must be an argparse.Namespace instance"
|
|
153
|
+
option = Configurer().config.option
|
|
154
|
+
return __combine_args(dest, option)
|
kmdr/core/registry.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from typing import Optional, Callable, TypeVar, Generic
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from argparse import Namespace
|
|
4
|
+
|
|
5
|
+
from .defaults import combine_args
|
|
6
|
+
|
|
7
|
+
T = TypeVar('T')
|
|
8
|
+
|
|
9
|
+
class Registry(Generic[T]):
|
|
10
|
+
|
|
11
|
+
def __init__(self, name: str, combine_args: bool = False):
|
|
12
|
+
self._name = name
|
|
13
|
+
self._modules: list['Predication'] = list()
|
|
14
|
+
self._combine_args = combine_args
|
|
15
|
+
|
|
16
|
+
def register(self,
|
|
17
|
+
hasattrs: frozenset[str] = frozenset(),
|
|
18
|
+
containattrs: frozenset[str] = frozenset(),
|
|
19
|
+
hasvalues: dict[str, object] = dict(),
|
|
20
|
+
predicate: Optional[Callable[[Namespace], bool]] = None,
|
|
21
|
+
order: int = 0,
|
|
22
|
+
name: Optional[str] = None
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
注册一个模块到注册表中。
|
|
26
|
+
总体的匹配逻辑: `{predicate} or {hasvalues} and ({hasattrs} or {containattrs})`
|
|
27
|
+
|
|
28
|
+
:param hasattrs: 模块处理的参数集合,必须全部匹配。如果未提供,则从类的 __init__ 方法中获取不可缺省的参数
|
|
29
|
+
:param containattrs: 模块处理的可选参数集合,只要满足其中一个即可。
|
|
30
|
+
:param hasvalues: 模块处理的属性值集合,必须全部满足。
|
|
31
|
+
:param predicate: 可以提供预定义以外的条件,只要满足该条件就视为匹配。
|
|
32
|
+
:param order: 模块的优先级,数字越小优先级越高。
|
|
33
|
+
:param name: 模块的名称,如果未提供,则使用类名。
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def wrapper(cls):
|
|
37
|
+
nonlocal hasattrs
|
|
38
|
+
nonlocal containattrs
|
|
39
|
+
nonlocal hasvalues
|
|
40
|
+
nonlocal name
|
|
41
|
+
nonlocal predicate
|
|
42
|
+
|
|
43
|
+
if name is None:
|
|
44
|
+
name = cls.__name__
|
|
45
|
+
|
|
46
|
+
if not hasattrs or len(hasattrs) == 0:
|
|
47
|
+
# 如果没有指定属性,则从类的 __init__ 方法中获取参数
|
|
48
|
+
if hasattr(cls, '__init__'):
|
|
49
|
+
init_signature = cls.__init__.__code__.co_varnames[1:cls.__init__.__code__.co_argcount]
|
|
50
|
+
init_defaults = cls.__init__.__defaults__ or ()
|
|
51
|
+
default_count = len(init_defaults)
|
|
52
|
+
required_params = init_signature[:len(init_signature) - default_count]
|
|
53
|
+
hasattrs = frozenset(required_params)
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError(f'{self._name} requires at least one attribute to be specified for {name}')
|
|
56
|
+
|
|
57
|
+
predication = Predication(
|
|
58
|
+
cls=cls,
|
|
59
|
+
hasattrs=frozenset(hasattrs),
|
|
60
|
+
containattrs=frozenset(containattrs),
|
|
61
|
+
hasvalues=hasvalues,
|
|
62
|
+
predicate=predicate,
|
|
63
|
+
order=order
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if predication in self._modules:
|
|
67
|
+
raise ValueError(f'{self._name} already has a module for {predication}')
|
|
68
|
+
|
|
69
|
+
self._modules.append(predication)
|
|
70
|
+
self._modules.sort()
|
|
71
|
+
|
|
72
|
+
return cls
|
|
73
|
+
|
|
74
|
+
return wrapper
|
|
75
|
+
|
|
76
|
+
def get(self, condition: Namespace) -> T:
|
|
77
|
+
if self._combine_args:
|
|
78
|
+
condition = combine_args(condition)
|
|
79
|
+
return self._get(condition)
|
|
80
|
+
|
|
81
|
+
def _get(self, condition: Namespace) -> T:
|
|
82
|
+
if not self._modules or len(self._modules) == 0:
|
|
83
|
+
raise ValueError(f'{self._name} has no registered modules')
|
|
84
|
+
|
|
85
|
+
if len(self._modules) == 1:
|
|
86
|
+
return self._modules[0].cls(**self._filter_nonone_args(condition))
|
|
87
|
+
|
|
88
|
+
for module in self._modules:
|
|
89
|
+
if (module.predicate is not None and module.predicate(condition)) or \
|
|
90
|
+
all(hasattr(condition, attr) and getattr(condition, attr) == value for attr, value in module.hasvalues.items()) and \
|
|
91
|
+
(all(hasattr(condition, attr) and getattr(condition, attr) is not None for attr in module.hasattrs) \
|
|
92
|
+
or any(hasattr(condition, attr) for attr in module.containattrs)):
|
|
93
|
+
|
|
94
|
+
# 手动配置的 predicate 优先级最高,只要满足 predicate 条件就返回
|
|
95
|
+
# hasvalues 配置的属性值必须完全匹配
|
|
96
|
+
# hasattrs 和 containattrs 二者只需要满足一个
|
|
97
|
+
|
|
98
|
+
return module.cls(**self._filter_nonone_args(condition))
|
|
99
|
+
|
|
100
|
+
raise ValueError(f'{self._name} does not have a module for {condition}')
|
|
101
|
+
|
|
102
|
+
def _filter_nonone_args(self, condition: Namespace) -> dict[str, object]:
|
|
103
|
+
return {k: v for k, v in vars(condition).items() if v is not None}
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Predication:
|
|
107
|
+
cls: type
|
|
108
|
+
|
|
109
|
+
hasattrs: frozenset[str] = frozenset({})
|
|
110
|
+
containattrs: frozenset[str] = frozenset({})
|
|
111
|
+
hasvalues: dict[str, object] = field(default_factory=dict)
|
|
112
|
+
predicate: Optional[Callable[[Namespace], bool]] = None
|
|
113
|
+
|
|
114
|
+
order: int = 0
|
|
115
|
+
|
|
116
|
+
def __lt__(self, other: 'Predication') -> bool:
|
|
117
|
+
if self.order == other.order:
|
|
118
|
+
# 如果 order 相同,则比较 hasattrs 的长度
|
|
119
|
+
# 通常情况下,hasattrs 的长度越长,优先级越高
|
|
120
|
+
return len(self.hasattrs) > len(other.hasattrs)
|
|
121
|
+
return self.order < other.order
|
|
122
|
+
|
|
123
|
+
def __hash__(self) -> int:
|
|
124
|
+
return hash((self.cls, self.hasattrs, frozenset(self.hasvalues.items()), self.predicate, self.order))
|
|
125
|
+
|
|
126
|
+
def __eq__(self, other: 'Predication') -> bool:
|
|
127
|
+
return (self.cls, self.hasattrs, frozenset(self.hasvalues.items()), self.predicate, self.order) == \
|
|
128
|
+
(other.cls, other.hasattrs, frozenset(other.hasvalues.items()), other.predicate, other.order)
|
kmdr/core/structure.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
class VolumeType(Enum):
|
|
6
|
+
VOLUME = "單行本"
|
|
7
|
+
EXTRA = "番外篇"
|
|
8
|
+
SERIALIZED = "連載話"
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class VolInfo:
|
|
12
|
+
"""
|
|
13
|
+
Kmoe 卷信息
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
|
|
18
|
+
extra_info: str
|
|
19
|
+
"""
|
|
20
|
+
额外信息
|
|
21
|
+
- 0: 无
|
|
22
|
+
- 1: 最近一週更新
|
|
23
|
+
- 2: 90天內曾下載/推送
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
is_last: bool
|
|
27
|
+
|
|
28
|
+
vol_type: VolumeType
|
|
29
|
+
|
|
30
|
+
index: int
|
|
31
|
+
"""
|
|
32
|
+
从1开始的卷索引
|
|
33
|
+
如果卷类型为「連載話」,则表示起始话数
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
|
|
38
|
+
pages: int
|
|
39
|
+
|
|
40
|
+
size: float
|
|
41
|
+
"""
|
|
42
|
+
卷大小,单位为MB
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class BookInfo:
|
|
48
|
+
id: str
|
|
49
|
+
name: str
|
|
50
|
+
url: str
|
|
51
|
+
author: str
|
|
52
|
+
status: str
|
|
53
|
+
last_update: str
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Config:
|
|
57
|
+
|
|
58
|
+
option: Optional[dict] = None
|
|
59
|
+
"""
|
|
60
|
+
用来存储下载相关的配置选项
|
|
61
|
+
- retry_times: 重试次数
|
|
62
|
+
- dest: 下载文件保存路径
|
|
63
|
+
- callback: 下载完成后的回调函数
|
|
64
|
+
- proxy: 下载时使用的代理
|
|
65
|
+
- num_workers: 下载时使用的线程数
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
cookie: Optional[dict[str, str]] = None
|
kmdr/core/utils.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Optional, Callable
|
|
3
|
+
|
|
4
|
+
from requests import Session
|
|
5
|
+
import threading
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from .structure import BookInfo, VolInfo
|
|
9
|
+
|
|
10
|
+
_session_instance: Optional[Session] = None
|
|
11
|
+
|
|
12
|
+
_session_lock = threading.Lock()
|
|
13
|
+
|
|
14
|
+
HEADERS = {
|
|
15
|
+
'User-Agent': 'kmdr/1.0 (https://github.com/chrisis58/kmoe-manga-downloader)'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def get_singleton_session() -> Session:
|
|
19
|
+
global _session_instance
|
|
20
|
+
|
|
21
|
+
if _session_instance is None:
|
|
22
|
+
with _session_lock:
|
|
23
|
+
if _session_instance is None:
|
|
24
|
+
_session_instance = Session()
|
|
25
|
+
_session_instance.headers.update(HEADERS)
|
|
26
|
+
|
|
27
|
+
return _session_instance
|
|
28
|
+
|
|
29
|
+
def clear_session_context():
|
|
30
|
+
session = get_singleton_session()
|
|
31
|
+
session.proxies.clear()
|
|
32
|
+
session.headers.clear()
|
|
33
|
+
session.cookies.clear()
|
|
34
|
+
session.headers.update(HEADERS)
|
|
35
|
+
|
|
36
|
+
def singleton(cls):
|
|
37
|
+
"""
|
|
38
|
+
**非线程安全**的单例装饰器
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
instances = {}
|
|
42
|
+
|
|
43
|
+
def get_instance(*args, **kwargs):
|
|
44
|
+
if cls not in instances:
|
|
45
|
+
instances[cls] = cls(*args, **kwargs)
|
|
46
|
+
return instances[cls]
|
|
47
|
+
|
|
48
|
+
return get_instance
|
|
49
|
+
|
|
50
|
+
def construct_callback(callback: Optional[str]) -> Optional[Callable]:
|
|
51
|
+
if callback is None or not isinstance(callback, str) or not callback.strip():
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def _callback(book: BookInfo, volume: VolInfo) -> int:
|
|
55
|
+
nonlocal callback
|
|
56
|
+
|
|
57
|
+
assert callback, "Callback script cannot be empty"
|
|
58
|
+
formatted_callback = callback.strip().format(b=book, v=volume)
|
|
59
|
+
|
|
60
|
+
return subprocess.run(formatted_callback, shell=True, check=True).returncode
|
|
61
|
+
|
|
62
|
+
return _callback
|
|
63
|
+
|
|
64
|
+
def no_proxy(func):
|
|
65
|
+
@functools.wraps(func)
|
|
66
|
+
def wrapper(*args, **kwargs):
|
|
67
|
+
session = get_singleton_session()
|
|
68
|
+
|
|
69
|
+
cached_proxies = session.proxies.copy()
|
|
70
|
+
session.proxies.clear()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
return func(*args, **kwargs)
|
|
74
|
+
finally:
|
|
75
|
+
session.proxies = cached_proxies
|
|
76
|
+
|
|
77
|
+
return wrapper
|
kmdr/main.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
from argparse import Namespace
|
|
3
|
+
|
|
4
|
+
from kmdr.core import *
|
|
5
|
+
from kmdr.module import *
|
|
6
|
+
|
|
7
|
+
def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPLEMENTED!')) -> None:
|
|
8
|
+
|
|
9
|
+
if args.command == 'login':
|
|
10
|
+
if not AUTHENTICATOR.get(args).authenticate():
|
|
11
|
+
raise RuntimeError("Authentication failed. Please check your credentials.")
|
|
12
|
+
|
|
13
|
+
elif args.command == 'status':
|
|
14
|
+
if not AUTHENTICATOR.get(args).authenticate():
|
|
15
|
+
raise RuntimeError("Authentication failed. Please check your credentials.")
|
|
16
|
+
|
|
17
|
+
elif args.command == 'download':
|
|
18
|
+
if not AUTHENTICATOR.get(args).authenticate():
|
|
19
|
+
raise RuntimeError("Authentication failed. Please check your credentials.")
|
|
20
|
+
|
|
21
|
+
book, volumes = LISTERS.get(args).list()
|
|
22
|
+
|
|
23
|
+
volumes = PICKERS.get(args).pick(volumes)
|
|
24
|
+
|
|
25
|
+
DOWNLOADER.get(args).download(book, volumes)
|
|
26
|
+
|
|
27
|
+
elif args.command == 'config':
|
|
28
|
+
CONFIGURER.get(args).operate()
|
|
29
|
+
|
|
30
|
+
else:
|
|
31
|
+
fallback()
|
|
32
|
+
|
|
33
|
+
def entry_point():
|
|
34
|
+
parser = argument_parser()
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
main(args, lambda: parser.print_help())
|
|
37
|
+
|
|
38
|
+
if __name__ == '__main__':
|
|
39
|
+
entry_point()
|
kmdr/module/__init__.py
ADDED
|
@@ -0,0 +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
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from kmdr.core import Authenticator, AUTHENTICATOR
|
|
4
|
+
|
|
5
|
+
from .utils import check_status
|
|
6
|
+
|
|
7
|
+
@AUTHENTICATOR.register()
|
|
8
|
+
class CookieAuthenticator(Authenticator):
|
|
9
|
+
def __init__(self, proxy: Optional[str] = None, *args, **kwargs):
|
|
10
|
+
super().__init__(proxy, *args, **kwargs)
|
|
11
|
+
|
|
12
|
+
if 'command' in kwargs and kwargs['command'] == 'status':
|
|
13
|
+
self._show_quota = True
|
|
14
|
+
else:
|
|
15
|
+
self._show_quota = False
|
|
16
|
+
|
|
17
|
+
def _authenticate(self) -> bool:
|
|
18
|
+
cookie = self._configurer.cookie
|
|
19
|
+
|
|
20
|
+
if not cookie:
|
|
21
|
+
print("No cookie found. Please login first.")
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
self._session.cookies.update(cookie)
|
|
25
|
+
return check_status(self._session, show_quota=self._show_quota)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from kmdr.core import Authenticator, AUTHENTICATOR
|
|
5
|
+
|
|
6
|
+
from .utils import check_status
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@AUTHENTICATOR.register(
|
|
10
|
+
hasvalues = {'command': 'login'}
|
|
11
|
+
)
|
|
12
|
+
class LoginAuthenticator(Authenticator):
|
|
13
|
+
def __init__(self, username: str, proxy: Optional[str] = None, password: Optional[str] = None, show_quota = True, *args, **kwargs):
|
|
14
|
+
super().__init__(proxy, *args, **kwargs)
|
|
15
|
+
self._username = username
|
|
16
|
+
self._show_quota = show_quota
|
|
17
|
+
|
|
18
|
+
if password is None:
|
|
19
|
+
password = input("please input your password: \n")
|
|
20
|
+
|
|
21
|
+
self._password = password
|
|
22
|
+
|
|
23
|
+
def _authenticate(self) -> bool:
|
|
24
|
+
|
|
25
|
+
response = self._session.post(
|
|
26
|
+
url = 'https://kox.moe/login_do.php',
|
|
27
|
+
data = {
|
|
28
|
+
'email': self._username,
|
|
29
|
+
'passwd': self._password,
|
|
30
|
+
'keepalive': 'on'
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
|
|
35
|
+
match = re.search('"\w+"', response.text)
|
|
36
|
+
if not match:
|
|
37
|
+
raise RuntimeError("Failed to extract authentication code from response.")
|
|
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)
|
|
49
|
+
|
|
50
|
+
if check_status(self._session, show_quota=self._show_quota):
|
|
51
|
+
self._configurer.cookie = self._session.cookies.get_dict()
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
return False
|