☰
Python爬虫实战:B站弹幕与QQ音乐热评抓取及反爬优化
2026/10/9 17:37:21 网站建设 项目流程

1. 从两个爬虫项目说起:为什么弹幕和热评值得单独做

弹幕和热评,看起来都是“文字”,但真正抓过的人都知道,这两类数据的脾气完全不一样。B站的弹幕是高并发、短生命周期、强时序的数据流,一条弹幕可能只有几个字,但同一秒内会有几十上百条涌进来;QQ音乐的热评则是低频、长文本、强互动的数据,一条评论可能几百字,点赞数从个位数到几十万不等。把这两个场景放在一起做,其实是一个很聪明的选择——它逼着你把爬虫的两套核心能力都练一遍:一套是协议逆向与高频请求,另一套是动态渲染与结构化提取。

我自己最早做这类项目的时候,踩过的坑能写满一页纸。比如B站的弹幕接口早期是明文XML,后来改成protobuf压缩,再后来又加了各种校验参数;QQ音乐的热评接口则长期依赖动态token和请求签名,直接拿浏览器里复制的URL去请求,十有八九返回的是空数据或者“参数错误”。所以这篇文章不会只给你两段能跑的代码,而是把为什么这么写、参数怎么来的、哪里容易翻车讲清楚。适合已经会一点Python基础、想从“能跑通”进阶到“跑得稳”的人,也适合完全没接触过爬虫、但愿意跟着一步步操作的新手。

核心关键词我先摆出来:python爬虫、bilibili弹幕、qq音乐热评、requests、scrapy。后面所有内容都围绕这几个词展开,不跑题。

2. 整体方案设计:为什么我选requests打底、scrapy做扩展

2.1 两个场景的技术选型对比

很多人一上来就问“用scrapy还是requests”,其实这个问题本身就不太对。工具没有优劣,只有合不合适。我先把两个场景的特征拆开看:

维度B站弹幕QQ音乐热评
数据量级单视频几千到几十万条单歌曲几百到几万条
请求频率极高,需要分片拉取中等,分页拉取
数据格式protobuf压缩二进制JSON明文
反爬强度中等,靠参数校验较高,靠签名和token
是否需要登录部分接口需要大部分需要
适合框架requests + 多线程requests + 会话保持

看这张表就能明白:B站弹幕的核心矛盾是吞吐量,QQ音乐热评的核心矛盾是身份校验。所以我的方案是——两个项目都用requests打底,因为requests对会话、header、cookie的控制最直接,调试成本最低。等你把单机跑通了,再考虑用scrapy做分布式扩展,那是后话。

提示:不要一上来就上scrapy。scrapy的调试曲线比requests陡得多,尤其是遇到需要手动构造签名参数的接口时,scrapy的中间件机制反而会增加排查难度。先用requests把逻辑跑通,再迁移。

2.2 为什么不用selenium或playwright

热搜词里出现了“scrapy playwright 动态 iframe”,说明很多人第一反应是上浏览器自动化。我的建议是:能抓接口就别渲染页面。原因有三点。

第一,浏览器自动化的资源消耗是纯请求的几十倍。一个chromium实例起步就是几百MB内存,你开十个并发,机器直接卡死。第二,动态渲染的稳定性极差,页面改一个class名,你的选择器就全废了。第三,B站和QQ音乐的核心数据都是通过XHR接口返回的,页面只是渲染层,直接抓接口拿到的数据更干净、更完整。

那什么时候才需要playwright?只有当接口的签名算法复杂到无法逆向,或者数据确实只存在于DOM里的时候。这两个项目都不属于这种情况。

2.3 项目整体架构

我把整个项目拆成四个模块,每个模块独立可测:

  1. 请求层:封装session、header管理、重试逻辑、代理池(可选)
  2. 解析层:B站弹幕用protobuf解析,QQ音乐热评用JSON解析
  3. 存储层:先落CSV,再考虑SQLite或MongoDB
  4. 调度层:单机用线程池,分布式再上scrapy-redis

这个分层的好处是,任何一层出问题,你都能快速定位。比如弹幕抓下来是乱码,那肯定是解析层的问题;如果请求直接403,那就是请求层的header或cookie没配对。

