数据挖掘部分的基本目标是:对于指定的UP主,能够获取其投稿视频列表;对于指定的视频,能够获取其视频标签、评论(包括评论下的回复)、弹幕。
文章默认读者对网络爬虫有一定的基础知识;
文章写作时(2020-06),B站正处于AV号像BV号过度的时期,部分API可能会在今后发生重大变化,请今后的读者注意。
首先,我们知道每一个B站帐号都有一个对应的数字UID,然后,通过在浏览器中访问用户的个人主页并查看后台请求,可以发现,用户的投稿视频列表信息是从api.bilibili.com/x/space/arc/search
获取的,响应为JSON格式,具体的查询参数附加在URL后的查询字符串中,基本的查询参数如下:
参数 | 含义 |
---|---|
mid | 用户的UID |
ps | 返回的结果中需要包含多少个视频的信息 |
tid | 视频的分类 |
pn | 需要获取第几页投稿视频 |
keyword | 搜索关键字 |
order | 返回结果的排序方式 |
其中,tid视频的分类指的是我们再B站主页上看到的分类信息:
API的响应会告诉我们不同的分类用哪个数字代表,也会告诉我们这个UP主在不同的分类下各有几个投稿视频:
需要注意的是,如果一个UP主在某一个分类下没有投稿视频,那么API的响应不会包含这个分类的信息;
order是搜索结果的排序方式,有三种排序方式:按更新时间、按播放数量、按点赞数量,这三种排序方式分别对应order=pubdate
、order=click
、order=stow
;
pn代表你想要第几页的投稿视频,ps代表一页要放下几个投稿视频,想象一下你在浏览网页,这两个参数的作用就不难理解了;
其中,tlist包含了视频分类的信息,与API的tid参数有关,如上上张图所示;vlist则包含了我们需要的投稿视频信息,vlist的长度由ps参数决定,vlist中每一个对象的结构如下:
属性 | 意义 |
---|---|
comment | 评论数量 |
paly | 播放数量 |
pic | 封面图片地址 |
subtitle | 小标题 |
description | 视频下方简介 |
title | 视频标题 |
author | UP主昵称 |
mid | UP主UID |
created | 视频上传日期的UNIX时间戳 |
length | 视频时长 |
aid | av号 |
bvid | bv号 |
到这里,API:api.bilibili.com/x/space/arc/search
的用法就介绍完成了,最后还需要注意两件事情:首先,为了防止爬虫被403,我们需要复制浏览器的请求header,并添加到爬虫中。其次,响应数据使用gzip压缩的,使用前需要解压缩,Python内置有gzip模块。
截至本文写作时,可以通过bilibili.com/video/BVxxxxxxx
或者bilibili.com/video/AVxxxxxxx
获取视频的播放页面,如果我们直接使用urlopen获取播放页面(这时候的页面是没有经过JS动态加载),得到的播放页面的结构如下:
meta标签中,有两个值得我们注意一下,第一个是拥有属性property="og:url"
的meta标签,这个标签的content的属性包含了这个视频使用AV号表示的播放页面(如果在只有BV号的情况下,想获取AV号,可以使用这个方法),第二个值得注意的meta标签拥有属性itemprop="commentCount"
,而这个标签的content属性记录了视频的评论数(如果需要最新的评论总数,可以使用这个方法)。
接下来,我们需要注意head中的最后两个script(第三个和第四个),第三个script中的内容如下:
可以看到,这个script标签中包含了大段的JSON数据,JSON数据中有很多URL,而这些URL中都包含了同一个ID:18xxxxx45,不难发现这个ID应该是指向视频的实际文件,但同时,这个ID也指向了视频的弹幕文件,所以在这里我们需要想办法提取出这个ID备用。
head中的第四个script标签中同样也是包含了大段JSON数据的JS代码,其中有一部分数据如下所示:
可以看到,这就是当前视频对应的标签,在我的课程设计中,我需要通过视频的标签对视频进行过滤,所以这里需要从页面中提取出标签信息。
我使用BeautifulSoup解析页面,并从meta标签和script标签中分离出我需要的信息,需要注意的是,在请求播放页面时仍需要完整的请求header以防止403,响应数据仍就经过gzip压缩,在进行分析前需要进行解压缩。
需要注意的是,这个页面结构只针对一般的视频,如果是电影、番剧、纪录片,页面结构会不一样,请注意。
获取B站弹幕的API是api.bilibili.com/x/v1/dm/list.so
,用这个API获取弹幕只需要在查询字符串中添加一个参数oid,而oid就是上面一节那个需要在播放页面的script标签中提取的id。
与其他API不同,获取弹幕的API的响应使用了deflate算法进行压缩而不是gzip压缩,具体到如何使用Python解压缩deflate,->https://www.baidu.com
。
解压缩后,我们发现弹幕数据是XML格式的,如下所示:
可以看到,弹幕内容记录在了d标签中,而弹幕的属性记录在了d标签的p属性中,弹幕的属性是几个用逗号分隔的数字组成的字符串,我只能认出这当中第一个数字是弹幕出现在视频中的时间,第五个数字是弹幕被发送的时刻的UNIX时间戳,其余属性的含义我就无能为力了。
使用Python解析XML有很多种方法,我才用了xml.etree
中的ElementTree
类进行XML解析。
在浏览器请求弹幕的请求头中,有一个字段为Refer,内容为https://www.bilibili.com./video/BVxxxxxxx
,所以在请求弹幕数据时,需要一并知道这个视频的BV号。
如果你的浏览器的请求头中还包含了Last-Modified这个字段,在复制请求头时请忽略这个字段(知道Last-Modified是干什么用的就能理解为什么要去掉它了)。
获取视频评论的API是https://api.bilibili.com/x/v2/reply
,使用的查询参数如下:
参数 | 含义 |
---|---|
type | 我不知道有什么用,设置成1就行了 |
pn | 获取第几页评论 |
oid | 对应视频的AV号 |
sort | 按热度排序的话,设置成0,按时间排序的话,设置成2 |
返回数据为JSON格式,如下所示:
其中,replies包含了我们需要的数据,每一个reply的格式如下:
其中,rpid表示这个评论的id,ctime代表评论时间的UNIX时间戳,comment下的message则是评论的具体内容,replies则代表了这个评论下的回复(不是所有,只有默认显示出来的几条)。
如果需要获取评论下的回复,则使用API:https://api.bilibili.com/x/v2/reply/reply
,查询字符串中的参数如下:
参数 | 含义
type | 不知道有什么用,设置成1就行了
oid | 对应视频的AV号
pn | 需要第几页回复
ps | 一页回复中需要几条回复
root | 回复所属于的评论的rpid
返回的数据也是JSON格式,结构与评论数据的结构一样,只是replies下不再会有replies;
注意事项:
Refer: https://bilibili.com/video/BVxxxxxxx
,严谨起见,在请求评论时一并提供视频的BV号;包装一下urlopen函数,如果发生错误会进行再次尝试,最多尝试3次,请求成功后会等待0.3s,防止请求过于频繁。
from urllib.request import urlopen, Request
from http.client import HTTPResponse
import time
firefox_cookie = '请从自己的浏览器获取’
def my_urlopen(url: Request) -> HTTPResponse:
err = Exception()
for _ in range(3):
try:
resp = urlopen(url=url) # type: HTTPResponse
except Exception as e:
err = e
print(e)
else:
time.sleep(0.3)
return resp
with open(file='./errors.data', mode='a', encoding='utf-8') as f:
f.write(url.get_full_url() + '\n')
f.write(str(err) + '\n')
f.write('\n')
raise Exception('HTTP请求失败!')
包含一个函数get_video_list_from_uploader_id(uid: str, start_time: datetime.datetime, end_time: datetime.datetime) -> list
,根据用户的UID获取一定时间段内所有的投稿视频信息。
from urllib.request import Request
from http.client import HTTPResponse
from .liteTool import firefox_cookie, my_urlopen
import json
import gzip
import datetime
def get_video_list_from_uploader_id(uid: str, start_time: datetime.datetime, end_time: datetime.datetime) -> list:
def __get_video_list_in_json(url: str, header: dict, method: str) -> list:
print('获取视频投稿列表:' + url)
try:
request = Request(url=url, headers=header, method=method)
response = my_urlopen(url=request) # type: HTTPResponse
video_list_str = gzip.decompress(response.read()).decode(encoding='utf-8')
video_list_json = json.loads(video_list_str)
return video_list_json['data']['list']['vlist']
except Exception as e:
print(e)
return []
if start_time > end_time:
start_time, end_time = end_time, start_time
page_capacity = 30
page_index = 1
finish = False
results = []
while not finish:
"""
mid: 用户的数字ID
ps: 一页放几个视频
tid: 视频分类信息
pn: 要获取第几页视频
keyword: 搜索关键字
order: 按什么排序
"""
video_url = 'https://api.bilibili.com/x/space/arc/search?' + \
f'mid={uid}&ps={page_capacity}&tid=0&pn={page_index}&keyword=&order=pubdate&jsonp=jsonp'
video_header = {
'Host': '请从自己的浏览器获取',
'User-Agent': '请从自己的浏览器获取',
'Accept': '请从自己的浏览器获取',
'Accept-Language': '请从自己的浏览器获取',
'Accept-Encoding': '请从自己的浏览器获取',
'Origin': '请从自己的浏览器获取',
'Connection': '请从自己的浏览器获取',
'Referer': f'https://space.bilibili.com/{uid}/video?tid=0&page={page_index}&keyword=&order=pubdate',
'Cookie': firefox_cookie,
'TE': '请从自己的浏览器获取',
}
videos = __get_video_list_in_json(url=video_url, header=video_header, method='GET')
if videos is None or len(videos) == 0:
break
for v in videos: # type: dict
time_stamp = int(v.get('created'))
upload_time = datetime.datetime.fromtimestamp(time_stamp)
if start_time <= upload_time <= end_time:
results.append(v)
if upload_time < start_time:
finish = True
break
page_index += 1
return results
if __name__ == '__main__':
pass
包含如下函数:
函数定义 | 用途 |
---|---|
get_av_vid_comment_number_and_tags_from_bv(bv: str) -> (str, str, int, list) | 根据视频的BV号,获取并解析播放页面,得到AV号、指向视频文件和弹幕文件的ID、评论数、标签 |
get_comments_and_replies_from_av_and_bv(av: str, bv: str, comment_total: int = -1, by_time: bool = False) -> list | 根据AV号和BV号获取视频的所有评论,可选参数包括评论总数和排序方式 |
get_dm_from_vid_and_bv(vid: str, bv: str) -> list | 根据指向弹幕文件的ID和BV号,获得弹幕列表 |
from bs4 import BeautifulSoup
from urllib.request import Request
from http.client import HTTPResponse
from xml.etree import ElementTree
from .liteTool import firefox_cookie, my_urlopen
import gzip
import zlib
import json
def get_av_vid_comment_number_and_tags_from_bv(bv: str) -> (str, str, int, list):
"""
抓取一般视频的数据,电影和番剧我还没研究过。
:param bv: B站视频的BV号
:return: 第一个是str形式的AV号(没有AV前缀),第二个是获取弹幕要用的一个id(姑且叫它vid),
第三个是视频评论数(包括回复),第四个是视频标签列表
"""
url = f'https://www.bilibili.com/video/{bv}'
headers = {
'Host': '请从自己的浏览器获取',
'User-Agent': '请从自己的浏览器获取',
'Accept': '请从自己的浏览器获取',
'Accept-Language': '请从自己的浏览器获取',
'Accept-Encoding': '请从自己的浏览器获取',
'Connection': '请从自己的浏览器获取',
'Cookie': firefox_cookie,
'Upgrade-Insecure-Requests': '请从自己的浏览器获取',
'Cache-Control': '请从自己的浏览器获取',
'TE': '请从自己的浏览器获取',
}
try:
print(f'获取视频页面:{url}')
request = Request(url=url, headers=headers, method='GET')
response = my_urlopen(url=request) # type: HTTPResponse
response_data = gzip.decompress(response.read()).decode(encoding='utf-8')
bs = BeautifulSoup(markup=response_data, features='html.parser')
head = bs.find(name='head')
scripts = head.find_all(name='script')
script_vid = scripts[2]
script_tag = scripts[3]
video_info_raw = script_vid.string # type: str
video_info_raw = video_info_raw[video_info_raw.find('{'):]
video_info_json = json.loads(video_info_raw)
base_url = video_info_json.get('data').get('dash').get('video')[0].get('baseUrl') # type: str
vid = base_url.split('/')[6]
tag_raw = script_tag.string # type: str
tag_raw = tag_raw[tag_raw.find('{'):tag_raw.find(';(function')]
tag_json = json.loads(tag_raw)
comment_meta = bs.find(name='meta', attrs={'itemprop': 'commentCount'})
av_meta = bs.find(name='meta', attrs={'property': 'og:url'})
comment_count = int(comment_meta.attrs['content'])
av_number = av_meta.attrs['content'].split('av')[-1][:-1]
return av_number, vid, comment_count, tag_json.get('tags')
except Exception as e:
print(e)
return '', '', -1, ''
def get_comments_and_replies_from_av_and_bv(av: str, bv: str, comment_total: int = -1, by_time: bool = False) -> list:
"""
获取评论和评论下面的回复
:param av: AV号,没有AV前缀
:param bv: BV号,需要有BV前缀
:param comment_total: 需要获取的评论数
:param by_time: 是否按时间顺序排列
:return: 评论列表,评论的回复在每一条评论的replies属性中
"""
results = []
page_index = 1
comment_count = 0
def __get_json_data(url: str, header: dict, method: str) -> list:
"""
获取评论和获取回复的过程雷同,写一个函数代替一下
:param url: URL
:param header: 请求头部
:param method: 请求方法
:return: 处理成JSON格式返回
"""
print('获取评论数据:' + url)
try:
request = Request(url=url, headers=header, method=method)
response = my_urlopen(url=request) # type: HTTPResponse
data_str = gzip.decompress(response.read()).decode(encoding='utf-8')
data_json = json.loads(data_str)
data_json = data_json.get('data').get('replies')
except Exception as e:
print(e)
return []
return data_json
while True:
comment_url = f'https://api.bilibili.com/x/v2/reply?type=1&pn={page_index}&oid={av}&sort={0 if by_time else 2}'
comment_header = {
'Host': '请从自己的浏览器获取',
'User-Agent': '请从自己的浏览器获取',
'Accept': '请从自己的浏览器获取',
'Accept-Language': '请从自己的浏览器获取',
'Accept-Encoding': '请从自己的浏览器获取',
'Connection': '请从自己的浏览器获取',
'Referer': f'https://www.bilibili.com/video/{bv}',
'Cookie': firefox_cookie,
'TE': '请从自己的浏览器获取',
}
comment_json = __get_json_data(url=comment_url, header=comment_header, method='GET')
if comment_json is None or len(comment_json) == 0:
break
comment_count += len(comment_json)
if 0 < comment_total <= comment_count:
break
for comment in comment_json:
if comment.get('replies'):
comment['replies'] = []
reply_id = comment.get('rpid')
reply_num = 10
reply_page = 1
while True:
reply_url = f'https://api.bilibili.com/x/v2/reply/reply?type=1&pn={reply_page}&oid={av}&ps=' + \
f'{reply_num}&root={reply_id}'
reply_json = __get_json_data(url=reply_url, header=comment_header, method='GET')
if reply_json is None or len(reply_json) == 0:
break
comment_count += len(reply_json)
comment['replies'] += reply_json
if 0 < comment_total <= comment_count or len(reply_json) < reply_num:
break
reply_page += 1
if 0 < comment_total <= comment_count:
break
results += comment_json
page_index += 1
return results
def get_dm_from_vid_and_bv(vid: str, bv: str) -> list:
"""
获取视频评论
:param vid: 跟弹幕文件和视频文件有关的一个东西,我姑且叫它vid
:param bv: BV号,要有BV前缀
:return: 弹幕列表,每一个弹幕是一个二元组,弹幕内容和弹幕属性
"""
def __deflate(s: bytes) -> str:
"""
弹幕文件是用deflate加密的
:param s: 加密前的bytes
:return: 解密后的Unicode字符串
"""
try:
return zlib.decompress(s, -zlib.MAX_WBITS).decode(encoding='utf-8')
except zlib.error as ze:
print(ze)
return zlib.decompress(s).decode(encoding='utf-8')
url = f'https://api.bilibili.com/x/v1/dm/list.so?oid={vid}'
header = {
'Host': '请从自己的浏览器获取',
'User-Agent': '请从自己的浏览器获取',
'Accept': '请从自己的浏览器获取',
'Accept-Language': '请从自己的浏览器获取',
'Accept-Encoding': '请从自己的浏览器获取',
'Origin': '请从自己的浏览器获取',
'Connection': '请从自己的浏览器获取',
'Referer': f'https://www.bilibili.com/video/{bv}',
'Cookie': firefox_cookie,
'TE': '请从自己的浏览器获取',
}
try:
print('获取弹幕文件:' + url)
request = Request(url=url, headers=header, method='GET')
response = my_urlopen(url=request) # type: HTTPResponse
xml_str = __deflate(response.read())
except Exception as e:
print(e)
return []
tree = ElementTree.fromstring(xml_str)
res = []
for child in tree: # type: ElementTree.Element
if child.tag == 'd':
res.append((child.text, child.attrib['p']))
return res
if __name__ == '__main__':
"""
使用示例
"""
BV = 'BV1CT4y1g7Ya' # 狂战士预告
AV, VID, comment_num, tags = get_av_vid_comment_number_and_tags_from_bv(bv=BV)
comments_and_replies = get_comments_and_replies_from_av_and_bv(av=AV, bv=BV, comment_total=comment_num)
dm_list = get_dm_from_vid_and_bv(vid=VID, bv=BV)
with open(file='D:/MyResources/爬虫数据/哔哩哔哩干杯/狂战士评论.json', mode='w', encoding='utf-8') as f:
j = json.dumps(comments_and_replies, ensure_ascii=False)
f.write(j)
with open(file='D:/MyReSources/爬虫数据/哔哩哔哩干杯/狂战士弹幕.txt', mode='w', encoding='utf-8') as f:
for i in dm_list:
f.write(i[0] + ' - ' + i[1] + '\n')
for i in dm_list:
print(i[0])
print()
for i in comments_and_replies:
print(i['content']['message'])
if i.get('replies'):
for j in i['replies']:
print(' ' + j['content']['message'])
print()