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 项目整体架构
我把整个项目拆成四个模块,每个模块独立可测:
- 请求层:封装session、header管理、重试逻辑、代理池(可选)
- 解析层:B站弹幕用protobuf解析,QQ音乐热评用JSON解析
- 存储层:先落CSV,再考虑SQLite或MongoDB
- 调度层:单机用线程池,分布式再上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_comments4.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 -= 15.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 None5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 返回412 | 请求频率过高 | 降低并发,加sleep |
| 返回-400 | Referer缺失或错误 | 补全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条就写一次文件,虽然慢一点,但安全。
这个项目后续还可以扩展的方向很多,比如把弹幕做成词云、把热评做情感分析、把两个数据源做交叉分析。但那是另一个话题了,先把爬取这一环做扎实,后面的分析才有意义。