[Bilibili爬虫] 评论与弹幕词云分析

发布于 2019-04-05  4,748 次阅读


突然想写个爬虫来爬一爬B站的数据做个分析,写个程序自娱自乐233

网页数据分析

做爬虫的第一步就是分析网页代码,我们先随便点进Bilibili的一个视频页面观察一下它的内容。下图展示了某个Bilibili视频页的内容

首先观察一下地址栏,发现是主站的域名www.bilibili.com,后面跟了一个视频id,再后面有一个东西不太清楚是什么,从名字来看它包含了from,估计是表示我是从哪里进到这个视频页面的,我们试着删除一下这部分内容,enter,发现依然进到了同一个页面。所以我们知道B站的视频页面的入口是:

"https://www.bilibili.com/video/av{}".format(av_id)

然后我们按F12启动Chrome开发者工具看一看网页代码。本人并不是做前端的,这些东西不是很懂,只能根据网页代码一点一点猜。

视频页面网页代码

选中network然后刷新整个页面,就可以看到该页面所包含的全部网络请求。从侧栏可以看到这里包含了很多图像,我们选中一个(然后选择preview)就可以看到这应该是某位用户的头像。我们所需要找的评论数据就在这一部分

然而,需要注意的是bilibili这样的网站肯定是异步加载的。所谓网页的同步,指的是网页是在你点开之后把所有内容一起加载完然后渲染显示的;而异步指的是先加载一部分,随着浏览的过程继续加载后续内容,一般是通过js脚本向html中插入内容来实现的(?个人理解)。所以说对于这种网站直接分析html代码的意义不大。

评论数据

评论数据查找

我第一眼就看到了在热评里面的一个舞见粉丝的评论,我们把这段文字粘贴到搜索框查找一下,发现有两个结果,其中一个是来源于html代码,另一个是来源于一个jQuery。html代码肯定不是我们需要的(理由见上),所以我们点开这个(其实我也不知道是啥的)jQuery看一看。

热评数据来源

选中这个代码之后我们选择preview(选择response其实也能看到内容只不过没有缩进人类不可读,preview是Chrome处理过的形式),在里面找一找发现在data->hosts->0->content里发现了我们刚才要找的评论内容。稍微分析一下我们猜测:hosts包含的是热评的内容,里面的0,1,2分别是三个热评。再看一看发现下面还有个replies,估计这个就是普通评论了。

普通评论数据

选中看一下发现的确是这样的。再多看这个东西几眼我们可以发现data->page里面还包含了一些数据。对比页面显示的数据猜测:count应该是评论的总数,acount估计是加上楼中楼的数据(?),size是一页包含了多少个评论。其实从逻辑上讲网页数据肯定包含了总共有多少条评论,总共的评论页数有多少(因为这两个都是已经在页面中显示出来了的)。我们在这里根据count和size来计算总计的评论页数。

接下来我们分析一下这个jQuery数据是如何获得的。

我们依然选中这个东西发现它是直接从api.bilibili.com获取数据的,获取数据的形式是get(把参数用&连接起来拼接在链接的末尾的一种有选择地向服务器索取数据地方法)。我们把这个地址 https://api.bilibili.com/x/v2/reply?callback=jQuery17207513556117066222_1553924356136&jsonp=jsonp&pn=1&type=1&oid=47475401&sort=0&_=1553924357590 然而直接把这个地址粘贴到地址栏访问显示我们在访问一个不存在的页面。

不存在之境界(x)

再观察一下这个链接的参数,猜测一下它们各自的含义:

  • callback:或许是表示该链接是从某个地方产生的回调
  • jsonp:在网上查了下jsonp是一种跨域请求的方法(?),这个参数估计是启用jsonp的意思吧
  • pn:表示这是评论的第几页
  • type:据我测试这是一个表示是写评论还是看评论的参数,意义不大但是乱改会出错
  • oid:视频av号
  • sort:表示如何排序(?),但是修改之后毫无反应
  • _:不知道是什么,估计仍然是和当前会话有关的东西

