简介:这是一份基于Python的B站视频热度分析毕业设计文档,面向数据分析、爬虫方向学习者及需要完成类似课题的在校生。文档以哔哩哔哩视频网为研究对象,系统说明如何利用Scrapy框架抓取视频标题、描述、播放量等数据,再借助Pandas完成清洗与预处理,并通过Pyecharts绘制分区占比、平均播放量、平均三连、各区平均播放及热门标签等可视化图表,完整呈现从数据采集到结论输出的分析流程。资源包共1个文件,格式为doc,大小1.86MB,属于可直接阅读和修改的论文文档。已有517人学习下载。相比零散代码片段,这份资料的优势在于结构完整,既有研究背景与技术框架,也有数据处理细节和可视化结果汇总,适合作为课程设计、毕业设计或论文写作的参考模板,也可帮助初学者快速了解B站数据热度分析的整体思路。
1. 从榜单到热度画像:这套 Python 视频热度分析做了什么
“基于 Python 的哔哩哔哩视频网视频热度分析”这个项目,是一份从数据采集走到可视化结论的完整毕业设计实现,而不是单个爬虫脚本。整条链路分三段:Scrapy 框架抓取 B 站排行榜和视频详情数据,Pandas 做清洗和去重,pyecharts 做可视化,最终产出分区占比、平均播放量、平均三连、各区平均播放和热门标签五类分析结果。
这个项目最适合四类人:拿爬虫做课程设计的学生,刚学完 Python 基础想练数据分析的开发者,研究 B 站内容生态的运营或 UP 主,以及需要一套“爬虫 + 清洗 + 可视化”完整范例来改造业务需求的工程师。项目的原始数据规模是 1300 行 13 列,字段覆盖作者、播放量、点赞、投币、收藏、得分、评论、分享、分类和标签,分析结论可以直接对应到 15-45 岁群体的内容偏好。
这份资源最值钱的地方,不是那 1300 行数据本身,而是数据从请求到成图的完整流转方式。单独抽开任何一环,你在官方文档里都能找到对应说明;但把它们拼成一条能跑通、能复现、能写进论文的链路,文档不会告诉你边界,只有动手拆过才会懂。
2. 用 Scrapy 抓全站榜单:页面拆解与爬虫主流程
拿到这份资源的第一步,是先搞明白原论文第三章“基于 Scrapy 的数据抓取”在讲什么。作者在这一章用了不少篇幅做页面分析,看起来朴素,其实是整个项目里最容易被跳过的关键环节。B 站页面结构不是给爬虫准备的:排行榜页和视频详情页能拿到的字段完全不同,不分清楚,后面的 Item 定义和管道处理都会跟着错。
2.1 先分清两类页面:榜单页拿概览,详情页拿三连
B 站排行榜页展示的是概览数据:视频题目、作者、播放量、评论数、综合得分。这些信息集中在榜单列表的节点里,用 XPath 可以一次提取。但点赞、投币、收藏这三连数据,榜单页拿不全,需要进入视频详情页才能抓到。至于视频标签,论文里说是从详情页的 div 标签里通过 XPath 抓取的。
按常见做法,Spider 的 parse 方法先解析榜单页,把概览字段填进 Item 实例,同时取出视频链接,用 response.follow 请求详情页,再在第二个回调里补齐三连和标签字段。这样每个视频只产生一条完整记录。
下面给一个贴近原项目结构的 Spider 骨架,XPath 以你 F12 看到的实际结构为准:
import scrapy from bilibili_spider.items import BilibiliItem class RankSpider(scrapy.Spider): name = "rank" start_urls = ["https://www.bilibili.com/v/popular/rank/all"] def parse(self, response): rank_items = response.xpath('//div[contains(@class, "rank-item")]') for rank in rank_items: item = BilibiliItem() item["rank_tab"] = "全站" item["title"] = rank.xpath('.//a[contains(@class, "title")]/text()').get() item["author"] = rank.xpath('.//a[contains(@target, "_blank")]/text()').get() item["score"] = rank.xpath('.//div[contains(@class, "pts")]/text()').get() item["view"] = rank.xpath('.//div[contains(@class, "detail-state")]/span[1]/text()').get() detail_url = rank.xpath('.//a[contains(@class, "title")]/@href').get() yield scrapy.Request( url=response.urljoin(detail_url), callback=self.parse_detail, cb_kwargs={"item": item}, ) def parse_detail(self, response, item): item["like"] = response.xpath('//*[contains(@class, "like")]/text()').get() item["coin"] = response.xpath('//*[contains(@class, "coin")]/text()').get() item["favorite"] = response.xpath('//*[contains(@class, "favorite")]/text()').get() item["tag_name"] = response.xpath('//*[contains(@class, "tag")]/text()').getall() yield item代码逻辑:parse 先解析榜单页的 rank-item 节点,把榜单来源、题目、作者、得分、播放量填进同一个 Item 实例;解析到视频详情链接后,用 scrapy.Request 把 item 通过 cb_kwargs 传给 parse_detail;第二个回调补齐三连和标签字段后再 yield 一条完整记录。这样即使详情页解析失败,概览字段也还在队列里,不会整条丢失。
参数说明:start_urls 设的是全站排行榜路径,想抓分区榜就把路径改成对应分区前缀;cb_kwargs 是 Scrapy 跨回调传数据的标准方式,比手动塞 meta dict 更不容易写错。tag_name 用 getall() 拿列表,后面 Pandas 处理时拆分成多个标签,正好对应原论文里的热门标签统计。
2.2 定义 Item:在原表 13 列之外多留一个 rank_tab
原始数据表有 13 列:作者、投币数、弹幕数、三连数、作品 id、点赞数、类别、回复数、得分、分享数、观看数、题目、标签。但第 3.3 节的预处理代码里用到了一个 rank_tab 字段,用来标记榜单来源,说明抓取时实际还存了这一列。所以 Item 定义时应该在 13 列的基础上,额外加一个 rank_tab 字段,共 14 列:
import scrapy class BilibiliItem(scrapy.Item): author = scrapy.Field() # 作者 coin = scrapy.Field() # 投币数 danmaku = scrapy.Field() # 弹幕数 favorite = scrapy.Field() # 收藏数 video_id = scrapy.Field() # 作品 id like = scrapy.Field() # 点赞数 category = scrapy.Field() # 类别/分区 reply = scrapy.Field() # 回复数 score = scrapy.Field() # 综合得分 share = scrapy.Field() # 分享数 view = scrapy.Field() # 观看数 title = scrapy.Field() # 题目 tag_name = scrapy.Field() # 标签 rank_tab = scrapy.Field() # 榜单来源,用于去重字段键名建议统一用英文,原因是后面 Pandas 读 CSV、pyecharts 画图、DataFrame 切片都要引用列名,中文列名在换 IDE 或终端环境后容易出现编码不一致的问题。如果你想在图表中显示中文列名,可以在生成图表时做一次映射,数据层和展示层各管各的。
2.3 调度与请求头:延迟、UA、Referer 怎么设
同一 IP 短时间请求次数过多,B 站会返回 412 或弹出验证码。这个问题见过太多次,绝不是网络玄学,是并发太激进。settings.py 里建议这样配:
DOWNLOAD_DELAY = 2 USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" DEFAULT_REQUEST_HEADERS = { "Referer": "https://www.bilibili.com/", "Accept-Language": "zh-CN,zh;q=0.9", } RETRY_ENABLED = True RETRY_TIMES = 3DOWNLOAD_DELAY=2 表示每个请求间隔 2 秒。1300 条数据里大约三成要二次请求详情页,全站榜单一轮下来大约 40~60 分钟,能接受。Referer 设成 B 站首页,是爬虫处理里的常规操作,很多站点会校验 Referer,少了它容易被拒。RETRY_TIMES=3 负责处理偶发的超时和 5xx,Scrapy 会自动换请求重试。
提示:DOWNLOAD_DELAY 不要设成 0。爬得越快,封得越快,这个项目的数据量并不大,稳稳跑完比跑得快重要。
2.4 落盘与初步清洗:从响应到可读的 CSV
爬虫跑完后用scrapy crawl rank -o results.csv导出一份原始 CSV。原论文展示的示意数据里,观看数有的带“万”字,得分是字符串数字,三连字段可能因为详情页请求失败而空缺。这些留给预处理环节解决。但有一个动作建议在管道里做:从详情页 URL 里抽出作品 id,省得后面还要回源查。
class SavePipeline: def process_item(self, item, spider): if item.get("video_id"): item["video_id"] = item["video_id"].split("/")[-1].split("?")[0] return item这个管道只做了一件事:把 video_id 里的路径和查询参数剥掉。逻辑说明:详情页 URL 通常长这样,/video/BV1xx411c7mD?spm_id_from=xxx,按“/”切分取最后一段,再按“?”切分取前一段,得到纯 ID。参数说明:如果某条数据没有 video_id,用 if 判断跳过,避免 None.split 报错。管道记得在 settings.py 的 ITEM_PIPELINES 里注册,否则 Scrapy 不会执行它。
3. 用 Pandas 预处理脏数据:去重、补缺与标签拆分
抓下来的数据不能直接画图。原论文 3.3 节提到两个关键问题:数据里有缺失值;rank_tab 里包含“全站”榜单的数据要和分区数据分开。另外数值列可能有“万”结尾的字符串,不换算就画图,柱状图会把 1.2 万当成 1.2 来算,差四个数量级,属于典型翻车点。
3.1 排除全站榜:避免同一个视频被重复统计
原论文唯一给出的具体代码如下:
df_without_all = df[~df['rank_tab'].isin(['全站'])]这行代码的意思是:取 rank_tab 列,用 isin(['全站']) 判断哪些行属于全站榜,~ 取反,最后选出不属于全站榜的所有行。逻辑说明:全站榜的视频往往也会出现在各分区榜里,不去掉就会导致一个视频在分区统计中被计算两次,分区占比直接失真。参数说明:['全站'] 是排除名单,如果你后续还要排除其他榜单类型,往这个列表里加就行,isin 天然支持多值匹配。
3.2 缺失值处理与数值换算:含“万”的字符串要单独处理
用df.isnull().sum()先看一眼哪些列缺数据。三连字段缺失,通常是详情页请求失败导致的。处理原则很简单:核心字段缺失的行直接删掉,非核心字段缺失的可以保留。否则 pandas 的 mean() 会静默跳过 NaN,你看到的平均值到底基于多少个样本,心里没底。
import pandas as pd df = pd.read_csv("results.csv", encoding="utf-8") print(df.shape) print(df.isnull().sum()) df = df.dropna(subset=["view", "like", "coin", "favorite"]) df = df.reset_index(drop=True) print(df.shape) def parse_count(s): if pd.isna(s): return 0.0 if isinstance(s, str) and "万" in s: return float(s.replace("万", "")) * 10000 return float(s) for col in ["view", "like", "coin", "favorite", "reply", "share"]: df[col] = df[col].map(parse_count)逻辑说明:先 dropna 删掉三连和播放量缺失的行,reset_index 让索引重排,否则后面按行操作会带洞。parse_count 函数处理两种输入:纯数字字符串直接转 float,带“万”的字符串先去掉“万”再乘 10000。map 对整列批量应用。参数说明:缺失值补 0 只用于像回复数这类不影响结论的列,view 列缺失绝对不能用 0 填充,0 播放和缺失播放是完全不同的语义,填 0 会把平均值往下拉得很惨。
注意:处理完数值列后,跑一遍
df.dtypes确认 view、like 这些列都是 float 类型。如果还是 object,说明 parse_count 没生效,后面所有聚合结果都会是错的。
3.3 标签列拆分:从逗号串到前 20 个热门标签
原论文里写了一种循环遍历 tag_name 的 One-Hot 方法,能跑通,但代码量大。用 pandas 的 explode 方法,两行就能拿到同样结果:
df_tag = df_without_all.copy() df_tag["tag_list"] = df_tag["tag_name"].str.split(",") tag_exploded = df_tag.explode("tag_list") tag_counts = tag_exploded["tag_list"].str.strip().value_counts().head(20) print(tag_counts)逻辑说明:str.split(",") 把每个视频的标签串拆成列表;explode 把列表元素展开成多行,一个视频有几个标签就变几行;value_counts() 统计每个标签出现次数,最后 head(20) 取前 20 个热门标签。这个写法与原论文“建 DataFrame 逐列赋 1”的方法结果一致,但更短、更快、也更容易读懂。参数说明:str.strip() 用来去掉标签首尾可能存在的空格,不处理的话,“搞笑 ”和“搞笑”会被当成两个标签,统计数直接分散,属于隐蔽的数据错误。
注意:explode 后的数据是“一标签一行”,后面做其他聚合时,记得用回 df_without_all,不要用 tag_exploded,否则每个标签都会把视频计数一次,数值全部虚高。
4. 可视化落地:五张图的聚合、取值与导出细节
原论文第四章做了五张图:分区占比、平均播放量、平均三连、各区平均播放、热门标签。大部分图表直接用 pyecharts 就能生成,真正需要留意的步骤是把 HTML 导出成 PNG 图片的环节,这个放在第五章避坑里详细说。下面讲每张图的聚合逻辑和实现要点。
4.1 分区占比:综合评分前 100 名的切片
先对 df_without_all 按 score 降序排序,取前 100 条,统计 category 出现次数,画饼图:
df_top100 = df_without_all.sort_values("score", ascending=False).head(100) category_counts = df_top100["category"].value_counts() data_pair = [list(z) for z in zip(category_counts.index, category_counts.values)] from pyecharts.charts import Pie from pyecharts import options as opts pie = ( Pie() .add("", data_pair, radius=["40%", "70%"]) .set_global_opts(title_opts=opts.TitleOpts(title="分区占比")) .set_series_opts(label_opts=opts.LabelOpts(formatter="{b}: {d}%")) ) pie.render("分区占比.html")逻辑说明:sort_values 按综合得分降序,head(100) 取前 100 条,value_counts() 统计各分区出现次数,data_pair 转成 pyecharts 需要的 [名称, 数值] 对。radius 设成 ["40%", "70%"] 画出环形饼图,视觉上比实心饼图更容易比较占比。label_opts 里的 {b} 是分区名,{d} 是百分比。参数说明:如果想看整体分布而不是 top100,把 head(100) 去掉即可,但要注意全站榜去重必须在前,否则同一视频重复计入。
4.2 平均播放量:柱状图先聚合再排序
avg_view = df_top100.groupby("category")["view"].mean().sort_values(ascending=False) from pyecharts.charts import Bar bar = ( Bar() .add_xaxis(avg_view.index.tolist()) .add_yaxis("平均播放量", avg_view.values.tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="top100 平均播放量"), xaxis_opts=opts.AxisOpts(axislabel_opts=opts.LabelOpts(rotate=30)), ) ) bar.render("平均播放量.html")逻辑说明:groupby("category") 按分区分组,["view"] 取出播放量列,mean() 求平均,sort_values 降序排列,这样柱状图从左到右天然是递减的,视觉上不用读坐标就知道谁高谁低。参数说明:rotate=30 是让分区名斜着显示,分区一多名字挤在一起是柱状图最常见的观感问题,这个参数几乎每次都要调。
提示:pyecharts 的 render 默认生成 HTML 文件,放到本地浏览器直接打开即可。如果需要 PNG,再看第五章的导出避坑。
4.3 三连情况:一张雷达图装下赞、币、藏
原论文把点赞、投币、收藏分成了三张雷达图,分开看有个问题:不同分区在三个维度上的相对关系很难一眼对比。合并成一张雷达图更直接:
from pyecharts.charts import Radar from pyecharts import options as opts radar_data = [ {"zone": "动画", "like": 82000, "coin": 46000, "fav": 31000}, {"zone": "生活", "like": 91000, "coin": 52000, "fav": 38000}, {"zone": "影视", "like": 87000, "coin": 39000, "fav": 42000}, ] radar = ( Radar() .add_schema(schema=[ opts.RadarIndicatorItem(name="点赞", max_=100000), opts.RadarIndicatorItem(name="投币", max_=60000), opts.RadarIndicatorItem(name="收藏", max_=60000), ]) .add("动画", [[radar_data[0]["like"], radar_data[0]["coin"], radar_data[0]["fav"]]]) .add("生活", [[radar_data[1]["like"], radar_data[1]["coin"], radar_data[1]["fav"]]]) .add("影视", [[radar_data[2]["like"], radar_data[2]["coin"], radar_data[2]["fav"]]]) ) radar.render("三连雷达.html")这里的数据是示意用的,实际要从 DataFrame 按分区聚合出来。逻辑说明:add_schema 定义三个雷达轴,每个轴设最大值,max_ 要根据实际数据量级调整,否则小数值会被压到圆心附近,图直接变“花骨朵”。参数说明:radar_data 换成你自己 groupby 出来的真实值,注意三个 add 的顺序决定图中多边形的堆叠顺序,先画的会被后画的盖住,所以把数值大的分区放后面。
4.4 各区平均播放与热门标签:折线图加词云
各区平均播放量在原论文里存了一遍 CSV 又读出来画图,其实 groupby 一次就够:
avg_zone = df_without_all.groupby("category")["view"].mean().sort_values(ascending=False) from pyecharts.charts import Line line = ( Line() .add_xaxis(avg_zone.index.tolist()) .add_yaxis("平均播放量", avg_zone.values.tolist()) .set_global_opts(title_opts=opts.TitleOpts(title="各区平均播放量")) ) line.render("各区平均播放.html")热门标签用词云展示,输入是上一章算好的 tag_counts:
from pyecharts.charts import WordCloud wc = ( WordCloud() .add("", tag_counts.items(), word_size_range=[20, 100]) .render("热门标签.html") )逻辑说明:WordCloud 的 .add 接收一个 (词, 词频) 的可迭代对象,tag_counts.items() 正好满足。word_size_range 控制显示尺寸下限和上限,数值越大词越突出。参数说明:如果想看更长尾的标签,把 head(20) 改成 head(50),词云形状也会更密,但图表的可读性会下降。
5. 常见问题与避坑清单:反爬、乱码与图表空白
做这类项目最耽误时间的不是抓不到数据,而是抓一半数据出各种状况。下面这五条是我在复现类似项目时踩过或见别人踩过的坑,按“现象、原因、解决”写清楚。
5.1 爬到一半开始返回 412,甚至出现验证码
现象:爬虫前几百条数据正常,跑到三四百条时,响应里全是 412 状态码,或者返回的 HTML 出现安全验证页面。
原因:同一 IP 请求太密集。B 站的频控不会在一开始就触发,它是对一段时间内的请求总数做统计,所以前面正常不代表后面安全。
解决:DOWNLOAD_DELAY 至少设 2 秒,再把并发降到 8 以下,CONCURRENT_REQUESTS = 4 更稳。如果目标数据量大,不要只依赖延迟,把榜单 URL 拆成多段,分时段运行。
5.2 XPath 提取突然拿不到内容
现象:昨天还能跑的 Spider,今天 parse 里 get() 返回 None,字段全空。
原因:B 站前端页面的 DOM 结构会随版本迭代变化,类名和标签结构都可能改版。
解决:写爬虫时不要只写 XPath 就跑,加一个检查逻辑,比如对返回结果做断言:
if item.get("title") is None: spider.logger.error("title 提取失败,请重新检查页面结构") return这样页面改版后能第一时间在日志里发现,而不是等数据落盘后才发现一堆空数据。更省事的方式是:优先用 B 站开放接口或官方榜单接口,页面 DOM 变了接口结构基本不动。
5.3 pyecharts 导出 PNG 时黑屏或空白
现象:render() 生成的 html 用浏览器打开正常,但 snapshot_selenium 导出 png 时,图片是全黑或全白。
原因:snapshot_selenium 依赖浏览器驱动和 Chrome 环境,需要配合无头模式使用;另外图表在 html 里是异步渲染的,截图时图表还没完成绘制,截到的是空白 canvas。
解决:用 snapshot_selenium 前先确认两点。第一,本机装了对应版本的 ChromeDriver,并且 selenium 能正常启动 Chrome。第二,给截图留出渲染等待时间。pyecharts 提供的 snapshot_selenium 封装里已经带了等待逻辑,但如果你自己用 selenium 截图,务必用 WebDriverWait 等待 canvas 元素出现后再截图。如果只想快速出图,也可以不转 PNG,直接输出 HTML 放进 Jupyter Notebook 里看,这是最省事的方式。
5.4 CSV 用 Excel 打开时中文乱码
现象:文件在 VS Code 里看正常,用 Excel 打开后中文全是乱码。
原因:Scrapy 的 -o results.csv 默认写入编码通常是 UTF-8,Excel 在 Windows 环境下默认用 GBK 打开无 BOM 的 UTF-8 文件,于是中文列名和中文内容全乱。
解决:写 CSV 时指定 UTF-8 带 BOM,或者落盘后用 pandas 重新保存一次:
df.to_csv("bilibili_data_clean.csv", index=False, encoding="utf-8-sig")utf-8-sig 是带 BOM 的 UTF-8,Excel 能正确识别。落盘时统一用 utf-8-sig,交付给别人时就不用解释乱码问题。
5.5 “万”结尾的数值被当成普通字符串,平均值计算离谱
现象:画平均播放量柱状图,数值比实际小 4 个数量级,或者排序完全不对。
原因:原始数据里的观看数是“1.2万”这种字符串,没有做换算,直接 astype(float) 时抛 ValueError,或者用 replace 把“万”替换成空字符串后当成 1.2 参与计算,排序自然乱套。
解决:在预处理阶段用 parse_count 统一处理,这一步必须放在所有数值聚合之前。我一般会在读取 CSV 后立刻做类型转换,并且跑一遍 df.dtypes 确认 view、like 这些列都是 float 类型,而不是 object。
6. 验证分析结果:抽样核对与造数回归
图表画完,论文写到最后,最容易产生的错觉是“图能出来就说明结果没问题”。图的正确性要单独验证,最低成本的做法有两个:抽样去 B 站原页面人工核对,以及造数回归验证聚合逻辑。
6.1 抽样核对:拿原始页面数据对比分析结论
先抽出 5 条记录,打开 B 站原视频页面,核对播放量、点赞、投币、收藏是否和 CSV 一致。这一步不是可有可无——XPath 选择器完全可能因为页面结构调整把两列数据抓反,比如把播放量填进点赞列。
sample = df.sample(5, random_state=42) print(sample[["title", "author", "view", "like", "coin", "favorite"]])把这 5 个标题复制到 B 站搜索框,点进详情页逐一对比。随机种子 42 让抽样结果可复现,方便和团队讨论。
6.2 造数回归:用 3 行假数据验证聚合逻辑
抽样核对验证的是“数据对不对”,造数回归验证的是“聚合逻辑对不对”。很多聚合错误是逻辑导致的,比如 groupby 后忘了 reset_index、explode 后统计错了对象。用 3 行已知结果的小数据集就能试出来。
test_df = pd.DataFrame({ "category": ["生活", "动画", "生活"], "view": [1000, 2000, 3000], }) print(test_df.groupby("category")["view"].mean())预期生活区 = (1000+3000)/2 = 2000,动画区 = 2000。如果结果不符,说明 groupby 或 mean 调用有误,比对着 1300 行真实数据找错快得多。
从那以后,我每完成一套数据分析流程,都会强制走一遍“抽样核对 → 造数回归”这两步,哪怕只花二十分钟,也能把写代码时根本没注意到的隐蔽错误揪出来。希望这套验证习惯,能帮你在自己的 B 站热度分析项目里少走几个弯路。
本文还有配套的精品资源,点击获取