3. B站弹幕爬取:从接口逆向到protobuf解析

3.1 弹幕接口的定位与参数拆解

B站弹幕的核心接口藏在视频播放页的XHR请求里。你打开一个视频,按F12进Network面板,筛选“segment”或者“dm”,就能看到类似这样的请求:

https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=视频cid&pid=视频aid&segment_index=1

这里有几个关键参数必须搞清楚:

  • oid:视频的cid,注意不是aid。aid是视频的av号,cid是弹幕池的编号,两者不一样。获取cid的方法是请求https://api.bilibili.com/x/player/pagelist?aid=xxx,返回的JSON里每个分P都有cid。
  • pid:就是aid,视频的av号。
  • segment_index:弹幕分片索引。B站的弹幕是按6分钟一片切分的,一个视频有多少片,取决于视频时长。比如一个60分钟的视频,就有10片,segment_index从1到10。
  • type:固定为1,表示视频弹幕。

注意:这个接口返回的是protobuf格式的二进制数据,不是JSON。你直接用浏览器打开会看到一堆乱码,这是正常的。

3.2 获取cid的完整流程

很多人卡在第一步——不知道cid怎么来。我写一段可直接跑的代码:

import requests def get_cid(aid): url = f"https://api.bilibili.com/x/player/pagelist?aid={aid}" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": f"https://www.bilibili.com/video/av{aid}" } resp = requests.get(url, headers=headers) data = resp.json() if data["code"] != 0: raise Exception(f"获取cid失败: {data['message']}") # 返回第一个分P的cid,多P视频需要遍历 return data["data"][0]["cid"]

这段代码里,Referer是必须的。B站的接口对Referer校验很严,不带的话大概率返回-400。User-Agent也要伪装成正常浏览器,否则会被识别为爬虫。

3.3 protobuf解析:为什么不能直接当文本读

弹幕接口返回的数据是protobuf序列化后的二进制。protobuf是Google搞的一种高效数据交换格式,优点是体积小、解析快,缺点是必须有对应的.proto定义文件才能解析。

B站弹幕的.proto结构大致是这样的(社区逆向出来的):

message DanmakuElem { int64 id = 1; int32 progress = 2; // 弹幕出现时间,毫秒 int32 mode = 3; // 弹幕类型 int32 fontsize = 4; uint32 color = 5; string midHash = 6; string content = 7; // 弹幕内容 int64 ctime = 8; int32 weight = 9; string action = 10; int32 pool = 11; string idStr = 12; }

解析的时候,你需要用protobuf库,把二进制数据反序列化成对象。如果你不想自己编译.proto文件,也可以用社区维护的bilibili-api或者dmproto这类库,直接调用现成的解析函数。

我个人的做法是:自己编译一份.proto,因为这样最可控,不依赖第三方库的更新。编译命令是:

protoc --python_out=. danmaku.proto

生成的danmaku_pb2.py就可以直接import使用了。

3.4 分片拉取与并发控制

一个视频的弹幕可能分散在几十个分片里,串行拉取太慢。我的做法是用ThreadPoolExecutor做并发,但并发数要控制好。

from concurrent.futures import ThreadPoolExecutor import requests def fetch_segment(cid, aid, index): url = "https://api.bilibili.com/x/v2/dm/web/seg.so" params = { "type": 1, "oid": cid, "pid": aid, "segment_index": index } headers = { "User-Agent": "Mozilla/5.0 ...", "Referer": f"https://www.bilibili.com/video/av{aid}" } resp = requests.get(url, params=params, headers=headers) return resp.content def fetch_all_segments(cid, aid, total_segments): results = [] with ThreadPoolExecutor(max_workers=5) as executor: futures = [executor.submit(fetch_segment, cid, aid, i) for i in range(1, total_segments + 1)] for f in futures: results.append(f.result()) return results

并发数建议控制在3到5之间。我试过开到10,结果触发了B站的限流,连续返回412错误。412是B站的风控状态码,一旦触发,你的IP会被临时封禁一段时间。所以宁可慢一点,也不要贪快。

3.5 弹幕数据的清洗与存储

原始弹幕数据里有很多噪音,比如重复弹幕、纯表情、广告。我一般会做几层过滤:

  • 去掉长度小于2的弹幕
  • 去掉包含URL的弹幕
  • 用collections.Counter统计重复弹幕,出现次数超过阈值的可能是刷屏

存储方面,先用CSV最省事:

import csv def save_danmaku(danmaku_list, filename): with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["时间(ms)", "内容", "发送者hash", "颜色"]) for d in danmaku_list: writer.writerow([d.progress, d.content, d.midHash, d.color])

