让弹幕飞一会儿——“要有风,要有肉”;“要有火锅,要有雾”;要有乐子,要有干货!
本文以腾讯视频(《都挺好》)为例,解析弹幕爬取的细节和难点,对思路感兴趣的旁友们可以跟着文章逻辑走一遍,对于想直接上手爬的同学,文末已给出完整代码。
相对于一般电影 OR 电视剧评论,弹幕能够贴合剧情,进行更多有意思的脑洞分析。
图源《让子弹飞》
作者 | 周志鹏
责编 | 仲培艺
注:“前传”《都挺好》弹幕分析文章所有数据(39W+)均基于本文代码爬取。
每次写爬虫,耳畔都会回响起那句经典的 freestyle:
“你看这个碗,它又大它又圆,你看这个面,它又长它又宽”
短短四句,揭示了两种本质——碗是大和圆的,面是长亦宽的。一秒就看清事物本质的人和一辈子才看透事物本质的人自然过着不同的人生。
所以,写爬虫也是一样,理清目标数据和网址的变化规律,也就是先看到碗的大和圆,面的长和宽,随后再去解决细节的数据定位和抓取(欣赏碗的花纹细节,面的Q弹),往往事半功倍。
——这就是我写爬虫所信奉的大碗宽面逻辑。
子弹(弹幕)轨迹规律探究
数据定位
打开腾讯视频的电视剧(这里以《都挺好》为例),F12 审查元素,默默等待目标猎物出现,因为弹幕是播放时不断滚动出现,所以我们先假设它在 JS 下。
图源《都挺好》
正片开始后,一群以“danmu"为开头的请求不断加载打破了短暂的平静,我们把这个疑似目标预览一下:
果然,弹幕内容赫然在列,对于我们分析有用的字段还有弹幕的 ID,upcount(点赞数)、opername(用户名)和 uservip_degree (会员等级)。
到这一步,我们先不纠结于这个 JSON 文件要如何伪装访问,如何解析,不妨跟随那句“大碗宽面”的旋律,跳出碗来,看看这个碗是大还是圆(找规律)。
弹道(弹幕网址)规律分析
在找网址规律的时候,有一个小技巧,就是尝试暴力删掉目标网址中不影响最终结果的部分参数,再从最精简的网址中寻找规律。
拿我们第一个弹幕网址来说,原网址是这样的:
https://mfm.video.qq.com/danmu?otype=json&callback=jQuery19109123255549841207_1553922882824×tamp=45&target_id=3753912718%26vid%3Dt00306i1e62&count=80&second_count=5&session_key=558401%2C8142%2C1553922887&_=1553922882831
在浏览器中打开是这样的:
网址最后一串数据好像是时间戳,我们删掉试试——果然,返回的内容没变。那个 sessiong_key 到底影不影响呢?删了试试,返回内容还是没变!
删到最后,我们把原网址精简成了下面的网址:
https://mfm.video.qq.com/danmu?otype=json×tamp=15&target_id=3753912718%26vid%3Dt00306i1e62&count=80
我们把第二页网址也精简一下:
https://mfm.video.qq.com/danmu?otype=json×tamp=45&target_id=3753912718%26vid%3Dt00306i1e62&count=80
对比很容易找到规律,从第一页到第二页,timestamp 值从 15 变到了 45,其他部分没有任何变化,我有一个大胆的猜测,这个 timestamp 值是控制页数的变量,并且是 30 秒更新一次弹幕。
那一级有多少页呢?我们把进度条拉到影片结束的边缘,发现最后一页网址的 timestamp 值变成了 2565。
整个过程,我们只需要构造步长为 30 的循环变量来替换 timestamp 参数就可以实现批量访问了。
到这里,单集中弹幕动态更新的规律我们已经探究清楚,下面来对单个页面进行解析。
PS:其实大碗宽面的逻辑下,我们这个时候应该再继续对比不同集数之间网址变化规律,并找到规律本身,但考虑到内容实操性与可读性,我们不妨把这一块往后稍稍。
解析单页弹幕内容
以第一集第一页的弹幕为例,我们只进行简单的 Headers 伪装,进行访问尝试:
异常顺利,成功返回目标结果,而且是友好的 JSON 格式,我们用 JSON 来解析一下:
纳尼?结果疯狂报错:
告诉我们在 35444 的位置有字符问题,经过排查,发现错误的原因是解析的部分内容因为格式问题没有通过 JSON 语法检查,解决方法很简单,我们将 json.loads 中 strict 参数变成 Fasle 即可:
OK,接下来遍历提取我们需要的关键数据:
#存储数据
df = pd.DataFrame()
#遍历获取目标字段
for i in bs['comments']:
content = i['content'] #弹幕内容
name = i['opername'] #用户名
upcount = i['upcount'] #点赞数
user_degree =i['uservip_degree'] #会员等级
timepoint = i['timepoint'] #发布时间
comment_id = i['commentid'] #弹幕ID
cache = pd.DataFrame({'用户名':[name],'内容':[content],'会员等级':[user_degree],
'评论时间点':[timepoint],'评论点赞':[upcount],'评论id':[comment_id]})
df = pd.concat([df,cache])
大写的 EASY!要进行多页爬取,只需要在外层构造一个循环,以 30 为步长改变 timestamp 的变量即可。
不同集之间网址规律探究
单页、单集的规律都搞清楚了,那不同集之间的网址有什么规律呢?
第一集是这样的:
https://mfm.video.qq.com/danmu?otype=json×tamp=15&target_id=3753912718%26vid%3Dt00306i1e62&count=80
我们把第二集的弹幕网址也暴力精简:
https://mfm.video.qq.com/danmu?otype=json×tamp=15&target_id=3753912717%26vid%3Dx003061htl5&count=80
发现是 target_id 值和 %3D 后面一串 ID(第一集是 t00306i1e62;第二集是 x003061htl5)的变化决定了不同的集数。(为了区分,我们把后面那一串 ID 叫做后缀 ID)
而难点就在于他们之间没有像 timestamp 那样明显的规律可循,弹幕内容所在的网址本身又没有任何关于两个 ID 的信息。
所以,我们必须跳出碗来找线索,看看有没有又大又黑的锅装这些碗(目的在于找到存储 target_id 和后面不规则 ID 的那口大锅)。
找到后缀 ID
这个时候,需要一些常识来开路了。我们发现播放视频的时候,在播放屏右边总会显示全部集数:
点击对应的集数就会进行相应的换集跳转,所以我们有理由相信 ID 相关的锅藏在其中。重新刷新网页,很容易找到了他们的踪迹:
可以看到,上面截图中第一集的 ID “t00306i1e62” 对应着我们前面找到的规律(后缀 ID)。打开任意一集,发现 1-30 集和 31-46 集相关的后缀 ID 都分别存储在两个相邻的网页。
所以,我们先尝试拿下所有的后缀 ID、对应剧集名称、播放量和集数:
part1_url = 'https://union.video.qq.com/fcgi-bin/data?otype=json&tid=682&appid=20001238&appkey=6c03bbe9658448a4&idlist=b0030velala,t00306i1e62,x003061htl5,b0030velala,w0030ilim7z,i0030r7v63u,z003044noq2,m0030sfinyr,c0030u884k7,k0030m5zbr7,l0030e5nglm,h0030b060vn,j003090ci7w,n0030falyoi,s00308u9kwx,p0030fohijf,g00303ob0cx,v0030960y6n,x0030bl84xw,v0030keuav1,t0030kups1i,n0030y2o52i,x0030s52mev,d0030xuekgw,o0030md1a2a,x0030peo3sk,d00303l5j4k,t0030aexmnt,a0030ybi45z,y0030wpe2wu&callback=jQuery19101240739643414368_1553238198070&_=1553238198071'
part2_url = 'https://union.video.qq.com/fcgi-bin/data?otype=json&tid=682&appid=20001238&appkey=6c03bbe9658448a4&idlist=t0030epjqsi,g003035mi84,n00301fxqbh,h0030zivlrq,d0030qc1yu2,m0030q9ywxj,h0030j0eq19,j0030jks835,t0030owh5uu,e0030xbj246,a00308xw434,l0030tb319m,a0030mhntt6,t0030wnr3t9,l0030t7o64e,b0030i9bi3o,m0030yklk6j,z0030tgz3pp,r00307wgnly,o00306b4zax,k00309i6ul6,j00304eu73n,v08521l667a,u0851gzzoqi,a0852328197,k0852mb3ymt,v00308p65xf,z08527pia6g,z08520difig,z0852ybpxn0&callback=jQuery19101240739643414368_1553238198072&_=1553238198073'
base_info = pd.DataFrame()
for url in [part1_url,part2_url]:
html = requests.get(url,headers = headers)
bs = json.loads(html.text[html.text.find('{'):-1])
for i in bs['results']:
v_id = i['id']
title = i['fields']['title']
view_count = i['fields']['view_all_count']
episode = int(i['fields']['episode'])
if episode == 0:
pass
else:
cache = pd.DataFrame({'id':[v_id],'title':[title],'播放量':[view_count],'第几集':[episode]})
base_info = pd.concat([base_info,cache])
OK,非常顺利。
目前来说我们拿到了所有的后缀 ID,但还是缺少 target_id,无法构造完整的网页进行自动循环爬取。而我们在这两个网页中找不到任何和 target_id 有关的信息,真让人头大!
死磕 target_id
每当没有头绪的时候,我总是想起莎翁的那句:
“一切过往,皆为序章”。
反之,一切序章,皆有过往,正在发生或者已经发生的万事万物一定有迹可循。
我们心心念念的 target_id 一定在某个动态网页中记录着。
这个时候就需要耐心地筛选了。最后,我们发现,单集的 target_id 隐藏在 XHR 下一个"regist"开头的动态网址中:
仔细观察,这是一个 POST 请求。
传递的参数如下:
翻了 N 集来对比,我们发现不同集数之间网址变化的只有传入的这个“vecIdList”,里面的参数正是我们上一步获取的那些后缀 ID。
真相渐渐浮出水面。
思路梳理
第一步,我们搞清楚了单集内部弹幕网址的动态变化,只需要改变 timestamp 的值即可循环爬取单集所有内容;
第二步,发现要自动爬取每一集,必须先找到构造网址的 target_id 和后缀的 ID;
第三步,任意一集网页中都能直接找到所有剧集的后缀 ID(我们已经拿下了所有的后缀 ID),但是却只能在一集中找到单集的一个 target_id;
第四步,也就是接下来的一步,我们可以基于已经爬到的后缀 ID,去循环访问每一集,拿到单集对应的 target_id,这样就能构造出完整的弹幕网页所需的 ID 们了。
说干就干,循环爬取 target_id:
#定义爬取单集target_id的函数
#只需要向函数传入v_id(后缀ID)和headers
def get_episode_danmu(v_id,headers):
#target_id所在基础网址
base_url = 'https://access.video.qq.com/danmu_manage/regist?vappid=97767206&vsecret=c0bdcbae120669fff425d0ef853674614aa659c605a613a4&raw=1'
#传递参数,只需要改变后缀ID
pay = {"wRegistType":2,"vecIdList":[v_id],
"wSpeSource":0,"bIsGetUserCfg":1,
"mapExtData":{v_id:{"strCid":"wu1e7mrffzvibjy","strLid":""}}}
html = requests.post(base_url,data = json.dumps(pay),headers = headers)
bs = json.loads(html.text)
#定位元素
danmu_key = bs['data']['stMap'][v_id]['strDanMuKey']
#解析出target_id
target_id = danmu_key[danmu_key.find('targetid') + 9 : danmu_key.find('vid') - 1]
return [v_id,target_id]
info_lst = []
#循环获取后缀ID并传递
for i in base_info['id']:
#得到每一集的后缀ID和target_id
info = get_episode_danmu(i,headers)
print(info)
info_lst.append(info)
time.sleep(3 + random.random())
当当当当~结果如下(截取了部分):
我们终于集齐了构成单页弹幕网址所需的 target_id、后缀 ID,只需要构造两个循环就可以实现完整的弹幕爬取(第一个循环构造每一集的基础网页,第二个循环构造单集内的弹幕页数)。
目前来说,对于弹幕爬取(腾讯视频),单纯的 Headers 伪装就能够畅通无阻,但也建议大家文明爬取,理性分析 :)
至此,我们锅、碗和面都已经准备到位了,再把刚才各模块写得精简一些,然后就可以酣畅淋漓地吃大碗宽面了。
Skrrrrrrrrrrr~
最后附上完整代码:
import requests
import json
import pandas as pd
import os
import time
import random
#页面基本信息解析,获取构成弹幕网址所需的后缀ID、播放量、集数等信息。
def parse_base_info(url,headers):
df = pd.DataFrame()
html = requests.get(url,headers = headers)
bs = json.loads(html.text[html.text.find('{'):-1])
for i in bs['results']:
v_id = i['id']
title = i['fields']['title']
view_count = i['fields']['view_all_count']
episode = int(i['fields']['episode'])
if episode == 0:
pass
else:
cache = pd.DataFrame({'id':[v_id],'title':[title],'播放量':[view_count],'第几集':[episode]})
df = pd.concat([df,cache])
return df
#传入后缀ID,获取该集的target_id并返回
def get_episode_danmu(v_id,headers):
base_url = 'https://access.video.qq.com/danmu_manage/regist?vappid=97767206&vsecret=c0bdcbae120669fff425d0ef853674614aa659c605a613a4&raw=1'
pay = {"wRegistType":2,"vecIdList":[v_id],
"wSpeSource":0,"bIsGetUserCfg":1,
"mapExtData":{v_id:{"strCid":"wu1e7mrffzvibjy","strLid":""}}}
html = requests.post(base_url,data = json.dumps(pay),headers = headers)
bs = json.loads(html.text)
danmu_key = bs['data']['stMap'][v_id]['strDanMuKey']
target_id = danmu_key[danmu_key.find('targetid') + 9 : danmu_key.find('vid') - 1]
return [v_id,target_id]
#解析单个弹幕页面,需传入target_id,v_id(后缀ID)和集数(方便匹配),返回具体的弹幕信息
def parse_danmu(url,target_id,v_id,headers,period):
html = requests.get(url,headers = headers)
bs = json.loads(html.text,strict = False)
df = pd.DataFrame()
for i in bs['comments']:
content = i['content']
name = i['opername']
upcount = i['upcount']
user_degree =i['uservip_degree']
timepoint = i['timepoint']
comment_id = i['commentid']
cache = pd.DataFrame({'用户名':[name],'内容':[content],'会员等级':[user_degree],
'弹幕时间点':[timepoint],'弹幕点赞':[upcount],'弹幕id':[comment_id],'集数':[period]})
df = pd.concat([df,cache])
return df
#构造单集弹幕的循环网页,传入target_id和后缀ID(v_id),通过设置爬取页数来改变timestamp的值完成翻页操作
def format_url(target_id,v_id,end = 85):
urls = []
base_url = 'https://mfm.video.qq.com/danmu?otype=json×tamp={}&target_id={}%26vid%3D{}&count=80&second_count=5'
for num in range(15,end * 30 + 15,30):
url = base_url.format(num,target_id,v_id)
urls.append(url)
return urls
def get_all_ids(part1_url,part2_url,headers):
#分别获取1-30,31-46的所有后缀ID(v_id)
part_1 = parse_base_info(part1_url,headers)
part_2 = parse_base_info(part2_url,headers)
df = pd.concat([part_1,part_2])
df.sort_values('第几集',ascending = True,inplace = True)
count = 1
#创建一个列表存储target_id
info_lst = []
for i in df['id']:
info = get_episode_danmu(i,headers)
info_lst.append(info)
print('正在努力爬取第 %d 集的target_id' % count)
count += 1
time.sleep(2 + random.random())
print('是不是发现多了一集?别担心,会去重的')
#根据后缀ID,将target_id和后缀ID所在的表合并
info_lst = pd.DataFrame(info_lst)
info_lst.columns = ['v_id','target_id']
combine = pd.merge(df,info_lst,left_on = 'id',right_on = 'v_id',how = 'inner')
#去重复值
combine = combine.loc[combine.duplicated('id') == False,:]
return combine
#输入包含v_id,target_id的表,并传入想要爬取多少集
def crawl_all(combine,num,page,headers):
c = 1
final_result = pd.DataFrame()
#print('Bro,马上要开始循环爬取每一集的弹幕了')
for v_id,target_id in zip(combine['v_id'][:num],combine['target_id'][:num]):
count = 1
urls = format_url(target_id,v_id,page)
for url in urls:
result = parse_danmu(url,target_id,v_id,headers,c)
final_result = pd.concat([final_result,result])
time.sleep(2+ random.random())
print('这是 %d 集的第 %d 页爬取..' % (c,count))
count += 1
print('-------------------------------------')
c += 1
return final_result
if __name__ == '__main__':
#《都挺好》1-30集的网址,31-46集的网址
#如果要爬取其他电视剧,只需要根据文章的提示,找到存储后缀ID的原网址即可
part1_url = 'https://union.video.qq.com/fcgi-bin/data?otype=json&tid=682&appid=20001238&appkey=6c03bbe9658448a4&idlist=x003061htl5,t00306i1e62,x003061htl5,b0030velala,w0030ilim7z,i0030r7v63u,z003044noq2,m0030sfinyr,c0030u884k7,k0030m5zbr7,l0030e5nglm,h0030b060vn,j003090ci7w,n0030falyoi,s00308u9kwx,p0030fohijf,g00303ob0cx,v0030960y6n,x0030bl84xw,v0030keuav1,t0030kups1i,n0030y2o52i,x0030s52mev,d0030xuekgw,o0030md1a2a,x0030peo3sk,d00303l5j4k,t0030aexmnt,a0030ybi45z,y0030wpe2wu&callback=jQuery191020844423583354543_1554200358596&_=1554200358597'
part2_url = 'https://union.video.qq.com/fcgi-bin/data?otype=json&tid=682&appid=20001238&appkey=6c03bbe9658448a4&idlist=t0030epjqsi,g003035mi84,n00301fxqbh,h0030zivlrq,d0030qc1yu2,m0030q9ywxj,h0030j0eq19,j0030jks835,a00308xw434,l0030tb319m,x0030xogl32,g0030fju3w3,a0030vrcww0,l0030jzi1mi,c0030mq8yjr,u00302fdo8v,a0030w9g57k,n0030wnj6i8,j0030h91ouj,j00304eu73n,t00305kc1f5,i0030x490o2,u0030jtmlj2,d003031ey5h,w0850w594k6,l0854pfn9lg,f08546r7l7a,d0854s0oq1z,m08546pcd9k,p0854r1nygj&callback=jQuery191020844423583354543_1554200358598&_=1554200358599'
headers = {'User-Agent':'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36'}
#得到所有的后缀ID,基于后缀ID爬取target_id
combine = get_all_ids(part1_url,part2_url,headers)
#设置要爬取多少集(num参数),每一集爬取多少页弹幕(1-85页,page参数),这里默认是爬取第一集的5页弹幕
#比如想要爬取30集,每一集85页,num = 30,page = 85
final_result = crawl_all(combine,num = 1,page = 5,headers = headers)
#final_result.to_excel('xxx.xlsx') 可以输出成EXCEL格式的文件
作者:周志鹏,2年数据分析,深切感受到数据分析的有趣和学习过程中缺少案例的无奈,遂新开公众号「数据不吹牛」,定期更新数据分析相关技巧和有趣案例(含实战数据集),欢迎大家关注交流。
声明:本文为作者投稿,版权归其所有。
热 文 推 荐
☞ 作为一名程序媛,我是如何在工作后脱单的 | 程序员有话说
☞ ICPC 2019国际大学生程序设计竞赛,中国高校未能夺冠
☞ 00后的AI开发者进阶之道:从入门到鏖战MIT编程大赛 | 人物志
☞ EOS现状: 72%应用涉赌被列为高危, 说好的诗和远方, 你竟沦落成了这样?
System.out.println("点个在看吧!");
console.log("点个在看吧!");
print("点个在看吧!");
printf("点个在看吧!\n");
cout << "点个在看吧!" << endl;
Console.WriteLine("点个在看吧!");
Response.Write("点个在看吧!");
alert("点个在看吧!")
echo "点个在看吧!"你点的每个“在看”,我都认真当成了喜欢