kmoe-manga-downloader 1.1.0__tar.gz → 1.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {kmoe_manga_downloader-1.1.0/src/kmoe_manga_downloader.egg-info → kmoe_manga_downloader-1.1.2}/PKG-INFO +5 -1
  2. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/README.md +4 -0
  3. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/pyproject.toml +1 -1
  4. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/core/bases.py +18 -8
  5. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/main.py +7 -3
  6. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/downloader/DirectDownloader.py +2 -2
  7. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/downloader/ReferViaDownloader.py +2 -2
  8. kmoe_manga_downloader-1.1.2/src/kmdr/module/downloader/utils.py +157 -0
  9. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2/src/kmoe_manga_downloader.egg-info}/PKG-INFO +5 -1
  10. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmoe_manga_downloader.egg-info/SOURCES.txt +0 -1
  11. kmoe_manga_downloader-1.1.0/src/kmdr/module/downloader/utils.py +0 -127
  12. kmoe_manga_downloader-1.1.0/tests/test_kmdr_login.py +0 -29
  13. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/LICENSE +0 -0
  14. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/setup.cfg +0 -0
  15. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/__init__.py +0 -0
  16. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/core/__init__.py +0 -0
  17. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/core/defaults.py +0 -0
  18. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/core/error.py +0 -0
  19. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/core/registry.py +0 -0
  20. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/core/structure.py +0 -0
  21. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/core/utils.py +0 -0
  22. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/__init__.py +0 -0
  23. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/authenticator/CookieAuthenticator.py +0 -0
  24. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/authenticator/LoginAuthenticator.py +0 -0
  25. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/authenticator/utils.py +0 -0
  26. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/configurer/ConfigClearer.py +0 -0
  27. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/configurer/ConfigUnsetter.py +0 -0
  28. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/configurer/OptionLister.py +0 -0
  29. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/configurer/OptionSetter.py +0 -0
  30. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/configurer/option_validate.py +0 -0
  31. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/lister/BookUrlLister.py +0 -0
  32. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/lister/FollowedBookLister.py +0 -0
  33. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/lister/utils.py +0 -0
  34. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/picker/ArgsFilterPicker.py +0 -0
  35. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/picker/DefaultVolPicker.py +0 -0
  36. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmdr/module/picker/utils.py +0 -0
  37. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmoe_manga_downloader.egg-info/dependency_links.txt +0 -0
  38. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmoe_manga_downloader.egg-info/entry_points.txt +0 -0
  39. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmoe_manga_downloader.egg-info/requires.txt +0 -0
  40. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/src/kmoe_manga_downloader.egg-info/top_level.txt +0 -0
  41. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/tests/test_cache_by_kwargs.py +0 -0
  42. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/tests/test_kmdr_config_option.py +0 -0
  43. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/tests/test_kmdr_download.py +0 -0
  44. {kmoe_manga_downloader-1.1.0 → kmoe_manga_downloader-1.1.2}/tests/test_utils_resolve_volme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -47,6 +47,10 @@ Dynamic: license-file
47
47
 
48
48
  `kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定书籍及其卷,并支持回调脚本执行。
49
49
 
50
+ <p align="center">
51
+ <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
52
+ </p>
53
+
50
54
  ## ✨功能特性
51
55
 
52
56
  - **凭证管理**: 命令行登录并持久化会话
@@ -4,6 +4,10 @@
4
4
 
5
5
  `kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定书籍及其卷,并支持回调脚本执行。
6
6
 
7
+ <p align="center">
8
+ <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
9
+ </p>
10
+
7
11
  ## ✨功能特性
8
12
 
9
13
  - **凭证管理**: 命令行登录并持久化会话
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kmoe-manga-downloader"
3
- version = "1.1.0"
3
+ version = "1.1.2"
4
4
  authors = [
5
5
  { name="Chris Zheng", email="chrisis58@outlook.com" },
6
6
  ]
@@ -107,16 +107,26 @@ class Downloader(SessionContext, UserProfileContext):
107
107
  def _download(self, book: BookInfo, volume: VolInfo, retry: int): ...
108
108
 
109
109
  def _download_with_multiple_workers(self, book: BookInfo, volumes: list[VolInfo], retry: int):
110
- from concurrent.futures import ThreadPoolExecutor
111
-
112
- max_workers = min(self._num_workers, len(volumes))
113
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
114
- futures = [
115
- executor.submit(self._download, book, volume, retry)
116
- for volume in volumes
117
- ]
110
+ from concurrent.futures import ThreadPoolExecutor, wait, FIRST_EXCEPTION
111
+
112
+ try:
113
+ max_workers = min(self._num_workers, len(volumes))
114
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
115
+ futures = [
116
+ executor.submit(self._download, book, volume, retry)
117
+ for volume in volumes
118
+ ]
119
+ wait(futures, return_when=FIRST_EXCEPTION)
118
120
  for future in futures:
