Files
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

281 lines
8.7 KiB
Python

#!/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()