背景
既然要从评论角度,对哔哩哔哩的课程进行优质与劣质的分类,那么其中一个很重要的环节就是爬取课程视频下方的评论。这些评论文本送入训练好的深度学习模型,进行情感分类,综合得到课程质量分类结果。本文主要记录个人在爬取哔哩哔哩视频评论的过程与心得。
目标
我们只需要爬取视频下方的一级评论。所有对评论的回复均不予关注。我们需要以表格形式将同一个视频的前200条评论展示出来。
抓包爬取
点开查看网页源代码会发现,评论部分的源代码实际上是不存在的(换言之,展示出来的页面源代码不完整)。因此,需要进行抓包爬取。按下F12键,点击“网络”选项卡,查找含有reply关键字的响应资源,不难找到评论所在的响应资源。
然后在标头中看一下它的请求链接:
完整链接为https://api.bilibili.com/x/v2/reply/main?csrf=660751d5c3b843b85b100ece74c17f36&mode=3&oid=443609142&pagination_str=%7B%22offset%22:%22%22%7D&plat=1&seek_rpid=&type=1
另外其实可以发现,原哔哩哔哩视频的评论是分批获取的,因为用户在不断往下拉时,会相应地刷新出现新的评论。因此评论的资源请求也是同样地分批进行。这个道理可以类比计算机网络中的数据报分组。全部评论就如同完整的数据报,而哔哩哔哩中每一页存在20条评论,可以类比为每一个分组大小就是20,由此进行分组请求。事实上,请求信息的相应字段也证实了这一点。
字段is_begin
和is_end
分别指示了当前的分片是否为开头或者结尾。next
字段指示的是下一个分片,可以理解为分片索引。理想状态是根据next
的迭代自增爬取完全部的评论,但是上面的请求链接似乎压根不含这个字段!怎么办?
关键:获取oid
参考网络上众多爬取哔哩哔哩评论的教程,我们可以发现现在的评论请求链接有一些变化了(过去请求链接中显式地包含着next
数组,显而易见地可以按照上面的理想情况爬取):
https://api.bilibili.com/x/v2/reply/main?jsonp=jsonp&next={num}&type=1&oid={oid}&mode=3&plat=1&_=1647577851745
不过不变的是,字段csrf
在请求链接中确实没用,可以直接删去。于是请求链接变为:
https://api.bilibili.com/x/v2/reply/main?mode=3&oid=443609142&pagination_str=%7B%22offset%22:%22%22%7D&plat=1&seek_rpid=&type=1
但是原来的请求链接现在还能用吗?不妨试一试,显然此处的字段oid
具有课程唯一性,不同的课程的全部评论可以通过该字段进行区分。那我们只需要拿到oid
字段,填入原先的请求链接中的oid
字段,看看能不能照样显示出网页文本。结果居然是可以的。那么我们仍然可以按照理想情况来进行爬取评论了:只需要填入课程的oid
即可。
爬取到的内容实际上是json对象。所包含的有用评论信息在其中的子属性下。通过不断地迭代取值,便可以取出所需要的属性值。
完整代码
考虑到后续需要封装到后端,我们实现一个爬取评论的函数即可。
def get_root_reply(oid):
# 网页头
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36",
"referer": "https://www.bilibili.com/"
}
num = 1
# oid = 443609142
replay_index = 1
file = open('lanyin.txt', 'w', encoding='utf-8')
replies = [] # 评论数组
while True:
# 只需要前10×20=200条评论
if num == 11:
break
URL = f"https://api.bilibili.com/x/v2/reply/main?jsonp=jsonp&next={num}&type=1&oid={oid}&mode=3&plat=1&_=1647577851745" # 获得网页源码
respond = requests.get(URL, headers=headers) # 获得源代码 抓包
if (respond.status_code == 200): # 如果响应为200就继续,否则退出
respond.encoding = "UTF-8"
html = respond.text
json_html = json.loads(html) # 把格式转化为json格式 一个是好让pprint打印,一个是好寻找关键代码
if json_html['data']['replies'] is None or len(json_html['data']['replies']) == 0:
break
for reply_num in range(0, len(json_html['data']['replies'])): # 一页只能读取20条评论
reply = json_html['data']['replies'][reply_num]['content']['message']
reply = post_filter(reply) # post_filter()暂时按下不表,他相当于是一个后处理过滤器
print(f"[{reply_num}/{num}]: {reply}")
replies.append(reply)
file.write(str(replay_index) + '.' + reply + '\n')
replay_index += 1
num += 1
time.sleep(0.5)
else:
file.close()
return "Error"
file.close()
return replies
post_filter()
函数负责去除评论中的换行符以及其中的emoji表情符转化后的[]文本:
def post_filter(text):
"""去除评论文本中的换行符,以及emoji经转化后的[]文本信息"""
text = text.replace('\n', ',')
return re.sub(u"\\{.*?}|\\[.*?]", "", text)
效果
做了一个简单的前端和后端,展示一下爬取到的评论效果: