目录
前言
需求
方案分析
方案一
?方案二
接口分析
请求流程
抓包演示
?请求接口
接口说明
接口测试
?代码
前言
想看接口分析和代码的,可跳过前言。
需求
我参加了一些up主的动态抽奖,转赞评那种的,如图1。
开奖周期一般在两周到四周,如果粉丝数多的话,会被大量评论,而且他就没及时开过奖(指说几号开奖就几号开奖),实际都是比说的日期再晚几天才开奖。
开奖后我想删除我的转赞评。转发在自己动态里就能删,点赞只要在up主原动态再点一下点赞按钮就能取消,评论却只能翻到自己的那条评论,才能删除。
问了B站客服,他说只有以下方法能删除自己的评论:
- 手动翻到自己的那条评论
- 别人回复你的评论,你收到提醒,在提醒里点击对方回复的内容,可跳转到你的评论?
方案分析
按热度排序,我不确定我到底在哪,反正不在靠前的位置,应该是中间或后面。
按时间排序,B站是把距离当前时间最近的评论,放在第一个(置顶除外),把从时间上真正第一个发的评论,放在最后一个。我认为这是降序,可我参加时一般都是up主发动态的当天,降序的话几乎得翻完全部评论才能找到我的评论,也就是说升序可以很快地找到我的评论,但B站不支持,我反馈了,客服说会给研发提。
由于参与评论的人太多,手动翻是翻不到的;可是也没人回复我的评论,那怎么办?
方案一
首先我想到让程序自动翻页,我知道的有两类:
- 按键精灵之类的软件,录制好键鼠动作,然后自动执行动作
- 用代码操作浏览器,实现自动翻页
第一类
我用的不是按键精灵,是别人自己写的软件,功能类似。一开始翻页时,新数据加载得挺快;随着时间的推移,新数据加载得越来越慢。例如最开始时,只要页面滚动的位置到了,下一页的数据马上就能加载出来;一段时间后,虽然页面滚动的位置到了,也出现了正在加载的提示,但需要几秒甚至十几秒甚至几分钟才能显示出下一页的数据,这个加载时间越来越长。
时间长点没事,只要能加载出来,我就能点删除。按热度排序的话,我得隔一会儿就搜下有没有加载出我的评论,且翻页次数非常非常多后,页面搜索很慢(我试了),也没搜到我。所以后来我又选了按时间排序,花了好几个小时,当加载到我发评论的前一天时,页面崩溃了,只能刷新,刷新后之前已加载的那么多评论全没了,又试了次依然这样。
针对以上加载速度慢、页面崩溃,我能想到的原因有:
- B站限制了数据返回的速率或频率
- 我的网络原因(这个可能性最低)
- 浏览器性能不够,我用的Edge
- 电脑硬件性能瓶颈,如CPU和内存,我是I7-6700HQ,12G内存,三星860EVO
实际是什么原因就不知道了,总之这个方法不行。
第二类
一开始,我用selenium打开那条动态,让selenium执行js代码,使页面滚动,但实际滚动的效果和手动用鼠标滚动不一样,好像是滚动几次就不行了,就加载不出新数据了,我忘了,总之这个方法不行。
后来,我在浏览器Console里手动执行同样的js代码,效果和selenium一样,可能是代码原因,有知道的可以告诉我,或者其他写法也行。我试了以下几种,当然不是一起运行,一次运行一种:
//第一种
document.body.scrollTop=10000 // PhantomJS使用,headless不支持
//第二种
document.documentElement.scrollTop=10000 // 页面向下滚动指定的像素,数值很大时肯定能滚动到底部。这是页面滚动,不是模拟鼠标滚轮滚动
//第三种
var q=document.documentElement.scrollTop=10000 // 或这种写法 from https://www.cnblogs.com/landhu/p/5761794.html
//第四种
window.scrollTo(0,document.body.scrollHeight) // 滚动条回到底部。连续滚动时,有时上面两种js代码滚动一会儿就失效了,可以用这种 from https://www.cnblogs.com/xyztank/articles/14259330.html
// window.scrollTo(0,0) // 滚动条回到顶部
?方案二
方案一中的方法都不行,我不准备删了,突然有人回复我在某视频下的评论,如图2。
??以往回复我的内容,字数很少,直接在图2中的界面就能看完,我就没点过。直到这次的太长了显示不完,我就点击了他回复的内容,然后竟然直接跳转到我的评论了,如图3。
?这正好符合客服说的第二种,并且向下翻,还看到了页码,如图4,不过这张图是后来测试时在某个动态的截图(因为我要删对动态的评论),但页码、跳转到指定页等内容都和当时视频那里一样,注意页面滚动条位于底部,平时B站无论动态还是视频,评论都是动态加载的,滚动条不会一直在底部,会触发加载下一页,除非所有评论真的记载完了,那样页面底部也有提示到底了。
?既然有页码了,那不是可以直接跳转到靠后的页面(按时间排序,我评论得早,一般都是up主发动态的当天),然后快速找到我的评论?最多就是多翻几页。可我点击页码后,页面内容还是当前页的内容,不能通过点击页码跳转页面,后面那个跳转到指定页也不管用。
然后我看了当前URL,reply后应该是当前评论的ID,即我的评论的ID。
https://www.bilibili.com/video/BV1aQ4xxxxxx#reply5816xxxxxx
于是有了新思路,找到所有我参加过评论的动态,用爬虫爬取它们的所有评论数据,里面就有我的评论的ID,然后将ID拼接到动态的URL的末尾,访问就能跳转到我的评论,然后就能删除。如图5,当然那条不是我发的,我为了验证这个思路随意找了一条测试的。
另外,因为是转赞评,有些抽奖动态我以前直接在我的动态里删除了转发 ,现在怎么找到它们从而删除评论?在up主的动态列表里,只要点赞按钮是蓝色,说明自己已点赞,已参加转赞评,如图6。但是转发和评论不会变蓝,无论自己有没有真的转发和评论。
点开这条动态,也能看到,如图7。
接口分析
请求流程
- 打开一条动态,如https://t.bilibili.com/56764707xxxxxxxxxx,注意已手动去除末尾的?tab=2
- 获取动态本身的信息,如类型、oid等
- 根据动态的类型、oid获取评论数据,当然必需的参数还有页码、排序方式
抓包演示
?请求接口
接口说明
接口1,步骤2请求的接口
https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/get_dynamic_detail?dynamic_id=
参数dynamic_id就是步骤1的URL末尾的一长串字符,我称为动态ID。
返回的json数据中有一个键是type,如图9,表示动态类型,它的值有以下几种可能:
- 1:有转发内容
- 2:动态有图片
- 4:纯文字
- 2048:可能是内容有站内分享(比如装扮推荐)
- 其他情况有知道的可以补充
接口2,步骤3请求的接口
图10中携带了很多参数,但callback和plat和_都不是必需的,带上了反而可能出错(接口测试部分第三步会说),有人说callback和_是根据某种规则生成的,用于跟踪用户,后来我发现_的值很像当前时间的时间戳。
https://api.bilibili.com/x/v2/reply/main?jsonp=jsonp&next=&type=&oid=&mode=
接口2的请求参数的说明
参数名 | 参数类型 | 含义 |
---|
next | 字符串 | 0和1都表示第一页,2表示第二页,3表示第三页... |
---|
type | 字符串 | 若接口1返回的type为2,则接口2的参数type的值为11 |
---|
若接口1返回的type为1、4、2048,则接口2的参数type的值为17 | oid | 字符串 | 若接口1返回的type为2,则oid为图9或图12中返回值中的rid的值 |
---|
若接口1返回的type为1、4、2048,则oid的值和动态ID一样,即dynamic_id | 其他情况有知道的可以补充 | mode | 字符串 | 3:按热度排序 |
---|
2:按时间排序 |
接口测试
为了确定抓包得到的URL、参数、响应之中,哪些参数是必需的,就得进行验证,在真正用代码验证之前,可以先用浏览器或postman测试,我以浏览器为例。
根据请求流程中的三步,这里我省略第一步
第二步,chrome直接访问构造好的带参数的URL(即接口1),成功返回json数据,如图11。另外,火狐可以直接格式化返回的json数据,如图12,这样就不用拿着chrome显示的json数据再去格式化网站上手动格式化,我之前用的格式化网站是JSON在线校验格式化工具(Be JSON),这是新版界面,旧版是在线JSON校验格式化工具(Be JSON),也可以在旧版界面点击新版跳转到新版,如图13。
?第三步,火狐直接访问构造好的带参数的URL(即接口2),成功返回json数据,如图14。另外,接口说明部分接口2处说带上callback和plat和_参数可能出错,如图15。
?修改接口2中参数的值,如表示页码的next,同样可以得到json数据,这里就不截图了。
测试完成,说明接口URL及参数都是正确的,都能得到正确的json数据。
?代码
整体流程就是请求流程部分中的那三步,最终你想爬取什么数据,去接口2返回的json数据中提取就行。页码可以根据for循环生成,即for循环中先生成一个页码,再构造URL,再请求并获取响应数据,再从响应数据中提取需要的数据。
另外,看完代码你可能有疑问,接口2返回的json数据中,已有all_count、is_end字段,如图14,为什么不直接用all_count除以每页的数据量得到页码总数进而得到最后一页的页码,或根据is_end判断是否是最后一页?
针对第一种,我测试时按每页20条数据算的,得到的页码总数与图4中那个样子不一致,而且后来发现有的动态请求一页得到18条数据,总之这种方法不行,起码我测试时不行,也可能我哪里做错了。
针对第二种,我测试时发现即便is_end为true,但依然可以请求下一页,只不过下一页得到的评论数据和本页相同;不过写文章时发现是测试时看错地方了,应该看与is_end的上一级cursor同级的replies,但是我代码里判断本页有没有数据确实是判断的这里的replies是否为空(在has_comment_data()中),所以也可以改成直接判断is_end是否为true,不过代码里我没改,因为最终都能判断本页是否有数据,是否到了最后一页。
# -*- coding: utf-8 -*-
# 上一行设置编码的代码不能删,否则有一句注释会报编码错误
import requests
import json
import math
import time
import os
import csv
headers1={ # 获取oid时使用
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 Edg/96.0.1054.34',
}
headers2={ # 获取评论内容时使用
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36 Edg/96.0.1054.34',
'Referer':'https://t.bilibili.com/'
}
# url_for_get_oid='https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/get_dynamic_detail?dynamic_id=567647071398441903' # 打开一条具体的动态时fiddler抓的原始URL。需从返回的信息中获取oid,dynamic_id是浏览器打开一条具体的动态时,地址栏末尾的一长串字符
url_for_get_oid='https://api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/get_dynamic_detail?dynamic_id={}'
# url_for_get_comment='https://api.bilibili.com/x/v2/reply/main?callback=jQuery3310715507182874241_1637852200168&jsonp=jsonp&next=0&type=11&oid=164467640&mode=3&plat=1&_=1637852200169' # 在一条具体的动态页,页面滚动到底部第一次触发"正在加载..."时fiddler抓的原始URL。浏览器地址栏直接访问这个链接显示B站自定义的404页面,但去掉开头的callback参数和末尾的_参数后,浏览器地址栏可直接访问,返回json数据,另外测试发现plat参数也可去掉
# https://api.bilibili.com/x/v2/reply/main?callback=jQuery3310715507182874241_1637852200168&jsonp=jsonp&next=2&type=11&oid=164467640&mode=3&plat=1&_=1637852200171 在一条具体的动态页,页面滚动到底部第二次触发"正在加载..."时fiddler抓的原始URL。除了末尾的_参数,就只有next参数变了,说明next参数和页码有关,因_参数不是必需的
url_for_get_comment='https://api.bilibili.com/x/v2/reply/main?jsonp=jsonp&next={next}&type={type}&oid={oid}&mode={mode}'
comment_detail_list=[] # 所有评论的详情,列表中每一条评论是一个字典
def check_json_data(r): # 检查返回的数据是否是json格式
try:
json_data=r.json()
except json.decoder.JSONDecodeError as e:
raise Exception('发生了异常,可能因返回的数据不是json格式,或检查传入的url_for_get_oid及参数是否有误,具体异常为:'+'json.decoder.JSONDecodeError: '+str(e)) # raise json.decoder.JSONDecodeError()需三个参数,所以直接raise Exception();而Exception()中异常提示只能是字符串类型,这里的e是JSONDecodeError类型,所以强制转换;另外,这种写法输出异常提示时没有异常类型,所以以字符串形式手动加上
except:
raise Exception('发生了未捕获的异常')
else:
return json_data
def get_oid_and_type_and_total_page_number(url_for_get_oid, dynamic_id): # 获取url_for_get_comment中用的oid和type的值,和页码总数
r=requests.get(url=url_for_get_oid,headers=headers1)
json_data=check_json_data(r)
try:
type=json_data['data']['card']['desc']['type']
total_comment_number=json_data['data']['card']['desc']['comment']
except KeyError: # 返回的数据中没有指定的键,导致获取不到对应的值,说明当前URL中没有需要的json数据,即URL有误
raise KeyError('无效的dynamic_id,请重新运行输入')
except:
raise Exception('发生了未捕获的异常')
if type==2:
oid=json_data['data']['card']['desc']['rid']
type_in_url_for_get_comment=11
elif type in [1,4,2048]:
oid=dynamic_id
type_in_url_for_get_comment=17
else:
print(f'当前type变量的值{type}未被if捕获,需重新分析URL中type参数的值的所有可能')
exit()
total_page_number=math.ceil(total_comment_number/20)+19 # math.ceil(x)返回大于等于x的最小整数。每页20条数据,手动加19页防止页码总数由于各种原因计算错误,导致获取的数据不完整,且多出的页码由于没有数据会直接终止for循环
return oid, type_in_url_for_get_comment, total_page_number
def get_mode(): # 获取url_for_get_comment中用的mode
while True:
sort_by=input('请输入评论的排序方式,3表示按热度排序,2表示按时间排序,最好不按时间排序,原因在save_data()的except语句的注释中:')
if (sort_by=='3') or (sort_by=='2'):
return sort_by
else:
print('无效的排序方式,请重新输入')
def has_comment_data(json_data): # 判断本页是否有评论,没有就直接终止for循环
try:
comment=json_data['data']['replies']
except:
raise Exception('发生了未捕获的异常')
else:
if comment:
return True
else:
print('本页无数据,爬取结束')
return False
def get_comment_data(json_data): # 获取评论内容,不含对评论的回复
try:
replies_list=json_data['data']['replies'] # 当前页的所有评论
except:
raise Exception('发生了未捕获的异常')
else:
for replies in replies_list:
comment_detail={} # 当前评论的详情
try:
comment_detail['rpid']=replies['rpid'] # 当前评论的id,可能是replyid的缩写。可在一条具体的动态的URL末尾(即dynamic_id后,且删除默认自带的?tab=2)加#及这条动态中某个评论的rpid,访问后会在对应的评论下出现回复文本框,这样就能快速在所有评论中找到需要的评论(如动态下某条自己的想删但又没被点赞或回复的评论,若当前动态有大量评论,则想删的这条评论不一定能靠滚动浏览器加载评论找到)
comment_detail['ctime']=time.strftime('%Y-%m-%d %H:%M',time.localtime(replies['ctime'])) # 评论时间
comment_detail['mid']=replies['member']['mid'] # 用户ID
comment_detail['uname']=replies['member']['uname'] # 用户名
comment_detail['current_level']=replies['member']['level_info']['current_level'] # 用户等级
comment_detail['message']=replies['content']['message'] # 评论内容
except:
raise Exception('发生了未捕获的异常')
else:
if comment_detail not in comment_detail_list: # 由于网络或其他原因(如反爬等),获取的数据可能有重复,需要去重
comment_detail_list.append(comment_detail)
def save_data(dynamic_id): # 保存所有评论的详情
dir_path=os.path.abspath(os.path.dirname(__file__))
file_name=f'B站动态ID{dynamic_id}的评论.csv'
file_path=dir_path+'/'+file_name
try:
header=comment_detail_list[0].keys()
except IndexError: # 按时间排序时由于部分页码(目前我发现的是页码为1和页码为2)返回的评论为空导致直接终止for循环,然后执行save_data(),若刚好这些页码是开始爬取的页码,则comment_detail_list中无数据,执行save_data()中try语句触发IndexError异常
raise IndexError('comment_detail_list中无数据,数据保存失败')
except:
raise Exception('发生了未捕获的异常')
else:
# 不同保存方式的注释在bs4爬取招聘.py中
with open(file_path,'w',newline='',encoding='utf-8') as f:
writer=csv.DictWriter(f,fieldnames=header)
writer.writeheader()
writer.writerows(comment_detail_list)
print('数据已保存到本地')
'''
string=json.dumps(comment_detail_list,ensure_ascii=False) # 将数据以json格式保存。ensure_ascii=False使json中的中文正常显示
with open(dir_path+'/'+f'B站动态ID{dynamic_id}的评论.txt','w',encoding='utf-8') as fp:
fp.write(string)
print('数据已保存到本地')
'''
'''
with open(dir_path+'/'+f'B站动态ID{dynamic_id}的评论.txt','w',encoding='utf-8') as f:
f.write(str(comment_detail_list)) # 将列表直接转为字符串写入文件
print('数据已保存到本地')
'''
if __name__=='__main__':
dynamic_id=input('请输入dynamic_id的值,它是浏览器打开一条具体的动态时,地址栏末尾的一长串字符:')
url_for_get_oid=url_for_get_oid.format(dynamic_id)
oid, type, total_page_number=get_oid_and_type_and_total_page_number(url_for_get_oid, dynamic_id)
mode=get_mode()
print(f'共有{total_page_number}页,这个数字已加入冗余,防止由于各种原因计算错误,导致获取的数据不完整,且多出的页码由于没有数据会直接终止for循环')
start_page_number=int(input('请输入开始爬取的页码:')) # for循环中需是int类型
end_page_number=int(input('请输入结束爬取的页码:'))
if start_page_number==0: # next为0或1都返回第一页的数据
start_page_number=1
loop_count=0 # 记录循环执行次数
for page_number in range(start_page_number,end_page_number+1):
print(f'第{page_number}页开始爬取')
comment_url=url_for_get_comment.format(next=page_number,type=type,oid=oid,mode=mode) # 注意等号左面不能写url_for_get_comment,会覆盖全局变量,导致next的值永远为第一轮循环中page_number的值,即start_page_number的值
r=requests.get(url=comment_url,headers=headers2)
json_data=check_json_data(r)
if not has_comment_data(json_data):
break
get_comment_data(json_data)
loop_count=loop_count+1
print(f'第{page_number}页结束爬取')
print() # 打印空行
time.sleep(3)
save_data(dynamic_id)
print(f'获取数据共执行了{loop_count}次循环,每页20条数据(有时18条),假设最后一页不足20条,则去重后的数据量应大于({loop_count}-1)*20={(loop_count-1)*20}条,请打开文件核对数据量是否正确。若数据量不足,根据需求决定是否重新爬取,如针对有些需求,比完整数据稍微少一些也不影响。')
参考链接:
手把手教你爬取B站动态下的评论(看了必会,2个字简单) - 哔哩哔哩
爬虫实战 - 如何爬取B站视频评论? - phyger - 博客园
【python爬虫】爬取bilibili动态下面的评论 - 哔哩哔哩
bilibili动态评论的爬取: 通过b站开放的APL接口进行一个评论的爬取,只爬取的评论本身内容,发布评论的网友信息没有进行一个爬取
第四个与第三个内容一样,但可读性更好
我在get_comment_data()末尾,只是简单判断新爬到的数据是否在已有数据中已存在,关于数据去重,可看以下参考链接:
Python数据去重_夜空下的凝视-CSDN博客_python 去重
Python判断列表里是否有重复元素的三种方法_宁宁Fingerstyle的博客-CSDN博客_python重复元素判定 python常用的去重方式 - 星牧 - 博客园
左手用R右手Python系列8——数据去重与缺失值处理 - 云+社区 - 腾讯云
python数据去重(pandas)_Oliver、He的博客-CSDN博客_python数据去重
|