來自公眾號:資料不吹牛
本文以騰訊影片(都挺好)為例,解析彈幕爬取的細節和難點,對思路感興趣的旁友們可以跟著文章邏輯走一遍,對於想直接上手爬的同學,文末已給出完整程式碼。 相對於一般電影OR電視劇評論,彈幕能夠貼合劇情,進行更多有意思的腦洞分析。
每次寫爬蟲,耳畔都會迴響起那句經典的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⌖_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⌖_id=3753912718%26vid%3Dt00306i1e62&count;=80
我們把第二頁網址也精簡一下:
https://mfm.video.qq.com/danmu?otype=json×tamp;=45⌖_id=3753912718%26vid%3Dt00306i1e62&count;=80
對比很容易找到規律,從第一頁到第二頁,timestamp值從15變到了45,其他部分沒有任何變化,我有一個大膽的猜測,這個timestamp值是控制頁數的變數,並且是30秒更新一次彈幕。
那一級有多少頁呢?我們把進度條拉到影片結束的邊緣,發現最後一頁的網址的timestamp的值變成了2565。
整個過程,我們只需要構造步長為30的迴圈變數來替換timestamp引數就可以實現批次訪問了。
到這裡,單集中彈幕動態更新的規律我們已經探究清楚,下麵來對單個頁面進行解析。
(PS:其實大碗寬面的邏輯下,我們這個時候應該再繼續對比不同集數之間網址變化規律,並找到規律本身,但考慮到內容實操性與可讀性,我們不妨把這一塊往後稍稍)
02 解析單頁彈幕內容
以第一集第一頁的彈幕為例,我們只進行簡單的essay-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⌖_id=3753912718%26vid%3Dt00306i1e62&count;=80
我們把第二集的彈幕網址也暴力精簡:
https://mfm.video.qq.com/danmu?otype=json×tamp;=15⌖_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、對應劇集名稱、播放量和集數:
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,essay-headers = essay-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有關的資訊,真讓人頭大!
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)和essay-headers
def get_episode_danmu(v_id,essay-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),essay-headers = essay-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,essay-headers)
print(info)
info_lst.append(info)
time.sleep(3 + random.random())
噹噹噹噹~結果如下:(截取了部分)
我們終於集齊了構成單頁彈幕網址所需的target_id,字尾ID,只需要構造兩個迴圈就可以實現完整的彈幕爬取(第一個迴圈構造每一集的基礎網頁,第二個迴圈構造單集內的彈幕頁數)。
目前來說,對於彈幕爬取(騰訊影片),單純的essay-headers偽裝就能夠暢通無阻,但也建議大家文明爬取,理性分析 :)
至此,我們鍋、碗和麵都已經準備到位了,再把剛才各模組寫的精簡一些,然後就可以酣暢淋漓的吃大碗寬面了。
Skrrrrrrrrrrr~
最後附上完整程式碼:
PS:如果覺得有用可以點個“在看”,感恩~
import requests
import json
import pandas as pd
import os
import time
import random
#頁面基本資訊解析,獲取構成彈幕網址所需的字尾ID、播放量、集數等資訊。
def parse_base_info(url,essay-headers):
df = pd.DataFrame()
html = requests.get(url,essay-headers = essay-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,essay-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),essay-headers = essay-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,essay-headers,period):
html = requests.get(url,essay-headers = essay-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;={}⌖_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,essay-headers):
#分別獲取1-30,31-46的所有字尾ID(v_id)
part_1 = parse_base_info(part1_url,essay-headers)
part_2 = parse_base_info(part2_url,essay-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,essay-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,essay-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,essay-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'
essay-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,essay-headers)
#設定要爬取多少集(num引數),每一集爬取多少頁彈幕(1-85頁,page引數),這裡預設是爬取第一集的5頁彈幕
#比如想要爬取30集,每一集85頁,num = 30,page = 85
final_result = crawl_all(combine,num = 1,page = 5,essay-headers = essay-headers)
#final_result.to_excel('xxx.xlsx') 可以輸出成EXCEL格式的檔案
朋友會在“發現-看一看”看到你“在看”的內容