【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表

数据挖掘部分的基本目标是:对于指定的UP主,能够获取其投稿视频列表;对于指定的视频,能够获取其视频标签、评论(包括评论下的回复)、弹幕。

文章默认读者对网络爬虫有一定的基础知识;

文章写作时(2020-06),B站正处于AV号像BV号过度的时期,部分API可能会在今后发生重大变化,请今后的读者注意。

获取指定UP主的投稿视频列表

首先,我们知道每一个B站帐号都有一个对应的数字UID,然后,通过在浏览器中访问用户的个人主页并查看后台请求,可以发现,用户的投稿视频列表信息是从api.bilibili.com/x/space/arc/search获取的,响应为JSON格式,具体的查询参数附加在URL后的查询字符串中,基本的查询参数如下:

参数 含义
mid 用户的UID
ps 返回的结果中需要包含多少个视频的信息
tid 视频的分类
pn 需要获取第几页投稿视频
keyword 搜索关键字
order 返回结果的排序方式

其中,tid视频的分类指的是我们再B站主页上看到的分类信息:
在这里插入图片描述
API的响应会告诉我们不同的分类用哪个数字代表,也会告诉我们这个UP主在不同的分类下各有几个投稿视频:

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第1张图片

需要注意的是,如果一个UP主在某一个分类下没有投稿视频,那么API的响应不会包含这个分类的信息;

order是搜索结果的排序方式,有三种排序方式:按更新时间、按播放数量、按点赞数量,这三种排序方式分别对应order=pubdateorder=clickorder=stow

pn代表你想要第几页的投稿视频,ps代表一页要放下几个投稿视频,想象一下你在浏览网页,这两个参数的作用就不难理解了;

API返回的响应为JSON格式,结构如下:
【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第2张图片

其中,tlist包含了视频分类的信息,与API的tid参数有关,如上上张图所示;vlist则包含了我们需要的投稿视频信息,vlist的长度由ps参数决定,vlist中每一个对象的结构如下:

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第3张图片
其中,我能确定意义的属性是:

属性 意义
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动态加载),得到的播放页面的结构如下:

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第4张图片

meta标签中,有两个值得我们注意一下,第一个是拥有属性property="og:url"的meta标签,这个标签的content的属性包含了这个视频使用AV号表示的播放页面(如果在只有BV号的情况下,想获取AV号,可以使用这个方法),第二个值得注意的meta标签拥有属性itemprop="commentCount",而这个标签的content属性记录了视频的评论数(如果需要最新的评论总数,可以使用这个方法)。

接下来,我们需要注意head中的最后两个script(第三个和第四个),第三个script中的内容如下:

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第5张图片

可以看到,这个script标签中包含了大段的JSON数据,JSON数据中有很多URL,而这些URL中都包含了同一个ID:18xxxxx45,不难发现这个ID应该是指向视频的实际文件,但同时,这个ID也指向了视频的弹幕文件,所以在这里我们需要想办法提取出这个ID备用。

head中的第四个script标签中同样也是包含了大段JSON数据的JS代码,其中有一部分数据如下所示:

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第6张图片

可以看到,这就是当前视频对应的标签,在我的课程设计中,我需要通过视频的标签对视频进行过滤,所以这里需要从页面中提取出标签信息。

我使用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格式的,如下所示:

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第7张图片

可以看到,弹幕内容记录在了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格式,如下所示:

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第8张图片

其中,replies包含了我们需要的数据,每一个reply的格式如下:

【Python】大数据挖掘课程作业1——使用爬虫爬取B站评论、弹幕与UP主的投稿视频列表_第9张图片

其中,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;

注意事项:

  • 返回的数据都是gzip压缩的,需要解压缩后使用;
  • 如果某一视频被关闭评论,则API返回未经压缩的提示信息,需要为此做好异常处理;
  • 浏览器的请求头中包含Refer: https://bilibili.com/video/BVxxxxxxx,严谨起见,在请求评论时一并提供视频的BV号;

相关代码

./liteTool.py

包装一下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请求失败!')

./GetBilibiliUploaderInfo.py

包含一个函数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

./GetBilibiliVideoInfo.py

包含如下函数:

函数定义 用途
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()

你可能感兴趣的:(一入Python深似海,数据挖掘,python,bilibili,网络爬虫)