kmoe-manga-downloader 1.2.0__py3-none-any.whl → 1.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. kmdr/core/__init__.py +4 -4
  2. kmdr/core/bases.py +13 -5
  3. kmdr/core/constants.py +74 -0
  4. kmdr/core/context.py +23 -7
  5. kmdr/core/defaults.py +31 -5
  6. kmdr/core/error.py +24 -1
  7. kmdr/core/protocol.py +10 -0
  8. kmdr/core/session.py +107 -8
  9. kmdr/core/structure.py +2 -0
  10. kmdr/core/utils.py +61 -4
  11. kmdr/main.py +5 -3
  12. kmdr/module/__init__.py +5 -5
  13. kmdr/module/authenticator/CookieAuthenticator.py +3 -4
  14. kmdr/module/authenticator/LoginAuthenticator.py +9 -12
  15. kmdr/module/authenticator/__init__.py +2 -0
  16. kmdr/module/authenticator/utils.py +5 -5
  17. kmdr/module/configurer/BaseUrlUpdator.py +16 -0
  18. kmdr/module/configurer/OptionLister.py +16 -11
  19. kmdr/module/configurer/__init__.py +5 -0
  20. kmdr/module/downloader/DirectDownloader.py +12 -5
  21. kmdr/module/downloader/ReferViaDownloader.py +15 -9
  22. kmdr/module/downloader/__init__.py +2 -0
  23. kmdr/module/downloader/{utils.py → download_utils.py} +63 -26
  24. kmdr/module/downloader/misc.py +64 -0
  25. kmdr/module/lister/FollowedBookLister.py +3 -3
  26. kmdr/module/lister/__init__.py +2 -0
  27. kmdr/module/lister/utils.py +9 -2
  28. kmdr/module/picker/__init__.py +2 -0
  29. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/METADATA +42 -19
  30. kmoe_manga_downloader-1.2.2.dist-info/RECORD +44 -0
  31. kmoe_manga_downloader-1.2.0.dist-info/RECORD +0 -35
  32. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/WHEEL +0 -0
  33. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/entry_points.txt +0 -0
  34. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/licenses/LICENSE +0 -0
  35. {kmoe_manga_downloader-1.2.0.dist-info → kmoe_manga_downloader-1.2.2.dist-info}/top_level.txt +0 -0
@@ -3,11 +3,11 @@ from typing import Optional, Callable
3
3
  from aiohttp import ClientSession
4
4
  from rich.console import Console
5
5
 
6
+ from yarl import URL
7
+
6
8
  from kmdr.core.error import LoginError
7
9
  from kmdr.core.utils import async_retry
8
-
9
- PROFILE_URL = 'https://kox.moe/my.php'
10
- LOGIN_URL = 'https://kox.moe/login.php'
10
+ from kmdr.core.constants import API_ROUTE
11
11
 
12
12
  NICKNAME_ID = 'div_nickname_display'
13
13
 
@@ -23,7 +23,7 @@ async def check_status(
23
23
  is_vip_setter: Optional[Callable[[int], None]] = None,
24
24
  level_setter: Optional[Callable[[int], None]] = None,
25
25
  ) -> bool:
26
- async with session.get(url = PROFILE_URL) as response:
26
+ async with session.get(url = API_ROUTE.PROFILE) as response:
27
27
  try:
28
28
  response.raise_for_status()
29
29
  except Exception as e:
