1. 背景
在视频网站上,一边看视频一边发弹幕已经是网友的习惯。在B站上有很多种类的视频,也聚集了各种爱好的网友。本项目,就是对B站弹幕数据进行分析。选取分析的对象是B站上点播量过1.4亿的一部剧《Re:从零开始的异世界生活》。
2. 算法
分两部分:
第一部分:
2.1 在《Re:从零开始的异世界生活》的首页面,找到共25集的所有对应播放链接和剧名的格式,获取每一集的播放链接,并保存。
2.2 从每一集的播放页面中,通过正则re获取它的cid号,获得cid号后,就可用于获取弹幕文件。由于是动态页面,所以使用了selenium库的PhantomJS()方法。
2.3 因为b站需要通过格式为:https://comment.bilibili.com/dmroll,{time},{cid}的链接获取弹幕历史文件,所以要获取30天内的弹幕历史,就要计算出最近30天的时间戳。最后将组装好的url 保存到history_danmu_url.txt 文件。
2.4 当弹幕文件的url准备完成后,就可以通过静态页面的获取方法requests.get(url) 获取弹幕页面。所有的弹幕历史格式:<d p="533.67199707031,1,25,41194,1498943949,0,7edeebe9,3511616609">刀还是没有枪快</d>
2.5 p这个字段里面的内容:(资料来自百度搜索)
0,1,25,16777215,1312863760,0,eff85771,42759017中几个逗号分割的数据
第一个参数是弹幕出现的时间以秒数为单位。
第二个参数是弹幕的模式1..3 滚动弹幕 4底端弹幕 5顶端弹幕 6.逆向弹幕 7精准定位 8高级弹幕
第三个参数是字号, 12非常小,16特小,18小,25中,36大,45很大,64特别大
第四个参数是字体的颜色以HTML颜色的十进制为准
第五个参数是Unix格式的时间戳。基准时间为 1970-1-1 08:00:00
第六个参数是弹幕池 0普通池 1字幕池 2特殊池【目前特殊池为高级弹幕专用】
第七个参数是发送者的ID,用于“屏蔽此弹幕的发送者”功能
第八个参数是弹幕在弹幕数据库中rowID 用于“历史弹幕”功能。
2.6 逐个弹幕历史文件爬取,将内容一集的30天的弹幕历史,以['dtTime', 'danmu_model', 'font', 'rgb', 'stamp','danmu_chi', 'userID', 'rowID', 'message', 'episode'] 的格式保存到d1.csv,d2.csv,d3.csv,……的文件中
2.7 除了将30天的所有历史弹幕保存在一起外,爬虫程序,还将每一集视频,在B站的最新弹幕历史文件的内容单独保存在文件名格式为now1.csv,now2.csv,……的文件中。获取最新弹幕历史文件的链接格式是:https://comment.bilibili.com/{cid}.xml,它们已经被保存在”comment.txt”文件。
第二部分:
2.8 保存了所有的数据后,对数据进行处理:先读取.csv文件,然后进行可视化分析:
2.8.1 每集弹幕总量的变化图
逐个读入弹幕历史文件:d1.csv,d2.csv,……经过去重后,统计出每集的弹幕总量:
data = pd.read_csv(item.strip(),encoding='gbk')
统计每一集,近30天,弹幕总量,保存在字典:{1:323233, 2:212121, .......}
episode_comment_dic[data.loc[1,'episode']] = every_episode_comment(data)
2.8.2 发弹幕总量top5用户
a.统计每一集,30天内的弹幕数量,依据弹幕数量,把用户排序,每一集排序后的结果是一个DataFrame:
结果的大致结构:user_sort_dic = {1: DataFrame1, 2:DataFrame2, ......,25: DataFrame25}。
user_sort_dic[data.loc[1, 'episode']] =every_episode_usersort(data)
b.把经过排序统计处理后的所有DataFrame 进行concat。然后就可以统计所有用户在30天内,对25集视频发送弹幕的数量。最后对用户排序。最终结果:d4_alldanmu_sort 是一个DataFrame 变量,将用户按弹幕数,降序排列。
d3_all_user = (pd.concat([item for k, itemin user_sort_dic.items()]))
d3_all_user['userID'] = d3_all_user.index
aSer =d3_all_user.groupby('userID').episode.sum()
d4_alldanmu_sort =pd.DataFrame(aSer).sort_values(by='episode', ascending=False)
2.8.3 用户发弹幕长度分布
统计发送弹幕的字符串长度。
danmu_length_dic[data.loc[1,'episode']] = static_danmu_length(data)
2.8.4 用户发弹幕数量分布
统计一集,用户发送弹幕数量的百分比分布图。
d_tmp = user_sort_dic[i] #统计用户排名时已经有数据了
every_episode_danmu_pie(d_tmp, i)
2.8.5 每集弹幕密度变化图
弹幕都有一个时间参数,代表了在视频的什么时间发了弹幕。可以统计出在相同时间参数发出的弹幕量,然后再画出“时间--弹幕”折线图,就可以看到弹幕量的变化了。
2.8.6 每集热词画出词云
对每一集的弹幕文本进行分析。先用jieba词库进行分词,然后逐行对弹幕进行词频统计,最后用WordCloud 画出词云图。
3. 安装
使用的是anaconda的环境。所以直接在anaconda里进行安装:
pip install -ihttps://pypi.tuna.tsinghua.edu.cn/simple jieba
conda –c https://conda.anaconda.org/conda-forge wordecloud #词云只支持到python3.4
4. 统计结果
4. 1 每集弹幕总量变化
4.2 发送弹幕总量top5 用户
4.3 用户发送弹幕长度分布
4.4 用户发送弹幕数量分布
4.5 每集弹幕密度分布图
4.6 每集的词云
5. 结果分析
通过以上的图表数据可以看出用户的弹幕的一些行为。
5.1 25集剧里,第一集和最后一集,弹幕数量是最多的,这和其他剧的开头和结尾两部的一样。中间的18集也获得了很多的弹幕数,原来是在那集里剧情出现了很大的变化,吸引了大家的讨论。
5.2 统计了25部剧,用户ID为356ed98的用户共发送了580多条弹幕,算是超级粉丝了。
5.3 通过弹幕数量和长度的分布,可以看出:
绝大部分用户只会发送2条以内的弹幕。
参与弹幕讨论的用户以10个字符以内的短语为主。
5.4 统计每一集的弹幕密度,可以通过用户观看时的讨论,大致确定视频的精彩点。
5.5 通过词云图,可以大致看出用户的分布。例如第一集里“重温”作为热词显示里。说明现在有很多用户是会多次观看这部剧的。
6. 参考代码
6.1 爬虫部分:
# -*- coding: utf-8 -*- """ Created on Mon Jul 10 16:34:27 2017 @author: ahchpr filename: re_zero_bili.py """ import requests, csv, re, time from bs4 import BeautifulSoup as BS from selenium import webdriver import datetime from multiprocessing import Pool import sys # Re:从零开始的异世界生活 的总剧情首页 first_url = 'https://bangumi.bilibili.com/anime/3461' headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)'} one_url = 'https://bangumi.bilibili.com/anime/3461/play#86298' #history_danmu_url = 'https://comment.bilibili.com/dmroll,time,cid' #now_danmu_url = 'https://comment.bilibili.com/{}.xml'.format(danmu_id) def get_danmu_id(url): MyDriver = webdriver.PhantomJS() MyDriver.get(url) time.sleep(3) danmu_id = re.findall(r'cid=(\d+)&', MyDriver.page_source)[0] return (danmu_id) def sele_get_first(url): MyDriver = webdriver.PhantomJS() MyDriver.get(url) time.sleep(5) response = MyDriver.page_source.encode('utf-8') page = response.decode('utf-8') return (page) def sele_get_re_list(page): pattern = re.compile('<a.*?href="(.*?)" title="(.*?)" target.*? class="v1-complete-text"><div class=') abstract = re.findall(pattern, page) return (abstract) def request_get_comment(url): headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)'} episode = url.split(" ")[0] url = url.split(" ")[1].strip() response = requests.get(url=url, headers=headers) soup = BS(response.text, 'lxml') result = soup.find_all('d') if len(result) == 0: return (result) all_list = [] for item in result: # danmu_list.append(item.get('p').split(",").append(item.string)) danmu_list = item.get('p').split(",") danmu_list.append(item.string) # danmu_list[0] = sec_to_str(danmu_list[0]) # danmu_list[4] = time.ctime(eval(danmu_list[4])) danmu_list.append(episode) # print(danmu_list) all_list.append(danmu_list) return (all_list) """将秒转换成固定格式 "hh:mm:ss" """ def sec_to_str(seconds): seconds = eval(seconds) m, s = divmod(seconds, 60) h, m = divmod(m, 60) dtEventTime = "%02d:%02d:%02d" % (h, m, s) return (dtEventTime) """计算最近30天的每天的时间戳,并返回,用于获取历史弹幕 """ def time_to_stamp(): today = datetime.date.today() end_day = datetime.datetime(today.year, today.month, today.day) start_day = end_day - datetime.timedelta(30) gap_day_sum = 30 stamp_list = [] for i in range(1, gap_day_sum): tmp = start_day + datetime.timedelta(i) stamp_list.append(int(time.mktime(tmp.timetuple()))) return (stamp_list) def csv_write(tablelist, num): tableheader = ['dtTime', 'danmu_model', 'font', 'rgb', 'stamp', 'danmu_chi', 'userID', 'rowID', 'message', 'episode'] file_name = "now{}.csv".format(num) print(file_name) with open(file_name, 'w', newline='', errors='ignore') as fd: writer = csv.writer(fd) writer.writerow(tableheader) for row in tablelist: writer.writerow(row) if __name__ == "__main__": sys.setrecursionlimit(1000000) """爬取首页,获取共25话 《re:从零开始的异世界生活》 的播放连接 """ page = sele_get_first(first_url) re_list = sele_get_re_list(page) # print(len(re_list)) """以字典的形式保存例如: {'1': ['初始的终结与结束的开始', 'https://bangumi.bilibili.com/anime/3461/play#85754'],...} """ re_dict = {} for item in re_list: re_dict[item[1].split(" ")[0]] = [item[1].split(" ")[1], item[0]] # print(re_dict) """获取每一话的播放连接,保存成列表 """ re_url_list = [] for i in range(1, len(re_dict)+1): re_url_list.append( re_dict[str(i)][1] ) """利用进程池,获取每一话的弹幕文件连接, """ re_danmu_id_list = [] pool = Pool(14) re_danmu_id_list = pool.map(get_danmu_id, re_url_list) pool.close() pool.join() re_danmu_id_dict = {} for n, p in enumerate(re_danmu_id_list): re_danmu_id_dict[str(n+1)] = p """将25话剧集的各自的最新的弹幕文件连接保存到一个文档 comment.txt,按照剧集的顺序保存 """ with open('comment.txt', 'w') as fd: for i in range(len(re_danmu_id_list)): fd.write('{} https://comment.bilibili.com/{}.xml\n'.format(i+1, re_danmu_id_list[i])) history_danmu_url_list = [] stamp_list = time_to_stamp() for i in range(1, len(re_danmu_id_list)+1): for stamp in stamp_list : history_danmu_url = '{} https://comment.bilibili.com/dmroll,{},{}'.format(i, stamp, re_danmu_id_dict[str(i)]) history_danmu_url_list.append(history_danmu_url) history_danmu_url_list.append('{} https://comment.bilibili.com/{}.xml'.format(i, re_danmu_id_dict[str(i)])) with open('history_danmu_url.txt', 'w') as fd: for line in history_danmu_url_list: fd.write("{}\n".format(line)) all_list = [] '''把每一集的弹幕文件链接,按照集数,整理到一个字典, ''' url_dict ={} with open ("history_danmu_url.txt", 'r') as fd: url_whole = fd.readlines() print(len(url_whole)) for i in range(1, len(re_danmu_id_list)+1): url_dict[str(i)] = [line for line in url_whole if int(line.split(" ")[0])==i] print (len(url_dict)) '''按照集数,取出弹幕链接,进行爬虫,获取弹幕记录,并保存到.csv 文件 ''' for i in range(1, len(url_dict)+1): n = 0 tmp_to_get_url = url_dict[(str(i))] file_name = "d{}.csv".format(i) tableheader = ['dtTime', 'danmu_model', 'font', 'rgb', 'stamp', 'danmu_chi', 'userID', 'rowID', 'message', 'episode'] with open(file_name, 'a', newline='', errors='ignore') as fd: writer = csv.writer(fd) writer.writerow(tableheader) for url in tmp_to_get_url : all_list = request_get_comment(url) if all_list: for row in all_list: writer.writerow(row) print("\n\n\n\n\n") n = n+1 print(n) del (all_list) del tmp_to_get_url """获取保存最新历史弹幕文件 """ now_danmu_all = {} with open ('comment.txt', 'r') as fd: for url in fd: now_danmu_all[int(url.strip().split(" ")[0])] = request_get_comment(url) for num, data in now_danmu_all.items(): csv_write(data, num)
6.2 数据统计可视化分析部分:
# -*- coding: utf-8 -*- """ Created on Wed Jul 12 08:43:04 2017 @author: ahchpr filename: static_danmu_comment.py """ import pandas as pd import matplotlib.pyplot as plt import os from scipy.misc import imread from wordcloud import WordCloud import jieba import jieba.posseg as pseg import math def danmu_compress_plot(data, num): plt.cla() plt.xlabel(u"视频时间", fontproperties='SimHei') plt.ylabel(u"弹幕量", fontproperties='SimHei') plt.title(u'第{}集_时间轴弹幕变化'.format(num), fontproperties='SimHei') keys = [item for item in data.index ] values = [item for item in data.dtTime] """弹幕密度折线图 """ plt.plot(keys, values) plt.show() def danmu_compress(data): df = data.drop_duplicates() dd = df.copy() # round_dic = {} # 向下取“整秒数” # round_dic['dtTime_new'] = [math.floor(item) for item in dd.dtTime] dd['dtTime_new'] = [math.floor(item) for item in dd.dtTime] # dd.sort_values(by='dtTime_new', inplace=True) # print (dd.iloc[1:200, [6, 0, 10]]) dc = dd.groupby('dtTime_new').count() result = dc.sort_index() return (result) '''result 大概的结构:只写出了一列参考,其中dtTime就是在0秒开始时的弹幕数量 dtTime dtTime_new 0 75 1 37 2 34 ''' def extract_words(data, num): df = data.drop_duplicates() dd = df.copy() message_list = [str(item) for item in dd.message] stop_words = set(line.strip() for line in open('stopwords.txt', encoding='utf-8')) newslist = [] for subject in message_list: if subject.isspace(): continue # segment words line by line word_list = pseg.cut(subject) for word, flag in word_list: if not word in stop_words and flag == 'n': newslist.append(word) d = os.path.dirname(__file__) mask_p_w_picpath = imread(os.path.join(d, "qiaodan.png")) content = ' '.join(newslist) wordcloud = WordCloud(font_path='simhei.ttf', background_color="grey", mask=mask_p_w_picpath, max_words=40).generate(content) # Display the generated p_w_picpath: file_name = u"第{}集_热词云.jpg".format(num) plt.imshow(wordcloud) plt.axis("off") plt.title(file_name, fontproperties='SimHei') wordcloud.to_file(file_name) plt.show() plt.cla() plt.close() def static_danmu_length(data): df = data.drop_duplicates() dd = df.copy() dd['message_len'] = [len(str(item)) for item in df.message] #统计每条弹幕的长度 d1 = dd.loc[:, ['userID', 'message', 'message_len', 'episode']] dr = d1.copy() return (dr) def every_episode_usersort(data): df = data.drop_duplicates() dd = df.groupby("userID").count() user_sort = dd.sort_values(by='episode', ascending=False).loc[:,['episode']] return (user_sort) def every_episode_user(data): '''30天内共有多少用户发弹幕 ''' df = data.drop_duplicates() dd = df.groupby("userID").count() user_sum = len(dd) return (user_sum) def every_episode_comment(data): '''30天内有多少弹幕发出 ''' df = data.drop_duplicates() danmu_sum = len(df) return (danmu_sum) def top_user_danmu(data): plt.cla() ing = range(5) x = data.head(5).episode.index y = data.head(5).episode.values plt.xticks(ing, x, rotation=30) plt.xlabel(u"用户ID", fontproperties='SimHei') plt.ylabel(u"发弹幕数量", fontproperties='SimHei') plt.title(u"发弹幕数top5用户",fontproperties='SimHei') plt.bar(ing, y) plt.show() def every_episode_comment_change(episode_comment_dic): plt.cla() plt.xlabel(u"剧集", fontproperties='SimHei') plt.ylabel(u"弹幕量", fontproperties='SimHei') plt.title(u'每集弹幕总量变化', fontproperties='SimHei') keys = range(1, len(episode_comment_dic)+1) values = [] for i in keys: values.append(episode_comment_dic[i]) """每一集弹幕总量的折线变化图 """ plt.plot(keys, values) plt.show() def every_episode_danmu_pie(d_tmp, num): a1 = float(len(d_tmp[(d_tmp.episode>=1) & (d_tmp.episode<=2)])) a2 = len(d_tmp[(d_tmp.episode>=3) & (d_tmp.episode<=8)]) a3 = len(d_tmp[(d_tmp.episode>=9) & (d_tmp.episode<=20)]) a4 = len(d_tmp[(d_tmp.episode>=21) ]) s = a1 + a2 + a3 + a4 s = float(s) li = [a1 , a2 , a3 , a4 ] xp = [] for i in li: i = float(i) if i<=0: t = 0 xp.append(t) else: t = (i/s*100) xp.append(t) plt.rcParams['font.sans-serif']=['SimHei'] #用来正常显示中文标签 plt.rcParams['axes.unicode_minus']=False #用来正常显示负号 plt.figure(figsize=(6,9)) labels = [u'1-2条', u'2-8条', u'9-20条', u'21条以上' ] sizes = xp colors = ['red', 'yellow', 'gray', 'lightskyblue'] explodes = [0 , 0, 0, 0.6] plt.axis('equal') plt.title(u'第{}集_用户发送弹幕数量百分比分布图'.format(num)) plt.pie(sizes, labels=labels,explode=explodes, colors=colors, labeldistance=0.5, autopct = '%2.2f%%', startangle = 90, pctdistance = 0.8) plt.show() plt.close() def danmu_length_pie(d_tmp, num): a1 = float(len(d_tmp[(d_tmp.message_len>=1) & (d_tmp.message_len<=4)])) a2 = float(len(d_tmp[(d_tmp.message_len>=5) & (d_tmp.message_len<=10)])) a3 = float(len(d_tmp[(d_tmp.message_len>=11) & (d_tmp.message_len<=15)])) a4 = float(len(d_tmp[(d_tmp.message_len>=16) ])) s = a1 + a2 + a3 + a4 s = float(s) li = [a1 , a2 , a3 , a4 ] xp = [] for i in li: i = float(i) if i<=0: t = 0 xp.append(t) else: t = (i/s*100) xp.append(t) plt.rcParams['font.sans-serif']=['SimHei'] #用来正常显示中文标签 plt.rcParams['axes.unicode_minus']=False #用来正常显示负号 plt.figure(figsize=(6,9)) labels = [u'1-4个', u'5-10个', u'11-15个', u'16个以上' ] sizes = xp colors = ['red', 'yellow', 'gray', 'lightskyblue'] explodes = [0 , 0, 0, 0.09] plt.axis('equal') plt.title(u'第{}集_用户发送弹幕长度百分比分布图'.format(num)) plt.pie(sizes, labels=labels, colors=colors, explode=explodes, labeldistance=0.5, autopct = '%2.2f%%', startangle = 90, pctdistance = 0.8) plt.show() plt.close() #秒转换成时间 def sec_to_str(seconds): seconds = eval(seconds) m, s = divmod(seconds, 60) h, m = divmod(m, 60) length_time = "%02d:%02d:%02d" % (h, m, s) return (length_time) if __name__ == "__main__": path = os.getcwd() path_list = [] for i in range(1, 26): path_list.append(path + "\\d{}.csv".format(i)) episode_comment_dic = {} user_sum_dic = {} user_sort_dic = {} danmu_length_dic = {} reyun_data_dic = {} for item in path_list: '''读取csv数据源文件,每一集的近30天弹幕都保存在一个csv文件 ''' data = pd.read_csv(item.strip(), encoding='gbk') '''统计每一集,近30天,弹幕总量,保存在字典:{1:323233, 2:212121, .......} ''' episode_comment_dic[data.loc[1, 'episode']] = every_episode_comment(data) '''统计每一集,近30天,共有多少用户发了弹幕,保存在字典:{1:3737, 2:34234,......} ''' user_sum_dic[data.loc[1, 'episode']] = every_episode_user(data) '''统计每一集,30天内的弹幕数量,依据弹幕数量,把用户排序,每一集排序后的结果是一个DataFrame, user_sort_dic = {1: DataFrame1, 2:DataFrame2, ......, 25: DataFrame25} ''' user_sort_dic[data.loc[1, 'episode']] = every_episode_usersort(data) '''统计发送弹幕的字符串长度 ''' danmu_length_dic[data.loc[1, 'episode']] = static_danmu_length(data) '''统计每一集的分词,热词,词云 ''' reyun_data_dic[data.loc[1, 'episode']] = data.copy() '''统计每一集,近30天的弹幕量,视频里的每一秒的弹幕数量,弹幕密度 ''' # danmu_compress_dic[data.loc[1, 'episode']] = danmu_compress(data) del data print(episode_comment_dic) print(user_sum_dic) '''把经过排序统计处理后的所有DataFrame 进行concat。然后就可以统计所有用户在30天内,对25集视频 发送弹幕的数量。最后对用户排序。最终结果: d4_alldanmu_sort 是一个DataFrame 变量,将用户按弹幕数,降序排列。 ''' d3_all_user = (pd.concat([item for k, item in user_sort_dic.items()])) d3_all_user['userID'] = d3_all_user.index aSer = d3_all_user.groupby('userID').episode.sum() d4_alldanmu_sort = pd.DataFrame(aSer).sort_values(by='episode', ascending=False) '''绘制折线图:25集视频,近30天每集弹幕总数 ''' every_episode_comment_change(episode_comment_dic) '''柱状图:25集视频,近30天,所有用户中,发弹幕数量最多的5个用户 ''' top_user_danmu(d4_alldanmu_sort) '''统计一集,用户发送弹幕数量的百分比分布图 ''' for i in range(1, len(user_sort_dic)+1): d_tmp = user_sort_dic[i] every_episode_danmu_pie(d_tmp, i) del d_tmp '''统计用户发送弹幕的长度分布百分比 ''' for i in range(1, len(user_sort_dic)+1): d_tmp = danmu_length_dic[i] danmu_length_pie(d_tmp, i) del d_tmp '''分析弹幕密度,使用当前最新的历史弹幕文件分析 ''' now_danmu_list = [] for i in range(1, 26): now_danmu_list.append(path + "\\now{}.csv".format(i)) danmu_compress_dic = {} for item in now_danmu_list: data = pd.read_csv(item.strip(), encoding='gbk') danmu_compress_dic[data.loc[1, 'episode']] = danmu_compress(data) for num, data in danmu_compress_dic.items(): danmu_compress_plot(data, num) """绘制热词云图,热词的运行时间太久了,先关闭 """ for num, data in reyun_data_dic.items(): extract_words(data, num)