把callback和_这两个估计是和会话有关的参数删去,再把jsonp删去,链接变为:

https://api.bilibili.com/x/v2/reply?pn=1&type=1&oid=47475401&sort=0

这个链接只能复制到地址栏直接访问,如果我你从这里跳转去那个页面会因为包含了refer信息而得到403。

此时访问这个页面就已经拿到了评论数据。根据这个实验我们可以知道(截至2019年3月30日)Bilibili的评论数据是这样获取的

"https://api.bilibili.com/x/v2/reply?&jsonp=jsonp&pn={:d}&type=1&oid={:d}".format(pg_no,av_id)

我们只需要对这个页面返回的字符串做解析就可以完成评论分析的后续工作了。

弹幕数据

弹幕的数据很微妙,如果我们用开发者工具分析这个网页,会发现一个很奇怪的请求

我们把它的地址粘贴到浏览器中尝试去访问一下,发现它其实就是弹幕文件,chrome没有正确预览它可能是编码或者后缀名的原因把。但是很奇怪的一点是,这个链接的oid并不是视频的av号(aid),而是另一串数字。在网页里面搜索一番发现这是一个叫做chatid的参数。但是这个参数究竟怎么来的暂时并不清楚。

本着既然这玩意不是av号那么一定是通过某个请求返回的。我在比这个弹幕文件更早加载的所有xhr请求中翻找了一遍,终于发现在这个地方有一个json返回了chatid

接下来就是很自然的操作了,构造请求拿到json,分析json拿到cid,用cid去访问

danmu_data=requests.get("https://api.bilibili.com/x/v1/dm/list.so?oid={cid}".format(cid=cid))

然后就获得了弹幕数据。

数据爬取

开发环境

我使用的开发环境是Jupyter Notebook,架设在一台VPS上,这样可以在服务器上不停地跑程序。选用 jupyter notebook 是因为它的交互很方便,对于写爬虫这样的工具会极大地加快开发速度。

评论数据爬取

首先来看一看如何构造请求的url

aid=47640067

raw_url="https://api.bilibili.com/x/v2/reply?pn={:d}&type=1&oid={:d}"
def video_entry_url(page_no:int, av_id:int)->str:
    return raw_url.format(page_no,av_id)

以上代码定义了一个函数,传入视频av号和对应的评论页码返回链接

print("getting api url:")

data_path="bilibili_comment_data/"

if not os.path.exists(data_path+"av{}".format(aid)):
    os.makedirs(data_path+"av{}".format(aid))

res=requests.get(video_entry_url(1,aid))
print(video_entry_url(1,aid))
js_data=json.loads(res.text)

count=js_data["data"]["page"]["count"]
page_sz=js_data["data"]["page"]["size"]
acount=js_data["data"]["page"]["acount"]
tot_pg=(count-1)//page_sz+1

print("total pages: {}".format(tot_pg))

pgnl=1
_tot_pg=tot_pg
while _tot_pg:
    pgnl+=1
    _tot_pg//=10
print("page number length: ",pgnl)

以上代码有两个作用,一是准备了用于储存以后数据的一个文件夹,二是通过一次request.get计算出总共的评论页数。

因为我们需要储存数据,先来定义一下文件名方便之后写入

def js_file_name(aid:int, page_no:int)->str:
    return data_path+"av{subfld}/comments_at_page_{num:0{width}}.json".format(subfld=aid,num=page_no,width=pgnl)

def words_file_name(aid:int)->str:
    return data_path+"av{subfld}/comments_words_split.txt".format(subfld=aid)

之后就是获取数据了,我们通过requests包的get方法不断地请求数据,然后对取得的数据进行分析,由于数据是json格式,我们直接使用json包来进行分析,然后把结果写入文件以准备进一步的数据分析。需要注意的一点就是一定要在循环内部进行sleep操作,这一方面是为了防止自己的爬虫被bilibili的反爬虫机制给ban掉,一方面是避免给bilibili服务器过大的压力。

