赞
踩
数据挖掘部分的基本目标是:对于指定的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代表一页要放下几个投稿视频,想象一下你在浏览网页,这两个参数的作用就不难理解了;
API返回的响应为JSON格式,结构如下:
其中,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()
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。