注意encoding="utf-8-sig",这样用Excel打开不会乱码。这个细节很小,但能省你很多事。

4. QQ音乐热评爬取:签名、token与分页策略

4.1 热评接口的定位

QQ音乐的热评数据在歌曲详情页的评论区。打开一首歌,F12筛选XHR,找comment相关的请求,你会看到类似:

https://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg?g_tk=xxx&songid=xxx&pagenum=0&pagesize=25

这个接口返回JSON,但有几个坑:

  • g_tk:这是一个基于cookie计算的签名参数,不是固定的。
  • songid:歌曲的mid,不是数字ID。
  • pagenum:从0开始,不是1。
  • pagesize:每页条数,最大25。

4.2 g_tk的计算逻辑

g_tk是QQ系产品通用的签名算法,核心逻辑是:从cookie里取skey或p_skey,经过一次哈希运算得到。具体算法是:

def get_g_tk(skey): hash_val = 5381 for c in skey: hash_val += (hash_val << 5) + ord(c) return hash_val & 0x7fffffff

这个算法看起来简单,但skey必须是从登录态cookie里取的。如果你没登录,拿到的skey是空的,算出来的g_tk就是5381,请求会返回“未登录”错误。

所以QQ音乐热评爬取的第一步,是手动登录一次,把cookie导出。我一般用浏览器插件导出cookie,存成JSON,然后在代码里加载。

4.3 会话保持与请求头构造

QQ音乐对请求头的校验比B站更细。除了User-Agent和Referer,还要注意:

  • Referer:必须是https://y.qq.com/开头
  • Cookie:必须包含完整的登录态
  • Origin:部分接口需要

我用requests.Session来保持会话:

import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", "Referer": "https://y.qq.com/", }) # 加载cookie cookies = load_cookies_from_json("qq_cookies.json") session.cookies.update(cookies)

提示:cookie是有有效期的,一般几天到几周。如果突然开始返回空数据,第一件事就是检查cookie是否过期。

4.4 分页拉取与去重

QQ音乐热评的分页逻辑是:pagenum从0开始,每次加1,直到返回的评论列表为空。但实际测试下来,热门歌曲的评论可能超过几千页,全量拉取不现实。我的策略是:

  • 只拉前50页(约1250条)
  • 按点赞数排序,取Top 500
  • 用评论ID去重
def fetch_comments(songid, g_tk, max_pages=50): all_comments = [] seen_ids = set() for page in range(max_pages): url = "https://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg" params = { "g_tk": g_tk, "songid": songid, "pagenum": page, "pagesize": 25, "cmd": 6, "reqtype": 2 } resp = session.get(url, params=params) data = resp.json() comments = data.get("comment", {}).get("commentlist", []) if not comments: break for c in comments: cid = c.get("commentid") if cid not in seen_ids: seen_ids.add(cid) all_comments.append(c) return all_comments

4.5 热评数据的结构化提取

QQ音乐评论的JSON结构比较深,关键字段有:

字段含义
commentid评论唯一ID
rootcommentcontent评论正文
praisenum点赞数
nick昵称
time时间戳
replynum回复数

提取的时候要注意,rootcommentcontent里可能包含表情符号的转义字符,需要做一次清洗。我一般用正则把[em]xxx[/em]这种标记去掉。

5. 反爬对抗与稳定性优化

5.1 请求频率控制

这是最容易被忽视、也最容易翻车的地方。我的经验是:

  • B站弹幕:单IP每秒不超过2个请求
  • QQ音乐热评:单IP每秒不超过1个请求