for pg in range(1,tot_pg+1):
    replies={}
    if pg%(tot_pg//100+1)==0:
        print("[!] curent page: {pg:{pg_width}} ({pg:{pg_width}}/{tot_pg:d}, {ratio:06.3f}%)"
              .format(pg=pg,pg_width=pgnl,tot_pg=tot_pg,ratio=pg*100/tot_pg))
    res=requests.get(video_entry_url(pg,aid))
    js_data=json.loads(res.text)
    for rep in js_data["data"]["replies"]:
        replies[int(rep["floor"])]={
            "uid":int(rep["member"]["mid"]),
            "uname":rep["member"]["uname"],
            "content":rep["content"]["message"]
        }
    rep_json_test=json.dumps(replies,ensure_ascii=False)
    with open(js_file_name(aid,pg),"w") as f:
        json.dump(rep_json_test,f,ensure_ascii=False)
    # sleep to avoid banning
    sleep(1.2)

评论数据分析与词云生成

拿到评论数据之后我们就可以来做数据的分析了,首先需要对文本进行词语拆分。这里使用一个中文分词工具jieba。通过该工具将语句切分为词语然后存入dict,key是词语,value是出现的次数。

words_dict={}
jieba.enable_parallel()
for pg in range(1,tot_pg+1):
    print("[!] current page number {}".format(pg))
    with open(js_file_name(aid,pg),"r") as f:
        # json.load returns a string and json.loads converts this string into a dictionary
        js_rd_test=json.loads(json.load(f,encoding='utf-8'),encoding='utf-8')
    # pprint(js_rd_test)
    for i in js_rd_test.keys():
        print("original comment content:")
        comment=js_rd_test[i]["content"]
        print(comment)
        print(" "*40)
        seg_list=jieba.lcut(comment,cut_all=False)
        print("==>Precise Mode: ",', '.join(seg_list))
        for item in seg_list:
            if words_dict.get(item):
                words_dict[item]=words_dict[item]+1
            else:
                words_dict[item]=1
        print("-"*40)
    print("="*40)
print("[END]")

然后对于得到的数据,还需要做一个处理就是除去无意义的词语和符号,主要是“的”,“是”这类语气词或助词,以及颜文字产生的各类特殊符号。之后再计算一下词频,最后调用wordcloud生成一张词云图就OK了。

word_list=[]
# remove some marks and some emojies
# pay attention that 'ؒ' and '̀' are not the same as ''
# QAQ
trash_marks=['.',',','[',']','(',')','(',')',' ','。',',',\
             '!','!','_','\n','\r','/','=','?','?','´','`','・','',';',';',\
            ':',':','°','ω','~','ؒ','的','了','啊','地','得','我','是','也','吗','呢',\
            '∀','~','~','ノ','・','*','+','-','△','&','#','๑','Д','ε','【','】','′','︵','‵',\
            'д','́','╰','╯','╮','╭','《','》','≧','つ','ロ','の','ಥ','﹡','﹡','ˆ','ฅ','Ő','╥','ᴗ',\
            ' ̄','…','▽','`','⌒','•','⊙','。','〜','&#','◡','ʃ','ƪ']

for word in words_dict.keys():
    if word not in trash_marks:
        word_list.append([word,words_dict[word]])
print("="*40)
print("sorted words list: ")
    
word_list=sorted(word_list,key=lambda x: x[1], reverse=True)
pprint(word_list)
tot_word_num=0
with open(words_file_name(aid),"w") as f:
    for word_item in word_list:
        f.write(word_item[0]+' '+str(word_item[1])+'\n')
        tot_word_num+=word_item[1]
def word_cloud_path(aid:int)->str:
    return data_path+"av{}/word_cloud_figure.png".format(aid)

cloud_tags_dict={}
for item in word_list:
    cloud_tags_dict[item[0]]=item[1]/tot_word_num
# pprint(cloud_tags_dict)
cloud=WordCloud(font_path="SourceHanSerifCN-Medium.ttf",
                width=1920,
                height=1080,
               max_font_size=550).generate_from_frequencies(cloud_tags_dict)
# plt.imshow(cloud)
# plt.axis("off")
# plt.show()
cloud.to_file(word_cloud_path(aid))
print("[!]written wordcloud figure into file")

弹幕数据爬取

弹幕和评论基本上是一样的,前面已经分析过chatid是怎么拿到的,现在就可以爬了

aid=47640067
info_url="https://api.bilibili.com/x/web-interface/view?aid={aid}".format(aid=aid)
info=requests.get(info_url)
js_data=json.loads(info.text)
# pprint(js_data)
cid=js_data["data"]["cid"]
print("chatid: {}".format(cid))
danmu_data=requests.get("https://api.bilibili.com/x/v1/dm/list.so?oid={cid}".format(cid=cid))
# this is important because the charset in the response is not recogised correctly!
danmu_data.encoding="utf-8"
# pprint(danmu_data.text)
data_path="bilibili_danmu_data/"
if not os.path.exists(data_path+"av{}".format(aid)):
    os.makedirs(data_path+"av{}".format(aid))
def get_danmu_xmlfile_name(av_id:int)->str:
    return data_path+"av{}/danmu.xml".format(av_id)
with open(get_danmu_xmlfile_name(aid),"w") as f:
    f.write(danmu_data.text)
doc=parse(get_danmu_xmlfile_name(aid))
def get_danmu_contentfile_name(av_id:int)->str:
    return data_path+"av{}/danmu.txt".format(av_id)
for item in doc.iter():
    sentence=[]
    ele=item.findall('d')
    for ch in ele:
        sentence.append(ch.text+'\n')
    with open(get_danmu_contentfile_name(aid),"a") as f:
        f.writelines(sentence)

弹幕数据分析与词云生成

words_dict={}
with open(get_danmu_contentfile_name(aid),"r") as f:
    for line in f.readlines():
        seg_list=jieba.lcut(line,cut_all=False)
        for item in seg_list:
            if words_dict.get(item):
                words_dict[item]=words_dict[item]+1
            else:
                words_dict[item]=1
print("[END]")
def danmu_words_file_name(av_id:int)->str:
    return "bilibili_danmu_data/av{}/danmu_words.txt".format(av_id)
word_list=[]
# remove some marks and some emojies
# pay attention that 'ؒ' and '̀' are not the same as ''
# QAQ
trash_marks=['.',',','[',']','(',')','(',')',' ','。',',',\
             '!','!','_','\n','\r','/','=','?','?','´','`','・','',';',';',\
            ':',':','°','ω','~','ؒ','的','了','啊','地','得','我','是','也','吗','呢',\
            '∀','~','~','ノ','・','*','+','-','△','&','#','๑','Д','ε','【','】','′','︵','‵',\
            'д','́','╰','╯','╮','╭','《','》','≧','つ','ロ','の','ಥ','﹡','﹡','ˆ','ฅ','Ő','╥','ᴗ',\
            ' ̄','…','▽','`','⌒','•','⊙','。','〜','&#','◡','ʃ','ƪ']

for word in words_dict.keys():
    if word not in trash_marks:
        word_list.append([word,words_dict[word]])
print("="*40)
print("sorted words list: ")
    
word_list=sorted(word_list,key=lambda x: x[1], reverse=True)
pprint(word_list)
tot_word_num=0
with open(danmu_words_file_name(aid),"w") as f:
    for word_item in word_list:
        f.write(word_item[0]+' '+str(word_item[1])+'\n')
        tot_word_num+=word_item[1]
def word_cloud_path(av_id:int)->str:
    return data_path+"av{}/word_cloud_figure.png".format(av_id)

cloud_tags_dict={}
for item in word_list:
    cloud_tags_dict[item[0]]=item[1]/tot_word_num
# pprint(cloud_tags_dict)
cloud=WordCloud(font_path="SourceHanSerifCN-Medium.ttf",
                width=1920,
                height=1080,
               max_font_size=550).generate_from_frequencies(cloud_tags_dict)
# plt.imshow(cloud)
# plt.axis("off")
# plt.show()
cloud.to_file(word_cloud_path(aid))
print("[!]written wordcloud figure into file")

效果展示

弹幕词云

终有一日, 仰望星空