mangadl-cli 1.1.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.
Potentially problematic release.
This version of mangadl-cli might be problematic. Click here for more details.
- mangadl/__init__.py +2 -0
- mangadl/__main__.py +4 -0
- mangadl/cli.py +532 -0
- mangadl/downloader.py +372 -0
- mangadl/scraper.py +142 -0
- mangadl_cli-1.1.0.dist-info/METADATA +206 -0
- mangadl_cli-1.1.0.dist-info/RECORD +10 -0
- mangadl_cli-1.1.0.dist-info/WHEEL +5 -0
- mangadl_cli-1.1.0.dist-info/entry_points.txt +2 -0
- mangadl_cli-1.1.0.dist-info/top_level.txt +1 -0
mangadl/__init__.py
ADDED
mangadl/__main__.py
ADDED
mangadl/cli.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import threading
|
|
7
|
+
import shutil
|
|
8
|
+
import argparse
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from .scraper import search_manga, get_all_chapters, filter_chapters, get_chapter_images
|
|
12
|
+
from .downloader import download_chapter, download_chapter_temp, _pack_cbz_merged, _is_termux, DEFAULT_DOWNLOAD_DIR, _has_aria2c, _has_pillow
|
|
13
|
+
|
|
14
|
+
BANNER = r"""
|
|
15
|
+
╔══════════════════════════════════════════════╗
|
|
16
|
+
║ Manga Downloader ║
|
|
17
|
+
║ weebcentral.com | aydinAGF | CBZ/PDF ║
|
|
18
|
+
╚══════════════════════════════════════════════╝
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
HELP = """
|
|
22
|
+
Chapter range formats:
|
|
23
|
+
1 → single chapter
|
|
24
|
+
2-8 → chapters 2 to 8
|
|
25
|
+
1-90 → chapters 1 to 90
|
|
26
|
+
all → every chapter
|
|
27
|
+
1,5,10 → specific chapters
|
|
28
|
+
1-10,20 → mixed
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
FORMAT_MENU = """
|
|
32
|
+
Output format:
|
|
33
|
+
1. CBZ — comic book archive (one per chapter)
|
|
34
|
+
2. PDF — single PDF per chapter{pdf_note}
|
|
35
|
+
3. Images — raw image folder (one folder per chapter)
|
|
36
|
+
4. CBZ (All-in-One) — merge all selected chapters into one big CBZ
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _session_path(download_dir: str, safe_title: str) -> str:
|
|
41
|
+
return os.path.join(download_dir, safe_title, ".mangadl_session.json")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_session(download_dir: str, safe_title: str) -> dict:
|
|
45
|
+
path = _session_path(download_dir, safe_title)
|
|
46
|
+
if os.path.exists(path):
|
|
47
|
+
try:
|
|
48
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
49
|
+
return json.load(f)
|
|
50
|
+
except (json.JSONDecodeError, OSError):
|
|
51
|
+
return {}
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save_session(download_dir: str, safe_title: str, session: dict):
|
|
56
|
+
path = _session_path(download_dir, safe_title)
|
|
57
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
58
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
59
|
+
json.dump(session, f, indent=2)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _clear_session(download_dir: str, safe_title: str):
|
|
63
|
+
path = _session_path(download_dir, safe_title)
|
|
64
|
+
if os.path.exists(path):
|
|
65
|
+
os.remove(path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _live_search() -> Optional[dict]:
|
|
69
|
+
try:
|
|
70
|
+
from prompt_toolkit import prompt
|
|
71
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
72
|
+
|
|
73
|
+
class MangaCompleter(Completer):
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self._cache: dict = {}
|
|
76
|
+
self._last_text = ""
|
|
77
|
+
self._results: List[dict] = []
|
|
78
|
+
|
|
79
|
+
def get_completions(self, document, complete_event):
|
|
80
|
+
text = document.text_before_cursor.strip()
|
|
81
|
+
if len(text) < 2 or text == self._last_text:
|
|
82
|
+
for r in self._results:
|
|
83
|
+
yield Completion(r["title"], start_position=-len(text))
|
|
84
|
+
return
|
|
85
|
+
self._last_text = text
|
|
86
|
+
try:
|
|
87
|
+
self._results = search_manga(text)
|
|
88
|
+
except Exception:
|
|
89
|
+
self._results = []
|
|
90
|
+
for r in self._results:
|
|
91
|
+
yield Completion(r["title"], start_position=-len(text))
|
|
92
|
+
|
|
93
|
+
completer = MangaCompleter()
|
|
94
|
+
print("\n Type manga name — suggestions appear as you type. Press Tab to pick, Enter to search.\n")
|
|
95
|
+
try:
|
|
96
|
+
query = prompt(" Search: ", completer=completer, complete_while_typing=True)
|
|
97
|
+
except (KeyboardInterrupt, EOFError):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
query = query.strip()
|
|
101
|
+
if not query:
|
|
102
|
+
return None
|
|
103
|
+
print(f"\n Searching for '{query}'...")
|
|
104
|
+
results = search_manga(query)
|
|
105
|
+
|
|
106
|
+
except ImportError:
|
|
107
|
+
try:
|
|
108
|
+
print("\n Type manga name and press Enter.\n")
|
|
109
|
+
query = input(" Search: ").strip()
|
|
110
|
+
except (KeyboardInterrupt, EOFError):
|
|
111
|
+
return None
|
|
112
|
+
if not query:
|
|
113
|
+
return None
|
|
114
|
+
print(f" Searching for '{query}'...")
|
|
115
|
+
results = search_manga(query)
|
|
116
|
+
|
|
117
|
+
if not results:
|
|
118
|
+
print(" [!] No results found. Try a different name.")
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
print()
|
|
122
|
+
print(" +--------------------------------------------+")
|
|
123
|
+
print(" | Search Results |")
|
|
124
|
+
print(" +--------------------------------------------+")
|
|
125
|
+
for i, r in enumerate(results[:15], 1):
|
|
126
|
+
title = r["title"][:48]
|
|
127
|
+
print(f" | {i:>2}. {title:<48}|")
|
|
128
|
+
print(" +--------------------------------------------+")
|
|
129
|
+
|
|
130
|
+
while True:
|
|
131
|
+
try:
|
|
132
|
+
choice = input(f"\n Pick a number (1-{min(len(results), 15)}): ").strip()
|
|
133
|
+
if not choice:
|
|
134
|
+
continue
|
|
135
|
+
idx = int(choice) - 1
|
|
136
|
+
if 0 <= idx < min(len(results), 15):
|
|
137
|
+
return results[idx]
|
|
138
|
+
print(" [!] Invalid choice.")
|
|
139
|
+
except ValueError:
|
|
140
|
+
print(" [!] Please enter a number.")
|
|
141
|
+
except (KeyboardInterrupt, EOFError):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_chapter_range(ch_min: float, ch_max: float, total: int) -> str:
|
|
146
|
+
print(HELP)
|
|
147
|
+
while True:
|
|
148
|
+
try:
|
|
149
|
+
raw = input(f" Enter chapter range ({ch_min:.0f}-{ch_max:.0f} available): ").strip()
|
|
150
|
+
if raw:
|
|
151
|
+
return raw
|
|
152
|
+
except (KeyboardInterrupt, EOFError):
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _get_output_format() -> str:
|
|
157
|
+
pdf_note = "" if _has_pillow() else " [!] requires: pip install Pillow"
|
|
158
|
+
print(FORMAT_MENU.format(pdf_note=pdf_note))
|
|
159
|
+
formats = {1: "cbz", 2: "pdf", 3: "images", 4: "cbz-merge"}
|
|
160
|
+
while True:
|
|
161
|
+
try:
|
|
162
|
+
raw = input(" Select format (1-4) [default: 1]: ").strip()
|
|
163
|
+
if not raw:
|
|
164
|
+
return "cbz"
|
|
165
|
+
num = int(raw)
|
|
166
|
+
if num in formats:
|
|
167
|
+
chosen = formats[num]
|
|
168
|
+
if chosen == "pdf" and not _has_pillow():
|
|
169
|
+
print(" [!] Pillow not installed. Install it first: pip install Pillow")
|
|
170
|
+
continue
|
|
171
|
+
return chosen
|
|
172
|
+
print(" [!] Enter 1, 2, 3, or 4.")
|
|
173
|
+
except ValueError:
|
|
174
|
+
print(" [!] Enter 1, 2, 3, or 4.")
|
|
175
|
+
except (KeyboardInterrupt, EOFError):
|
|
176
|
+
return "cbz"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _progress_bar(done: int, total: int, width: int = 30) -> str:
|
|
180
|
+
pct = done / total if total else 0
|
|
181
|
+
filled = int(width * pct)
|
|
182
|
+
bar = "#" * filled + "-" * (width - filled)
|
|
183
|
+
return f"[{bar}] {done}/{total}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _spinner_thread(stop: threading.Event, label: str):
|
|
187
|
+
frames = ["|", "/", "-", "\\"]
|
|
188
|
+
i = 0
|
|
189
|
+
while not stop.is_set():
|
|
190
|
+
sys.stdout.write(f"\r {frames[i % len(frames)]} {label}...")
|
|
191
|
+
sys.stdout.flush()
|
|
192
|
+
stop.wait(0.1)
|
|
193
|
+
i += 1
|
|
194
|
+
sys.stdout.write("\r" + " " * 70 + "\r")
|
|
195
|
+
sys.stdout.flush()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _pick_download_dir(default: str) -> str:
|
|
199
|
+
print(f" Download directory: {default}")
|
|
200
|
+
try:
|
|
201
|
+
raw = input(" Change directory? (y/N) [default: keep current]: ").strip().lower()
|
|
202
|
+
except (KeyboardInterrupt, EOFError):
|
|
203
|
+
print()
|
|
204
|
+
return default
|
|
205
|
+
if raw not in ("y", "yes"):
|
|
206
|
+
return default
|
|
207
|
+
print(" Enter new path (or press Enter to keep current):")
|
|
208
|
+
try:
|
|
209
|
+
new_path = input(f" [{default}]: ").strip()
|
|
210
|
+
except (KeyboardInterrupt, EOFError):
|
|
211
|
+
print()
|
|
212
|
+
return default
|
|
213
|
+
if not new_path:
|
|
214
|
+
return default
|
|
215
|
+
new_path = os.path.expanduser(new_path)
|
|
216
|
+
os.makedirs(new_path, exist_ok=True)
|
|
217
|
+
print(f" Download directory set to: {new_path}\n")
|
|
218
|
+
return new_path
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def main():
|
|
222
|
+
parser = argparse.ArgumentParser(
|
|
223
|
+
prog="mangadl",
|
|
224
|
+
description="Manga downloader for Termux — downloads from weebcentral.com",
|
|
225
|
+
add_help=True,
|
|
226
|
+
)
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"--dir",
|
|
229
|
+
metavar="PATH",
|
|
230
|
+
default=None,
|
|
231
|
+
help=f"Set download directory (default: {DEFAULT_DOWNLOAD_DIR})",
|
|
232
|
+
)
|
|
233
|
+
args = parser.parse_args()
|
|
234
|
+
|
|
235
|
+
print(BANNER)
|
|
236
|
+
|
|
237
|
+
if not _has_aria2c():
|
|
238
|
+
if _is_termux():
|
|
239
|
+
print(" [~] aria2c not found — downloads will use threads (slower).")
|
|
240
|
+
print(" Install with: pkg install aria2\n")
|
|
241
|
+
else:
|
|
242
|
+
print(" [~] aria2c not found — downloads will use threads (slower).")
|
|
243
|
+
print(" Install: https://aria2.github.io/\n")
|
|
244
|
+
|
|
245
|
+
if args.dir:
|
|
246
|
+
download_dir = os.path.expanduser(args.dir)
|
|
247
|
+
os.makedirs(download_dir, exist_ok=True)
|
|
248
|
+
print(f" Download directory: {download_dir}\n")
|
|
249
|
+
else:
|
|
250
|
+
download_dir = os.environ.get("MANGA_DOWNLOAD_DIR", DEFAULT_DOWNLOAD_DIR)
|
|
251
|
+
download_dir = _pick_download_dir(download_dir)
|
|
252
|
+
|
|
253
|
+
while True:
|
|
254
|
+
manga = _live_search()
|
|
255
|
+
if not manga:
|
|
256
|
+
print("\n Goodbye!\n")
|
|
257
|
+
break
|
|
258
|
+
|
|
259
|
+
title = manga["title"]
|
|
260
|
+
series_id = manga["series_id"]
|
|
261
|
+
print(f"\n Selected: {title}")
|
|
262
|
+
|
|
263
|
+
stop = threading.Event()
|
|
264
|
+
t = threading.Thread(target=_spinner_thread, args=(stop, "Fetching chapter list"), daemon=True)
|
|
265
|
+
t.start()
|
|
266
|
+
chapters = get_all_chapters(series_id)
|
|
267
|
+
stop.set()
|
|
268
|
+
t.join()
|
|
269
|
+
|
|
270
|
+
if not chapters:
|
|
271
|
+
print(" [!] Could not fetch chapter list. Try again.")
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
total_ch = len(chapters)
|
|
275
|
+
ch_min = chapters[0][0]
|
|
276
|
+
ch_max = chapters[-1][0]
|
|
277
|
+
print(f" Found {total_ch} chapters (Ch {ch_min:.0f} - Ch {ch_max:.0f})\n")
|
|
278
|
+
|
|
279
|
+
raw_range = _get_chapter_range(ch_min, ch_max, total_ch)
|
|
280
|
+
if not raw_range:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
selected = filter_chapters(chapters, raw_range)
|
|
284
|
+
if not selected:
|
|
285
|
+
print(" [!] No chapters matched that range.")
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
output_format = _get_output_format()
|
|
289
|
+
ext_label = {"cbz": "CBZ", "pdf": "PDF", "images": "Images", "cbz-merge": "CBZ (All-in-One)"}[output_format]
|
|
290
|
+
|
|
291
|
+
print(f"\n Downloading {len(selected)} chapter(s) -> {ext_label}")
|
|
292
|
+
print(f" Saving to: {download_dir}/{title}/")
|
|
293
|
+
print(f" Downloader: {'aria2c' if _has_aria2c() else 'threaded'}\n")
|
|
294
|
+
|
|
295
|
+
safe_title = re.sub(r'[\\/*?:"<>|]', "_", title).strip()
|
|
296
|
+
|
|
297
|
+
if output_format == "cbz-merge":
|
|
298
|
+
session = _load_session(download_dir, safe_title)
|
|
299
|
+
resume = False
|
|
300
|
+
tmp_dirs = []
|
|
301
|
+
downloaded_chapters = set()
|
|
302
|
+
|
|
303
|
+
if session and session.get("title") == title:
|
|
304
|
+
saved_selection = session.get("selected_chapters", [])
|
|
305
|
+
saved_tmps = session.get("tmp_dirs", [])
|
|
306
|
+
valid_tmps = [td for td in saved_tmps if os.path.isdir(td)]
|
|
307
|
+
if valid_tmps and saved_selection:
|
|
308
|
+
downloaded_chapters = set(saved_selection)
|
|
309
|
+
tmp_dirs = valid_tmps
|
|
310
|
+
print(f" [~] Found previous session: {len(valid_tmps)} chapter(s) already downloaded.")
|
|
311
|
+
try:
|
|
312
|
+
choice = input(" Resume? (Y/n): ").strip().lower()
|
|
313
|
+
except (KeyboardInterrupt, EOFError):
|
|
314
|
+
choice = "n"
|
|
315
|
+
if choice != "n":
|
|
316
|
+
resume = True
|
|
317
|
+
print(f" Resuming with {len(valid_tmps)} cached chapter(s)...\n")
|
|
318
|
+
|
|
319
|
+
if not resume:
|
|
320
|
+
session = {"title": title, "selected_chapters": [], "tmp_dirs": []}
|
|
321
|
+
downloaded_chapters = set()
|
|
322
|
+
|
|
323
|
+
pending = [(ch_num, ch_url) for ch_num, ch_url in selected if ch_num not in downloaded_chapters]
|
|
324
|
+
total_selected = len(selected)
|
|
325
|
+
|
|
326
|
+
max_retries = 3
|
|
327
|
+
for attempt in range(max_retries):
|
|
328
|
+
if attempt > 0:
|
|
329
|
+
print(f"\n Retry attempt {attempt + 1}/{max_retries}...")
|
|
330
|
+
pending = [(ch_num, ch_url) for ch_num, ch_url in selected if ch_num not in downloaded_chapters]
|
|
331
|
+
|
|
332
|
+
if not pending:
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
for i, (ch_num, ch_url) in enumerate(pending, 1):
|
|
336
|
+
idx = [c for c, _ in selected].index(ch_num) + 1
|
|
337
|
+
ch_label = f"Ch {ch_num:.0f}" if ch_num == int(ch_num) else f"Ch {ch_num}"
|
|
338
|
+
prefix = f" [{idx}/{total_selected}] {ch_label}"
|
|
339
|
+
|
|
340
|
+
stop2 = threading.Event()
|
|
341
|
+
t2 = threading.Thread(target=_spinner_thread, args=(stop2, f"{prefix} -- fetching pages"), daemon=True)
|
|
342
|
+
t2.start()
|
|
343
|
+
images = get_chapter_images(ch_url)
|
|
344
|
+
stop2.set()
|
|
345
|
+
t2.join()
|
|
346
|
+
|
|
347
|
+
if not images:
|
|
348
|
+
print(f" [!] {ch_label}: no images found, skipping.")
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
page_count = len(images)
|
|
352
|
+
|
|
353
|
+
def on_progress(done: int, total: int, _prefix=prefix):
|
|
354
|
+
bar = _progress_bar(done, total)
|
|
355
|
+
sys.stdout.write(f"\r {_prefix} {bar}")
|
|
356
|
+
sys.stdout.flush()
|
|
357
|
+
|
|
358
|
+
sys.stdout.write(f"\r {prefix} {_progress_bar(0, page_count)}")
|
|
359
|
+
sys.stdout.flush()
|
|
360
|
+
|
|
361
|
+
tmp_dir, ok, log = download_chapter_temp(ch_num, images, on_progress)
|
|
362
|
+
|
|
363
|
+
if ok:
|
|
364
|
+
tmp_dirs.append(tmp_dir)
|
|
365
|
+
downloaded_chapters.add(ch_num)
|
|
366
|
+
sys.stdout.write(f"\r {prefix} {_progress_bar(page_count, page_count)} OK\n")
|
|
367
|
+
session["selected_chapters"] = list(downloaded_chapters)
|
|
368
|
+
session["tmp_dirs"] = tmp_dirs
|
|
369
|
+
_save_session(download_dir, safe_title, session)
|
|
370
|
+
else:
|
|
371
|
+
sys.stdout.write(f"\r {prefix} FAILED\n")
|
|
372
|
+
sys.stdout.flush()
|
|
373
|
+
sys.stdout.flush()
|
|
374
|
+
|
|
375
|
+
failed = [ch for ch, _ in selected if ch not in downloaded_chapters]
|
|
376
|
+
if failed:
|
|
377
|
+
failed_labels = [f"Ch {ch:.0f}" if ch == int(ch) else f"Ch {ch}" for ch in failed]
|
|
378
|
+
print(f"\n [!] {len(failed)} chapter(s) failed: {', '.join(failed_labels)}")
|
|
379
|
+
while True:
|
|
380
|
+
try:
|
|
381
|
+
choice = input(" Retry failed chapters? (Y/n): ").strip().lower()
|
|
382
|
+
except (KeyboardInterrupt, EOFError):
|
|
383
|
+
choice = "n"
|
|
384
|
+
if choice == "n":
|
|
385
|
+
break
|
|
386
|
+
retry_failed = [(ch, [u for c, u in selected if c == ch][0]) for ch in failed]
|
|
387
|
+
for ch_num, ch_url in retry_failed:
|
|
388
|
+
idx = [c for c, _ in selected].index(ch_num) + 1
|
|
389
|
+
ch_label = f"Ch {ch_num:.0f}" if ch_num == int(ch_num) else f"Ch {ch_num}"
|
|
390
|
+
prefix = f" [{idx}/{total_selected}] {ch_label}"
|
|
391
|
+
|
|
392
|
+
stop2 = threading.Event()
|
|
393
|
+
t2 = threading.Thread(target=_spinner_thread, args=(stop2, f"{prefix} -- fetching pages"), daemon=True)
|
|
394
|
+
t2.start()
|
|
395
|
+
images = get_chapter_images(ch_url)
|
|
396
|
+
stop2.set()
|
|
397
|
+
t2.join()
|
|
398
|
+
|
|
399
|
+
if not images:
|
|
400
|
+
print(f" [!] {ch_label}: no images found, skipping.")
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
page_count = len(images)
|
|
404
|
+
|
|
405
|
+
def on_progress(done: int, total: int, _prefix=prefix):
|
|
406
|
+
bar = _progress_bar(done, total)
|
|
407
|
+
sys.stdout.write(f"\r {_prefix} {bar}")
|
|
408
|
+
sys.stdout.flush()
|
|
409
|
+
|
|
410
|
+
sys.stdout.write(f"\r {prefix} {_progress_bar(0, page_count)}")
|
|
411
|
+
sys.stdout.flush()
|
|
412
|
+
|
|
413
|
+
tmp_dir, ok, log = download_chapter_temp(ch_num, images, on_progress)
|
|
414
|
+
|
|
415
|
+
if ok:
|
|
416
|
+
tmp_dirs.append(tmp_dir)
|
|
417
|
+
downloaded_chapters.add(ch_num)
|
|
418
|
+
sys.stdout.write(f"\r {prefix} {_progress_bar(page_count, page_count)} OK\n")
|
|
419
|
+
session["selected_chapters"] = list(downloaded_chapters)
|
|
420
|
+
session["tmp_dirs"] = tmp_dirs
|
|
421
|
+
_save_session(download_dir, safe_title, session)
|
|
422
|
+
else:
|
|
423
|
+
sys.stdout.write(f"\r {prefix} FAILED\n")
|
|
424
|
+
sys.stdout.flush()
|
|
425
|
+
sys.stdout.flush()
|
|
426
|
+
failed = [ch for ch, _ in selected if ch not in downloaded_chapters]
|
|
427
|
+
if not failed:
|
|
428
|
+
break
|
|
429
|
+
print(f"\n [!] Still {len(failed)} chapter(s) failed.")
|
|
430
|
+
|
|
431
|
+
if tmp_dirs:
|
|
432
|
+
out_path = os.path.join(download_dir, safe_title, f"{safe_title} - Merged ({len(tmp_dirs)} ch).cbz")
|
|
433
|
+
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
|
434
|
+
|
|
435
|
+
def on_merge_progress(done: int, total: int):
|
|
436
|
+
bar = _progress_bar(done, total)
|
|
437
|
+
sys.stdout.write(f"\r Merging pages {bar}")
|
|
438
|
+
sys.stdout.flush()
|
|
439
|
+
|
|
440
|
+
print(f"\n Merging {len(tmp_dirs)} chapter(s) into one CBZ...")
|
|
441
|
+
merge_ok, merge_log = _pack_cbz_merged(tmp_dirs, out_path, on_merge_progress)
|
|
442
|
+
|
|
443
|
+
if merge_ok:
|
|
444
|
+
for td in tmp_dirs:
|
|
445
|
+
shutil.rmtree(td, ignore_errors=True)
|
|
446
|
+
_clear_session(download_dir, safe_title)
|
|
447
|
+
sys.stdout.write(f"\r Merging pages {_progress_bar(1, 1)} OK\n")
|
|
448
|
+
print(f"\n Saved: {out_path}")
|
|
449
|
+
print(f" ({len(downloaded_chapters)}/{total_selected} chapters, {merge_log})")
|
|
450
|
+
if failed:
|
|
451
|
+
print(f" [!] {len(failed)} chapter(s) skipped: {', '.join(f'Ch {c:.0f}' if c == int(c) else f'Ch {c}' for c in failed)}")
|
|
452
|
+
else:
|
|
453
|
+
print(f"\n [!] Merge failed: {merge_log}")
|
|
454
|
+
print(" Temp files kept — you can resume next time.")
|
|
455
|
+
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
ok_count = 0
|
|
459
|
+
for i, (ch_num, ch_url) in enumerate(selected, 1):
|
|
460
|
+
ch_label = f"Ch {ch_num:.0f}" if ch_num == int(ch_num) else f"Ch {ch_num}"
|
|
461
|
+
prefix = f" [{i}/{len(selected)}] {ch_label}"
|
|
462
|
+
|
|
463
|
+
stop2 = threading.Event()
|
|
464
|
+
t2 = threading.Thread(target=_spinner_thread, args=(stop2, f"{prefix} -- fetching pages"), daemon=True)
|
|
465
|
+
t2.start()
|
|
466
|
+
images = get_chapter_images(ch_url)
|
|
467
|
+
stop2.set()
|
|
468
|
+
t2.join()
|
|
469
|
+
|
|
470
|
+
if not images:
|
|
471
|
+
print(f" [!] {ch_label}: no images found, skipping.")
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
page_count = len(images)
|
|
475
|
+
|
|
476
|
+
def on_progress(done: int, total: int, _prefix=prefix):
|
|
477
|
+
bar = _progress_bar(done, total)
|
|
478
|
+
sys.stdout.write(f"\r {_prefix} {bar}")
|
|
479
|
+
sys.stdout.flush()
|
|
480
|
+
|
|
481
|
+
sys.stdout.write(f"\r {prefix} {_progress_bar(0, page_count)}")
|
|
482
|
+
sys.stdout.flush()
|
|
483
|
+
|
|
484
|
+
ok, log = download_chapter(
|
|
485
|
+
ch_num, images, title, download_dir,
|
|
486
|
+
on_progress, verbose=True,
|
|
487
|
+
output_format=output_format,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
ch_full = ch_label.replace("Ch ", "Chapter ")
|
|
491
|
+
if output_format == "pdf":
|
|
492
|
+
out_name = f"{safe_title} - {ch_full}.pdf"
|
|
493
|
+
elif output_format == "images":
|
|
494
|
+
out_name = f"{ch_full}/"
|
|
495
|
+
else:
|
|
496
|
+
out_name = f"{safe_title} - {ch_full}.cbz"
|
|
497
|
+
|
|
498
|
+
if ok:
|
|
499
|
+
sys.stdout.write(
|
|
500
|
+
f"\r {prefix} {_progress_bar(page_count, page_count)} OK -> {out_name}\n"
|
|
501
|
+
)
|
|
502
|
+
ok_count += 1
|
|
503
|
+
else:
|
|
504
|
+
sys.stdout.write(f"\r {prefix} FAILED\n")
|
|
505
|
+
sys.stdout.flush()
|
|
506
|
+
print()
|
|
507
|
+
print(" -- Error log -----------------------------------------")
|
|
508
|
+
for line in log.strip().splitlines():
|
|
509
|
+
print(f" {line}")
|
|
510
|
+
print(" -------------------------------------------------------")
|
|
511
|
+
print()
|
|
512
|
+
sys.stdout.flush()
|
|
513
|
+
|
|
514
|
+
print()
|
|
515
|
+
if ok_count == len(selected):
|
|
516
|
+
print(f" All {ok_count} chapter(s) saved as {ext_label} to: {download_dir}/{title}/")
|
|
517
|
+
else:
|
|
518
|
+
print(f" {ok_count}/{len(selected)} chapter(s) saved.")
|
|
519
|
+
|
|
520
|
+
print()
|
|
521
|
+
try:
|
|
522
|
+
again = input(" Download another manga? (Y/n): ").strip().lower()
|
|
523
|
+
if again == "n":
|
|
524
|
+
print("\n Goodbye!\n")
|
|
525
|
+
break
|
|
526
|
+
except (KeyboardInterrupt, EOFError):
|
|
527
|
+
print("\n\n Goodbye!\n")
|
|
528
|
+
break
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
if __name__ == "__main__":
|
|
532
|
+
main()
|
mangadl/downloader.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import threading
|
|
6
|
+
import zipfile
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import List, Callable, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _is_termux() -> bool:
|
|
12
|
+
return os.path.isdir("/data/data/com.termux") or "TERMUX_VERSION" in os.environ
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _default_download_dir() -> str:
|
|
16
|
+
if os.name == "nt":
|
|
17
|
+
home = os.path.expanduser("~")
|
|
18
|
+
for candidate in [
|
|
19
|
+
os.path.join(home, "Downloads", "Manga"),
|
|
20
|
+
os.path.join(home, "Manga"),
|
|
21
|
+
]:
|
|
22
|
+
parent = os.path.dirname(candidate)
|
|
23
|
+
if os.path.isdir(parent):
|
|
24
|
+
return candidate
|
|
25
|
+
return os.path.join(home, "Downloads", "Manga")
|
|
26
|
+
if os.path.isdir("/sdcard"):
|
|
27
|
+
return "/sdcard/Manga"
|
|
28
|
+
home = os.path.expanduser("~")
|
|
29
|
+
return os.path.join(home, "Downloads", "Manga")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_temp_dir() -> str:
|
|
33
|
+
if _is_termux():
|
|
34
|
+
termux_tmp = "/data/data/com.termux/files/usr/tmp"
|
|
35
|
+
if os.path.isdir(termux_tmp):
|
|
36
|
+
return termux_tmp
|
|
37
|
+
return "/tmp"
|
|
38
|
+
for var in ["TEMP", "TMP"]:
|
|
39
|
+
d = os.environ.get(var)
|
|
40
|
+
if d and os.path.isdir(d):
|
|
41
|
+
return d
|
|
42
|
+
return "/tmp"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEFAULT_DOWNLOAD_DIR = _default_download_dir()
|
|
46
|
+
ARIA2C_CONNECTIONS = 8 if _is_termux() else 16
|
|
47
|
+
FALLBACK_WORKERS = 4 if _is_termux() else 8
|
|
48
|
+
MIN_IMAGE_BYTES = 2048
|
|
49
|
+
REQUEST_TIMEOUT = 30 if _is_termux() else 20
|
|
50
|
+
HEADERS = {
|
|
51
|
+
"User-Agent": "Mozilla/5.0 (Linux; Android 13; Termux) AppleWebKit/537.36",
|
|
52
|
+
"Referer": "https://weebcentral.com/",
|
|
53
|
+
"Accept": "image/png,image/webp,image/*,*/*",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _safe_dir_name(name: str) -> str:
|
|
58
|
+
return re.sub(r'[\\/*?:"<>|]', "_", name).strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _has_aria2c() -> bool:
|
|
62
|
+
return shutil.which("aria2c") is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _has_pillow() -> bool:
|
|
66
|
+
try:
|
|
67
|
+
import PIL
|
|
68
|
+
return True
|
|
69
|
+
except ImportError:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _count_valid_images(directory: str) -> int:
|
|
74
|
+
count = 0
|
|
75
|
+
for f in os.listdir(directory):
|
|
76
|
+
if f.startswith("."):
|
|
77
|
+
continue
|
|
78
|
+
if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
|
|
79
|
+
path = os.path.join(directory, f)
|
|
80
|
+
if os.path.getsize(path) >= MIN_IMAGE_BYTES:
|
|
81
|
+
count += 1
|
|
82
|
+
return count
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sorted_images(directory: str) -> List[str]:
|
|
86
|
+
files = []
|
|
87
|
+
for f in sorted(os.listdir(directory)):
|
|
88
|
+
if f.startswith("."):
|
|
89
|
+
continue
|
|
90
|
+
if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".gif")):
|
|
91
|
+
path = os.path.join(directory, f)
|
|
92
|
+
if os.path.getsize(path) >= MIN_IMAGE_BYTES:
|
|
93
|
+
files.append(path)
|
|
94
|
+
return files
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _download_with_aria2c(urls: List[str], output_dir: str) -> tuple:
|
|
98
|
+
url_file = os.path.join(output_dir, ".aria2c_urls.txt")
|
|
99
|
+
with open(url_file, "w") as f:
|
|
100
|
+
for i, url in enumerate(urls):
|
|
101
|
+
ext = url.split("?")[0].rsplit(".", 1)[-1] or "jpg"
|
|
102
|
+
filename = f"{i+1:03d}.{ext}"
|
|
103
|
+
f.write(f"{url}\n")
|
|
104
|
+
f.write(f"\tout={filename}\n")
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
proc = subprocess.Popen(
|
|
108
|
+
[
|
|
109
|
+
"aria2c",
|
|
110
|
+
f"--input-file={url_file}",
|
|
111
|
+
f"--dir={output_dir}",
|
|
112
|
+
f"-x{ARIA2C_CONNECTIONS}",
|
|
113
|
+
f"-s{ARIA2C_CONNECTIONS}",
|
|
114
|
+
"-k", "512K",
|
|
115
|
+
"--auto-file-renaming=false",
|
|
116
|
+
"--allow-overwrite=true",
|
|
117
|
+
"--summary-interval=0",
|
|
118
|
+
"--console-log-level=notice",
|
|
119
|
+
"--referer=https://weebcentral.com/",
|
|
120
|
+
"--user-agent=Mozilla/5.0 (Linux; Android 13; Termux) AppleWebKit/537.36",
|
|
121
|
+
"--header=Accept: image/png,image/webp,image/*,*/*",
|
|
122
|
+
"--header=Accept-Language: en-US,en;q=0.9",
|
|
123
|
+
],
|
|
124
|
+
stdout=subprocess.PIPE,
|
|
125
|
+
stderr=subprocess.STDOUT,
|
|
126
|
+
)
|
|
127
|
+
raw_out, _ = proc.communicate()
|
|
128
|
+
log = raw_out.decode("utf-8", errors="ignore")
|
|
129
|
+
valid = _count_valid_images(output_dir)
|
|
130
|
+
success = valid >= max(1, int(len(urls) * 0.9))
|
|
131
|
+
return success, log
|
|
132
|
+
finally:
|
|
133
|
+
if os.path.exists(url_file):
|
|
134
|
+
os.remove(url_file)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _download_fallback(
|
|
138
|
+
urls: List[str],
|
|
139
|
+
output_dir: str,
|
|
140
|
+
on_progress: Optional[Callable] = None,
|
|
141
|
+
) -> tuple:
|
|
142
|
+
lock = threading.Lock()
|
|
143
|
+
results = [False] * len(urls)
|
|
144
|
+
errors: List[str] = []
|
|
145
|
+
|
|
146
|
+
def worker(idx: int, url: str):
|
|
147
|
+
ext = url.split("?")[0].rsplit(".", 1)[-1] or "jpg"
|
|
148
|
+
path = os.path.join(output_dir, f"{idx+1:03d}.{ext}")
|
|
149
|
+
ok = False
|
|
150
|
+
try:
|
|
151
|
+
req = urllib.request.Request(url, headers=HEADERS)
|
|
152
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
|
|
153
|
+
data = resp.read()
|
|
154
|
+
if len(data) >= MIN_IMAGE_BYTES:
|
|
155
|
+
with open(path, "wb") as fh:
|
|
156
|
+
fh.write(data)
|
|
157
|
+
ok = True
|
|
158
|
+
except Exception as e:
|
|
159
|
+
with lock:
|
|
160
|
+
errors.append(f" [{idx+1:03d}] {e}")
|
|
161
|
+
results[idx] = ok
|
|
162
|
+
if on_progress:
|
|
163
|
+
with lock:
|
|
164
|
+
on_progress(sum(1 for r in results if r), len(urls))
|
|
165
|
+
|
|
166
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
167
|
+
with ThreadPoolExecutor(max_workers=FALLBACK_WORKERS) as pool:
|
|
168
|
+
futures = [pool.submit(worker, i, u) for i, u in enumerate(urls)]
|
|
169
|
+
for f in futures:
|
|
170
|
+
f.result()
|
|
171
|
+
|
|
172
|
+
success = sum(results) >= max(1, int(len(urls) * 0.9))
|
|
173
|
+
return success, "\n".join(errors) if errors else "OK"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _pack_cbz(image_dir: str, cbz_path: str) -> bool:
|
|
177
|
+
files = _sorted_images(image_dir)
|
|
178
|
+
if not files:
|
|
179
|
+
return False
|
|
180
|
+
try:
|
|
181
|
+
with zipfile.ZipFile(cbz_path, "w", compression=zipfile.ZIP_STORED) as zf:
|
|
182
|
+
for fpath in files:
|
|
183
|
+
zf.write(fpath, arcname=os.path.basename(fpath))
|
|
184
|
+
return True
|
|
185
|
+
except Exception:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _pack_cbz_merged(
|
|
190
|
+
chapter_dirs: List[str],
|
|
191
|
+
cbz_path: str,
|
|
192
|
+
on_progress: Optional[Callable] = None,
|
|
193
|
+
) -> tuple:
|
|
194
|
+
all_files = []
|
|
195
|
+
for ch_dir in chapter_dirs:
|
|
196
|
+
files = _sorted_images(ch_dir)
|
|
197
|
+
all_files.extend(files)
|
|
198
|
+
|
|
199
|
+
if not all_files:
|
|
200
|
+
return False, "No valid images found across any chapter."
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
total = len(all_files)
|
|
204
|
+
with zipfile.ZipFile(cbz_path, "w", compression=zipfile.ZIP_STORED) as zf:
|
|
205
|
+
for i, fpath in enumerate(all_files):
|
|
206
|
+
ext = os.path.splitext(fpath)[1].lower() or ".jpg"
|
|
207
|
+
arcname = f"{i + 1:04d}{ext}"
|
|
208
|
+
zf.write(fpath, arcname=arcname)
|
|
209
|
+
if on_progress:
|
|
210
|
+
on_progress(i + 1, total)
|
|
211
|
+
return True, f"Merged {total} pages into single CBZ."
|
|
212
|
+
except Exception as e:
|
|
213
|
+
return False, f"Merged CBZ failed: {e}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def download_chapter_temp(
|
|
217
|
+
chapter_num: float,
|
|
218
|
+
images: List[str],
|
|
219
|
+
on_progress: Optional[Callable] = None,
|
|
220
|
+
) -> tuple:
|
|
221
|
+
tmp_dir = os.path.join(_get_temp_dir(), f".mangadl_ch_{chapter_num}_{os.getpid()}")
|
|
222
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
if _has_aria2c():
|
|
226
|
+
ok, log = _download_with_aria2c(images, tmp_dir)
|
|
227
|
+
if on_progress:
|
|
228
|
+
on_progress(len(images), len(images))
|
|
229
|
+
if not ok:
|
|
230
|
+
log += "\n [~] aria2c failed — retrying with built-in downloader...\n"
|
|
231
|
+
for f in os.listdir(tmp_dir):
|
|
232
|
+
fp = os.path.join(tmp_dir, f)
|
|
233
|
+
if os.path.getsize(fp) < MIN_IMAGE_BYTES:
|
|
234
|
+
os.remove(fp)
|
|
235
|
+
ok2, log2 = _download_fallback(images, tmp_dir, on_progress)
|
|
236
|
+
log += log2
|
|
237
|
+
ok = ok2
|
|
238
|
+
else:
|
|
239
|
+
ok, log = _download_fallback(images, tmp_dir, on_progress)
|
|
240
|
+
|
|
241
|
+
if not ok:
|
|
242
|
+
valid = _count_valid_images(tmp_dir)
|
|
243
|
+
log += f"\n [!] Only {valid}/{len(images)} pages downloaded successfully."
|
|
244
|
+
return tmp_dir, False, log
|
|
245
|
+
|
|
246
|
+
return tmp_dir, True, log
|
|
247
|
+
except Exception as e:
|
|
248
|
+
return tmp_dir, False, str(e)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _pack_pdf(image_dir: str, pdf_path: str) -> bool:
|
|
252
|
+
try:
|
|
253
|
+
from PIL import Image
|
|
254
|
+
except ImportError:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
files = _sorted_images(image_dir)
|
|
258
|
+
if not files:
|
|
259
|
+
return False
|
|
260
|
+
try:
|
|
261
|
+
imgs = []
|
|
262
|
+
for fpath in files:
|
|
263
|
+
img = Image.open(fpath).convert("RGB")
|
|
264
|
+
imgs.append(img)
|
|
265
|
+
if not imgs:
|
|
266
|
+
return False
|
|
267
|
+
imgs[0].save(
|
|
268
|
+
pdf_path,
|
|
269
|
+
save_all=True,
|
|
270
|
+
append_images=imgs[1:],
|
|
271
|
+
format="PDF",
|
|
272
|
+
)
|
|
273
|
+
return True
|
|
274
|
+
except Exception:
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _copy_images(image_dir: str, dest_dir: str) -> bool:
|
|
279
|
+
files = _sorted_images(image_dir)
|
|
280
|
+
if not files:
|
|
281
|
+
return False
|
|
282
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
283
|
+
for fpath in files:
|
|
284
|
+
shutil.copy2(fpath, dest_dir)
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def download_chapter(
|
|
289
|
+
chapter_num: float,
|
|
290
|
+
images: List[str],
|
|
291
|
+
manga_title: str,
|
|
292
|
+
base_dir: str = DEFAULT_DOWNLOAD_DIR,
|
|
293
|
+
on_progress: Optional[Callable] = None,
|
|
294
|
+
verbose: bool = False,
|
|
295
|
+
output_format: str = "cbz",
|
|
296
|
+
) -> tuple:
|
|
297
|
+
if not images:
|
|
298
|
+
return False, "No image URLs found for this chapter."
|
|
299
|
+
|
|
300
|
+
safe_title = _safe_dir_name(manga_title)
|
|
301
|
+
ch_label = (
|
|
302
|
+
f"Chapter {int(chapter_num)}"
|
|
303
|
+
if chapter_num == int(chapter_num)
|
|
304
|
+
else f"Chapter {chapter_num}"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
series_dir = os.path.join(base_dir, safe_title)
|
|
308
|
+
os.makedirs(series_dir, exist_ok=True)
|
|
309
|
+
|
|
310
|
+
if output_format == "pdf":
|
|
311
|
+
out_path = os.path.join(series_dir, f"{safe_title} - {ch_label}.pdf")
|
|
312
|
+
elif output_format == "images":
|
|
313
|
+
out_path = os.path.join(series_dir, ch_label)
|
|
314
|
+
else:
|
|
315
|
+
out_path = os.path.join(series_dir, f"{safe_title} - {ch_label}.cbz")
|
|
316
|
+
|
|
317
|
+
if output_format == "images":
|
|
318
|
+
if os.path.isdir(out_path) and len(_sorted_images(out_path)) > 0:
|
|
319
|
+
if on_progress:
|
|
320
|
+
on_progress(len(images), len(images))
|
|
321
|
+
return True, "Already downloaded (skipped)."
|
|
322
|
+
else:
|
|
323
|
+
if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
|
|
324
|
+
if on_progress:
|
|
325
|
+
on_progress(len(images), len(images))
|
|
326
|
+
return True, "Already downloaded (skipped)."
|
|
327
|
+
|
|
328
|
+
tmp_dir = os.path.join(series_dir, f".tmp_{ch_label}")
|
|
329
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
330
|
+
|
|
331
|
+
log = ""
|
|
332
|
+
try:
|
|
333
|
+
if _has_aria2c():
|
|
334
|
+
ok, log = _download_with_aria2c(images, tmp_dir)
|
|
335
|
+
if on_progress:
|
|
336
|
+
on_progress(len(images), len(images))
|
|
337
|
+
if not ok:
|
|
338
|
+
log += "\n [~] aria2c failed — retrying with built-in downloader...\n"
|
|
339
|
+
for f in os.listdir(tmp_dir):
|
|
340
|
+
fp = os.path.join(tmp_dir, f)
|
|
341
|
+
if os.path.getsize(fp) < MIN_IMAGE_BYTES:
|
|
342
|
+
os.remove(fp)
|
|
343
|
+
ok2, log2 = _download_fallback(images, tmp_dir, on_progress)
|
|
344
|
+
log += log2
|
|
345
|
+
ok = ok2
|
|
346
|
+
else:
|
|
347
|
+
ok, log = _download_fallback(images, tmp_dir, on_progress)
|
|
348
|
+
|
|
349
|
+
if not ok:
|
|
350
|
+
valid = _count_valid_images(tmp_dir)
|
|
351
|
+
log += f"\n [!] Only {valid}/{len(images)} pages downloaded successfully."
|
|
352
|
+
return False, log
|
|
353
|
+
|
|
354
|
+
if output_format == "pdf":
|
|
355
|
+
packed = _pack_pdf(tmp_dir, out_path)
|
|
356
|
+
elif output_format == "images":
|
|
357
|
+
packed = _copy_images(tmp_dir, out_path)
|
|
358
|
+
else:
|
|
359
|
+
packed = _pack_cbz(tmp_dir, out_path)
|
|
360
|
+
|
|
361
|
+
if not packed:
|
|
362
|
+
if output_format == "pdf" and not _has_pillow():
|
|
363
|
+
log += "\n [!] PDF output requires Pillow: pip install Pillow"
|
|
364
|
+
else:
|
|
365
|
+
log += "\n [!] Packing failed — no valid images in temp folder."
|
|
366
|
+
return False, log
|
|
367
|
+
|
|
368
|
+
return True, log
|
|
369
|
+
|
|
370
|
+
finally:
|
|
371
|
+
if os.path.exists(tmp_dir):
|
|
372
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
mangadl/scraper.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import os
|
|
3
|
+
import urllib.request
|
|
4
|
+
import urllib.parse
|
|
5
|
+
from typing import List, Optional, Tuple, Dict
|
|
6
|
+
|
|
7
|
+
BASE_URL = "https://weebcentral.com"
|
|
8
|
+
REQUEST_TIMEOUT = 30 if os.path.isdir("/data/data/com.termux") or "TERMUX_VERSION" in os.environ else 20
|
|
9
|
+
HEADERS = {
|
|
10
|
+
"User-Agent": "Mozilla/5.0 (Linux; Android 13; Termux) AppleWebKit/537.36",
|
|
11
|
+
"Accept": "text/html,*/*",
|
|
12
|
+
"Referer": BASE_URL + "/",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get(url: str, extra_headers: Optional[dict] = None) -> str:
|
|
17
|
+
h = dict(HEADERS)
|
|
18
|
+
if extra_headers:
|
|
19
|
+
h.update(extra_headers)
|
|
20
|
+
req = urllib.request.Request(url, headers=h)
|
|
21
|
+
resp = urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT)
|
|
22
|
+
return resp.read().decode("utf-8", errors="ignore")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _post(url: str, data: dict, extra_headers: Optional[dict] = None) -> str:
|
|
26
|
+
h = dict(HEADERS)
|
|
27
|
+
h["Content-Type"] = "application/x-www-form-urlencoded"
|
|
28
|
+
h["HX-Request"] = "true"
|
|
29
|
+
if extra_headers:
|
|
30
|
+
h.update(extra_headers)
|
|
31
|
+
encoded = urllib.parse.urlencode(data).encode()
|
|
32
|
+
req = urllib.request.Request(url, data=encoded, headers=h)
|
|
33
|
+
resp = urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT)
|
|
34
|
+
return resp.read().decode("utf-8", errors="ignore")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def search_manga(query: str) -> List[Dict]:
|
|
38
|
+
try:
|
|
39
|
+
html = _post(
|
|
40
|
+
f"{BASE_URL}/search/simple?location=main",
|
|
41
|
+
{"text": query},
|
|
42
|
+
{"HX-Request": "true"},
|
|
43
|
+
)
|
|
44
|
+
except Exception:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
results = []
|
|
48
|
+
pairs = re.findall(
|
|
49
|
+
r'href="(https://weebcentral\.com/series/([^/]+)/([^"]+))"[^>]*>.*?'
|
|
50
|
+
r'<div[^>]*class="[^"]*flex-1[^"]*"[^>]*>\s*(.*?)\s*</div>',
|
|
51
|
+
html,
|
|
52
|
+
re.DOTALL,
|
|
53
|
+
)
|
|
54
|
+
for url, series_id, slug, raw_title in pairs:
|
|
55
|
+
title = re.sub(r"<[^>]+>", "", raw_title).strip()
|
|
56
|
+
if title:
|
|
57
|
+
results.append({"title": title, "url": url, "series_id": series_id, "slug": slug})
|
|
58
|
+
return results
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_all_chapters(series_id: str) -> List[Tuple[float, str]]:
|
|
62
|
+
url = f"{BASE_URL}/series/{series_id}/full-chapter-list"
|
|
63
|
+
try:
|
|
64
|
+
html = _get(url, {"HX-Request": "true"})
|
|
65
|
+
except Exception:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
pairs = re.findall(
|
|
69
|
+
r'href="(https://weebcentral\.com/chapters/[^"]+)"[^>]*>.*?'
|
|
70
|
+
r'<span class="">\s*([^<]+?)\s*</span>',
|
|
71
|
+
html,
|
|
72
|
+
re.DOTALL,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
result = []
|
|
76
|
+
for ch_url, title_text in pairs:
|
|
77
|
+
num_match = re.search(r'([\d]+(?:\.\d+)?)\s*$', title_text.strip())
|
|
78
|
+
if not num_match:
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
ch_num = float(num_match.group(1))
|
|
82
|
+
result.append((ch_num, ch_url))
|
|
83
|
+
except ValueError:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
result.sort(key=lambda x: x[0])
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def filter_chapters(
|
|
91
|
+
chapters: List[Tuple[float, str]], chapter_range: str
|
|
92
|
+
) -> List[Tuple[float, str]]:
|
|
93
|
+
if chapter_range.strip().lower() == "all":
|
|
94
|
+
return chapters
|
|
95
|
+
|
|
96
|
+
ch_map = {ch_num: ch_url for ch_num, ch_url in chapters}
|
|
97
|
+
all_nums = sorted(ch_map.keys())
|
|
98
|
+
|
|
99
|
+
selected = set()
|
|
100
|
+
for part in chapter_range.split(","):
|
|
101
|
+
part = part.strip()
|
|
102
|
+
if "-" in part:
|
|
103
|
+
bounds = part.split("-", 1)
|
|
104
|
+
try:
|
|
105
|
+
lo, hi = float(bounds[0]), float(bounds[1])
|
|
106
|
+
for n in all_nums:
|
|
107
|
+
if lo <= n <= hi:
|
|
108
|
+
selected.add(n)
|
|
109
|
+
except ValueError:
|
|
110
|
+
pass
|
|
111
|
+
else:
|
|
112
|
+
try:
|
|
113
|
+
n = float(part)
|
|
114
|
+
closest = min(all_nums, key=lambda x: abs(x - n))
|
|
115
|
+
if abs(closest - n) < 0.6:
|
|
116
|
+
selected.add(closest)
|
|
117
|
+
except ValueError:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
return [(n, ch_map[n]) for n in sorted(selected) if n in ch_map]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_chapter_images(chapter_url: str) -> List[str]:
|
|
124
|
+
chapter_id = chapter_url.rstrip("/").split("/")[-1]
|
|
125
|
+
img_url = f"{BASE_URL}/chapters/{chapter_id}/images?is_prev=False&reading_style=long_strip"
|
|
126
|
+
try:
|
|
127
|
+
html = _get(img_url, {"HX-Request": "true"})
|
|
128
|
+
except Exception:
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
imgs = re.findall(
|
|
132
|
+
r'<img\s[^>]*src="(https://[^"]+\.(?:jpg|png|webp|jpeg)[^"]*)"',
|
|
133
|
+
html,
|
|
134
|
+
re.IGNORECASE,
|
|
135
|
+
)
|
|
136
|
+
seen = set()
|
|
137
|
+
unique = []
|
|
138
|
+
for img in imgs:
|
|
139
|
+
if img not in seen:
|
|
140
|
+
seen.add(img)
|
|
141
|
+
unique.append(img)
|
|
142
|
+
return unique
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mangadl-cli
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: A manga downloader CLI for Termux — downloads from weebcentral.com with CBZ, PDF, or image output, chapter range selection, live search, and aria2c downloading.
|
|
5
|
+
Author: aydinAGF
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/aydinAGF/mangadl
|
|
8
|
+
Keywords: manga,downloader,termux,weebcentral,android,cli,aria2c
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Topic :: Multimedia
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: prompt_toolkit>=3.0.0
|
|
17
|
+
Requires-Dist: Pillow>=9.0.0
|
|
18
|
+
|
|
19
|
+
# mangadl
|
|
20
|
+
|
|
21
|
+
A manga downloader CLI that downloads from [weebcentral.com](https://weebcentral.com). Works on **Android (Termux)**, **Windows**, **Linux**, and **macOS**.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- **Live search** — type manga name, get suggestions as you type
|
|
28
|
+
- **Chapter range selection** — single, range, batch, mixed
|
|
29
|
+
- **Multiple output formats** — CBZ, PDF, Images, or merged All-in-One CBZ
|
|
30
|
+
- **Fast downloads** — aria2c multi-connection downloading
|
|
31
|
+
- **Resume support** — interrupted downloads continue from where they stopped
|
|
32
|
+
- **Auto-retry** — failed chapters retry automatically
|
|
33
|
+
- **Cross-platform** — same command on all platforms
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
### Android (Termux)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Update packages
|
|
43
|
+
pkg update && pkg upgrade
|
|
44
|
+
|
|
45
|
+
# Install dependencies
|
|
46
|
+
pkg install aria2 python
|
|
47
|
+
|
|
48
|
+
# Install mangadl
|
|
49
|
+
pip install mangadl
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Grant storage access so mangadl can save to your gallery:
|
|
53
|
+
```bash
|
|
54
|
+
termux-setup-storage
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Windows
|
|
58
|
+
|
|
59
|
+
1. Install Python from https://www.python.org/downloads/ (check "Add to PATH")
|
|
60
|
+
2. Open Command Prompt or PowerShell:
|
|
61
|
+
```bash
|
|
62
|
+
pip install mangadl
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Optional — install aria2 for faster downloads: https://aria2.github.io/
|
|
66
|
+
|
|
67
|
+
### Linux (Debian/Ubuntu)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Install dependencies
|
|
71
|
+
sudo apt update
|
|
72
|
+
sudo apt install python3-pip aria2
|
|
73
|
+
|
|
74
|
+
# Install mangadl
|
|
75
|
+
pip install mangadl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### macOS
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Install dependencies
|
|
82
|
+
brew install aria2
|
|
83
|
+
|
|
84
|
+
# Install mangadl
|
|
85
|
+
pip3 install mangadl
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Usage
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
mangadl
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
1. Type manga name — suggestions appear as you type
|
|
97
|
+
2. Pick a result from the numbered list
|
|
98
|
+
3. Enter chapter range (see format below)
|
|
99
|
+
4. Choose output format
|
|
100
|
+
5. Downloads start automatically
|
|
101
|
+
|
|
102
|
+
### Command-line options
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Set custom download directory
|
|
106
|
+
mangadl --dir /path/to/manga
|
|
107
|
+
|
|
108
|
+
# Show help
|
|
109
|
+
mangadl --help
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Output formats
|
|
115
|
+
|
|
116
|
+
| Format | Description |
|
|
117
|
+
|--------|-------------|
|
|
118
|
+
| **CBZ** | Comic book archive — one file per chapter, best for comic readers |
|
|
119
|
+
| **PDF** | Single PDF per chapter (requires `pip install Pillow`) |
|
|
120
|
+
| **Images** | Raw image folder — one folder per chapter |
|
|
121
|
+
| **CBZ (All-in-One)** | Merge all selected chapters into one big CBZ file |
|
|
122
|
+
|
|
123
|
+
### All-in-One CBZ
|
|
124
|
+
|
|
125
|
+
When you select "CBZ (All-in-One)", mangadl:
|
|
126
|
+
1. Downloads all selected chapters to temp files
|
|
127
|
+
2. Numbers pages sequentially across chapters (0001, 0002, ...)
|
|
128
|
+
3. Merges everything into a single CBZ
|
|
129
|
+
|
|
130
|
+
Example: Chapter 1 (20 pages) + Chapter 2 (15 pages) = one CBZ with 35 pages numbered 0001-0035.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Chapter range formats
|
|
135
|
+
|
|
136
|
+
| Input | Meaning |
|
|
137
|
+
|-------|---------|
|
|
138
|
+
| `1` | Single chapter 1 |
|
|
139
|
+
| `2-8` | Chapters 2 through 8 |
|
|
140
|
+
| `1-90` | Chapters 1 through 90 |
|
|
141
|
+
| `all` | Every chapter |
|
|
142
|
+
| `1,5,10` | Specific chapters |
|
|
143
|
+
| `1-10,20,30-35` | Mixed ranges |
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Default download locations
|
|
148
|
+
|
|
149
|
+
| Platform | Default path |
|
|
150
|
+
|----------|-------------|
|
|
151
|
+
| Android (Termux) | `/sdcard/Manga/` |
|
|
152
|
+
| Windows | `C:\Users\<you>\Downloads\Manga\` |
|
|
153
|
+
| Linux | `~/Downloads/Manga/` |
|
|
154
|
+
| macOS | `~/Downloads/Manga/` |
|
|
155
|
+
|
|
156
|
+
You can change the directory:
|
|
157
|
+
- At startup when prompted
|
|
158
|
+
- With the `--dir` flag: `mangadl --dir /custom/path`
|
|
159
|
+
- With environment variable: `MANGA_DOWNLOAD_DIR=/path manga2`
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Requirements
|
|
164
|
+
|
|
165
|
+
| Tool | Required? | Install |
|
|
166
|
+
|------|-----------|---------|
|
|
167
|
+
| Python 3.8+ | Yes | Pre-installed on most systems |
|
|
168
|
+
| aria2 | Recommended | `pkg install aria2` / `apt install aria2` / `brew install aria2` |
|
|
169
|
+
| prompt_toolkit | Yes (included) | Installed automatically |
|
|
170
|
+
| Pillow | Optional | `pip install Pillow` (for PDF output) |
|
|
171
|
+
|
|
172
|
+
Without aria2, mangadl uses built-in threaded downloads (slower but works).
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Troubleshooting
|
|
177
|
+
|
|
178
|
+
### aria2c not found
|
|
179
|
+
```bash
|
|
180
|
+
# Termux
|
|
181
|
+
pkg install aria2
|
|
182
|
+
|
|
183
|
+
# Windows — download from https://aria2.github.io/
|
|
184
|
+
# Linux
|
|
185
|
+
sudo apt install aria2
|
|
186
|
+
|
|
187
|
+
# macOS
|
|
188
|
+
brew install aria2
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Permission denied on /sdcard (Termux)
|
|
192
|
+
```bash
|
|
193
|
+
termux-setup-storage
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Python not found (Windows)
|
|
197
|
+
Make sure you checked "Add Python to PATH" during install. Then restart your terminal.
|
|
198
|
+
|
|
199
|
+
### Slow downloads
|
|
200
|
+
Install aria2 for faster multi-connection downloading.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mangadl/__init__.py,sha256=wtxrJTbU0eJJbo3nQ9mx96IOvuQAtlScbTKpRfwisbc,46
|
|
2
|
+
mangadl/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
|
|
3
|
+
mangadl/cli.py,sha256=LW5K0zG8bARokEq-ucB2FSXtWzGR0rRlDfpWh1n1fMs,20864
|
|
4
|
+
mangadl/downloader.py,sha256=CaYdHyXnPoAq_j_2SWuCZGqwP9LwjID0qty50xqvYS8,11911
|
|
5
|
+
mangadl/scraper.py,sha256=C3-cXfnNrVn2Y5QAj6XnVyA6kyoMIlqjX1o9P4GECYs,4329
|
|
6
|
+
mangadl_cli-1.1.0.dist-info/METADATA,sha256=qeE2jsxMQdx7SWlGLnyDp2ufmdI6QAxrHe70yD-aX5I,4984
|
|
7
|
+
mangadl_cli-1.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
mangadl_cli-1.1.0.dist-info/entry_points.txt,sha256=kHnLVJiPFwNS91CXriWmpm1mCryeGcksuG-EQv6wxqY,45
|
|
9
|
+
mangadl_cli-1.1.0.dist-info/top_level.txt,sha256=QYyBQW5jyNN7kfHTEAdG5Sub3KjfcNYgnWPThzhBhDg,8
|
|
10
|
+
mangadl_cli-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mangadl
|