test
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,12 @@
|
||||
# tools
|
||||
|
||||
#### 介绍
|
||||
个人脚本存放
|
||||
|
||||
#### 软件架构
|
||||
软件架构说明
|
||||
|
||||
#### 使用说明
|
||||
获取bilibili上面免费视频
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
2025-08-10 19:20:08,907 - WARNING - 新版格式解析失败,切换旧版格式
|
||||
2025-08-10 19:20:08,928 - WARNING - 新版格式解析失败,切换旧版格式
|
||||
2025-08-10 19:20:08,930 - WARNING - 新版格式解析失败,切换旧版格式
|
||||
2025-08-10 19:20:16,658 - WARNING - 新版格式解析失败,切换旧版格式
|
||||
2025-08-10 19:20:16,677 - WARNING - 新版格式解析失败,切换旧版格式
|
||||
Reference in New Issue
Block a user