Files
pachong/练习/003_B站视频下载/BliBli.py
T
2026-06-29 16:09:05 +08:00

349 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ------------------------------------
# Time : 2025/8/10
# File : BliBli.py
# 脚本作用 : 获取B站视频
# 项目名称 :
# ------------------------------------
import logging
import os
import re
import shutil
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from moviepy import AudioFileClip, VideoFileClip
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# -------------------------- 配置参数(可自定义) --------------------------
# 自定义存储路径(默认在当前目录下的download文件夹,可修改为绝对路径如"D:/B站视频"
STORAGE_PATH = "E:/download/B站视频"
# 日志输出级别(INFO=基本信息,WARNING=仅警告和错误,ERROR=仅错误)
LOG_LEVEL = logging.WARNING
# 最大线程数(避免请求过于频繁)
MAX_THREADS = 3
# 最大重试次数
MAX_RETRY = 5
# -------------------------------------------------------------------------
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler("bilibili.log"), logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# 全局请求头
headers = {
'referer': 'https://www.bilibili.com/',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
}
def get_response(url, retry_times=3, timeout=15):
"""获取HTTP响应,包含重试机制和错误处理"""
session = requests.Session()
retries = Retry(
total=retry_times,
backoff_factor=0.5,
status_forcelist=[500, 502, 503, 504, 429]
)
session.mount('http://', HTTPAdapter(max_retries=retries))
session.mount('https://', HTTPAdapter(max_retries=retries))
try:
response = session.get(url, headers=headers, timeout=timeout)
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
logger.error(f"请求失败: {url}(错误: {str(e)}")
return None
def get_video_urls_advanced(bv_id, cid, page=1):
"""增强版视频链接获取函数,支持新旧格式自动切换"""
try:
# 优先尝试新版DASH格式(视频音频分离)
url = f'https://api.bilibili.com/x/player/playurl?bvid={bv_id}&cid={cid}&qn=112&type=&otype=json'
response = get_response(url)
if not response:
logger.warning(f"尝试旧版格式获取链接: {bv_id}")
return get_old_format_urls(bv_id, cid, page)
data = response.json()
if data.get('code') != 0:
logger.warning(f"新版格式错误,尝试旧版: {data.get('message')}")
return get_old_format_urls(bv_id, cid, page)
dash_data = data.get('data', {}).get('dash', {})
video_urls = dash_data.get('video', [])
audio_urls = dash_data.get('audio', [])
if video_urls and audio_urls:
video_url = video_urls[0].get('backupUrl', [video_urls[0].get('url', '')])[0]
audio_url = audio_urls[0].get('backupUrl', [audio_urls[0].get('url', '')])[0]
return video_url, audio_url
# DASH格式获取失败,尝试旧版格式
#logger.warning(f"新版格式解析失败,切换旧版格式")
return get_old_format_urls(bv_id, cid, page)
except Exception as e:
logger.error(f"链接获取异常,切换旧版: {str(e)}")
return get_old_format_urls(bv_id, cid, page)
def get_old_format_urls(bv_id, cid, page=1):
"""获取旧版FLV格式视频链接(视频音频合并)"""
try:
url = f'https://api.bilibili.com/x/player/playurl?bvid={bv_id}&cid={cid}&qn=112&type=flv&otype=json'
response = get_response(url)
if not response:
logger.error(f"旧版格式请求失败: {bv_id}")
return None, None
data = response.json()
if data.get('code') != 0:
logger.error(f"旧版格式API错误: {data.get('message')}")
return None, None
durl = data.get('data', {}).get('durl', [{}])[0].get('url', '')
if not durl:
logger.error(f"旧版格式链接为空: {bv_id}")
return None, None
return durl, None # 旧版无独立音频链接
except Exception as e:
logger.error(f"旧版格式处理异常: {str(e)}")
return None, None
def download_video_audio_and_combine_compatible(video_url, audio_url, video_path, audio_path, output_path):
"""兼容新旧格式的下载合并函数"""
try:
# 下载视频
print(f"正在下载视频: {os.path.basename(output_path)}") # 控制台显示进度
video_resp = get_response(video_url)
if not video_resp:
logger.error(f"视频下载失败: {os.path.basename(output_path)}")
return False
with open(video_path, 'wb') as f:
f.write(video_resp.content)
# 处理旧版格式(无独立音频)
if not audio_url:
shutil.copy2(video_path, output_path)
print(f"下载完成: {os.path.basename(output_path)}")
return True
# 下载音频(新版格式)
audio_resp = get_response(audio_url)
if not audio_resp:
logger.warning(f"音频下载失败,仅保存视频: {os.path.basename(output_path)}")
shutil.copy2(video_path, output_path)
return True
with open(audio_path, 'wb') as f:
f.write(audio_resp.content)
# 合并视频和音频
try:
video = VideoFileClip(video_path)
audio = AudioFileClip(audio_path)
final_video = video.set_audio(audio)
final_video.write_videofile(output_path, codec="libx264", audio_codec="aac")
print(f"合并完成: {os.path.basename(output_path)}")
return True
except Exception as e:
logger.warning(f"合并失败,保存原始视频: {str(e)}")
shutil.copy2(video_path, output_path)
return True
finally:
# 清理临时文件
if os.path.exists(audio_path):
os.remove(audio_path)
except Exception as e:
logger.error(f"处理失败: {str(e)}")
return False
finally:
if os.path.exists(video_path):
os.remove(video_path)
def add_copyright_headers():
"""添加版权敏感内容所需的请求头(可选)"""
global headers
headers.update({
'Referer': 'https://www.bilibili.com/cheese/',
'Origin': 'https://www.bilibili.com',
# 如需下载会员内容,可添加Cookie(格式: 'Cookie': 'SESSDATA=xxx; bili_jct=xxx;'
# 'Cookie': 'your_cookie_here'
})
def create_directory(path):
"""创建目录(支持自定义存储路径)"""
dir_path = os.path.dirname(path)
try:
os.makedirs(dir_path, exist_ok=True)
return True
except Exception as e:
logger.error(f"目录创建失败: {dir_path}(错误: {str(e)}")
return False
def download_part_video_advanced(part_info, main_title):
"""增强版分P下载函数"""
bv_id = part_info['bv']
page = part_info['page']
part_title = part_info['title']
cid = part_info.get('cid')
# 生成文件路径(基于自定义存储路径)
safe_title = re.sub(r'[\\/:*?"<>|]', '_', part_title)
main_dir = os.path.join(STORAGE_PATH, re.sub(r'[\\/:*?"<>|]', '_', main_title))
video_path = os.path.join(main_dir, f'{safe_title}_video.mp4') # 临时视频文件
audio_path = os.path.join(main_dir, f'{safe_title}_audio.mp3') # 临时音频文件
output_path = os.path.join(main_dir, f'{safe_title}.mp4') # 最终输出路径
# 创建目录(包括临时文件夹)
if not create_directory(video_path) or not create_directory(output_path):
logger.error(f"跳过下载: {part_title}(目录创建失败)")
return False
# 重试机制
for retry in range(MAX_RETRY):
wait_time = 2 ** retry # 指数退避(1s, 2s, 4s...
if retry > 0:
print(f"重试 {retry}/{MAX_RETRY}(等待{wait_time}秒): {part_title}")
time.sleep(wait_time)
try:
# 获取视频链接
video_url, audio_url = get_video_urls_advanced(bv_id, cid, page)
if not video_url:
continue
# 针对版权敏感内容添加额外头
if "会员" in part_title or "加密" in part_title:
add_copyright_headers()
# 下载并合并
if download_video_audio_and_combine_compatible(video_url, audio_url, video_path, audio_path, output_path):
return True
except Exception as e:
logger.error(f"下载异常: {str(e)}")
continue
logger.error(f"下载失败: {part_title}(已达最大重试次数)")
return False
def parse_video_info(bv_id):
"""解析视频基本信息和分P信息"""
base_url = f'https://www.bilibili.com/video/{bv_id}'
response = get_response(base_url)
if not response:
logger.error(f"无法访问视频页面: {base_url}")
return None, None
html = response.text
try:
# 提取视频标题
title_match = re.search(r'<h1 data-title="(.*?)" title="', html)
if not title_match:
logger.error("无法提取视频标题")
return None, None
main_title = title_match.group(1).strip()
# 通过API获取分P信息
try:
api_url = f'https://api.bilibili.com/x/player/pagelist?bvid={bv_id}&jsonp=jsonp'
api_resp = get_response(api_url)
if not api_resp:
raise Exception("分P API请求失败")
api_data = api_resp.json()
if api_data.get('code') != 0:
raise Exception(f"API错误: {api_data.get('message')}")
parts = api_data.get('data', [])
if not parts:
# 单P视频
cid = re.search(r'"cid":(\d+),', html).group(1)
return [{'title': main_title, 'bv': bv_id, 'page': 1, 'cid': cid}], main_title
# 多P视频
part_list = []
for i, part in enumerate(parts, 1):
part_list.append({
'title': part.get('part', f'{i}'),
'bv': bv_id,
'page': part.get('page', i),
'cid': part.get('cid')
})
return part_list, main_title
except Exception as e:
logger.warning(f"API解析失败,尝试HTML解析: {str(e)}")
# HTML备用解析
part_pattern = r'"cid":(\d+),"page":(\d+),"part":"(.*?)","duration"'
parts = re.findall(part_pattern, html)
if not parts:
cid = re.search(r'"cid":(\d+),', html).group(1)
return [{'title': main_title, 'bv': bv_id, 'page': 1, 'cid': cid}], main_title
part_list = []
for cid, page, title in parts:
part_list.append({
'title': title.strip(),
'bv': bv_id,
'page': int(page),
'cid': cid
})
return part_list, main_title
except Exception as e:
logger.error(f"视频信息解析失败: {str(e)}")
return None, None
def download_bilibili_video(bv_id):
"""下载B站视频主函数"""
print(f"开始处理视频: {bv_id}")
part_list, main_title = parse_video_info(bv_id)
if not part_list or not main_title:
logger.error("视频信息解析失败,退出")
return False
print(f"视频标题: {main_title}(共{len(part_list)}个分P")
print(f"文件将保存至: {os.path.abspath(os.path.join(STORAGE_PATH, main_title))}\n")
# 多线程下载
with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
futures = [executor.submit(download_part_video_advanced, part, main_title) for part in part_list]
# 等待所有任务完成
completed = 0
for future in futures:
if future.result():
completed += 1
# 下载结果统计
success_rate = completed / len(part_list) * 100
print(f"\n下载结束: 共{len(part_list)}个分P,成功{completed}个(成功率: {success_rate:.1f}%")
print(f"文件保存位置: {os.path.abspath(os.path.join(STORAGE_PATH, main_title))}")
return success_rate >= 50
if __name__ == '__main__':
# 示例:替换为需要下载的BV号
bv_id = 'BV1xe4y1s7Uq' # 可修改为目标视频的BV号
download_bilibili_video(bv_id)