119
121
  future.result()
122
+ except KeyboardInterrupt:
123
+ print("\n操作已取消(KeyboardInterrupt)")
124
+ try:
125
+ executor.shutdown(wait=False, cancel_futures=True)
126
+ except NameError:
127
+ pass
128
+ finally:
129
+ exit(130)
120
130
 
121
131
  AUTHENTICATOR = Registry[Authenticator]('Authenticator')
122
132
  LISTERS = Registry[Lister]('Lister')
@@ -28,9 +28,13 @@ def main(args: Namespace, fallback: Callable[[], None] = lambda: print('NOT IMPL
28
28
  fallback()
29
29
 
30
30
  def entry_point():
31
- parser = argument_parser()
32
- args = parser.parse_args()
33
- main(args, lambda: parser.print_help())
31
+ try:
32
+ parser = argument_parser()
33
+ args = parser.parse_args()
34
+ main(args, lambda: parser.print_help())
35
+ except KeyboardInterrupt:
36
+ print("\n操作已取消(KeyboardInterrupt)")
37
+ exit(130)
34
38
 
35
39
  if __name__ == '__main__':
36
40
  entry_point()
@@ -17,9 +17,9 @@ 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
  )
@@ -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(book_id=book.id, volume_id=volume.id),
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"
@@ -0,0 +1,157 @@
1
+ from typing import Callable, Optional, Union
2
+ import os
3
+ import time
4
+ import threading
5
+ from functools import wraps
6
+
7
+ from requests import Session, HTTPError
8
+ from requests.exceptions import ChunkedEncodingError
9
+ from tqdm import tqdm
10
+ import re
11
+
12
+ BLOCK_SIZE_REDUCTION_FACTOR = 0.75
13
+ MIN_BLOCK_SIZE = 2048
14
+
15
+ def download_file(
16
+ session: Session,
17
+ url: Union[str, Callable[[], str]],
18
+ dest_path: str,
19
+ filename: str,
20
+ retry_times: int = 3,
21
+ headers: Optional[dict] = None,
22
+ callback: Optional[Callable] = None,
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
+ """
35
+ if headers is None:
36
+ headers = {}
37
+ filename_downloading = f'{filename}.downloading'
38
+ file_path = os.path.join(dest_path, filename)
39
+ tmp_file_path = os.path.join(dest_path, filename_downloading)
40
+
41
+ if not os.path.exists(dest_path):
42
+ os.makedirs(dest_path, exist_ok=True)
43
+
44
+ if os.path.exists(file_path):
45
+ tqdm.write(f"{filename} 已经存在")
46
+ return
47
+
48
+ block_size = 8192
49
+
50
+ attempts_left = retry_times + 1
51
+ progress_bar = tqdm(
52
+ total=0, unit='B', unit_scale=True,
53
+ desc=f'{filename} (连接中...)',
54
+ leave=False,
55
+ dynamic_ncols=True
56
+ )
57
+
58
+ try:
59
+ while attempts_left > 0:
60
+ attempts_left -= 1
61
+
62
+ resume_from = os.path.getsize(tmp_file_path) if os.path.exists(tmp_file_path) else 0
63
+
64
+ if resume_from:
65
+ headers['Range'] = f'bytes={resume_from}-'
66
+
67
+ try:
68
+ current_url = url() if callable(url) else url
69
+ with session.get(url=current_url, stream=True, headers=headers) as r:
70
+ r.raise_for_status()
71
+
72
+ total_size_in_bytes = int(r.headers.get('content-length', 0)) + resume_from
73
+
74
+ progress_bar.set_description(f'{filename}')
75
+ progress_bar.total = total_size_in_bytes
76
+ progress_bar.n = resume_from
77
+ progress_bar.refresh()
78
+
79
+ with open(tmp_file_path, 'ab') as f:
80
+ for chunk in r.iter_content(chunk_size=block_size):
81
+ if chunk:
82
+ f.write(chunk)
83
+ progress_bar.update(len(chunk))
84
+
85
+ if os.path.getsize(tmp_file_path) >= total_size_in_bytes:
86
+ os.rename(tmp_file_path, file_path)
87
+ if callback:
88
+ callback()
89
+ return
90
+
91
+ except Exception as e:
92
+ if attempts_left > 0:
93
+ progress_bar.set_description(f'{filename} (重试中...)')
94
+ if isinstance(e, ChunkedEncodingError):
95
+ new_block_size = max(int(block_size * BLOCK_SIZE_REDUCTION_FACTOR), MIN_BLOCK_SIZE)
96
+ if new_block_size < block_size:
97
+ block_size = new_block_size
98
+
99
+ # 避免限流
100
+ time.sleep(3)
101
+ else:
102
+ raise e
103
+ finally:
104
+ if progress_bar.total and progress_bar.n >= progress_bar.total:
105
+ tqdm.write(f"{filename} 下载完成")
106
+ elif progress_bar.total is not None:
107
+ tqdm.write(f"{filename} 下载失败")
108
+ progress_bar.close()
109
+
110
+
111
+ def safe_filename(name: str) -> str:
112
+ """
113
+ 替换非法文件名字符为下划线
114
+ """
115
+ return re.sub(r'[\\/:*?"<>|]', '_', name)
116
+
117
+
118
+ function_cache = {}
119
+
120
+ def cached_by_kwargs(func):
121
+ """
122
+ 根据关键字参数缓存函数结果的装饰器。
123
+
124
+ Example:
125
+ >>> @kwargs_cached
126
+ >>> def add(a, b, c):
127
+ >>> return a + b + c
128
+ >>> result1 = add(1, 2, c=3) # Calls the function
129
+ >>> result2 = add(3, 2, c=3) # Uses cached result
130
+ >>> assert result1 == result2 # Both results are the same
131
+ """
132
+
133
+ global function_cache
134
+ if func not in function_cache:
135
+ function_cache[func] = {}
136
+
137
+ @wraps(func)
138
+ def wrapper(*args, **kwargs):
139
+ if not kwargs:
140
+ return func(*args, **kwargs)
141
+
142
+ key = frozenset(kwargs.items())
143
+
144
+ if key not in function_cache[func]:
145
+ function_cache[func][key] = func(*args, **kwargs)
146
+ return function_cache[func][key]
147
+
148
+ return wrapper
149
+
150
+ def clear_cache(func):
151
+ assert hasattr(func, "__wrapped__"), "Function is not wrapped"
152
+ global function_cache
153
+
154
+ wrapped = func.__wrapped__
155
+
156
+ if wrapped in function_cache:
157
+ function_cache[wrapped] = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: A CLI-downloader for site @kox.moe.
5
5
  Author-email: Chris Zheng <chrisis58@outlook.com>
6
6
  License: MIT License
@@ -47,6 +47,10 @@ Dynamic: license-file
47
47
 
48
48
  `kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定书籍及其卷,并支持回调脚本执行。
49
49
 
50
+ <p align="center">
51
+ <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
52
+ </p>
53
+
50
54
  ## ✨功能特性
51
55
 
52
56
  - **凭证管理**: 命令行登录并持久化会话
@@ -37,5 +37,4 @@ src/kmoe_manga_downloader.egg-info/top_level.txt
37
37
  tests/test_cache_by_kwargs.py
38
38
  tests/test_kmdr_config_option.py
39
39
  tests/test_kmdr_download.py
40
- tests/test_kmdr_login.py
41
40
  tests/test_utils_resolve_volme.py
@@ -1,127 +0,0 @@
1
- from typing import Callable, Optional
2
- import os
3
- import time
4
- from functools import wraps
5
-
6
- from requests import Session, HTTPError
7
- from requests.exceptions import ChunkedEncodingError
8
- from tqdm import tqdm
9
- import re
10
-
11
- BLOCK_SIZE_REDUCTION_FACTOR = 0.75
12
- MIN_BLOCK_SIZE = 2048
13
-
14
- def download_file(
15
- session: Session,
16
- url: str,
17
- dest_path: str,
18
- filename: str,
19
- retry_times: int = 0,
20
- headers: Optional[dict] = None,
21
- callback: Optional[Callable] = None,
22
- block_size: int = 8192
23
- ):
24
- if headers is None:
25
- headers = {}
26
- filename_downloading = f'{filename}.downloading'
27
-
28
- file_path = f'{dest_path}/{filename}'
29
- tmp_file_path = f'{dest_path}/{filename_downloading}'
30
-
31
- if not os.path.exists(dest_path):
32
- os.makedirs(dest_path, exist_ok=True)
33
-
34
- if os.path.exists(file_path):
35
- tqdm.write(f"{filename} already exists.")
36
- return
37
-
38
- resume_from = 0
39
- total_size_in_bytes = 0
40
-
41
- if os.path.exists(tmp_file_path):
42
- resume_from = os.path.getsize(tmp_file_path)
43
-
44
- if resume_from:
45
- headers['Range'] = f'bytes={resume_from}-'
46
-
47
- try:
48
- with session.get(url = url, stream=True, headers=headers) as r:
49
- r.raise_for_status()
50
-
51
- total_size_in_bytes = int(r.headers.get('content-length', 0)) + resume_from
52
-
53
- with open(tmp_file_path, 'ab') as f:
54
- with tqdm(total=total_size_in_bytes, unit='B', unit_scale=True, desc=f'{filename}', initial=resume_from) as progress_bar:
55
- for chunk in r.iter_content(chunk_size=block_size):
56
- if chunk:
57
- f.write(chunk)
58
- progress_bar.update(len(chunk))
59
-
60
- if (os.path.getsize(tmp_file_path) == total_size_in_bytes):
61
- os.rename(tmp_file_path, file_path)
62
-
63
- if callback:
64
- callback()
65
- except Exception as e:
66
- prefix = f"{type(e).__name__} occurred while downloading {filename}. "
67
-
68
- new_block_size = block_size
69
- if isinstance(e, ChunkedEncodingError):
70
- new_block_size = max(int(block_size * BLOCK_SIZE_REDUCTION_FACTOR), MIN_BLOCK_SIZE)
71
-
72
- if retry_times > 0:
73
- # 重试下载
74
- tqdm.write(f"{prefix} Retry after 3 seconds...")
75
- time.sleep(3) # 等待3秒后重试,避免触发限流
76
- download_file(session, url, dest_path, filename, retry_times - 1, headers, callback, new_block_size)
77
- else:
78
- tqdm.write(f"{prefix} Meet max retry times, download failed.")
79
- raise e
80
-
81
- def safe_filename(name: str) -> str:
82
- """
83
- 替换非法文件名字符为下划线
84
- """
85
- return re.sub(r'[\\/:*?"<>|]', '_', name)
86
-
87
-
88
- function_cache = {}
89
-
90
- def cached_by_kwargs(func):
91
- """
92
- 根据关键字参数缓存函数结果的装饰器。
93
-
94
- Example:
95
- >>> @kwargs_cached
96
- >>> def add(a, b, c):
97
- >>> return a + b + c
98
- >>> result1 = add(1, 2, c=3) # Calls the function
99
- >>> result2 = add(3, 2, c=3) # Uses cached result
100
- >>> assert result1 == result2 # Both results are the same
101
- """
102
-
103
- global function_cache
104
- if func not in function_cache:
105
- function_cache[func] = {}
106
-
107
- @wraps(func)
108
- def wrapper(*args, **kwargs):
109
- if not kwargs:
110
- return func(*args, **kwargs)
111
-
112
- key = frozenset(kwargs.items())
113
-
114
- if key not in function_cache[func]:
115
- function_cache[func][key] = func(*args, **kwargs)
116
- return function_cache[func][key]
117
-
118
- return wrapper
119
-
120
- def clear_cache(func):
121
- assert hasattr(func, "__wrapped__"), "Function is not wrapped"
122
- global function_cache
123
-
124
- wrapped = func.__wrapped__
125
-
126
- if wrapped in function_cache:
127
- function_cache[wrapped] = {}
@@ -1,29 +0,0 @@
1
- import os
2
-
3
- import unittest
4
- from argparse import Namespace
5
-
6
- from kmdr.core.utils import clear_session_context
7
- from kmdr.main import main as kmdr_main
8
-
9
- KMOE_USERNAME = os.environ.get('KMOE_USERNAME')
10
- KMOE_PASSWORD = os.environ.get('KMOE_PASSWORD')
11
-
12
- @unittest.skipUnless(KMOE_USERNAME and KMOE_PASSWORD, "KMOE_USERNAME and KMOE_PASSWORD must be set in environment variables")
13
- class TestKmdrLogin(unittest.TestCase):
14
-
15
- @classmethod
16
- def tearDownClass(cls):
17
- clear_session_context()
18
-
19
- def test_login_with_username_and_password(self):
20
- assert KMOE_USERNAME and KMOE_PASSWORD, "KMOE_USERNAME and KMOE_PASSWORD must be set in environment variables"
21
-
22
- kmdr_main(
23
- Namespace(
24
- command='login',
25
- username=KMOE_USERNAME,
26
- password=KMOE_PASSWORD,
27
- show_quota=False
28
- )
29
- )