#!/usr/bin/env python3 """ Loyal Books MP3 Downloader for Windows 从 mp3_link.txt 下载 MP3 压缩包 使用方法: python loyalbooks_downloader.py 功能: - 读取 mp3_link.txt,逐行下载 - 支持断点续传 - 自动更新下载状态 (0->1成功, 0->2失败) - 失败链接可手动改为 0 重新尝试 """ import os import re import time from pathlib import Path from urllib.parse import urlparse try: import requests except ImportError: print("请先安装 requests: pip install requests") exit(1) # 配置 LINKS_FILE = "mp3_link.txt" # 链接文件 DOWNLOAD_DIR = Path("downloads") # 下载目录 DELAY_SECONDS = 10 # 下载间隔(秒) MIN_FILE_SIZE = 1024 * 1024 # 最小文件大小 1MB MAX_RETRIES = 3 # 最大重试次数 CHUNK_SIZE = 8192 # 下载块大小 def sanitize_filename(name): """清理文件名""" # 移除不安全字符 name = re.sub(r'[<>:"/\\|?*]', '_', name) # 限制长度 return name[:100] def parse_links_file(): """解析 mp3_link.txt 文件""" if not os.path.exists(LINKS_FILE): print(f"❌ 找不到文件: {LINKS_FILE}") print("请确保 mp3_link.txt 与脚本在同一目录") return [] links = [] with open(LINKS_FILE, 'r', encoding='utf-8') as f: for line_num, line in enumerate(f, 1): line = line.strip() if not line or line.startswith('#'): continue parts = line.split('|', 1) if len(parts) != 2: print(f"⚠️ 第 {line_num} 行格式错误,跳过: {line}") continue status = parts[0].strip() url = parts[1].strip() if status not in ['0', '1', '2']: print(f"⚠️ 第 {line_num} 行状态码无效,跳过: {line}") continue links.append({ 'line_num': line_num, 'status': status, 'url': url, 'original_line': line }) return links def get_local_filename(url): """从 URL 生成本地文件名""" # 从 URL 提取路径 parsed = urlparse(f"http://{url}") path_parts = parsed.path.split('/') # 使用最后一部分作为文件名 if len(path_parts) >= 2: # 格式: archive.org/download/xxx/xxx.zip # 使用最后两级目录名组合 folder = sanitize_filename(path_parts[-2]) if len(path_parts) >= 2 else "unknown" filename = sanitize_filename(path_parts[-1]) if not filename.endswith('.zip'): filename += '.zip' return folder, filename else: return "unknown", sanitize_filename(path_parts[-1]) def update_link_status(line_num, new_status): """更新 mp3_link.txt 中的状态""" try: with open(LINKS_FILE, 'r', encoding='utf-8') as f: lines = f.readlines() # 修改指定行 if 1 <= line_num <= len(lines): original_line = lines[line_num - 1].strip() if '|' in original_line: parts = original_line.split('|', 1) lines[line_num - 1] = f"{new_status}|{parts[1]}\n" with open(LINKS_FILE, 'w', encoding='utf-8') as f: f.writelines(lines) return True except Exception as e: print(f" ⚠️ 更新状态失败: {e}") return False def download_file(url, dest_path, retries=0): """ 下载文件,支持断点续传 返回: (success: bool, size: int) """ headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } # 确保目录存在 dest_path.parent.mkdir(parents=True, exist_ok=True) # 检查是否已存在有效文件 if dest_path.exists(): existing_size = dest_path.stat().st_size if existing_size >= MIN_FILE_SIZE: print(f" ⏭️ 已存在 ({existing_size // 1024 // 1024}MB),跳过") return True, existing_size # 临时文件路径 temp_path = dest_path.with_suffix('.tmp') resume_byte_pos = 0 # 检查是否有部分下载的文件 if temp_path.exists(): resume_byte_pos = temp_path.stat().st_size print(f" 🔄 续传 ({resume_byte_pos // 1024 // 1024}MB)...", end=' ') headers['Range'] = f'bytes={resume_byte_pos}-' else: print(f" 下载中...", end=' ') try: response = requests.get(f"http://{url}", headers=headers, timeout=300, stream=True) # 如果服务器不支持断点续传,从头下载 if response.status_code == 416: headers.pop('Range', None) resume_byte_pos = 0 response = requests.get(f"http://{url}", headers=headers, timeout=300, stream=True) response.raise_for_status() # 确定写入模式 mode = 'ab' if resume_byte_pos > 0 and response.status_code == 206 else 'wb' downloaded = resume_byte_pos with open(temp_path, mode) as f: for chunk in response.iter_content(chunk_size=CHUNK_SIZE): if chunk: f.write(chunk) downloaded += len(chunk) # 下载完成,重命名为正式文件 temp_path.rename(dest_path) # 检查文件大小 final_size = dest_path.stat().st_size if final_size < MIN_FILE_SIZE: print(f"❌ 文件过小 ({final_size} bytes)") return False, final_size print(f"✅ {final_size // 1024 // 1024}MB") return True, final_size except requests.exceptions.RequestException as e: print(f"❌ 网络错误: {e}") if retries < MAX_RETRIES: print(f" 等待 5 秒后重试 ({retries + 1}/{MAX_RETRIES})...") time.sleep(5) return download_file(url, dest_path, retries + 1) return False, 0 except Exception as e: print(f"❌ 错误: {e}") return False, 0 def main(): """主函数""" print("=" * 60) print("📚 Loyal Books MP3 Downloader") print("=" * 60) print(f"💾 下载目录: {DOWNLOAD_DIR.absolute()}") print(f"🔗 链接文件: {LINKS_FILE}") print(f"⏱️ 下载间隔: {DELAY_SECONDS} 秒") print() # 解析链接文件 links = parse_links_file() if not links: print("❌ 没有需要下载的链接") return # 统计 pending = [l for l in links if l['status'] == '0'] completed = [l for l in links if l['status'] == '1'] skipped = [l for l in links if l['status'] == '2'] print(f"📊 链接统计:") print(f" 总计: {len(links)}") print(f" 待下载 (0): {len(pending)}") print(f" 已完成 (1): {len(completed)}") print(f" 跳过 (2): {len(skipped)}") print() if not pending: print("✅ 所有文件已下载完成!") return print(f"即将开始下载 {len(pending)} 个文件...") print("按 Ctrl+C 可随时暂停\n") success_count = 0 fail_count = 0 try: for i, link in enumerate(pending, 1): print(f"[{i}/{len(pending)}] 行 {link['line_num']}") # 获取本地文件名 folder, filename = get_local_filename(link['url']) dest_dir = DOWNLOAD_DIR / folder dest_path = dest_dir / filename print(f" URL: {link['url'][:60]}...") print(f" 保存到: {dest_path}") # 下载 success, size = download_file(link['url'], dest_path) if success: # 更新状态为 1 (已完成) update_link_status(link['line_num'], '1') success_count += 1 else: # 更新状态为 2 (失败,不再尝试) update_link_status(link['line_num'], '2') fail_count += 1 # 间隔 if i < len(pending): time.sleep(DELAY_SECONDS) print("\n" + "=" * 60) print("✅ 下载完成!") print(f" 成功: {success_count}") print(f" 失败: {fail_count}") print("=" * 60) print() print("提示:") print(" - 失败的链接可在 mp3_link.txt 中将状态改为 0 重新尝试") print(f" - 下载的文件保存在: {DOWNLOAD_DIR.absolute()}") except KeyboardInterrupt: print("\n\n⚠️ 用户中断") print("已保存进度,下次会继续从断点下载") if __name__ == '__main__': main()