一、 弹幕的本质:不只是屏幕上的文字
当我们在B站“看番”时,那些从右至左飘过的弹幕,早已成为观剧体验中不可或缺的一部分。但从技术视角看,这些弹幕并非直接嵌入在视频文件中,而是通过一套精巧的异步加载机制实现的。
核心认知:弹幕数据与视频数据是分离的。
二、 弹幕获取的技术路线
1. 找到弹幕的“身份证”:CID
每个B站视频(包括分P视频的每一P)都有一个唯一的CID(Content ID)。这个CID是获取弹幕的关键凭证,相当于弹幕库的“房间号”。
如何获取CID?
- 方案A:解析视频页面源码
在视频播放页的HTML源代码中搜索"cid",可以找到这个关键数值。 - 方案B:调用B站官方API
通过B站提供的视频信息接口,传入BV号即可获取包含CID的完整视频信息。
2. 访问弹幕的“仓库”:官方API接口
获得CID后,最简单的获取方式就是直接请求B站的弹幕接口:
text
https://comment.bilibili.com/{cid}.xml
这个接口会返回一个结构化的XML文件,包含了该视频的所有弹幕数据。
3. 解析弹幕数据
接口返回的XML数据中,每一条弹幕都以<d>标签的形式存在,其中包含了:
发送者的匿名ID
弹幕文本内容
发送时间点(相对于视频的秒数)
弹幕类型(滚动、顶部、底部等)
字体大小、颜色等信息
三、使用 requests 自动获取 CID
手动 F12 查找 CID 对于单个视频尚可,但对于批量爬取则效率极低。通过程序自动获取是唯一可行的方案。
核心思路
B站的前端页面是通过 JavaScript 动态加载数据的,直接解析初始 HTML 可能会很复杂。更可靠的方法是:模拟浏览器行为,直接调用 B站提供给前端使用的数据接口。
关键 API 接口
B站提供了一个公开的视频信息 API,其结构如下:
text
https://api.bilibili.com/x/web-interface/view?bvid={视频的BV号}
{视频的BV号}:即视频链接https://www.bilibili.com/video/BV1xxxxxx/中的BV1xxxxxx。
向这个接口发送一个 GET 请求,它会返回一个结构清晰的 JSON 对象,其中就包含了我们梦寐以求的 cid。
实现步骤分解
- 构造请求 URL
将你想要爬取视频的 BV 号填入上述 API 的占位符中,形成完整的 URL。 - 发送 HTTP 请求
使用requests.get()函数向这个 URL 发送请求。至关重要的一步是设置请求头(Headers),特别是User-Agent字段,用于将自己伪装成一个正常的浏览器,而不是 Python 脚本,以此来绕过 B站基础的反爬机制。 - 解析返回的 JSON 数据
上述 API 接口返回的是标准的 JSON 格式数据。我们需要使用response.json()方法将其解析为 Python 的字典(dict)或列表(list),然后就可以像操作普通字典一样,通过键(key)来提取值(value)。 - 提取 CID
在解析后的 JSON 数据中,cid位于data -> cid的路径下。只需逐层访问即可拿到这个关键 ID。
一个具体的例子
假设我们要获取 BV号 为 BV1GJ4m1p7 的视频的 CID。
- 构造 URL:
https://api.bilibili.com/x/web-interface/view?bvid=BV1GJ4m1p7 - 携带合理的 Headers 发送 GET 请求。
- 收到返回的 JSON,其结构大致如下:json{ "code": 0, "message": "0", "ttl": 1, "data": { "bvid": "BV1GJ4m1p7", "aid": 9, "cid": 1458833168, // <--- 这就是我们需要的 CID! ... // 其他大量视频信息(标题、作者、简介等) } }
- 从 Python 字典中提取:
cid = json_data['data']['cid']
至此,您就成功地、自动化地获取到了视频的 CID。将这个 CID 拼接到弹幕 API https://comment.bilibili.com/{cid}.xml 上,即可进行下一步的弹幕获取工作。
这种方法高效、稳定,是爬取 B站数据的标准做法。
import requests
import re
import os
import sys
import threading
import time
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from PIL import Image, ImageTk
from io import BytesIO
# ----------------- 启动页 -----------------
class SplashScreen:
def __init__(self, root):
self.root = root
self.splash = tk.Toplevel(root)
self.splash.overrideredirect(True)
try:
# 确定图片路径(兼容 pyinstaller 打包)
if hasattr(sys, '_MEIPASS'):
img_path = os.path.join(sys._MEIPASS, "splash_image.png")
else:
img_path = "splash_image.png"
img = Image.open(img_path).resize((400, 300), Image.LANCZOS)
self.splash_img = ImageTk.PhotoImage(img)
canvas = tk.Canvas(self.splash, width=400, height=300)
canvas.pack()
canvas.create_image(0, 0, anchor=tk.NW, image=self.splash_img)
except Exception:
self.splash_img = None
tk.Label(
self.splash,
text="B站弹幕下载器\n加载中...",
font=("微软雅黑", 16)
).pack(fill=tk.BOTH, expand=True)
sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
x, y = (sw - 400) // 2, (sh - 300) // 2
self.splash.geometry(f"400x300+{x}+{y}")
self.splash.attributes('-alpha', 0.9)
self.splash.update()
def close(self):
def fade(i=10):
if i > 0:
self.splash.attributes('-alpha', i / 10)
self.splash.after(50, fade, i - 1)
else:
self.splash.destroy()
fade()
# ----------------- 主应用 -----------------
class BiliDanmuDownloader:
def __init__(self, root):
self.root = root
self.root.title("B 站弹幕批量下载工具")
self.root.geometry("800x600")
self.root.resizable(True, True)
# 初始化保存目录
self.download_folder = os.path.join(os.path.expanduser('~'), 'BiliDanmu')
os.makedirs(self.download_folder, exist_ok=True)
# 请求头
self.headers = {
"origin": "https://www.bilibili.com",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
}
# 状态变量
self.downloading = False
self.stop_flag = False
self.progress = 0
self.total_tasks = 0
self.completed_tasks = 0
self.setup_ui()
# ----------------- GUI 构建 -----------------
def setup_ui(self):
main_frame = ttk.Frame(self.root, padding=10)
main_frame.pack(fill=tk.BOTH, expand=True)
# 标题栏
title_frame = ttk.Frame(main_frame)
title_frame.pack(fill=tk.X, pady=10)
try:
response = requests.get("https://www.bilibili.com/favicon.ico", timeout=5)
img = Image.open(BytesIO(response.content)).resize((48, 48), Image.LANCZOS)
self.bili_icon = ImageTk.PhotoImage(img)
ttk.Label(title_frame, image=self.bili_icon).pack(side=tk.LEFT, padx=5)
except Exception:
pass
ttk.Label(title_frame, text="B 站弹幕批量下载工具", font=("微软雅黑", 16, "bold")).pack(side=tk.LEFT, padx=10)
desc_label = ttk.Label(
main_frame,
text="输入视频链接(每行一个), 点击下载按钮获取弹幕 XML 文件\n支持单个视频和多集连续剧批量下载",
font=("微软雅黑", 10),
foreground="#666"
)
desc_label.pack(fill=tk.X, pady=(0, 10))
# 输入框
input_frame = ttk.LabelFrame(main_frame, text="视频链接", padding=10)
input_frame.pack(fill=tk.BOTH, expand=True)
self.url_text = scrolledtext.ScrolledText(input_frame, width=80, height=8, font=("微软雅黑", 10))
self.url_text.pack(fill=tk.BOTH, expand=True)
ttk.Button(input_frame, text="插入示例链接", command=self.insert_example, width=15).pack(side=tk.LEFT, pady=5)
ttk.Button(input_frame, text="清空", command=self.clear_urls, width=10).pack(side=tk.RIGHT, pady=5)
# 控制栏
control_frame = ttk.Frame(main_frame)
control_frame.pack(fill=tk.X, pady=10)
dir_frame = ttk.Frame(control_frame)
dir_frame.pack(fill=tk.X, pady=5)
ttk.Label(dir_frame, text="保存位置:").pack(side=tk.LEFT)
self.dir_var = tk.StringVar(value=self.download_folder)
self.dir_entry = ttk.Entry(dir_frame, textvariable=self.dir_var, width=50)
self.dir_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
ttk.Button(dir_frame, text="浏览...", command=self.select_directory, width=10).pack(side=tk.RIGHT)
# 按钮
btn_frame = ttk.Frame(control_frame)
btn_frame.pack(fill=tk.X, pady=10)
self.download_btn = ttk.Button(btn_frame, text="开始下载", command=self.start_download, width=15)
self.download_btn.pack(side=tk.LEFT, padx=5)
self.stop_btn = ttk.Button(btn_frame, text="停止", command=self.stop_download, state=tk.DISABLED, width=10)
self.stop_btn.pack(side=tk.LEFT, padx=5)
# 进度区
progress_frame = ttk.Frame(main_frame)
progress_frame.pack(fill=tk.X, pady=5)
self.progress_var = tk.DoubleVar()
self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, maximum=100, length=600)
self.progress_bar.pack(fill=tk.X, pady=5)
self.status_var = tk.StringVar(value="准备就绪")
ttk.Label(progress_frame, textvariable=self.status_var).pack(fill=tk.X)
# 日志
log_frame = ttk.LabelFrame(main_frame, text="下载日志", padding=10)
log_frame.pack(fill=tk.BOTH, expand=True)
self.log_text = scrolledtext.ScrolledText(log_frame, width=80, height=10, font=("微软雅黑", 9))
self.log_text.pack(fill=tk.BOTH, expand=True)
self.log_text.config(state=tk.DISABLED)
footer_frame = ttk.Frame(main_frame)
footer_frame.pack(fill=tk.X, pady=5)
ttk.Label(footer_frame, text="© 2024 B 站弹幕下载工具", foreground="#666").pack(side=tk.RIGHT)
# ----------------- 工具方法 -----------------
def insert_example(self):
for url in [
"https://www.bilibili.com/video/BV1GJ411x7h7",
"https://www.bilibili.com/video/BV1qK411K7wR",
"https://www.bilibili.com/video/BV1d54y1d7Z3"
]:
self.url_text.insert(tk.END, url + "\n")
self.log("已插入示例链接")
def clear_urls(self):
self.url_text.delete(1.0, tk.END)
self.log("已清空输入框")
def select_directory(self):
folder = filedialog.askdirectory()
if folder:
self.dir_var.set(folder)
self.download_folder = folder
self.log(f"保存位置已更新: {folder}")
def log(self, message):
self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, f"[{time.strftime('%H:%M:%S')}] {message}\n")
self.log_text.see(tk.END)
self.log_text.config(state=tk.DISABLED)
# ----------------- 下载流程 -----------------
def start_download(self):
urls = self.url_text.get(1.0, tk.END).strip().splitlines()
if not urls:
messagebox.showwarning("警告", "请输入至少一个视频链接!")
return
self.download_folder = self.dir_var.get()
os.makedirs(self.download_folder, exist_ok=True)
self.downloading = True
self.stop_flag = False
self.progress = 0
self.total_tasks = len(urls)
self.completed_tasks = 0
self.download_btn.config(state=tk.DISABLED)
self.stop_btn.config(state=tk.NORMAL)
self.log(f"开始批量下载, 共 {self.total_tasks} 个任务...")
self.status_var.set(f"开始下载: 0/{self.total_tasks} (0%)")
threading.Thread(target=self.batch_download, args=(urls,), daemon=True).start()
def stop_download(self):
self.stop_flag = True
self.log("正在停止下载任务...")
self.status_var.set("正在停止...")
def update_progress(self):
self.progress_var.set(self.progress)
self.status_var.set(f"下载中: {self.completed_tasks}/{self.total_tasks} ({self.progress:.1f}%)")
def batch_download(self, urls):
for i, url in enumerate(urls):
if self.stop_flag:
break
self.completed_tasks = i + 1
self.progress = (self.completed_tasks / self.total_tasks) * 100
self.root.after(100, self.update_progress)
self.log(f"正在处理 ({i + 1}/{len(urls)}): {url}")
success, filename = self.download_single(url)
if success:
self.log(f"√ 下载成功: {filename}")
else:
self.log(f"× 下载失败: {url}")
time.sleep(0.5)
self.progress = 100
self.completed_tasks = self.total_tasks
self.root.after(100, self.update_progress)
if self.stop_flag:
self.log(f"下载已停止, 已完成 {i}/{len(urls)} 个任务")
self.status_var.set(f"已停止: {i}/{self.total_tasks} 个任务")
else:
self.log(f"批量下载完成! 共完成 {len(urls)} 个任务")
self.status_var.set(f"下载完成: {len(urls)}/{len(urls)} 个任务")
self.downloading = False
self.root.after(100, lambda: self.download_btn.config(state=tk.NORMAL))
self.root.after(100, lambda: self.stop_btn.config(state=tk.DISABLED))
# ----------------- 核心下载逻辑 -----------------
def download_single(self, base_url):
try:
s = requests.get(base_url, headers=self.headers, timeout=10).text
# 解析 CID
obj1 = re.compile(r'"cid":(?P<cid>.*?),', re.S)
cid = ''
for i in obj1.finditer(s):
if len(i.group('cid')) < 5:
continue
elif cid == i.group('cid'):
continue
else:
cid = i.group('cid')
break
if not cid:
self.log("未找到 CID")
return False, ""
# 解析标题
obj2 = re.compile(r'<meta name="keywords" content="(?P<name>.*?),.*?"', re.S)
juji = ''
for j in obj2.finditer(s):
juji = j.group('name').replace(' ', '').replace(':', '-')
if juji == '':
obj3 = re.compile(r'<title data-vue-meta="true">(?P<two_name>.*?)_哔哩哔哩_bilibili</title>', re.S)
for z in obj3.finditer(s):
juji = z.group("two_name")
break
if not juji:
juji = "未知标题"
# 下载 XML
url = f'https://comment.bilibili.com/{cid}.xml'
res = requests.get(url, headers=self.headers, timeout=10)
filename = f"{cid}_{juji}.xml"
filepath = os.path.join(self.download_folder, filename)
with open(filepath, 'wb') as f:
f.write(res.content)
return True, filename
except Exception as e:
self.log(f"下载出错: {str(e)}")
return False, ""
# ----------------- 程序入口 -----------------
if __name__ == "__main__":
root = tk.Tk()
root.withdraw()
splash = SplashScreen(root)
app = BiliDanmuDownloader(root)
def show_main():
splash.close()
root.deiconify()
try:
from ttkthemes import ThemedStyle
style = ThemedStyle(root)
style.set_theme("arc")
except Exception:
pass
root.after(2000, show_main)
root.mainloop()

Comments NOTHING