起因
悠星联动导致炎上,ba评论区被冲了20w+楼,有贴吧老哥写了个爬虫分析发现,有1/3的评论是一个人发的。
所以我也想写一个爬虫,一是验证该结论,二是想学爬虫很久了
正文
打开动态,进入f12,选择网络,筛选SHR,清楚缓存
进入评论区——最新,往下滑一会会发现多了些请求
随便找一个点进去看看,里面包含了json数据,其中有我要的评论内容和其他数据
纵向对比几个数据,消息头里的请求头都是一样的,到时候直接复制就好了
但是在get里面发现多个参数
与其他请求纵向对比并与其他动态评论区的横向对比后发现,其中的oid是动态的一种编号,type是什么类型暂位置,但同一个动态评论区里的都是一样的,mode和web_location不变直接复制就好了,w_rid暂时未知,目前来时是个用来校验的“随机数”,wts是时间戳,最后是字典pagination_str中的内容,里面是个字典offset,它里面有三个参数,type,direction,字典Data,type指的是热评和最新两个选项,对应的数值分别为1、3,但是热评的话pagination_str中还有其他内容需要另外分析,我是针对最新写的,direction不变应该不用管,字典Data中有一个参数cursor,应该说是该json包含的评论内容的结束的楼数(一条json里最多有20条评论)。
下面这段看个热闹就好了,后来发现走复杂了。
在一个js中找到了w_rid,逆向分析一下这段
function formatImgByLocalParams(Q, z)
{
z ||(z = {});
const {imgKey: J,subKey: G}= getImgFormatConfig(z);
if (J && G)
{
const H = getPictureHashKey(J + G),
K = Math.round(Date.now() / 1000),
$ = Object.assign({}, Q, {wts: K}),
q = Object.keys($).sort(),
ne = [],
te = /[!'()*]/g;
for (let oe = 0; oe < q.length; oe++)
{
const le = q[oe];
let de = $[le];
de && typeof de == 'string' && (de = de.replace(te, '')),
de != null && ne.push(`${ encodeURIComponent(le) }=${ encodeURIComponent(de) }`)
}
const ee = ne.join('&');
return{w_rid: md5(ee + H),wts: K.toString()}
}
return null
}
逻辑蛮简单的,但是出现了两个函数getImgFormatConfig(z)和getPictureHashKey(J + G),及其传入的参数Q, z是什么不知道。
先看getImgFormatConfig(z)
function getImgFormatConfig(Q)
{
var $;
if (Q.useAssignKey)
return {imgKey: Q.wbiImgKey,subKey: Q.wbiSubKey};
const z = (($ = getLocal(LOCAL_STORAGE_KEY$1)) == null ? void 0 : $.split('-')) ||
[],
J = z[0],
G = z[1],
H = J ? getKeyFromURL(J) : Q.wbiImgKey,
K = G ? getKeyFromURL(G) : Q.wbiSubKey;
return {imgKey: H,subKey: K}
}
嗯,逻辑同样简单,但是问题更多了,需要找LOCAL_STORAGE_KEY$1这个本地值,getKeyFromURL(J)和getKeyFromURL(G)获取的是url中的什么后面也要注意下
嗨,没意思,一搜就搜出来了LOCAL_STORAGE_KEY$1 = 'wbi_img_urls'
然后是函数getPictureHashKey。嗯...后面那段return的内容有点难理解,这个函数的作用就是遍历z,拿z中的数字作为索引来索引Q,将索引返回的内容连成字符串return
function getPictureHashKey(Q)
{
const z = [46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,61,26,17,0,1,60,51,30,4,22,25,54,21,56,59,6,63,57,62,11,36,20,34,44,52,],
J = [];
return z.forEach(G => {Q.charAt(G) &&J.push(Q.charAt(G))}),J.join('').slice(0, 32)
}
好,几个函数都看完了,接下来就是怎么看调用函数了
getImgFormatConfig和getPictureHashKey函数都只在formatImgByLocalParams中出现了,那么找formatImgByLocalParams就好了。然后发现了这一段
wbiEncode = (Q, z, J) => Et(
this,
null,
function * () {
var K;
if (typeof window == 'undefined') return yield z();
if (!Q.request) return yield z();
const G = Q.request.params ||
{
},
H = formatImgByLocalParams(
G,
((K = J == null ? void 0 : J.payload) == null ? void 0 : K.encWbiKeys) ||
{
wbiImgKey: formatString$1(BASIC_WBI_KEYS.wbiImgKey),
wbiSubKey: formatString$1(BASIC_WBI_KEYS.wbiSubKey)
}
);
if (!H) return yield z();
Q.request.params = Object.assign({
}, Q.request.params, H),
yield z()
}
);
在调试器里面搜里面搜包含w_rid的js(火狐的快捷键为ctrl+shift+f),依次给每一个打上断点来看看我们要的w_rid是走哪里的
最后发现是走这个的,和其他的没关系。可以看到w_rid是靠ee和H进行md5加密的,ee的内容很明显是get传参除了w_rid以外的参数,H的话横向纵向对比了一下,是个常量,那个w_rid就搞定了
简单验证下,出来的结果和json里的数据一样
import hashlib
import time
def md5_encrypt(data):
md5_hash = hashlib.md5()
md5_hash.update(data.encode('utf-8'))
return md5_hash.hexdigest()
x=int(time.time())
x=1713314404
print(x)
cursor=str(271659)
# 示例
data = r'mode=2&oid=915271360576487425&pagination_str=%7B%22offset%22%3A%22%7B%5C%22type%5C%22%3A3%2C%5C%22direction%5C%22%3A1%2C%5C%22Data%5C%22%3A%7B%5C%22cursor%5C%22%3A'+cursor+'%7D%7D%22%7D&plat=1&type=17&web_location=1315875&wts='+str(x)
H='ea1db124af3c7062474693fa704f4ff8'
encrypted_data = md5_encrypt(data+H)
print(encrypted_data)
接下来是分析json
很明显,我要的数据都在json.data.replies里面
这里面有20条信息,每一条里面都是一个主楼评论数据,分楼的数据包含在主楼里面
首先的ctime发布评论的时间戳。每一条中的member里面主楼的用户信息,有用的有mid用户bid,uname用户姓名,sex性别(男 女 or 未知),sign个性签名,level_info.current_level用户等级。content里面的评论信息,主要是content.message评论内容。reply_control里面是发布时间和ip,reply_control.time_desc发布时间(几天前几小时前那种),reply_control.location ip地址
分析完接下来就是写爬虫了
import pandas
import hashlib
import time
import requests
def md5_encrypt(data):
md5_hash = hashlib.md5()
md5_hash.update(data.encode('utf-8'))
return md5_hash.hexdigest()
def read_data(res:dict,i:int):
message = res[i]['content']['message']
ctime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(res[i]['ctime']))
uid = res[i]['member']['mid']
sex = res[i]['member']['sex']
text = res[i]['member']['sign']
name = res[i]['member']['uname']
level = res[i]['member']['level_info']['current_level']
ip = res[i]['reply_control']['location']
data.append({'评论': message, '时间': ctime, '名称': name, 'uid': uid, '性别': sex, '个性签名': text, '等级': level,'ip': ip})
#print({'评论': message, '时间': ctime, '名称': name, 'uid': uid, '性别': sex, '个性签名': text, '等级': level,'ip': ip})
data=[]
start:272060
oid=''
for n in range(start,0,-20):
print(n,'/ ',start)
wts = int(time.time())
cursor=str(n)
ee = r'mode=2&oid='+oid+'&pagination_str=%7B%22offset%22%3A%22%7B%5C%22type%5C%22%3A3%2C%5C%22direction%5C%22%3A1%2C%5C%22Data%5C%22%3A%7B%5C%22cursor%5C%22%3A'+cursor+'%7D%7D%22%7D&plat=1&type=17&web_location=1315875&wts='+str(wts)
H='ea1db124af3c7062474693fa704f4ff8'
w_rid = md5_encrypt(ee+H)
pagination_str=r'{"offset":"{\"type\":3,\"direction\":1,\"Data\":{\"cursor\":'+cursor+'}}"}'
params = {
'oid': oid,
'type': '17',
'mode': '2',
'pagination_str': pagination_str,
'plat': '1',
'web_location': '1315875',
'w_rid': w_rid,
'wts': wts
}
cookie=""
cookie=cookie.encode('utf-8')
headers = {
'Accept':'*/*',
'Accept-Encoding':'gzip, deflate, br',
'Accept-Language':'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
'Connection':'keep-alive',
'Cookie':cookie,
'Host':'api.bilibili.com',
'Origin':'https://t.bilibili.com',
'Referer':'https://t.bilibili.com/'+oid+'?spm_id_from=333.999.0.0',
'Sec-Fetch-Dest':'empty',
'Sec-Fetch-Mode':'cors',
'Sec-Fetch-Site':'same-site',
'TE':'trailers',
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0'
}
url = 'https://api.bilibili.com/x/v2/reply/wbi/main'
try:
res = requests.get(url, params=params, headers=headers)
except:
break
for i in range(20):
try:
read_data(res.json()['data']['replies'],i)
except:
data.append({'评论': '被删了', '时间': '被删了', '名称': '被删了', 'uid': '被删了', '性别': '被删了', '个性签名': '被删了', '等级': '被删了','ip': '被删了'})
print(' 删了删了删了删了删了')
pass
df = pandas.DataFrame(data)
df.to_csv('output.csv',index=False, encoding='utf-8-sig')
md5_encrypt用来md5加密,read_data用来读取json.data.replies里的第i条数据。for n in range(start,0,-20):最外层循环用的你序是因为每一个json中的评论都是逆时间顺序的,这样方便后续数据分析(但其实关系也不大),range的起始数据由要爬取的楼数决定,不能但看b站里显示的评论数量,那个只包含有效主楼评论和楼中楼评论,不包括被删了的评论,而cursor里的楼数是指主楼楼数,包括了被删的楼,被删的内容虽然没了,但依然会占着茅坑。所有range的start最好是去手动抓一下json看下最新的cursor是多少。cookie里面的填你自己的cookie,抓json的时候可以看到的,只要浏览器登着b站,cookie应该是不会生效的。把cookie转成byte流是因为b站的cookie中包含中文字符‘…’,不能被正常传输。web_location不清楚具体是什么,所以还是手动抓一下好。oid的话是动态的编号,手动填一下。
最后的结果会存入到output.csv中
所以这个脚本目前还有很多需要手动处理的数据,未能实现完全自动化