@@ -31,7 +31,7 @@ async def check_status(
31
31
  return False
32
32
 
33
33
  if response.history and any(resp.status in (301, 302, 307) for resp in response.history) \
34
- and str(response.url) == LOGIN_URL:
34
+ and URL(response.url).path == API_ROUTE.LOGIN:
35
35
  raise LoginError("凭证已失效,请重新登录。", ['kmdr config -c cookie', 'kmdr login -u <username>'])
36
36
 
37
37
  if not is_vip_setter and not level_setter and not show_quota:
@@ -0,0 +1,16 @@
1
+ from kmdr.core import Configurer, CONFIGURER
2
+
3
+ @CONFIGURER.register()
4
+ class BaseUrlUpdator(Configurer):
5
+ def __init__(self, base_url: str, *args, **kwargs):
6
+ super().__init__(*args, **kwargs)
7
+ self._base_url = base_url
8
+
9
+ def operate(self) -> None:
10
+ try:
11
+ self._configurer.set_base_url(self._base_url)
12
+ except KeyError as e:
13
+ self._console.print(e.args[0])
14
+ exit(1)
15
+
16
+ self._console.print(f"已设置基础 URL: {self._base_url}")
@@ -13,21 +13,26 @@ class OptionLister(Configurer):
13
13
  super().__init__(*args, **kwargs)
14
14
 
15
15
  def operate(self) -> None:
16
- if self._configurer.option is None:
16
+ if self._configurer.option is None and self._configurer.base_url is None:
17
17
  self._console.print("[blue]当前没有任何配置项。[/blue]")
18
18
  return
19
19
 
20
20
  table = Table(title="[green]当前 Kmdr 配置项[/green]", show_header=False, header_style="blue")
21
21
 
22
+ table.add_column("配置类型 (Type)", style="magenta", no_wrap=True, min_width=10)
22
23
  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
-
25
- for key, value in self._configurer.option.items():
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
-
24
+ table.add_column("值 (Value)", no_wrap=False, min_width=20)
25
+
26
+ if self._configurer.option is not None:
27
+ for idx, (key, value) in enumerate(self._configurer.option.items()):
28
+ value_to_display = value
29
+ if isinstance(value, (dict, list, set, tuple)):
30
+ value_to_display = Pretty(value)
31
+
32
+ table.add_row('下载配置' if idx == 0 else '', key, value_to_display)
33
+ table.add_section()
34
+
35
+ if self._configurer.base_url is not None:
36
+ table.add_row('应用配置', '镜像地址', self._configurer.base_url or '未设置')
37
+
33
38
  self._console.print(table)
@@ -0,0 +1,5 @@
1
+ from .BaseUrlUpdator import BaseUrlUpdator
2
+ from .ConfigClearer import ConfigClearer
3
+ from .OptionLister import OptionLister
4
+ from .OptionSetter import OptionSetter
5
+ from .ConfigUnsetter import ConfigUnsetter
@@ -1,6 +1,9 @@
1
+ from functools import partial
2
+
1
3
  from kmdr.core import Downloader, BookInfo, VolInfo, DOWNLOADER
4
+ from kmdr.core.constants import API_ROUTE
2
5
 
3
- from .utils import download_file, safe_filename, download_file_multipart
6
+ from .download_utils import download_file_multipart, readable_safe_filename
4
7
 
5
8
  @DOWNLOADER.register(
6
9
  hasvalues={
@@ -12,19 +15,23 @@ class DirectDownloader(Downloader):
12
15
  super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
13
16
 
14
17
  async def _download(self, book: BookInfo, volume: VolInfo):
15
- sub_dir = safe_filename(book.name)
18
+ sub_dir = readable_safe_filename(book.name)
16
19
  download_path = f'{self._dest}/{sub_dir}'
17
20
 
18
21
  await download_file_multipart(
19
22
  self._session,
20
23
  self._semaphore,
21
24
  self._progress,
22
- lambda: self.construct_download_url(book, volume),
25
+ partial(self.construct_download_url, book, volume),
23
26
  download_path,
24
- safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
27
+ readable_safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
25
28
  self._retry,
26
29
  callback=lambda: self._callback(book, volume) if self._callback else None
27
30
  )
28
31
 
29
32
  def construct_download_url(self, book: BookInfo, volume: VolInfo) -> str:
30
- return f'https://kox.moe/dl/{book.id}/{volume.id}/1/2/{self._profile.is_vip}/'
33
+ return API_ROUTE.DOWNLOAD.format(
34
+ book_id=book.id,
35
+ volume_id=volume.id,
36
+ is_vip=self._profile.is_vip
37
+ )
@@ -4,8 +4,10 @@ import json
4
4
  from async_lru import alru_cache
5
5
 
6
6
  from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
7
+ from kmdr.core.constants import API_ROUTE
8
+ from kmdr.core.error import ResponseError
7
9
 
8
- from .utils import download_file, safe_filename, download_file_multipart
10
+ from .download_utils import download_file_multipart, readable_safe_filename
9
11
 
10
12
 
11
13
  @DOWNLOADER.register(order=10)
@@ -15,7 +17,7 @@ class ReferViaDownloader(Downloader):
15
17
 
16
18
 
17
19
  async def _download(self, book: BookInfo, volume: VolInfo):
18
- sub_dir = safe_filename(book.name)
20
+ sub_dir = readable_safe_filename(book.name)
19
21
  download_path = f'{self._dest}/{sub_dir}'
20
22
 
21
23
  await download_file_multipart(
@@ -24,7 +26,7 @@ class ReferViaDownloader(Downloader):
24
26
  self._progress,
25
27
  partial(self.fetch_download_url, book.id, volume.id),
26
28
  download_path,
27
- safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
29
+ readable_safe_filename(f'[Kmoe][{book.name}][{volume.name}].epub'),
28
30
  self._retry,
29
31
  headers={
30
32
  "X-Km-From": "kb_http_down"
@@ -35,13 +37,17 @@ class ReferViaDownloader(Downloader):
35
37
  @alru_cache(maxsize=128)
36
38
  async def fetch_download_url(self, book_id: str, volume_id: str) -> str:
37
39
 
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:
40
+ async with self._session.get(
41
+ API_ROUTE.GETDOWNURL.format(
42
+ book_id=book_id,
43
+ volume_id=volume_id,
44
+ is_vip=self._profile.is_vip
45
+ )
46
+ ) as response:
41
47
  response.raise_for_status()
42
48
  data = await response.text()
43
49
  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
-
50
+ if (code := data.get('code')) != 200:
51
+ raise ResponseError(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}", code)
52
+
47
53
  return data['url']
@@ -0,0 +1,2 @@
1
+ from .DirectDownloader import DirectDownloader
2
+ from .ReferViaDownloader import ReferViaDownloader
@@ -3,27 +3,22 @@ import os
3
3
  import re
4
4
  import math
5
5
  from typing import Callable, Optional, Union, Awaitable
6
- from enum import Enum
7
6
 
8
- from deprecation import deprecated
7
+ from typing_extensions import deprecated
8
+
9
9
  import aiohttp
10
10
  import aiofiles
11
11
  import aiofiles.os as aio_os
12
12
  from rich.progress import Progress
13
13
  from aiohttp.client_exceptions import ClientPayloadError
14
14
 
15
+ from .misc import STATUS, StateManager
16
+
15
17
  BLOCK_SIZE_REDUCTION_FACTOR = 0.75
16
18
  MIN_BLOCK_SIZE = 2048
17
19
 
18
- class STATUS(Enum):
19
- WAITING='[blue]等待中[/blue]'
20
- DOWNLOADING='[cyan]下载中[/cyan]'
21
- RETRYING='[yellow]重试中[/yellow]'
22
- MERGING='[magenta]合并中[/magenta]'
23
- COMPLETED='[green]完成[/green]'
24
- FAILED='[red]失败[/red]'
25
20
 
26
- @deprecated(details="请使用 'download_file_multipart'")
21
+ @deprecated("请使用 'download_file_multipart'")
27
22
  async def download_file(
28
23
  session: aiohttp.ClientSession,
29
24
  semaphore: asyncio.Semaphore,
@@ -163,6 +158,7 @@ async def download_file_multipart(
163
158
  return
164
159
 
165
160
  part_paths = []
161
+ part_expected_sizes = []
166
162
  task_id = None
167
163
  try:
168
164
  current_url = await fetch_url(url)
@@ -184,9 +180,11 @@ async def download_file_multipart(
184
180
  resumed_size += (await aio_os.stat(part_path)).st_size
185
181
 
186
182
  task_id = progress.add_task("download", filename=filename, status=STATUS.WAITING.value, total=total_size, completed=resumed_size)
183
+ state_manager = StateManager(progress=progress, task_id=task_id)
187
184
 
188
185
  for i, start in enumerate(range(0, total_size, chunk_size)):
189
186
  end = min(start + chunk_size - 1, total_size - 1)
187
+ part_expected_sizes.append(end - start + 1)
190
188
 
191
189
  task = _download_part(
192
190
  session=session,
@@ -195,8 +193,7 @@ async def download_file_multipart(
195
193
  start=start,
196
194
  end=end,
197
195
  part_path=part_paths[i],
198
- progress=progress,
199
- task_id=task_id,
196
+ state_manager=state_manager,
200
197
  headers=headers,
201
198
  retry_times=retry_times
202
199
  )
@@ -204,24 +201,29 @@ async def download_file_multipart(
204
201
 
205
202
  await asyncio.gather(*tasks)
206
203
 
207
- progress.update(task_id, status=STATUS.MERGING.value, refresh=True)
208
- await _merge_parts(part_paths, filename_downloading)
209
-
210
- os.rename(filename_downloading, file_path)
211
- except Exception as e:
212
- if task_id is not None:
213
- progress.update(task_id, status=STATUS.FAILED.value, visible=False)
204
+ assert len(part_paths) == len(part_expected_sizes)
205
+ results = await asyncio.gather(*[_validate_part(part_paths[i], part_expected_sizes[i]) for i in range(num_chunks)])
206
+ if all(results):
207
+ await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.MERGING)
208
+ await _merge_parts(part_paths, filename_downloading)
209
+ os.rename(filename_downloading, file_path)
210
+ else:
211
+ # 如果有任何一个分片校验失败,则视为下载失败
212
+ await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.FAILED)
214
213
 
215
214
  finally:
216
215
  if await aio_os.path.exists(file_path):
217
216
  if task_id is not None:
218
- progress.update(task_id, status=STATUS.COMPLETED.value, completed=total_size, refresh=True)
217
+ await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.COMPLETED)
219
218
 
220
219
  cleanup_tasks = [aio_os.remove(p) for p in part_paths if await aio_os.path.exists(p)]
221
220
  if cleanup_tasks:
222
221
  await asyncio.gather(*cleanup_tasks)
223
222
  if callback:
224
223
  callback()
224
+ else:
225
+ if task_id is not None:
226
+ await state_manager.request_status_update(part_id=StateManager.PARENT_ID, status=STATUS.FAILED)
225
227
 
226
228
  async def _download_part(
227
229
  session: aiohttp.ClientSession,
@@ -230,8 +232,7 @@ async def _download_part(
230
232
  start: int,
231
233
  end: int,
232
234
  part_path: str,
233
- progress: Progress,
234
- task_id,
235
+ state_manager: StateManager,
235
236
  headers: Optional[dict] = None,
236
237
  retry_times: int = 3
237
238
  ):
@@ -258,21 +259,33 @@ async def _download_part(
258
259
  async with session.get(url, headers=local_headers) as response:
259
260
  response.raise_for_status()
260
261
 
261
- if progress.tasks[task_id].fields.get("status") != STATUS.DOWNLOADING.value:
262
- progress.update(task_id, status=STATUS.DOWNLOADING.value, refresh=True)
262
+ await state_manager.request_status_update(part_id=start, status=STATUS.DOWNLOADING)
263
263
 
264
264
  async with aiofiles.open(part_path, 'ab') as f:
265
265
  async for chunk in response.content.iter_chunked(block_size):
266
266
  if chunk:
267
267
  await f.write(chunk)
268
- progress.update(task_id, advance=len(chunk))
268
+ state_manager.advance(len(chunk))
269
269
  return
270
+
271
+ except asyncio.CancelledError:
272
+ # 如果任务被取消,更新状态为已取消
273
+ await state_manager.request_status_update(part_id=start, status=STATUS.CANCELLED)
274
+ raise
275
+
270
276
  except Exception as e:
271
277
  if attempts_left > 0:
272
278
  await asyncio.sleep(3)
279
+ await state_manager.request_status_update(part_id=start, status=STATUS.WAITING)
273
280
  else:
274
281
  # console.print(f"[red]分片 {os.path.basename(part_path)} 下载失败: {e}[/red]")
275
- raise
282
+ await state_manager.request_status_update(part_id=start, status=STATUS.PARTIALLY_FAILED)
283
+
284
+ async def _validate_part(part_path: str, expected_size: int) -> bool:
285
+ if not await aio_os.path.exists(part_path):
286
+ return False
287
+ actual_size = await aio_os.path.getsize(part_path)
288
+ return actual_size == expected_size
276
289
 
277
290
  async def _merge_parts(part_paths: list[str], final_path: str):
278
291
  async with aiofiles.open(final_path, 'wb') as final_file:
@@ -290,7 +303,31 @@ async def _merge_parts(part_paths: list[str], final_path: str):
290
303
  raise e
291
304
 
292
305
 
306
+ CHAR_MAPPING = {
307
+ '\\': '\',
308
+ '/': '/',
309
+ ':': ':',
310
+ '*': '*',
311
+ '?': '?',
312
+ '"': '"',
313
+ '<': '<',
314
+ '>': '>',
315
+ '|': '|',
316
+ }
317
+ DEFAULT_ILLEGAL_CHARS_REPLACEMENT = '_'
318
+ ILLEGAL_CHARS_RE = re.compile(r'[\\/:*?"<>|]')
319
+
320
+ def readable_safe_filename(name: str) -> str:
321
+ """
322
+ 将字符串转换为安全的文件名,替换掉非法字符。
323
+ """
324
+ def replace_char(match):
325
+ char = match.group(0)
326
+ return CHAR_MAPPING.get(char, DEFAULT_ILLEGAL_CHARS_REPLACEMENT)
327
+
328
+ return ILLEGAL_CHARS_RE.sub(replace_char, name).strip()
293
329
 
330
+ @deprecated("请使用 'readable_safe_filename'")
294
331
  def safe_filename(name: str) -> str:
295
332
  """
296
333
  替换非法文件名字符为下划线
@@ -0,0 +1,64 @@
1
+ from enum import Enum
2
+ import asyncio
3
+
4
+ from rich.progress import Progress, TaskID
5
+
6
+
7
+ class STATUS(Enum):
8
+ WAITING='[blue]等待中[/blue]'
9
+ RETRYING='[yellow]重试中[/yellow]'
10
+ DOWNLOADING='[cyan]下载中[/cyan]'
11
+ MERGING='[magenta]合并中[/magenta]'
12
+ COMPLETED='[green]完成[/green]'
13
+ PARTIALLY_FAILED='[red]分片失败[/red]'
14
+ FAILED='[red]失败[/red]'
15
+ CANCELLED='[yellow]已取消[/yellow]'
16
+
17
+ @property
18
+ def order(self) -> int:
19
+ order_mapping = {
20
+ STATUS.WAITING: 1,
21
+ STATUS.RETRYING: 2,
22
+ STATUS.DOWNLOADING: 3,
23
+ STATUS.MERGING: 4,
24
+ STATUS.COMPLETED: 5,
25
+ STATUS.PARTIALLY_FAILED: 6,
26
+ STATUS.FAILED: 7,
27
+ STATUS.CANCELLED: 8,
28
+ }
29
+ return order_mapping[self]
30
+
31
+ def __lt__(self, other):
32
+ if not isinstance(other, STATUS):
33
+ return NotImplemented
34
+ return self.order < other.order
35
+
36
+
37
+ class StateManager:
38
+
39
+ def __init__(self, progress: Progress, task_id: TaskID):
40
+ self._part_states: dict[int, STATUS] = {}
41
+ self._progress = progress
42
+ self._task_id = task_id
43
+ self._current_status = STATUS.WAITING
44
+
45
+ self._lock = asyncio.Lock()
46
+
47
+ PARENT_ID: int = -1
48
+
49
+ def advance(self, advance: int):
50
+ self._progress.update(self._task_id, advance=advance)
51
+
52
+ def _update_status(self):
53
+ if not self._part_states:
54
+ return
55
+
56
+ highest_status = max(self._part_states.values())
57
+ if highest_status != self._current_status:
58
+ self._current_status = highest_status
59
+ self._progress.update(self._task_id, status=highest_status.value, refresh=True)
60
+
61
+ async def request_status_update(self, part_id: int, status: STATUS):
62
+ async with self._lock:
63
+ self._part_states[part_id] = status
64
+ self._update_status()
@@ -1,15 +1,15 @@
1
1
  import asyncio
2
+
2
3
  from bs4 import BeautifulSoup
3
4
  from rich.table import Table
4
5
  from rich.prompt import IntPrompt
5
6
 
6
7
  from kmdr.core import Lister, LISTERS, BookInfo, VolInfo
7
8
  from kmdr.core.utils import async_retry
9
+ from kmdr.core.constants import API_ROUTE
8
10
 
9
11
  from .utils import extract_book_info_and_volumes
10
12
 
11
- MY_FOLLOW_URL = 'https://kox.moe/myfollow.php'
12
-
13
13
  @LISTERS.register()
14
14
  class FollowedBookLister(Lister):
15
15
 
@@ -62,7 +62,7 @@ class FollowedBookLister(Lister):
62
62
 
63
63
  @async_retry()
64
64
  async def _list_followed_books(self) -> 'list[BookInfo]':
65
- async with self._session.get(MY_FOLLOW_URL) as response:
65
+ async with self._session.get(API_ROUTE.MY_FOLLOW) as response:
66
66
  response.raise_for_status()
67
67
  html_text = await response.text()
68
68
 
@@ -0,0 +1,2 @@
1
+ from .BookUrlLister import BookUrlLister
2
+ from .FollowedBookLister import FollowedBookLister
@@ -2,6 +2,7 @@ from bs4 import BeautifulSoup
2
2
  import re
3
3
  from typing import Optional
4
4
 
5
+ from yarl import URL
5
6
  from aiohttp import ClientSession as Session
6
7
 
7
8
  from kmdr.core import BookInfo, VolInfo, VolumeType
@@ -16,7 +17,13 @@ async def extract_book_info_and_volumes(session: Session, url: str, book_info: O
16
17
  :param url: 书籍页面的 URL。
17
18
  :return: 包含书籍信息和卷信息的元组。
18
19
  """
19
- async with session.get(url) as response:
20
+ structured_url = URL(url)
21
+
22
+ # 移除移动端路径部分,统一为桌面端路径
23
+ # 因为移动端页面的结构与桌面端不同,可能会影响解析
24
+ route = structured_url.path[2:] if structured_url.path.startswith('/m/') else structured_url.path
25
+
26
+ async with session.get(route) as response:
20
27
  response.raise_for_status()
21
28
 
22
29
  # 如果后续有性能问题,可以先考虑使用 lxml 进行解析
@@ -48,7 +55,7 @@ async def __extract_volumes(session: Session, book_page: BeautifulSoup) -> list[
48
55
  pattern = re.compile(r'/book_data.php\?h=\w+')
49
56
  book_data_url = pattern.search(script).group(0)
50
57
 
51
- async with session.get(url = f"https://kox.moe{book_data_url}") as response:
58
+ async with session.get(url = book_data_url) as response:
52
59
  response.raise_for_status()
53
60
 
54
61
  book_data = (await response.text()).split('\n')
@@ -0,0 +1,2 @@
1
+ from .ArgsFilterPicker import ArgsFilterPicker
2
+ from .DefaultVolPicker import DefaultVolPicker
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kmoe-manga-downloader
3
- Version: 1.2.0
3
+ Version: 1.2.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
@@ -35,19 +35,20 @@ Classifier: Operating System :: OS Independent
35
35
  Requires-Python: >=3.9
36
36
  Description-Content-Type: text/markdown
37
37
  License-File: LICENSE
38
- Requires-Dist: deprecation~=2.1.0
39
38
  Requires-Dist: beautifulsoup4~=4.13.4
40
39
  Requires-Dist: aiohttp~=3.12.15
41
40
  Requires-Dist: aiofiles~=24.1.0
42
41
  Requires-Dist: async-lru~=2.0.5
43
42
  Requires-Dist: rich~=13.9.4
43
+ Requires-Dist: yarl~=1.20.1
44
+ Requires-Dist: typing-extensions~=4.15.0
44
45
  Dynamic: license-file
45
46
 
46
47
  # Kmoe Manga Downloader
47
48
 
48
49
  [![PyPI Downloads](https://static.pepy.tech/badge/kmoe-manga-downloader)](https://pepy.tech/projects/kmoe-manga-downloader) [![PyPI version](https://img.shields.io/pypi/v/kmoe-manga-downloader.svg)](https://pypi.org/project/kmoe-manga-downloader/) [![Unit Tests](https://github.com/chrisis58/kmdr/actions/workflows/unit-test.yml/badge.svg)](https://github.com/chrisis58/kmdr/actions/workflows/unit-test.yml) [![Interpretor](https://img.shields.io/badge/python-3.9+-blue)](https://www.python.org/) [![License](https://img.shields.io/badge/License-MIT-green)](https://github.com/chrisis58/kmdr/blob/main/LICENSE)
49
50
 
50
- `kmdr (Kmoe Manga Downloader)` 是一个 Python 应用,用于从 [Kmoe](https://kox.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
51
+ `kmdr (Kmoe Manga Downloader)` 是一个 Python 终端应用,用于从 [Kmoe](https://kxx.moe/) 网站下载漫画。它支持在终端环境下的登录、下载指定漫画及其卷,并支持回调脚本执行。
51
52
 
52
53
  <p align="center">
53
54
  <img src="assets/kmdr-demo.gif" alt="kmdr 使用演示" width="720">
@@ -55,11 +56,17 @@ Dynamic: license-file
55
56
 
56
57
  ## ✨功能特性
57
58
 
58
- - **现代化终端界面**: 使用 [rich](https://github.com/Textualize/rich) 构建的终端用户界面(TUI),提供进度条和菜单等现代化、美观的交互式终端界面。
59
+ - **现代化终端界面**: 基于 [rich](https://github.com/Textualize/rich) 构建的终端用户界面(TUI),提供进度条和菜单等现代化、美观的交互式终端界面。
59
60
  - **凭证和配置管理**: 应用自动维护登录凭证和下载设置,实现一次配置、持久有效,提升使用效率。
60
- - **高效下载的性能**: 采用 `asyncio` 并发分片下载技术,充分利用网络带宽,极大加速单个大文件的下载速度。
61
- - **强大的高可用性**: 内置强大的自动重试与断点续传机制,无惧网络中断,确保下载任务最终成功。
62
- - **灵活的自动化接口**: 支持下载完成后自动执行自定义回调脚本,轻松集成到您的自动化流程。
61
+ - **高效下载的性能**: 采用 `asyncio` 并发分片下载方式,充分利用网络带宽,显著加速单个大文件的下载速度。
62
+ - **强大的高可用性**: 内置自动重试与断点续传机制,无惧网络中断,确保下载任务在不稳定环境下依然能够成功。
63
+ - **灵活的自动化接口**: 支持在每个文件下载成功后自动执行自定义回调脚本,轻松集成到您的自动化工作流。
64
+
65
+ ## 🖼️ 使用场景
66
+
67
+ - **通用的加速体验**: 采用并发分片下载方式,充分地利用不同类型用户的网络带宽,提升数据传输效率,从而有效缩短下载的等待时间。
68
+ - **灵活部署与远程控制**: 支持在远端服务器或 NAS 上运行,可以在其他设备(PC、平板)上浏览,而通过简单的命令触发远程下载任务,实现浏览与存储的分离。
69
+ - **智能化自动追新**: 应用支持识别重复内容,可配合定时任务实等现无人值守下载最新的内容,轻松打造时刻保持同步的个人资料库。
63
70
 
64
71
  ## 🛠️安装应用
65
72
 
@@ -73,7 +80,7 @@ pip install kmoe-manga-downloader
73
80
 
74
81
  ### 1. 登录 `kmoe`
75
82
 
76
- 首先需要登录 `kox.moe` 并保存登录状态(Cookie)。
83
+ 首先需要登录 `kmoe` 并保存登录状态(Cookie)。
77
84
 
78
85
  ```bash
79
86
  kmdr login -u <your_username> -p <your_password>
@@ -91,26 +98,28 @@ kmdr login -u <your_username>
91
98
 
92
99
  ```bash
93
100
  # 在当前目录下载第一、二、三卷
94
- kmdr download --dest . --book-url https://kox.moe/c/50076.htm --volume 1,2,3
95
- kmdr download -l https://kox.moe/c/50076.htm -v 1-3
101
+ kmdr download --dest . --book-url https://kxx.moe/c/50076.htm --volume 1,2,3
102
+ # 下面命令的功能与上面相同
103
+ kmdr download -l https://kxx.moe/c/50076.htm -v 1-3
96
104
  ```
97
105
 
98
106
  ```bash
99
107
  # 在目标目录下载全部番外篇
100
- kmdr download --dest path/to/destination --book-url https://kox.moe/c/50076.htm --vol-type extra -v all
101
- kmdr download -d path/to/destination -l https://kox.moe/c/50076.htm -t extra -v all
108
+ kmdr download --dest path/to/destination --book-url https://kxx.moe/c/50076.htm --vol-type extra -v all
109
+ # 下面命令的功能与上面相同
110
+ kmdr download -d path/to/destination -l https://kxx.moe/c/50076.htm -t extra -v all
102
111
  ```
103
112
 
104
113
  #### 常用参数说明:
105
114
 
106
- - `-d`, `--dest`: 下载的目标目录(默认为当前目录),在此基础上会额外添加一个为书籍名称的子目录
107
- - `-l`, `--book-url`: 指定书籍的主页地址
108
- - `-v`, `--volume`: 指定卷的名称,多个名称使用逗号分隔,`all` 表示下载所有卷
115
+ - `-d`, `--dest`: 下载的目标目录(默认为当前目录),在此基础上会额外添加一个为漫画名称的子目录
116
+ - `-l`, `--book-url`: 指定漫画的主页地址
117
+ - `-v`, `--volume`: 指定下载的卷,多个用逗号分隔,例如 `1,2,3` 或 `1-5,8`,`all` 表示全部
109
118
  - `-t`, `--vol-type`: 卷类型,`vol`: 单行本(默认);`extra`: 番外;`seri`: 连载话;`all`: 全部
110
119
  - `-p`, `--proxy`: 代理服务器地址
111
- - `-r`, `--retry`: 下载失败时的重试次数
120
+ - `-r`, `--retry`: 下载失败时的重试次数,默认为 3
112
121
  - `-c`, `--callback`: 下载完成后的回调脚本(使用方式详见 [4. 回调函数](https://github.com/chrisis58/kmoe-manga-downlaoder?tab=readme-ov-file#4-%E5%9B%9E%E8%B0%83%E5%87%BD%E6%95%B0))
113
- - `--num-workers`: 最大同时下载数量,默认为 1
122
+ - `--num-workers`: 最大下载并发数量,默认为 8
114
123
 
115
124
  > 完整的参数说明可以从 `help` 指令中获取。
116
125
 
@@ -127,7 +136,7 @@ kmdr status
127
136
  你可以设置一个回调函数,下载完成后执行。回调可以是任何你想要的命令:
128
137
 
129
138
  ```bash
130
- kmdr download -d path/to/destination --book-url https://kox.moe/c/50076.htm -v 1-3 \
139
+ kmdr download -d path/to/destination --book-url https://kxx.moe/c/50076.htm -v 1-3 \
131
140
  --callback "echo '{b.name} {v.name} downloaded!' >> ~/kmdr.log"
132
141
  ```
133
142
 
@@ -143,7 +152,7 @@ kmdr download -d path/to/destination --book-url https://kox.moe/c/50076.htm -v 1
143
152
  | b.name | 对应漫画的名字 |
144
153
  | b.author | 对应漫画的作者 |
145
154
 
146
- > 完整的可用参数请参考 [structure.py](https://github.com/chrisis58/kmdr/blob/main/core/structure.py#L11) 中关于 `VolInfo` 的定义。
155
+ > 完整的可用参数请参考 [structure.py](https://github.com/chrisis58/kmoe-manga-downloader/blob/main/src/kmdr/core/structure.py#L11) 中关于 `VolInfo` 的定义。
147
156
 
148
157
  ### 5. 持久化配置
149
158
 
@@ -167,6 +176,20 @@ kmdr config -s num_workers=5 "callback=echo '{b.name} {v.name} downloaded!' >> ~
167
176
 
168
177
  > 当前仅支持部分下载参数的持久化:`num_workers`, `dest`, `retry`, `callback`, `proxy`
169
178
 
179
+ ### 6. 镜像源切换
180
+
181
+ 为了保证服务的长期可用性,并让用户能根据自己的网络环境选择最快的服务器,应用支持灵活地切换镜像源。
182
+
183
+ 当您发现默认源(当前为 `kxx.moe`)访问缓慢或失效时,可以通过 `config` 命令轻松切换到其他备用镜像源:
184
+
185
+ ```
186
+ kmdr config --base-url https://mox.moe
187
+ # 或者
188
+ kmdr config -b https://mox.moe
189
+ ```
190
+
191
+ 你可以参考 [镜像目录](./mirror/mirrors.json) 来选择合适的镜像源,如果你发现部分镜像源过时或者有缺失,欢迎贡献你的内容!
192
+
170
193
  ## ⚠️ 声明
171
194
 
172
195
  - 本工具仅作学习、研究、交流使用,使用本工具的用户应自行承担风险