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
+111 -361
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 download_video(self, bvid: str, quality: int = 32) -> Dict[str, Any]:
"""下载B站视频"""
result = {"success": False, "bvid": bvid, "message": "", "filepath": None, "metadata": {}}
async def _make_request(self, session, url: str, params: Dict = None) -> Optional[Dict]:
"""发送API请求"""
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]: if data.get('code') != 0:
""" result["message"] = f"获取失败: {data.get('message')}"
下载B站视频 return result
Args: video_data = data.get('data', {})
bvid: B站视频BV号 (如: BV1xx411c7mD) title = video_data.get('title', '未知标题')
quality: 视频质量 (默认480) cid = video_data.get('cid')
- 127: 8K
- 126:杜比
- 125:HDR
- 120:4K
- 116:1080P60
- 112:1080P+
- 80:1080P
- 74:720P60
- 64:720P
- 32:480P
- 16:360P
Returns: result["metadata"] = {"title": title, "cid": cid, "bvid": bvid}
Dict: 包含状态、消息和文件路径的结果 print(f"标题: {title}")
"""
result = {
"success": False,
"bvid": bvid,
"message": "",
"filepath": None,
"metadata": {}
}
async with aiohttp.ClientSession() as session: # 获取播放地址
# 1. 获取视频信息 print(f"获取视频流...")
print(f"正在获取视频信息: {bvid}") async with session.get("https://api.bilibili.com/x/player/playurl",
api_url = "https://api.bilibili.com/x/web-interface/view" params={'bvid': bvid, 'cid': cid, 'qn': quality}) as resp:
data = await self._make_request(session, api_url, {'bvid': bvid}) play_data = await resp.json()
if not data or data.get('code') != 0: if play_data.get('code') != 0:
result["message"] = f"获取视频信息失败: {data.get('message', '未知错误')}" result["message"] = f"获取失败: {play_data.get('message')}"
return result return result
video_data = data.get('data', {}) durl = play_data.get('data', {}).get('durl', [])
title = video_data.get('title', '未知标题') if not durl:
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:
# 选择质量最高的视频流
video_stream = video_list[0]
video_url = video_stream.get('base_url') or video_stream.get('baseUrl')
video_size = video_list[0].get('size', 0)
# 获取音频流
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
print(f"找到视频流: {video_url[:100]}...")
# 3. 下载视频流
print(f"\n正在下载视频...")
# 下载视频
async with session.get(video_url, headers=self._get_headers()) as resp:
if resp.status == 200:
video_content = await resp.read()
print(f"视频下载完成: {len(video_content) / 1024 / 1024:.2f}MB")
else:
result["message"] = f"视频下载失败: HTTP {resp.status}"
return result
# 下载音频
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', [])
if durl:
video_url = durl[0].get('url')
print(f"使用FLV格式: {video_url[:100]}...")
async with session.get(video_url, headers=self._get_headers()) as resp: video_url = durl[0].get('url')
if resp.status == 200: print(f"下载视频...")
video_content = await resp.read()
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:50] # 下载
safe_title = re.sub(r'[<>:"/\\|?*]', '', safe_title) safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:30]
output_path = self.output_dir / f"{safe_title}_{bvid}.flv"
output_path = self.output_dir / f"{safe_title}_{bvid}.flv" async with session.get(video_url) as resp:
with open(output_path, 'wb') as f: with open(output_path, 'wb') as f:
f.write(video_content) async for chunk in resp.content.iter_chunked(81920):
f.write(chunk)
print(f"\r下载中...", end="", flush=True)
result["filepath"] = str(output_path) print(f"\n✅ 已保存: {output_path}")
result["success"] = True result["success"] = True
result["message"] = f"视频已保存: {output_path}" result["filepath"] = str(output_path)
print(f"✅ 下载完成: {output_path}") result["message"] = f"下载完成"
else:
result["message"] = f"视频下载失败: HTTP {resp.status}" except Exception as e:
else: result["message"] = f"错误: {str(e)}"
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:
data = await resp.json()
Returns: pages = data.get('data', [])
Dict: 包含字幕内容的JSON结果 if not pages:
""" result["message"] = "未找到字幕"
result = { return result
"success": False,
"bvid": bvid,
"subtitles": [],
"message": ""
}
async with aiohttp.ClientSession() as session: cid = pages[0].get('cid', '')
# 获取视频信息
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: async with session.get(f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}") as resp:
result["message"] = f"获取视频信息失败: {data.get('message', '未知错误')}" sub_data = await resp.json()
return result
cid = data.get('data', {}).get('cid') subtitles = sub_data.get('data', {}).get('subtitle', {}).get('list', [])
# 获取字幕列表 if not subtitles:
subtitle_api = "https://api.bilibili.com/x/player/pagelist" result["message"] = "未找到字幕"
page_data = await self._make_request(session, subtitle_api, {'bvid': bvid}) return result
# 获取字幕详情 result["success"] = True
subtitle_list = [] result["subtitles"] = [{"lan": s.get('lan'), "title": s.get('lan_doc')} for s in subtitles]
if page_data and page_data.get('code') == 0: result["message"] = f"找到 {len(subtitles)} 个字幕"
cid = page_data.get('data', [{}])[0].get('cid', cid) print(result["message"])
subtitle_url = f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}" except Exception as e:
subtitle_data = await self._make_request(session, subtitle_url) result["message"] = f"错误: {str(e)}"
if subtitle_data and subtitle_data.get('code') == 0:
subtitles = subtitle_data.get('data', {}).get('subtitle', {}).get('list', [])
for sub in subtitles:
sub_title = sub.get('lan_doc', '未知语言')
sub_url = sub.get('subtitle_url')
if sub_url:
# 下载字幕文件
async with session.get(sub_url) as resp:
if resp.status == 200:
sub_content = await resp.json()
subtitle_list.append({
"language": sub.get('lan', 'unknown'),
"title": sub_title,
"content": sub_content
})
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()
Returns: if data.get('code') != 0:
Dict: 包含专栏内容的JSON结果 result["message"] = f"获取失败: {data.get('message')}"
""" return result
result = {
"success": False,
"cvid": cvid,
"title": "",
"content": "",
"message": ""
}
async with aiohttp.ClientSession() as session: article = data.get('data', {})
# 获取专栏信息 result["title"] = article.get('title', '')
api_url = "https://api.bilibili.com/x/article/viewinfo" result["content"] = article.get('content', '')
data = await self._make_request(session, api_url, {'id': cvid}) result["success"] = bool(result["title"] and result["content"])
result["message"] = f"获取: {result['title']}" if result["success"] else "获取失败"
if not data or data.get('code') != 0: if result["success"]:
result["message"] = f"获取专栏信息失败: {data.get('message', '未知错误')}" print(f"✅ {result['title']}")
return result
article_data = data.get('data', {}) except Exception as e:
title = article_data.get('title', '') result["message"] = f"错误: {str(e)}"
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)