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.
@@ -0,0 +1,25 @@
1
+ from requests import Session
2
+
3
+ def check_status(session: Session, show_quota: bool = False) -> bool:
4
+ response = session.get(url = 'https://kox.moe/my.php')
5
+
6
+ try:
7
+ response.raise_for_status()
8
+ except Exception as e:
9
+ print(f"Error: {type(e).__name__}: {e}")
10
+ return False
11
+
12
+ if not show_quota:
13
+ return True
14
+
15
+ from bs4 import BeautifulSoup
16
+
17
+ soup = BeautifulSoup(response.text, 'html.parser')
18
+
19
+ nickname = soup.find('div', id='div_nickname_display').text.strip().split(' ')[0]
20
+ print(f"=========================\n\nLogged in as {nickname}\n\n=========================\n")
21
+
22
+ quota = soup.find('div', id='div_user_vip').text.strip()
23
+ print(f"=========================\n\n{quota}\n\n=========================\n")
24
+ return True
25
+
@@ -0,0 +1,11 @@
1
+ from kmdr.core import Configurer, CONFIGURER
2
+
3
+ @CONFIGURER.register()
4
+ class ConfigClearer(Configurer):
5
+ def __init__(self, clear: str, *args, **kwargs):
6
+ super().__init__(*args, **kwargs)
7
+ self._clear = clear
8
+
9
+ def operate(self) -> None:
10
+ self._configurer.clear(self._clear)
11
+ print(f"Cleared configuration: {self._clear}")
@@ -0,0 +1,15 @@
1
+ from kmdr.core import Configurer, CONFIGURER
2
+
3
+ @CONFIGURER.register()
4
+ class ConfigUnsetter(Configurer):
5
+ def __init__(self, unset: str, *args, **kwargs):
6
+ super().__init__(*args, **kwargs)
7
+ self._unset = unset
8
+
9
+ def operate(self) -> None:
10
+ if not self._unset:
11
+ print("No option specified to unset.")
12
+ return
13
+
14
+ self._configurer.unset_option(self._unset)
15
+ print(f"Unset configuration: {self._unset}")
@@ -0,0 +1,19 @@
1
+ from kmdr.core import CONFIGURER, Configurer
2
+
3
+ @CONFIGURER.register(
4
+ hasvalues={
5
+ 'list_option': True
6
+ }
7
+ )
8
+ class OptionLister(Configurer):
9
+ def __init__(self, *args, **kwargs):
10
+ super().__init__(*args, **kwargs)
11
+
12
+ def operate(self) -> None:
13
+ if self._configurer.option is None:
14
+ print("No configurations found.")
15
+ return
16
+
17
+ print("Current configurations:")
18
+ for key, value in self._configurer.option.items():
19
+ print(f"\t{key} = {value}")
@@ -0,0 +1,32 @@
1
+ from kmdr.core import Configurer, CONFIGURER
2
+
3
+ from .option_validate import validate
4
+
5
+ @CONFIGURER.register()
6
+ class OptionSetter(Configurer):
7
+ def __init__(self, set: list[str], *args, **kwargs):
8
+ super().__init__(*args, **kwargs)
9
+ self._set = set
10
+
11
+ def operate(self) -> None:
12
+ for option in self._set:
13
+ if '=' not in option:
14
+ print(f"Invalid option format: `{option}`. Expected format is key=value.")
15
+ continue
16
+
17
+ key, value = option.split('=', 1)
18
+ key = key.strip()
19
+ value = value.strip()
20
+
21
+ validated_value = validate(key, value)
22
+ if validated_value is None:
23
+ continue
24
+
25
+ self._configurer.set_option(key, validated_value)
26
+ print(f"Set configuration: {key} = {validated_value}")
27
+
28
+
29
+
30
+
31
+
32
+
@@ -0,0 +1,76 @@
1
+ from typing import Optional
2
+ import os
3
+
4
+ __OPTIONS_VALIDATOR = {}
5
+
6
+ def validate(key: str, value: str) -> Optional[object]:
7
+ if key in __OPTIONS_VALIDATOR:
8
+ return __OPTIONS_VALIDATOR[key](value)
9
+ else:
10
+ print(f"Unsupported option: {key}. Supported options are: {', '.join(__OPTIONS_VALIDATOR.keys())}")
11
+ return None
12
+
13
+ def _register_validator(func):
14
+ global __OPTIONS_VALIDATOR
15
+
16
+ func_name = func.__name__
17
+
18
+ assert func_name.startswith('validate_'), \
19
+ f"Validator function name must start with 'validate_', got '{func_name}'"
20
+
21
+ __OPTIONS_VALIDATOR[func.__name__[9:]] = func
22
+ return func
23
+
24
+ @_register_validator
25
+ def validate_num_workers(value: str) -> Optional[int]:
26
+ try:
27
+ num_workers = int(value)
28
+ if num_workers <= 0:
29
+ raise ValueError("Number of workers must be a positive integer.")
30
+ return num_workers
31
+ except ValueError as e:
32
+ print(f"Invalid value for num_workers: {value}. {str(e)}")
33
+ return None
34
+
35
+ @_register_validator
36
+ def validate_dest(value: str) -> Optional[str]:
37
+ if not value:
38
+ print("Destination cannot be empty.")
39
+ return None
40
+ if not os.path.exists(value) or not os.path.isdir(value):
41
+ print(f"Destination directory does not exist or is not a directory: {value}")
42
+ return None
43
+
44
+ if not os.access(value, os.W_OK):
45
+ print(f"Destination directory is not writable: {value}")
46
+ return None
47
+
48
+ if not os.path.isabs(value):
49
+ print(f"Destination better be an absolute path: {value}")
50
+
51
+ return value
52
+
53
+ @_register_validator
54
+ def validate_retry(value: str) -> Optional[int]:
55
+ try:
56
+ retry = int(value)
57
+ if retry < 0:
58
+ raise ValueError("Retry count must be a non-negative integer.")
59
+ return retry
60
+ except ValueError as e:
61
+ print(f"Invalid value for retry: {value}. {str(e)}")
62
+ return None
63
+
64
+ @_register_validator
65
+ def validate_callback(value: str) -> Optional[str]:
66
+ if not value:
67
+ print("Callback cannot be empty.")
68
+ return None
69
+ return value
70
+
71
+ @_register_validator
72
+ def validate_proxy(value: str) -> Optional[str]:
73
+ if not value:
74
+ print("Proxy cannot be empty.")
75
+ return None
76
+ return value
@@ -0,0 +1,28 @@
1
+ from kmdr.core import Downloader, BookInfo, VolInfo, DOWNLOADER
2
+
3
+ from .utils import download_file, safe_filename
4
+
5
+ @DOWNLOADER.register(
6
+ hasvalues={
7
+ 'method': 1
8
+ }
9
+ )
10
+ class DirectDownloader(Downloader):
11
+ def __init__(self, dest='.', callback=None, retry=3, num_workers=1, proxy=None, *args, **kwargs):
12
+ super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
13
+
14
+ def _download(self, book: BookInfo, volume: VolInfo, retry: int):
15
+ sub_dir = safe_filename(book.name)
16
+ download_path = f'{self._dest}/{sub_dir}'
17
+
18
+ download_file(
19
+ self._session,
20
+ self.construct_download_url(book, volume),
21
+ download_path,
22
+ f'[Kmoe][{book.name}][{volume.name}].epub',
23
+ retry,
24
+ callback=lambda: self._callback(book, volume) if self._callback else None
25
+ )
26
+
27
+ def construct_download_url(self, book: BookInfo, volume: VolInfo) -> str:
28
+ return f'https://kox.moe/dl/{book.id}/{volume.id}/1/2/0/'
@@ -0,0 +1,44 @@
1
+ from kmdr.core import Downloader, VolInfo, DOWNLOADER, BookInfo
2
+
3
+ from .utils import download_file, safe_filename, cached_by_kwargs
4
+
5
+ try:
6
+ import cloudscraper
7
+ except ImportError:
8
+ cloudscraper = None
9
+
10
+ @DOWNLOADER.register(order=10)
11
+ class ReferViaDownloader(Downloader):
12
+ def __init__(self, dest='.', callback=None, retry=3, num_workers=1, proxy=None, *args, **kwargs):
13
+ super().__init__(dest, callback, retry, num_workers, proxy, *args, **kwargs)
14
+
15
+ if cloudscraper:
16
+ self._scraper = cloudscraper.create_scraper()
17
+ else:
18
+ self._scraper = None
19
+
20
+ def _download(self, book: BookInfo, volume: VolInfo, retry: int):
21
+ sub_dir = safe_filename(book.name)
22
+ download_path = f'{self._dest}/{sub_dir}'
23
+
24
+ download_file(
25
+ self._session if not self._scraper else self._scraper,
26
+ self.fetch_download_url(book=book, volume=volume),
27
+ download_path,
28
+ f'[Kmoe][{book.name}][{volume.name}].epub',
29
+ retry,
30
+ headers={
31
+ "X-Km-From": "kb_http_down"
32
+ },
33
+ callback=lambda: self._callback(book, volume) if self._callback else None
34
+ )
35
+
36
+ @cached_by_kwargs
37
+ def fetch_download_url(self, book: BookInfo, volume: VolInfo) -> str:
38
+ response = self._session.get(f"https://kox.moe/getdownurl.php?b={book.id}&v={volume.id}&mobi=2&vip=0&json=1")
39
+ response.raise_for_status()
40
+ data = response.json()
41
+ if data.get('code') != 200:
42
+ raise Exception(f"Failed to fetch download URL: {data.get('msg', 'Unknown error')}")
43
+
44
+ return data['url']
@@ -0,0 +1,118 @@
1
+ from typing import Callable, Optional
2
+ import os
3
+ import time
4
+
5
+ from requests import Session, HTTPError
6
+ from requests.exceptions import ChunkedEncodingError
7
+ from tqdm import tqdm
8
+ import re
9
+
10
+ BLOCK_SIZE_REDUCTION_FACTOR = 0.75
11
+ MIN_BLOCK_SIZE = 2048
12
+
13
+ def download_file(
14
+ session: Session,
15
+ url: str,
16
+ dest_path: str,
17
+ filename: str,
18
+ retry_times: int = 0,
19
+ headers: Optional[dict] = None,
20
+ callback: Optional[Callable] = None,
21
+ block_size: int = 8192
22
+ ):
23
+ if headers is None:
24
+ headers = {}
25
+ filename_downloading = f'{filename}.downloading'
26
+
27
+ file_path = f'{dest_path}/{filename}'
28
+ tmp_file_path = f'{dest_path}/{filename_downloading}'
29
+
30
+ if not os.path.exists(dest_path):
31
+ os.makedirs(dest_path, exist_ok=True)
32
+
33
+ if os.path.exists(file_path):
34
+ tqdm.write(f"{filename} already exists.")
35
+ return
36
+
37
+ resume_from = 0
38
+ total_size_in_bytes = 0
39
+
40
+ if os.path.exists(tmp_file_path):
41
+ resume_from = os.path.getsize(tmp_file_path)
42
+
43
+ if resume_from:
44
+ headers['Range'] = f'bytes={resume_from}-'
45
+
46
+ try:
47
+ with session.get(url = url, stream=True, headers=headers) as r:
48
+ r.raise_for_status()
49
+
50
+ total_size_in_bytes = int(r.headers.get('content-length', 0)) + resume_from
51
+
52
+ with open(tmp_file_path, 'ab') as f:
53
+ with tqdm(total=total_size_in_bytes, unit='B', unit_scale=True, desc=f'{filename}', initial=resume_from) as progress_bar:
54
+ for chunk in r.iter_content(chunk_size=block_size):
55
+ if chunk:
56
+ f.write(chunk)
57
+ progress_bar.update(len(chunk))
58
+
59
+ if (os.path.getsize(tmp_file_path) == total_size_in_bytes):
60
+ os.rename(tmp_file_path, file_path)
61
+
62
+ if callback:
63
+ callback()
64
+ except Exception as e:
65
+ prefix = f"{type(e).__name__} occurred while downloading {filename}. "
66
+
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
+ new_block_size = block_size
73
+ if isinstance(e, ChunkedEncodingError):
74
+ new_block_size = max(int(block_size * BLOCK_SIZE_REDUCTION_FACTOR), MIN_BLOCK_SIZE)
75
+
76
+ if retry_times > 0:
77
+ # 重试下载
78
+ tqdm.write(f"{prefix} Retry after 3 seconds...")
79
+ time.sleep(3) # 等待3秒后重试,避免触发限流
80
+ download_file(session, url, dest_path, filename, retry_times - 1, headers, callback, new_block_size)
81
+ else:
82
+ tqdm.write(f"{prefix} Meet max retry times, download failed.")
83
+ raise e
84
+
85
+ def safe_filename(name: str) -> str:
86
+ """
87
+ 替换非法文件名字符为下划线
88
+ """
89
+ return re.sub(r'[\\/:*?"<>|]', '_', name)
90
+
91
+
92
+ def cached_by_kwargs(func):
93
+ """
94
+ 根据关键字参数缓存函数结果的装饰器。
95
+
96
+ Example:
97
+ >>> @kwargs_cached
98
+ >>> def add(a, b, c):
99
+ >>> return a + b + c
100
+ >>> result1 = add(1, 2, c=3) # Calls the function
101
+ >>> result2 = add(3, 2, c=3) # Uses cached result
102
+ >>> assert result1 == result2 # Both results are the same
103
+ """
104
+ cache = {}
105
+
106
+ def wrapper(*args, **kwargs):
107
+ if not kwargs:
108
+ return func(*args, **kwargs)
109
+
110
+ nonlocal cache
111
+
112
+ key = frozenset(kwargs.items())
113
+
114
+ if key not in cache:
115
+ cache[key] = func(*args, **kwargs)
116
+ return cache[key]
117
+
118
+ return wrapper
@@ -0,0 +1,15 @@
1
+ from kmdr.core import Lister, LISTERS, BookInfo, VolInfo
2
+
3
+ from .utils import extract_book_info_and_volumes
4
+
5
+
6
+ @LISTERS.register()
7
+ class BookUrlLister(Lister):
8
+
9
+ def __init__(self, book_url: str, *args, **kwargs):
10
+ super().__init__(*args, **kwargs)
11
+ self._book_url = book_url
12
+
13
+ def list(self) -> tuple[BookInfo, list[VolInfo]]:
14
+ book_info, volumes = extract_book_info_and_volumes(self._session, self._book_url)
15
+ return book_info, volumes
@@ -0,0 +1,38 @@
1
+ from bs4 import BeautifulSoup
2
+
3
+ from kmdr.core import Lister, LISTERS, BookInfo, VolInfo
4
+
5
+ from .utils import extract_book_info_and_volumes
6
+
7
+ MY_FOLLOW_URL = 'https://kox.moe/myfollow.php'
8
+
9
+ @LISTERS.register()
10
+ class FollowedBookLister(Lister):
11
+
12
+ def __init__(self, *args, **kwargs):
13
+ super().__init__(*args, **kwargs)
14
+
15
+ def list(self) -> tuple[BookInfo, list[VolInfo]]:
16
+ followed_rows = BeautifulSoup(self._session.get(url = MY_FOLLOW_URL).text, 'html.parser').find_all('tr', style='height:36px;')
17
+ mapped = map(lambda x: x.find_all('td'), followed_rows)
18
+ filtered = filter(lambda x: '書名' not in x[1].text, mapped)
19
+ books = map(lambda x: BookInfo(name = x[1].text, url = x[1].find('a')['href'], author = x[2].text, status = x[-1].text, last_update = x[-2].text, id = ''), filtered)
20
+ books = list(books)
21
+
22
+ print("\t最后更新时间\t书名")
23
+ for v in range(len(books)):
24
+ print(f"[{v + 1}]\t{books[v].last_update}\t{books[v].name}")
25
+
26
+ choosed = input("choose a book to download: ")
27
+ while not choosed.isdigit() or int(choosed) > len(books) or int(choosed) < 1:
28
+ choosed = input("choose a book to download: ")
29
+ choosed = int(choosed) - 1
30
+ book = books[choosed]
31
+
32
+ book_info, volumes = extract_book_info_and_volumes(self._session, book.url)
33
+ book_info.author = book.author
34
+ book_info.status = book.status
35
+ book_info.last_update = book.last_update
36
+
37
+ return book_info, volumes
38
+
@@ -0,0 +1,79 @@
1
+ from requests import Session
2
+ from bs4 import BeautifulSoup
3
+ import re
4
+
5
+ from kmdr.core import BookInfo, VolInfo, VolumeType
6
+
7
+ def extract_book_info_and_volumes(session: Session, url: str) -> tuple[BookInfo, list[VolInfo]]:
8
+ """
9
+ 从指定的书籍页面 URL 中提取书籍信息和卷信息。
10
+
11
+ :param session: 已经建立的 requests.Session 实例。
12
+ :param url: 书籍页面的 URL。
13
+ :return: 包含书籍信息和卷信息的元组。
14
+ """
15
+ book_page = BeautifulSoup(session.get(url).text, 'html.parser')
16
+
17
+ book_info = __extract_book_info(url, book_page)
18
+ volumes = __extract_volumes(session, book_page)
19
+
20
+ return book_info, volumes
21
+
22
+ def __extract_book_info(url: str, book_page: BeautifulSoup) -> BookInfo:
23
+ book_name = book_page.find('font', class_='text_bglight_big').text
24
+
25
+ id = book_page.find('input', attrs={'name': 'bookid'})['value']
26
+
27
+ return BookInfo(
28
+ id = id,
29
+ name = book_name,
30
+ url = url,
31
+ author = '',
32
+ status = '',
33
+ last_update = ''
34
+ )
35
+
36
+
37
+ def __extract_volumes(session: Session, book_page: BeautifulSoup) -> list[VolInfo]:
38
+ script = book_page.find_all('script', language="javascript")[-1].text
39
+
40
+ pattern = re.compile(r'/book_data.php\?h=\w+')
41
+ book_data_url = pattern.search(script).group(0)
42
+
43
+ book_data = session.get(url = f"https://kox.moe{book_data_url}").text.split('\n')
44
+ book_data = filter(lambda x: 'volinfo' in x, book_data)
45
+ book_data = map(lambda x: x.split("\"")[1], book_data)
46
+ book_data = map(lambda x: x[8:].split(','), book_data)
47
+
48
+ volume_data = list(map(lambda x: VolInfo(
49
+ id = x[0],
50
+ extra_info = __extract_extra_info(x[1]),
51
+ is_last = x[2] == '1',
52
+ vol_type = __extract_volume_type(x[3]),
53
+ index = int(x[4]),
54
+ pages = int(x[6]),
55
+ name = x[5],
56
+ size = float(x[11])), book_data))
57
+ volume_data: list[VolInfo] = volume_data
58
+
59
+ return volume_data
60
+
61
+ def __extract_extra_info(value: str) -> str:
62
+ if value == '0':
63
+ return '无'
64
+ elif value == '1':
65
+ return '最近一週更新'
66
+ elif value == '2':
67
+ return '90天內曾下載/推送'
68
+ else:
69
+ return f'未知({value})'
70
+
71
+ def __extract_volume_type(value: str) -> VolumeType:
72
+ if value == '單行本':
73
+ return VolumeType.VOLUME
74
+ elif value == '番外篇':
75
+ return VolumeType.EXTRA
76
+ elif value == '話':
77
+ return VolumeType.SERIALIZED
78
+ else:
79
+ raise ValueError(f'未知的卷类型: {value}')
@@ -0,0 +1,49 @@
1
+ from typing import Optional
2
+
3
+ from kmdr.core import Picker, PICKERS, VolInfo, VolumeType
4
+
5
+ from .utils import resolve_volume
6
+
7
+ @PICKERS.register()
8
+ class ArgsFilterPicker(Picker):
9
+ """
10
+ 通过命令行参数过滤卷信息的选择器。
11
+ """
12
+
13
+ def __init__(self, volume: str, vol_type: str = 'vol', max_size: Optional[float] = None, limit: Optional[int] = None, *args, **kwargs):
14
+ super().__init__(*args, **kwargs)
15
+ self._volume = volume
16
+ self._vol_type = self.__get_volume_type(vol_type)
17
+ self._max_size: Optional[float] = max_size
18
+ self._limit: Optional[int] = limit
19
+
20
+ def pick(self, volumes: list[VolInfo]) -> list[VolInfo]:
21
+ volume_data = volumes
22
+
23
+ if self._vol_type is not None:
24
+ volume_data = filter(lambda x: x.vol_type == self._vol_type, volume_data)
25
+
26
+ if (choice := resolve_volume(self._volume)) is not None:
27
+ volume_data = filter(lambda x: x.index in choice, volume_data)
28
+
29
+ if self._max_size is not None:
30
+ volume_data = filter(lambda x: x.size <= self._max_size, volume_data)
31
+
32
+ if self._limit is not None:
33
+ return list(volume_data)[:self._limit]
34
+ else:
35
+ return list(volume_data)
36
+
37
+ def __get_volume_type(self, vol_type: str) -> Optional[VolumeType]:
38
+ assert vol_type in {'vol', 'extra', 'seri', 'all'}, f"Invalid volume type: {vol_type}"
39
+
40
+ if vol_type == 'vol':
41
+ return VolumeType.VOLUME
42
+ elif vol_type == 'extra':
43
+ return VolumeType.EXTRA
44
+ elif vol_type == 'seri':
45
+ return VolumeType.SERIALIZED
46
+ elif vol_type == 'all':
47
+ return None
48
+ else:
49
+ raise ValueError(f"Unknown volume type: {vol_type}")
@@ -0,0 +1,21 @@
1
+ from kmdr.core import Picker, PICKERS, VolInfo
2
+
3
+ from .utils import resolve_volume
4
+
5
+ @PICKERS.register()
6
+ class DefaultVolPicker(Picker):
7
+
8
+ def __init__(self, *args, **kwargs):
9
+ super().__init__(*args, **kwargs)
10
+
11
+ def pick(self, volumes: list[VolInfo]) -> list[VolInfo]:
12
+ print("\t卷类型\t页数\t大小(MB)\t卷名")
13
+ for index, volume in enumerate(volumes):
14
+ print(f"[{index + 1}]\t{volume.vol_type.value}\t{volume.pages}\t{volume.size:.2f}\t\t{volume.name}")
15
+
16
+ choosed = input("choose a volume to download (e.g. 'all', '1,2,3', '1-3,4-6'):\n")
17
+
18
+ if (chosen := resolve_volume(choosed)) is None:
19
+ return volumes
20
+
21
+ return [volumes[i - 1] for i in chosen if 1 <= i <= len(volumes)]
@@ -0,0 +1,37 @@
1
+ from typing import Optional
2
+
3
+ def resolve_volume(volume: str) -> Optional[set[int]]:
4
+ if volume == 'all':
5
+ return None
6
+
7
+ if ',' in volume:
8
+ # 如果使用分隔符
9
+ volumes = volume.split(',')
10
+ volumes = [resolve_volume(v) for v in volumes]
11
+
12
+ ret = set()
13
+ for v in volumes:
14
+ if v is not None:
15
+ ret.update(v)
16
+
17
+ return ret
18
+
19
+ if (volume := volume.strip()).isdigit():
20
+ # 只有一个数字
21
+ assert (volume := int(volume)) > 0, "Volume number must be greater than 0."
22
+ return {volume}
23
+ elif '-' in volume and volume.count('-') == 1 and ',' not in volume:
24
+ # 使用了范围符号
25
+ start, end = volume.split('-')
26
+
27
+ assert start.strip().isdigit() and end.strip().isdigit(), "Invalid range format. Use 'start-end' or 'start, end'."
28
+
29
+ start = int(start.strip())
30
+ end = int(end.strip())
31
+
32
+ assert start > 0 and end > 0, "Volume numbers must be greater than 0."
33
+ assert start <= end, "Start of range must be less than or equal to end."
34
+
35
+ return set(range(start, end + 1))
36
+
37
+ raise ValueError(f"Invalid volume format: {volume}. Use 'all', '1,2,3', '1-3', or '1-3,4-6'.")