kmoe-manga-downloader 1.1.1__py3-none-any.whl → 1.2.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/core/__init__.py +5 -3
- kmdr/core/bases.py +53 -81
- kmdr/core/context.py +28 -0
- kmdr/core/defaults.py +64 -28
- kmdr/core/error.py +1 -1
- kmdr/core/session.py +16 -0
- kmdr/core/utils.py +33 -40
- kmdr/main.py +30 -17
- kmdr/module/authenticator/CookieAuthenticator.py +8 -4
- kmdr/module/authenticator/LoginAuthenticator.py +25 -21
- kmdr/module/authenticator/utils.py +47 -43
- kmdr/module/configurer/ConfigClearer.py +7 -2
- kmdr/module/configurer/ConfigUnsetter.py +2 -2
- kmdr/module/configurer/OptionLister.py +17 -3
- kmdr/module/configurer/OptionSetter.py +2 -2
- kmdr/module/configurer/option_validate.py +14 -12
- kmdr/module/downloader/DirectDownloader.py +7 -5
- kmdr/module/downloader/ReferViaDownloader.py +27 -24
- kmdr/module/downloader/utils.py +274 -101
- kmdr/module/lister/BookUrlLister.py +4 -3
- kmdr/module/lister/FollowedBookLister.py +59 -22
- kmdr/module/lister/utils.py +39 -28
- kmdr/module/picker/ArgsFilterPicker.py +1 -1
- kmdr/module/picker/DefaultVolPicker.py +34 -5
- {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/METADATA +17 -11
- kmoe_manga_downloader-1.2.0.dist-info/RECORD +35 -0
- kmoe_manga_downloader-1.1.1.dist-info/RECORD +0 -33
- {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/WHEEL +0 -0
- {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/entry_points.txt +0 -0
- {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {kmoe_manga_downloader-1.1.1.dist-info → kmoe_manga_downloader-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from typing import Optional, Callable
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from aiohttp import ClientSession
|
|
4
|
+
from rich.console import Console
|
|
4
5
|
|
|
5
6
|
from kmdr.core.error import LoginError
|
|
7
|
+
from kmdr.core.utils import async_retry
|
|
6
8
|
|
|
7
9
|
PROFILE_URL = 'https://kox.moe/my.php'
|
|
8
10
|
LOGIN_URL = 'https://kox.moe/login.php'
|
|
@@ -13,52 +15,54 @@ VIP_ID = 'div_user_vip'
|
|
|
13
15
|
NOR_ID = 'div_user_nor'
|
|
14
16
|
LV1_ID = 'div_user_lv1'
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
@async_retry()
|
|
19
|
+
async def check_status(
|
|
20
|
+
session: ClientSession,
|
|
21
|
+
console: Console,
|
|
18
22
|
show_quota: bool = False,
|
|
19
23
|
is_vip_setter: Optional[Callable[[int], None]] = None,
|
|
20
|
-
level_setter: Optional[Callable[[int], None]] = None
|
|
24
|
+
level_setter: Optional[Callable[[int], None]] = None,
|
|
21
25
|
) -> bool:
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
async with session.get(url = PROFILE_URL) as response:
|
|
27
|
+
try:
|
|
28
|
+
response.raise_for_status()
|
|
29
|
+
except Exception as e:
|
|
30
|
+
console.print(f"Error: {type(e).__name__}: {e}")
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
if response.history and any(resp.status in (301, 302, 307) for resp in response.history) \
|
|
34
|
+
and str(response.url) == LOGIN_URL:
|
|
35
|
+
raise LoginError("凭证已失效,请重新登录。", ['kmdr config -c cookie', 'kmdr login -u <username>'])
|
|
36
|
+
|
|
37
|
+
if not is_vip_setter and not level_setter and not show_quota:
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
from bs4 import BeautifulSoup
|
|
41
|
+
|
|
42
|
+
# 如果后续有性能问题,可以先考虑使用 lxml 进行解析
|
|
43
|
+
soup = BeautifulSoup(await response.text(), 'html.parser')
|
|
44
|
+
|
|
45
|
+
script = soup.find('script', language="javascript")
|
|
46
|
+
|
|
47
|
+
if script:
|
|
48
|
+
var_define = extract_var_define(script.text[:100])
|
|
49
|
+
|
|
50
|
+
is_vip = int(var_define.get('is_vip', '0'))
|
|
51
|
+
user_level = int(var_define.get('user_level', '0'))
|
|
52
|
+
|
|
53
|
+
if is_vip_setter:
|
|
54
|
+
is_vip_setter(is_vip)
|
|
55
|
+
if level_setter:
|
|
56
|
+
level_setter(user_level)
|
|
57
|
+
|
|
58
|
+
if not show_quota:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
nickname = soup.find('div', id=NICKNAME_ID).text.strip().split(' ')[0]
|
|
62
|
+
quota = soup.find('div', id=__resolve_quota_id(is_vip, user_level)).text.strip()
|
|
63
|
+
|
|
64
|
+
console.print(f"\n当前登录为 [bold cyan]{nickname}[/bold cyan]\n\n{quota}")
|
|
35
65
|
return True
|
|
36
|
-
|
|
37
|
-
from bs4 import BeautifulSoup
|
|
38
|
-
|
|
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)
|
|
53
|
-
|
|
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}")
|
|
61
|
-
return True
|
|
62
66
|
|
|
63
67
|
def extract_var_define(script_text) -> dict[str, str]:
|
|
64
68
|
var_define = {}
|
|
@@ -7,5 +7,10 @@ class ConfigClearer(Configurer):
|
|
|
7
7
|
self._clear = clear
|
|
8
8
|
|
|
9
9
|
def operate(self) -> None:
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
try:
|
|
11
|
+
self._configurer.clear(self._clear)
|
|
12
|
+
except KeyError as e:
|
|
13
|
+
self._console.print(e.args[0])
|
|
14
|
+
exit(1)
|
|
15
|
+
|
|
16
|
+
self._console.print(f"Cleared configuration: {self._clear}")
|
|
@@ -10,9 +10,9 @@ class ConfigUnsetter(Configurer):
|
|
|
10
10
|
|
|
11
11
|
def operate(self) -> None:
|
|
12
12
|
if not self._unset:
|
|
13
|
-
print("
|
|
13
|
+
self._console.print("[yellow]请提供要取消设置的配置项。[/yellow]")
|
|
14
14
|
return
|
|
15
15
|
|
|
16
16
|
check_key(self._unset)
|
|
17
17
|
self._configurer.unset_option(self._unset)
|
|
18
|
-
print(f"
|
|
18
|
+
self._console.print(f"[green]取消配置项: {self._unset}[/green]")
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
from rich.table import Table
|
|
2
|
+
from rich.pretty import Pretty
|
|
3
|
+
|
|
1
4
|
from kmdr.core import CONFIGURER, Configurer
|
|
2
5
|
|
|
3
6
|
@CONFIGURER.register(
|
|
@@ -11,9 +14,20 @@ class OptionLister(Configurer):
|
|
|
11
14
|
|
|
12
15
|
def operate(self) -> None:
|
|
13
16
|
if self._configurer.option is None:
|
|
14
|
-
print("
|
|
17
|
+
self._console.print("[blue]当前没有任何配置项。[/blue]")
|
|
15
18
|
return
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
table = Table(title="[green]当前 Kmdr 配置项[/green]", show_header=False, header_style="blue")
|
|
21
|
+
|
|
22
|
+
table.add_column("配置项 (Key)", style="cyan", no_wrap=True, min_width=10)
|
|
23
|
+
table.add_column("值 (Value)", style="white", no_wrap=False, min_width=20)
|
|
24
|
+
|
|
18
25
|
for key, value in self._configurer.option.items():
|
|
19
|
-
|
|
26
|
+
value_to_display = value
|
|
27
|
+
if isinstance(value, (dict, list, set, tuple)):
|
|
28
|
+
value_to_display = Pretty(value)
|
|
29
|
+
|
|
30
|
+
table.add_row(key, value_to_display)
|
|
31
|
+
table.add_section()
|
|
32
|
+
|
|
33
|
+
self._console.print(table)
|
|
@@ -11,7 +11,7 @@ class OptionSetter(Configurer):
|
|
|
11
11
|
def operate(self) -> None:
|
|
12
12
|
for option in self._set:
|
|
13
13
|
if '=' not in option:
|
|
14
|
-
print(f"
|
|
14
|
+
self._console.print(f"[red]无效的选项格式: `{option}`。[/red] 应为 key=value 格式。")
|
|
15
15
|
continue
|
|
16
16
|
|
|
17
17
|
key, value = option.split('=', 1)
|
|
@@ -23,7 +23,7 @@ class OptionSetter(Configurer):
|
|
|
23
23
|
continue
|
|
24
24
|
|
|
25
25
|
self._configurer.set_option(key, validated_value)
|
|
26
|
-
print(f"
|
|
26
|
+
self._console.print(f"[green]已设置配置: {key} = {validated_value}[/green]")
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
|
|
@@ -2,6 +2,8 @@ from typing import Optional
|
|
|
2
2
|
from functools import wraps
|
|
3
3
|
import os
|
|
4
4
|
|
|
5
|
+
from kmdr.core.defaults import console_print as print
|
|
6
|
+
|
|
5
7
|
__OPTIONS_VALIDATOR = {}
|
|
6
8
|
|
|
7
9
|
def validate(key: str, value: str) -> Optional[object]:
|
|
@@ -15,7 +17,7 @@ def validate(key: str, value: str) -> Optional[object]:
|
|
|
15
17
|
if key in __OPTIONS_VALIDATOR:
|
|
16
18
|
return __OPTIONS_VALIDATOR[key](value)
|
|
17
19
|
else:
|
|
18
|
-
print(f"
|
|
20
|
+
print(f"[red]不支持的配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
|
|
19
21
|
return None
|
|
20
22
|
|
|
21
23
|
def check_key(key: str, exit_if_invalid: bool = True) -> None:
|
|
@@ -27,7 +29,7 @@ def check_key(key: str, exit_if_invalid: bool = True) -> None:
|
|
|
27
29
|
:param exit_if_invalid: 如果键名无效,是否退出程序
|
|
28
30
|
"""
|
|
29
31
|
if key not in __OPTIONS_VALIDATOR:
|
|
30
|
-
print(f"
|
|
32
|
+
print(f"[red]未知配置项: {key}。可用配置项:{', '.join(__OPTIONS_VALIDATOR.keys())}[/red]")
|
|
31
33
|
if exit_if_invalid:
|
|
32
34
|
exit(1)
|
|
33
35
|
|
|
@@ -60,27 +62,27 @@ def validate_num_workers(value: str) -> Optional[int]:
|
|
|
60
62
|
try:
|
|
61
63
|
num_workers = int(value)
|
|
62
64
|
if num_workers <= 0:
|
|
63
|
-
raise ValueError("
|
|
65
|
+
raise ValueError("必须是正值。")
|
|
64
66
|
return num_workers
|
|
65
67
|
except ValueError as e:
|
|
66
|
-
print(f"
|
|
68
|
+
print(f"[red]无效的 num_workers 值: {value}。{str(e)}[/red]")
|
|
67
69
|
return None
|
|
68
70
|
|
|
69
71
|
@register_validator('dest')
|
|
70
72
|
def validate_dest(value: str) -> Optional[str]:
|
|
71
73
|
if not value:
|
|
72
|
-
print("
|
|
74
|
+
print("[red]目标目录不能为空。[/red]")
|
|
73
75
|
return None
|
|
74
76
|
if not os.path.exists(value) or not os.path.isdir(value):
|
|
75
|
-
print(f"
|
|
77
|
+
print(f"[red]目标目录不存在或不是目录: {value}[/red]")
|
|
76
78
|
return None
|
|
77
79
|
|
|
78
80
|
if not os.access(value, os.W_OK):
|
|
79
|
-
print(f"
|
|
81
|
+
print(f"[red]目标目录不可写: {value}[/red]")
|
|
80
82
|
return None
|
|
81
83
|
|
|
82
84
|
if not os.path.isabs(value):
|
|
83
|
-
print(f"
|
|
85
|
+
print(f"[yellow]目标目录最好是绝对路径: {value}[/yellow]")
|
|
84
86
|
|
|
85
87
|
return value
|
|
86
88
|
|
|
@@ -89,22 +91,22 @@ def validate_retry(value: str) -> Optional[int]:
|
|
|
89
91
|
try:
|
|
90
92
|
retry = int(value)
|
|
91
93
|
if retry < 0:
|
|
92
|
-
raise ValueError("
|
|
94
|
+
raise ValueError("必须是正值。")
|
|
93
95
|
return retry
|
|
94
96
|
except ValueError as e:
|
|
95
|
-
print(f"
|
|
97
|
+
print(f"[red]无效的 retry 值: {value}。{str(e)}[/red]")
|
|
96
98
|
return None
|
|
97
99
|
|
|
98
100
|
@register_validator('callback')
|
|
99
101
|
def validate_callback(value: str) -> Optional[str]:
|
|
100
102
|
if not value:
|
|
101
|
-
print("
|
|
103
|
+
print("[red]回调不能为空。[/red]")
|
|
102
104
|
return None
|
|
103
105
|
return value
|
|
104
106
|
|
|
105
107
|
@register_validator('proxy')
|
|
106
108
|
def validate_proxy(value: str) -> Optional[str]:
|
|
107
109
|
if not value:
|
|
108
|
-
print("
|
|
110
|
+
print("[red]代理不能为空。[/red]")
|
|
109
111
|
return None
|
|
110
112
|
return value
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from kmdr.core import Downloader, BookInfo, VolInfo, DOWNLOADER
|
|
2
2
|
|
|
3
|
-
from .utils import download_file, safe_filename
|
|
3
|
+
from .utils import download_file, safe_filename, download_file_multipart
|
|
4
4
|
|
|
5
5
|
@DOWNLOADER.register(
|
|
6
6
|
hasvalues={
|
|
@@ -8,19 +8,21 @@ from .utils import download_file, safe_filename
|
|
|
8
8
|
}
|
|
9
9
|
)
|
|
10
10
|
class DirectDownloader(Downloader):
|
|
11
|
-
def __init__(self, dest='.', callback=None, retry=3, num_workers=
|
|
11
|
+
def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
|
|
12
12
|
super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
|
|
13
13
|
|
|
14
|
-
def _download(self, book: BookInfo, volume: VolInfo
|
|
14
|
+
async def _download(self, book: BookInfo, volume: VolInfo):
|
|
15
15
|
sub_dir = safe_filename(book.name)
|
|
16
16
|
download_path = f'{self._dest}/{sub_dir}'
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
await download_file_multipart(
|
|
19
19
|
self._session,
|
|
20
|
+
self._semaphore,
|
|
21
|
+
self._progress,
|
|
20
22
|
lambda: self.construct_download_url(book, volume),
|
|
21
23
|
download_path,
|
|
22
24
|
safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
|
|
23
|
-
|
|
25
|
+
self._retry,
|
|
24
26
|
callback=lambda: self._callback(book, volume) if self._callback else None
|
|
25
27
|
)
|
|
26
28
|
|
|
@@ -1,44 +1,47 @@
|
|
|
1
|
+
from functools import partial
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from async_lru import alru_cache
|
|
5
|
+
|
|
1
6
|
from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
|
|
2
7
|
|
|
3
|
-
from .utils import download_file, safe_filename,
|
|
8
|
+
from .utils import download_file, safe_filename, download_file_multipart
|
|
4
9
|
|
|
5
|
-
try:
|
|
6
|
-
import cloudscraper
|
|
7
|
-
except ImportError:
|
|
8
|
-
cloudscraper = None
|
|
9
10
|
|
|
10
11
|
@DOWNLOADER.register(order=10)
|
|
11
12
|
class ReferViaDownloader(Downloader):
|
|
12
|
-
def __init__(self, dest='.', callback=None, retry=3, num_workers=
|
|
13
|
+
def __init__(self, dest='.', callback=None, retry=3, num_workers=8, proxy=None, *args, **kwargs):
|
|
13
14
|
super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
|
|
14
15
|
|
|
15
|
-
if cloudscraper:
|
|
16
|
-
self._scraper = cloudscraper.create_scraper()
|
|
17
|
-
else:
|
|
18
|
-
self._scraper = None
|
|
19
16
|
|
|
20
|
-
def _download(self, book: BookInfo, volume: VolInfo
|
|
17
|
+
async def _download(self, book: BookInfo, volume: VolInfo):
|
|
21
18
|
sub_dir = safe_filename(book.name)
|
|
22
19
|
download_path = f'{self._dest}/{sub_dir}'
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
self._session
|
|
26
|
-
|
|
21
|
+
await download_file_multipart(
|
|
22
|
+
self._session,
|
|
23
|
+
self._semaphore,
|
|
24
|
+
self._progress,
|
|
25
|
+
partial(self.fetch_download_url, book.id, volume.id),
|
|
27
26
|
download_path,
|
|
28
27
|
safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
|
|
29
|
-
|
|
28
|
+
self._retry,
|
|
30
29
|
headers={
|
|
31
30
|
"X-Km-From": "kb_http_down"
|
|
32
31
|
},
|
|
33
32
|
callback=lambda: self._callback(book, volume) if self._callback else None
|
|
34
33
|
)
|
|
35
34
|
|
|
36
|
-
@
|
|
37
|
-
def fetch_download_url(self, book_id: str, volume_id: str) -> str:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
@alru_cache(maxsize=128)
|
|
36
|
+
async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
|
|
37
|
+
|
|
38
|
+
url = f"https://kox.moe/getdownurl.php?b={book_id}&v={volume_id}&mobi=2&vip={self._profile.is_vip}&json=1"
|
|
39
|
+
|
|
40
|
+
async with self._session.get(url) as response:
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
data = await response.text()
|
|
43
|
+
data = json.loads(data)
|
|
44
|
+
if data.get('code') != 200:
|
|
45
|
+
raise Exception(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}")
|
|
46
|
+
|
|
47
|
+
return data['url']
|