feat: 重大优化更新 - 修复v14.0并发布Pro版
✨ 新功能: - 创建bilibili_audio_downloader_pro.py (Pro版) - 全新架构重构 - 添加config.json配置文件支持 - 新增INSTALL.md详细安装指南 - 新增VERSION_COMPARISON.md版本对比说明 🐛 Bug修复 (v14.0): - 修复中文编码显示问题 - 修复ffmpeg进度条计算不准确 - 改进yt-dlp下载进度解析 - 添加重试机制和超时设置 - 完善错误处理和日志记录 🏗️ Pro版优势: - 面向对象架构设计,代码更清晰 - 自定义异常类和智能错误恢复 - 准确的进度条显示算法 - 完整的日志系统 - 灵活的配置管理 - 更好的用户体验 📚 文档更新: - 更新README.md,添加版本对比指引 - 更新requirements.txt依赖列表 - 完善项目文档结构
This commit is contained in:
1 parent
af14c12eee
commit
8db1d171a0
465 files changed
+200378
-9739
No files matched your search
@@ -0,0 +1,367 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Dict
|
||||
from tqdm import tqdm
|
||||
import re
|
||||
|
||||
def convert_browser_cookies(browser_cookies: list) -> Dict:
|
||||
"""转换浏览器导出的cookies格式为简单的键值对"""
|
||||
essential_cookies = {}
|
||||
required_keys = ['SESSDATA', 'bili_jct', 'DedeUserID']
|
||||
|
||||
for cookie in browser_cookies:
|
||||
if cookie['name'] in required_keys:
|
||||
essential_cookies[cookie['name']] = cookie['value']
|
||||
|
||||
return essential_cookies
|
||||
|
||||
def load_cookies_from_file() -> Dict:
|
||||
"""从文件加载cookies"""
|
||||
cookie_files = ['cookies.txt', 'bilibili_cookies.json']
|
||||
required_keys = ['SESSDATA', 'bili_jct', 'DedeUserID']
|
||||
|
||||
for cookie_file in cookie_files:
|
||||
if os.path.exists(cookie_file):
|
||||
try:
|
||||
with open(cookie_file, 'r', encoding='utf-8') as f:
|
||||
cookies = {}
|
||||
# 逐行读取文件
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# 跳过空行和注释行(但不跳过#HttpOnly_开头的行)
|
||||
if not line or (line.startswith('#') and not line.startswith('#HttpOnly_')):
|
||||
continue
|
||||
|
||||
try:
|
||||
# 处理HttpOnly标记
|
||||
if line.startswith('#HttpOnly_'):
|
||||
line = line[9:] # 移除'#HttpOnly_'前缀
|
||||
|
||||
# 分割字段并移除可能的空字段
|
||||
fields = [f for f in line.split('\t') if f]
|
||||
if len(fields) >= 7:
|
||||
name = fields[5]
|
||||
value = fields[6]
|
||||
if name in required_keys:
|
||||
# 对于SESSDATA,需要处理URL编码
|
||||
if name == 'SESSDATA' and '%2C' in value:
|
||||
value = value.replace('%2C', ',')
|
||||
cookies[name] = value
|
||||
print(f"找到cookie: {name}") # 调试信息
|
||||
except Exception as e:
|
||||
print(f"警告: 解析行 '{line}' 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
# 检查是否找到所有必需的cookies
|
||||
if all(key in cookies for key in required_keys):
|
||||
print(f"已从 {cookie_file} 加载cookies")
|
||||
return cookies
|
||||
else:
|
||||
missing = [key for key in required_keys if key not in cookies]
|
||||
print(f"警告: {cookie_file} 中缺少以下cookie字段: {', '.join(missing)}")
|
||||
except Exception as e:
|
||||
print(f"警告: 读取 {cookie_file} 时出错: {str(e)}")
|
||||
return {}
|
||||
|
||||
def parse_cookie_string(cookie_str: str) -> Dict:
|
||||
"""解析浏览器直接复制的cookie字符串"""
|
||||
cookies = {}
|
||||
required_keys = ['SESSDATA', 'bili_jct', 'DedeUserID']
|
||||
|
||||
try:
|
||||
# 分割cookie字符串
|
||||
cookie_pairs = cookie_str.split(';')
|
||||
for pair in cookie_pairs:
|
||||
if '=' in pair:
|
||||
name, value = pair.strip().split('=', 1)
|
||||
if name in required_keys:
|
||||
cookies[name] = value
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
return cookies
|
||||
|
||||
def get_cookies_from_user() -> Dict:
|
||||
"""获取用户输入的cookies"""
|
||||
print("请输入B站cookies (支持JSON格式、浏览器导出格式或直接复制的cookie字符串):")
|
||||
cookies_str = input().strip()
|
||||
|
||||
# 首先尝试解析为JSON
|
||||
try:
|
||||
cookies_data = json.loads(cookies_str)
|
||||
if isinstance(cookies_data, list):
|
||||
cookies = convert_browser_cookies(cookies_data)
|
||||
else:
|
||||
cookies = cookies_data
|
||||
except json.JSONDecodeError:
|
||||
# 如果不是JSON,尝试解析为cookie字符串
|
||||
cookies = parse_cookie_string(cookies_str)
|
||||
|
||||
# 验证必要的cookie字段
|
||||
if all(key in cookies for key in ['SESSDATA', 'bili_jct', 'DedeUserID']):
|
||||
return cookies
|
||||
else:
|
||||
print("Cookie缺少必要字段!需要SESSDATA、bili_jct和DedeUserID")
|
||||
return {}
|
||||
|
||||
def save_cookies(cookies: Dict):
|
||||
"""保存cookies为Netscape格式"""
|
||||
with open('cookies.txt', 'w', encoding='utf-8') as f:
|
||||
# 写入Netscape cookies文件头
|
||||
f.write("# Netscape HTTP Cookie File\n")
|
||||
f.write("# https://curl.haxx.se/rfc/cookie_spec.html\n")
|
||||
f.write("# This is a generated file! Do not edit.\n\n")
|
||||
|
||||
# 为每个cookie写入Netscape格式的行
|
||||
for name, value in cookies.items():
|
||||
# 特殊处理SESSDATA,添加HttpOnly和Secure标记
|
||||
if name == 'SESSDATA':
|
||||
f.write(f"#HttpOnly_.bilibili.com\tTRUE\t/\tTRUE\t1735689600\t{name}\t{value}\n")
|
||||
else:
|
||||
f.write(f".bilibili.com\tTRUE\t/\tFALSE\t1735689600\t{name}\t{value}\n")
|
||||
|
||||
print(f"cookies已保存到cookies.txt,包含以下字段:{', '.join(cookies.keys())}")
|
||||
|
||||
def find_ffmpeg() -> str:
|
||||
"""查找ffmpeg可执行文件的路径"""
|
||||
try:
|
||||
# 直接检查系统是否能执行ffmpeg命令
|
||||
result = subprocess.run(['ffmpeg', '-version'],
|
||||
capture_output=True,
|
||||
text=True)
|
||||
if result.returncode == 0:
|
||||
print("找到系统ffmpeg")
|
||||
return 'ffmpeg' # 直接返回命令名称
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果直接执行失败,尝试完整路径
|
||||
ffmpeg_path = r'C:\Program Files\ffmpeg\bin\ffmpeg.exe'
|
||||
if os.path.isfile(ffmpeg_path):
|
||||
print(f"找到ffmpeg: {ffmpeg_path}")
|
||||
return ffmpeg_path
|
||||
|
||||
print("警告:未找到ffmpeg,请确保ffmpeg已正确安装并添加到系统PATH中")
|
||||
return ''
|
||||
|
||||
def convert_to_mp3(input_file: str) -> bool:
|
||||
"""将音频文件转换为MP3格式"""
|
||||
if not os.path.exists(input_file):
|
||||
print(f"错误:找不到文件 {input_file}")
|
||||
return False
|
||||
|
||||
output_file = os.path.splitext(input_file)[0] + '.mp3'
|
||||
|
||||
try:
|
||||
# 构建ffmpeg命令
|
||||
cmd = [
|
||||
'ffmpeg',
|
||||
'-i', input_file,
|
||||
'-acodec', 'libmp3lame',
|
||||
'-q:a', '0', # 最高质量
|
||||
'-threads', '4', # 使用4个线程
|
||||
'-progress', 'pipe:1', # 输出进度信息到stdout
|
||||
'-nostats', # 不输出额外统计信息
|
||||
output_file
|
||||
]
|
||||
|
||||
print(f"正在转换为MP3: {os.path.basename(input_file)}")
|
||||
|
||||
# 创建进度条
|
||||
with tqdm(total=100,
|
||||
desc="转换进度",
|
||||
ncols=80,
|
||||
bar_format='{desc}: {percentage:3.1f}%|{bar}| [{elapsed}<{remaining}]') as pbar:
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# 读取并更新进度
|
||||
last_percent = 0
|
||||
while True:
|
||||
line = process.stdout.readline()
|
||||
if not line and process.poll() is not None:
|
||||
break
|
||||
|
||||
# 从输出中提取时间信息
|
||||
if line.startswith('out_time_ms='):
|
||||
time_ms = int(line.split('=')[1])
|
||||
# 假设每秒处理速度相同,使用已处理时间估算进度
|
||||
percent = min(100, max(0, int(time_ms / 10000000))) # 假设平均时长100秒
|
||||
|
||||
# 更新进度条
|
||||
if percent > last_percent:
|
||||
pbar.update(percent - last_percent)
|
||||
last_percent = percent
|
||||
|
||||
# 确保进度条到达100%
|
||||
if last_percent < 100:
|
||||
pbar.update(100 - last_percent)
|
||||
|
||||
# 检查转换结果
|
||||
if process.returncode == 0:
|
||||
print(f"\n转换完成:{os.path.basename(output_file)}")
|
||||
# 询问是否删除原始文件
|
||||
if input("是否删除原始音频文件?(y/n): ").lower() == 'y':
|
||||
os.remove(input_file)
|
||||
print("原始文件已删除")
|
||||
return True
|
||||
else:
|
||||
error = process.stderr.read()
|
||||
print(f"转换失败!错误信息:\n{error}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"转换过程中发生错误: {str(e)}")
|
||||
return False
|
||||
|
||||
def download_audio(url: str, cookies: Dict):
|
||||
"""下载音频"""
|
||||
save_cookies(cookies)
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
'yt-dlp',
|
||||
'--cookies', 'cookies.txt',
|
||||
'-f', 'ba',
|
||||
'--no-playlist',
|
||||
'--no-check-certificates',
|
||||
'--progress',
|
||||
'--newline',
|
||||
]
|
||||
|
||||
cmd.append(url)
|
||||
|
||||
print("开始下载音频...")
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
downloaded_file = None
|
||||
pbar = None
|
||||
total_size = None
|
||||
|
||||
# 用于解析下载进度的正则表达式
|
||||
progress_pattern = re.compile(r'\[download\]\s+(\d+\.\d+)%\s+of\s+(\d+\.\d+)([KMG])iB\s+at\s+.*?ETA\s+(.*)')
|
||||
|
||||
while True:
|
||||
output = process.stdout.readline()
|
||||
if output == '' and process.poll() is not None:
|
||||
break
|
||||
|
||||
if output:
|
||||
output = output.strip()
|
||||
|
||||
# 获取文件名
|
||||
if '[download] Destination:' in output:
|
||||
downloaded_file = output.split('[download] Destination:', 1)[1].strip()
|
||||
elif 'has already been downloaded' in output:
|
||||
downloaded_file = output.split('[download] ', 1)[1].split(' has already', 1)[0].strip()
|
||||
|
||||
# 解析进度信息
|
||||
match = progress_pattern.search(output)
|
||||
if match:
|
||||
percentage, size, unit, eta = match.groups()
|
||||
|
||||
# 计算总大小(转换为字节)
|
||||
if total_size is None:
|
||||
multiplier = {'K': 1024, 'M': 1024**2, 'G': 1024**3}
|
||||
total_size = int(float(size) * multiplier[unit])
|
||||
# 创建进度条
|
||||
pbar = tqdm(
|
||||
total=total_size,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
desc="下载进度",
|
||||
ncols=80,
|
||||
bar_format='{desc}: {percentage:3.1f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]'
|
||||
)
|
||||
|
||||
# 更新进度条
|
||||
if pbar:
|
||||
current = int(total_size * float(percentage) / 100)
|
||||
pbar.n = current
|
||||
pbar.refresh()
|
||||
|
||||
# 其他输出信息
|
||||
elif not output.startswith('[download]'):
|
||||
print(output)
|
||||
|
||||
# 关闭进度条
|
||||
if pbar:
|
||||
pbar.close()
|
||||
|
||||
# 检查下载结果
|
||||
if process.returncode == 0:
|
||||
print("\n下载完成!")
|
||||
|
||||
if downloaded_file and os.path.exists(downloaded_file):
|
||||
print(f"找到音频文件: {downloaded_file}")
|
||||
if input("是否转换为MP3格式?(y/n): ").lower() == 'y':
|
||||
if find_ffmpeg():
|
||||
convert_to_mp3(downloaded_file)
|
||||
else:
|
||||
print("未找到ffmpeg,无法转换为MP3格式")
|
||||
else:
|
||||
print(f"警告:无法找到下载的文件")
|
||||
m4a_files = [f for f in os.listdir('.') if f.endswith('.m4a')]
|
||||
if m4a_files:
|
||||
print(f"在当前目录找到以下.m4a文件:")
|
||||
for i, file in enumerate(m4a_files, 1):
|
||||
print(f"{i}. {file}")
|
||||
choice = input("请选择要转换的文件编号(输入q取消):")
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(m4a_files):
|
||||
if find_ffmpeg():
|
||||
convert_to_mp3(m4a_files[int(choice)-1])
|
||||
else:
|
||||
print("未找到ffmpeg,无法转换为MP3格式")
|
||||
else:
|
||||
error = process.stderr.read()
|
||||
print(f"下载失败!错误信息:\n{error}")
|
||||
|
||||
except FileNotFoundError:
|
||||
print("错误:请先安装 yt-dlp")
|
||||
print("可以使用以下命令安装:")
|
||||
print("pip install yt-dlp")
|
||||
except Exception as e:
|
||||
print(f"发生错误: {str(e)}")
|
||||
|
||||
def main():
|
||||
# 先尝试从文件加载cookies
|
||||
cookies = load_cookies_from_file()
|
||||
|
||||
# 如果没有找到有效的cookies文件,则请求用户输入
|
||||
if not cookies:
|
||||
cookies = get_cookies_from_user()
|
||||
if not cookies:
|
||||
return
|
||||
# 保存用户输入的cookies
|
||||
save_cookies(cookies)
|
||||
|
||||
while True:
|
||||
print("\n请输入B站视频URL (输入q退出):")
|
||||
url = input().strip()
|
||||
|
||||
if url.lower() == 'q':
|
||||
break
|
||||
|
||||
if not url.startswith(('https://www.bilibili.com', 'https://b23.tv')):
|
||||
print("请输入有效的B站视频链接!")
|
||||
continue
|
||||
|
||||
download_audio(url, cookies)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user