今
日
鸡
汤
健儿须快马,快马须健儿。
愚人节立下本周要给出代码的flag,放假跪着也得实现...
本文以腾讯视频(都挺好)为例,解析弹幕爬取的细节和难点,对思路感兴趣的旁友们可以跟着文章逻辑走一遍,对于想直接上手爬的同学,文末已给出完整代码。
相对于一般电影OR电视剧评论,弹幕能够贴合剧情,进行更多有意思的脑洞分析。
注:上一篇《都挺好》弹幕分析文章所有数据(39W+)均基于本文代码爬取。
每次写爬虫,耳畔都会回响起那句经典的freestyle:
“你看这个碗,它又大它又圆,你看这个面,它又长它又宽”
短短四句,揭示了两种本质——碗是大和圆的,面是长亦宽的。一秒就看清事物本质的人和一辈子才看透事物本质的人自然过着不同的人生。
所以,写爬虫也是一样的,理清目标数据和网址的变化规律,也就是先看到碗的大和圆,面的长和宽,随后再去解决细节的数据定位和抓取(欣赏碗的花纹细节,面的Q弹),往往事半功倍。
#这就是我写爬虫所信奉的大碗宽面逻辑。
01 子弹(弹幕)轨迹规律探究
1、数据定位:
打开腾讯视频的电视剧(这里以《都挺好》为例),F12审查元素,默默的等待目标猎物出现,因为弹幕是播放时不断滚动出现,所以我们先假设它在JS下。
正片开始后,一群以“danmu"为开头的请求不断加载打破了短暂的平静,我们把这个疑似目标预览一下:
果然,弹幕内容赫然在列,对于我们分析有用的字段还有弹幕的ID,upcount(点赞数),opername(用户名)和uservip_degree(会员等级)。
到这一步,我们先不纠结于这个JSON文件要如何伪装访问,如何解析,不妨跟随那句“大碗宽面”的旋律,跳出碗来,看看这个碗是大还是圆(找规律)
2、弹道(弹幕网址)规律分析:
在找网址规律的时候,有一个小技巧,就是尝试暴力删掉目标网址中不影响最终结果的部分参数,再从最精简的网址中寻找规律。
拿我们第一个弹幕网址来说,原网址是这样的:
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:其实大碗宽面的逻辑下,我们这个时候应该再继续对比不同集数之间网址变化规律,并找到规律本身,但考虑到内容实操性与可读性,我们不妨把这一块往后稍稍)
02 解析单页弹幕内容
以第一集第一页的弹幕为例,我们只进行简单的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的变量即可。
03 不同集之间网址规律探究
单页、单集的规律都搞清楚了,那不同集之间的网址有什么规律呢?
第一集是这样的:
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的那口大锅)。
1、找到后缀ID
这个时候,需要一些常识来开路了。我们发现播放视频的时候,在播放屏右边总会显示全部集数:
点击对应的集数就会进行相应的换集跳转,所以我们有理由相信ID相关的锅藏在其中。重新刷新网页,很容易找到了他们的踪迹:
可以看到,上面截图中第一集的ID“t00306i1e62”对应着我们前面找到的规律(后缀ID)。打开任意一集,发现1-30集和31-46集相关的后缀ID都分别存储在两个相邻的网页。
所以,我们先尝试拿下所有的后缀ID、对应剧集名称、播放量和集数:
#打开任意一集,1-30和31-46存储在两个网页
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']:
#后缀ID
v_id = i['id']
#这一集的名字,比如“都挺好_01”
title = i['fields']['title']
#播放量
view_count = i['fields']['view_all_count']
#整型存储的集数,片花则为0
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有关的信息,真让人头大!
2、死磕target_id
每当没有头绪的时候,我总是想起莎翁的那句:
“一切过往,皆为序章”
反之,一切序章,皆有过往,正在发生或者已经发生的万事万物一定有迹可循。
我们心心念念的target_id一定在某个动态网页中记录着。
这个时候就需要耐心的筛选了,最后,我们发现,单集的target_id,隐藏在XHR下的一个"regist"开头的动态网址中:
仔细观察,他是一个POST请求
传递的参数如下:
翻了N集来对比,我们发现不同集数之间网址变化的只有传入的这个“vecIdList”,里面的参数正是我们上一步获取的那些后缀ID。
真相渐渐浮出水面。
3、思路梳理:
- 第一步,我们搞清楚了单集内部弹幕网址的动态变化,只需要改变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格式的文件
------------------- End -------------------