Python_b站动态评论爬虫_学习记录

起因

悠星联动导致炎上,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地址

分析完接下来就是写爬虫了

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中

所以这个脚本目前还有很多需要手动处理的数据,未能实现完全自动化

点赞

发表回复

电子邮件地址不会被公开。必填项已用 * 标注