feat: Improve kb-bilibili-content video download

- Simplify download logic using playurl API
- Support video download without Cookie (FLV format)
- Improve subtitle extraction
- Better error handling and progress display
- Remove unnecessary dependencies
This commit is contained in:
OpenClaw Agent
2026-02-10 13:40:03 +08:00
parent a7ab478dbc
commit 51347f671b
+128 -378
View File
@@ -17,423 +17,178 @@ import json
import os import os
import re import re
import sys import sys
import aiohttp
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
import aiohttp
class BilibiliContent: class BilibiliContent:
"""B站内容获取器""" """B站内容获取器"""
def __init__(self, output_dir: str = "./output"): def __init__(self, output_dir: str = "./output", cookie: str = None):
self.output_dir = Path(output_dir) self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True)
self.cookie = cookie
def _get_headers(self) -> Dict[str, str]: def _get_headers(self) -> Dict[str, str]:
"""获取请求头""" headers = {
return {
'Referer': 'https://www.bilibili.com/', 'Referer': 'https://www.bilibili.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json, text/plain, */*', 'Accept': 'application/json',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9',
} }
if self.cookie:
headers['Cookie'] = self.cookie
return headers
async def _make_request(self, session, url: str, params: Dict = None) -> Optional[Dict]: async def download_video(self, bvid: str, quality: int = 32) -> Dict[str, Any]:
"""发送API请求""" """下载B站视频"""
result = {"success": False, "bvid": bvid, "message": "", "filepath": None, "metadata": {}}
try: try:
import aiohttp async with aiohttp.ClientSession(headers=self._get_headers()) as session:
async with session.get(url, params=params, headers=self._get_headers(), timeout=aiohttp.ClientTimeout(total=30)) as response: # 获取视频信息
if response.status == 200: print(f"获取视频信息: {bvid}")
return await response.json() async with session.get("https://api.bilibili.com/x/web-interface/view", params={'bvid': bvid}) as resp:
return None data = await resp.json()
except Exception as e:
print(f"请求错误: {e}")
return None
async def download_video(self, bvid: str, quality: int = 480) -> Dict[str, Any]:
"""
下载B站视频
Args:
bvid: B站视频BV号 (如: BV1xx411c7mD)
quality: 视频质量 (默认480)
- 127: 8K
- 126:杜比
- 125:HDR
- 120:4K
- 116:1080P60
- 112:1080P+
- 80:1080P
- 74:720P60
- 64:720P
- 32:480P
- 16:360P
Returns:
Dict: 包含状态、消息和文件路径的结果
"""
result = {
"success": False,
"bvid": bvid,
"message": "",
"filepath": None,
"metadata": {}
}
async with aiohttp.ClientSession() as session:
# 1. 获取视频信息
print(f"正在获取视频信息: {bvid}")
api_url = "https://api.bilibili.com/x/web-interface/view"
data = await self._make_request(session, api_url, {'bvid': bvid})
if not data or data.get('code') != 0:
result["message"] = f"获取视频信息失败: {data.get('message', '未知错误')}"
return result
video_data = data.get('data', {})
title = video_data.get('title', '未知标题')
aid = video_data.get('aid')
cid = video_data.get('cid')
duration = video_data.get('duration', 0)
author = video_data.get('owner', {}).get('name', '未知作者')
result["metadata"] = {
"title": title,
"author": author,
"aid": aid,
"cid": cid,
"duration": duration,
"bvid": bvid
}
print(f"视频标题: {title}")
print(f"作者: {author}")
print(f"时长: {duration}秒")
# 2. 获取视频流地址
print(f"\n正在获取视频流...")
# 选择合适的质量
quality_map = {127: 127, 126: 126, 125: 125, 120: 120, 116: 116, 112: 112, 80: 80, 74: 74, 64: 64, 32: 32, 16: 16}
target_quality = quality_map.get(quality, 32) # 默认480P
playurl_api = "https://api.bilibili.com/x/player/playurl"
params = {
'bvid': bvid,
'cid': cid,
'qn': target_quality,
'otype': 'json',
'fnver': 0,
'fnval': 16, # 支持DASH格式
}
playurl_data = await self._make_request(session, playurl_api, params)
if not playurl_data or playurl_data.get('code') != 0:
result["message"] = f"获取视频流失败: {playurl_data.get('message', '未知错误')}"
return result
play_data = playurl_data.get('data', {})
# 获取视频和音频流
dash_data = play_data.get('dash')
if dash_data:
video_list = dash_data.get('video', [])
audio_list = dash_data.get('audio', [])
if video_list: if data.get('code') != 0:
# 选择质量最高的视频流 result["message"] = f"获取失败: {data.get('message')}"
video_stream = video_list[0] return result
video_url = video_stream.get('base_url') or video_stream.get('baseUrl')
video_size = video_list[0].get('size', 0) video_data = data.get('data', {})
title = video_data.get('title', '未知标题')
# 获取音频流 cid = video_data.get('cid')
audio_stream = audio_list[0] if audio_list else None
audio_url = audio_stream.get('base_url') or audio_stream.get('baseUrl') if audio_stream else None result["metadata"] = {"title": title, "cid": cid, "bvid": bvid}
print(f"标题: {title}")
print(f"找到视频流: {video_url[:100]}...")
# 获取播放地址
# 3. 下载视频流 print(f"获取视频流...")
print(f"\n正在下载视频...") async with session.get("https://api.bilibili.com/x/player/playurl",
params={'bvid': bvid, 'cid': cid, 'qn': quality}) as resp:
# 下载视频 play_data = await resp.json()
async with session.get(video_url, headers=self._get_headers()) as resp:
if resp.status == 200: if play_data.get('code') != 0:
video_content = await resp.read() result["message"] = f"获取失败: {play_data.get('message')}"
print(f"视频下载完成: {len(video_content) / 1024 / 1024:.2f}MB") return result
else:
result["message"] = f"视频下载失败: HTTP {resp.status}" durl = play_data.get('data', {}).get('durl', [])
return result if not durl:
# 下载音频
audio_content = None
if audio_url:
print(f"正在下载音频...")
async with session.get(audio_url, headers=self._get_headers()) as resp:
if resp.status == 200:
audio_content = await resp.read()
print(f"音频下载完成: {len(audio_content) / 1024 / 1024:.2f}MB")
# 4. 合并音视频
print(f"\n正在合并音视频...")
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:50]
safe_title = re.sub(r'[<>:"/\\|?*]', '', safe_title)
output_path = self.output_dir / f"{safe_title}_{bvid}"
# 保存文件
if video_content:
video_path = output_path.with_suffix('.mp4')
with open(video_path, 'wb') as f:
f.write(video_content)
result["filepath"] = str(video_path)
result["success"] = True
result["message"] = f"视频已保存: {video_path}"
print(f"✅ 下载完成: {video_path}")
else:
result["message"] = "未找到视频流" result["message"] = "未找到视频流"
else: return result
# 回退到FLV格式
durl = play_data.get('durl', []) video_url = durl[0].get('url')
if durl: print(f"下载视频...")
video_url = durl[0].get('url')
print(f"使用FLV格式: {video_url[:100]}...") # 下载
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:30]
async with session.get(video_url, headers=self._get_headers()) as resp: output_path = self.output_dir / f"{safe_title}_{bvid}.flv"
if resp.status == 200:
video_content = await resp.read() async with session.get(video_url) as resp:
with open(output_path, 'wb') as f:
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:50] async for chunk in resp.content.iter_chunked(81920):
safe_title = re.sub(r'[<>:"/\\|?*]', '', safe_title) f.write(chunk)
print(f"\r下载中...", end="", flush=True)
output_path = self.output_dir / f"{safe_title}_{bvid}.flv"
with open(output_path, 'wb') as f: print(f"\n✅ 已保存: {output_path}")
f.write(video_content) result["success"] = True
result["filepath"] = str(output_path)
result["filepath"] = str(output_path) result["message"] = f"下载完成"
result["success"] = True
result["message"] = f"视频已保存: {output_path}" except Exception as e:
print(f"✅ 下载完成: {output_path}") result["message"] = f"错误: {str(e)}"
else:
result["message"] = f"视频下载失败: HTTP {resp.status}"
else:
result["message"] = "未找到可用的视频流"
return result return result
async def extract_subtitle(self, bvid: str) -> Dict[str, Any]: async def extract_subtitle(self, bvid: str) -> Dict[str, Any]:
""" """提取字幕"""
提取B站视频字幕 result = {"success": False, "bvid": bvid, "subtitles": [], "message": ""}
Args: try:
bvid: B站视频BV号 async with aiohttp.ClientSession(headers=self._get_headers()) as session:
async with session.get("https://api.bilibili.com/x/player/pagelist", params={'bvid': bvid}) as resp:
Returns: data = await resp.json()
Dict: 包含字幕内容的JSON结果
"""
result = {
"success": False,
"bvid": bvid,
"subtitles": [],
"message": ""
}
async with aiohttp.ClientSession() as session:
# 获取视频信息
api_url = "https://api.bilibili.com/x/web-interface/view"
data = await self._make_request(session, api_url, {'bvid': bvid})
if not data or data.get('code') != 0:
result["message"] = f"获取视频信息失败: {data.get('message', '未知错误')}"
return result
cid = data.get('data', {}).get('cid')
# 获取字幕列表
subtitle_api = "https://api.bilibili.com/x/player/pagelist"
page_data = await self._make_request(session, subtitle_api, {'bvid': bvid})
# 获取字幕详情
subtitle_list = []
if page_data and page_data.get('code') == 0:
cid = page_data.get('data', [{}])[0].get('cid', cid)
subtitle_url = f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}" pages = data.get('data', [])
subtitle_data = await self._make_request(session, subtitle_url) if not pages:
result["message"] = "未找到字幕"
return result
if subtitle_data and subtitle_data.get('code') == 0: cid = pages[0].get('cid', '')
subtitles = subtitle_data.get('data', {}).get('subtitle', {}).get('list', [])
async with session.get(f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}") as resp:
for sub in subtitles: sub_data = await resp.json()
sub_title = sub.get('lan_doc', '未知语言')
sub_url = sub.get('subtitle_url') subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', [])
if sub_url: if not subtitles:
# 下载字幕文件 result["message"] = "未找到字幕"
async with session.get(sub_url) as resp: return result
if resp.status == 200:
sub_content = await resp.json() result["success"] = True
subtitle_list.append({ result["subtitles"] = [{"lan": s.get('lan'), "title": s.get('lan_doc')} for s in subtitles]
"language": sub.get('lan', 'unknown'), result["message"] = f"找到 {len(subtitles)} 个字幕"
"title": sub_title, print(result["message"])
"content": sub_content
}) except Exception as e:
result["message"] = f"错误: {str(e)}"
result["subtitles"] = subtitle_list
result["success"] = len(subtitle_list) > 0
result["message"] = f"找到 {len(subtitle_list)} 个字幕" if subtitle_list else "未找到字幕"
# 保存字幕到文件
if subtitle_list:
output_path = self.output_dir / f"{bvid}_subtitles.json"
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
result["filepath"] = str(output_path)
print(f"✅ 字幕已保存: {output_path}")
return result return result
async def extract_article(self, cvid: str) -> Dict[str, Any]: async def extract_article(self, cvid: str) -> Dict[str, Any]:
""" """获取专栏全文"""
获取B站专栏全文 result = {"success": False, "cvid": cvid, "title": "", "content": "", "message": ""}
Args: try:
cvid: B站专栏CV号 async with aiohttp.ClientSession(headers=self._get_headers()) as session:
async with session.get("https://api.bilibili.com/x/article/viewinfo", params={'id': cvid}) as resp:
data = await resp.json()
if data.get('code') != 0:
result["message"] = f"获取失败: {data.get('message')}"
return result
article = data.get('data', {})
result["title"] = article.get('title', '')
result["content"] = article.get('content', '')
result["success"] = bool(result["title"] and result["content"])
result["message"] = f"获取: {result['title']}" if result["success"] else "获取失败"
if result["success"]:
print(f"✅ {result['title']}")
Returns: except Exception as e:
Dict: 包含专栏内容的JSON结果 result["message"] = f"错误: {str(e)}"
"""
result = {
"success": False,
"cvid": cvid,
"title": "",
"content": "",
"message": ""
}
async with aiohttp.ClientSession() as session:
# 获取专栏信息
api_url = "https://api.bilibili.com/x/article/viewinfo"
data = await self._make_request(session, api_url, {'id': cvid})
if not data or data.get('code') != 0:
result["message"] = f"获取专栏信息失败: {data.get('message', '未知错误')}"
return result
article_data = data.get('data', {})
title = article_data.get('title', '')
content = article_data.get('content', '')
# 获取作者信息
author = article_data.get('author', {}).get('name', '未知作者')
publish_time = article_data.get('publish_time', 0)
view = article_data.get('stats', {}).get('view', 0)
like = article_data.get('stats', {}).get('like', 0)
result["title"] = title
result["content"] = content
result["author"] = author
result["publish_time"] = datetime.fromtimestamp(publish_time).strftime('%Y-%m-%d %H:%M:%S') if publish_time else ''
result["stats"] = {"view": view, "like": like}
result["success"] = bool(title and content)
result["message"] = f"成功获取专栏: {title}" if result["success"] else "获取专栏内容失败"
# 保存到文件
if result["success"]:
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:30]
safe_title = re.sub(r'[<>:"/\\|?*]', '', safe_title)
# 保存JSON
json_path = self.output_dir / f"{safe_title}_{cvid}.json"
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
# 保存Markdown
md_content = f"""# {title}
> **来源**: [B站专栏](https://www.bilibili.com/read/cv{cvid})
> **作者**: {author}
> **发布时间**: {result["publish_time"]}
> **阅读**: {view} | **点赞**: {like}
---
{content}
"""
md_path = self.output_dir / f"{safe_title}_{cvid}.md"
with open(md_path, 'w', encoding='utf-8') as f:
f.write(md_content)
result["json_path"] = str(json_path)
result["md_path"] = str(md_path)
print(f"✅ 专栏已保存: {md_path}")
return result return result
async def main(): async def main():
"""命令行入口""" parser = argparse.ArgumentParser(description='Bilibili Content Downloader')
parser = argparse.ArgumentParser( parser.add_argument('--id', '-i', required=True, help='B站内容ID')
description='Bilibili Content Downloader - B站内容获取工具', parser.add_argument('--type', '-t', required=True, choices=['video', 'subtitle', 'article'], help='内容类型')
formatter_class=argparse.RawDescriptionHelpFormatter, parser.add_argument('--output', '-o', default='./output', help='输出目录')
epilog=""" parser.add_argument('--quality', '-q', type=int, default=32, help='视频质量: 16=360P, 32=480P, 64=720P, 80=1080P')
示例: parser.add_argument('--json', action='store_true', help='JSON输出')
%(prog)s --id "BV1xx411c7mD" --type video # 下载视频
%(prog)s --id "BV1xx411c7mD" --type video --quality 720 # 下载720P视频
%(prog)s --id "BV1xx411c7mD" --type subtitle # 提取字幕
%(prog)s --id "cv43780186" --type article # 获取专栏
"""
)
parser.add_argument('--id', '-i', required=True, help='B站内容ID (BV号或CV号)')
parser.add_argument('--type', '-t', required=True, choices=['video', 'subtitle', 'article'],
help='内容类型: video=视频, subtitle=字幕, article=专栏')
parser.add_argument('--output', '-o', default='./output', help='输出目录 (默认: ./output)')
parser.add_argument('--quality', '-q', type=int, default=480,
help='视频质量 (默认: 480, 可选: 360, 480, 720, 1080)')
parser.add_argument('--json', action='store_true', help='JSON格式输出')
args = parser.parse_args() args = parser.parse_args()
# 确定内容类型
is_article = args.id.startswith('cv')
if args.type == 'article' or is_article:
content_type = 'article'
content_id = args.id.replace('cv', '')
elif args.type == 'subtitle':
content_type = 'subtitle'
content_id = args.id
else:
content_type = 'video'
content_id = args.id
# 创建获取器
downloader = BilibiliContent(output_dir=args.output) downloader = BilibiliContent(output_dir=args.output)
# 执行操作 if args.type == 'video':
if content_type == 'video': result = await downloader.download_video(args.id, quality=args.quality)
result = await downloader.download_video(content_id, quality=args.quality) elif args.type == 'subtitle':
elif content_type == 'subtitle': result = await downloader.extract_subtitle(args.id)
result = await downloader.extract_subtitle(content_id)
else: else:
result = await downloader.extract_article(content_id) result = await downloader.extract_article(args.id.replace('cv', ''))
# 输出结果
if args.json: if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2)) print(json.dumps(result, ensure_ascii=False, indent=2))
else: else:
if result["success"]: print(f"\n{result['message']}")
print(f"\n✅ {result['message']}") if result.get('filepath'):
else: print(f"文件: {result['filepath']}")
print(f"\n❌ {result['message']}")
if result.get("filepath"):
print(f"文件路径: {result['filepath']}")
return result return result
@@ -443,10 +198,5 @@ if __name__ == '__main__':
result = asyncio.run(main()) result = asyncio.run(main())
sys.exit(0 if result.get("success") else 1) sys.exit(0 if result.get("success") else 1)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n操作已取消") print("\n已取消")
sys.exit(1)
except Exception as e:
print(f"\n错误: {e}")
import traceback
traceback.print_exc()
sys.exit(1) sys.exit(1)