超过这个频率,短则返回412,长则IP被封。控制频率最简单的方法是用time.sleep,但更优雅的做法是用令牌桶算法:

import time class RateLimiter: def __init__(self, rate): self.rate = rate self.tokens = rate self.last_time = time.time() def acquire(self): now = time.time() self.tokens += (now - self.last_time) * self.rate self.tokens = min(self.tokens, self.rate) self.last_time = now if self.tokens < 1: time.sleep((1 - self.tokens) / self.rate) self.tokens = 0 else: self.tokens -= 1

5.2 重试与异常处理

网络请求失败是常态,关键是要有分级重试策略:

  • 网络超时:立即重试,最多3次
  • 429/412:等待指数退避时间后重试
  • 403:检查header和cookie,不盲目重试
def request_with_retry(url, params, max_retries=3): for i in range(max_retries): try: resp = session.get(url, params=params, timeout=10) if resp.status_code == 200: return resp elif resp.status_code in (412, 429): time.sleep(2 ** i) else: break except requests.RequestException: time.sleep(1) return None

5.3 常见问题速查表

问题现象可能原因解决方法
返回412请求频率过高降低并发,加sleep
返回-400Referer缺失或错误补全Referer
弹幕乱码未做protobuf解析用.proto文件反序列化
热评为空cookie过期重新导出cookie
g_tk错误skey为空确认登录态
分页重复未去重用commentid去重

6. 从单机到分布式:scrapy的接入时机

6.1 什么时候该上scrapy

单机requests跑得稳之后,如果你需要:

  • 同时抓取几千个视频的弹幕
  • 需要断点续爬
  • 需要多机协同

那就可以考虑scrapy了。但要注意,scrapy的Downloader Middleware和Spider Middleware需要重新设计,尤其是cookie管理和签名参数的注入。

6.2 scrapy-redis的简单接入

scrapy-redis的核心是把请求队列放到redis里,多个爬虫实例共享。配置很简单:

# settings.py SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" REDIS_URL = "redis://localhost:6379"

但实际用的时候,去重逻辑要自己写,因为scrapy-redis默认的去重是基于请求URL的,而弹幕和热评的URL可能相同但参数不同。

6.3 数据存储的扩展

单机用CSV没问题,分布式就要上数据库了。我的建议是:

  • 弹幕数据:MongoDB,因为字段不固定,写入快
  • 热评数据:MySQL或PostgreSQL,因为需要做统计分析

7. 实操心得与避坑清单

最后分享几条我踩过坑之后总结的经验,都是文档里不会写的。

第一条:不要用免费的代理IP池。我试过十几个免费代理源,可用率不到5%,而且很多是被目标网站标记过的。与其花时间维护代理池,不如把请求频率降下来,用单IP慢慢跑。

第二条:cookie要定期更新。QQ音乐的cookie一般7天左右失效,B站的cookie如果只是游客态,可能几小时就失效。我的做法是写一个定时任务,每天检查一次cookie有效性,失效就提醒手动更新。

第三条:弹幕的时间戳是毫秒,不是秒。这个坑我踩过,存到数据库里发现时间全是错的,后来才发现要除以1000。

第四条:热评的点赞数会变。你今天抓的Top 100,明天可能就变了。如果要做趋势分析,必须记录抓取时间,否则数据没有意义。

第五条:不要忽略robots.txt。虽然技术上可以绕过,但从合规角度,建议只抓取公开数据,不要碰需要登录才能看的内容。这个边界要自己把握好。

第六条:代码要加日志。我早期写的爬虫没有日志,出了问题只能靠print,效率极低。后来改用logging模块,把每个请求的URL、状态码、耗时都记下来,排查问题快了很多。

第七条:数据要备份。有一次我跑了三个小时的弹幕抓取,结果程序崩溃,数据全丢了。从那以后,我改成每抓100条就写一次文件,虽然慢一点,但安全。

这个项目后续还可以扩展的方向很多,比如把弹幕做成词云、把热评做情感分析、把两个数据源做交叉分析。但那是另一个话题了,先把爬取这一环做扎实,后面的分析才有意义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询