简介:一份基于 Python 的“哔哩哔哩视频网”视频热度分析文档,面向对爬虫、数据分析与可视化感兴趣的 Python 学习者,以及想了解 B 站用户内容偏好与热度规律的产品、运营研究人员。文档完整介绍了从 Scrapy 框架抓取视频标题、播放量、热度等数据,到 Pandas 进行清洗、转换与过滤,再到 Pyecharts 输出分区占比、平均播放量、平均三连、各区平均播放及热门标签的可视化分析全过程。压缩包仅含 1 个 doc 文档,大小 1.86MB,内容包含中英文摘要、目录、技术介绍、数据抓取结果、可视化图表与总结展望,结构清晰,适合作为课程设计或论文写作的参考模板。已有 517 人学习下载。通过这份文档,既能掌握 Scrapy+Pandas+Pyecharts 的完整数据分析流程,也能直接借鉴其研究框架和图表展示方式;同时其中关于页面分析、字段抽取与可视化参数配置的细节,可帮助学习者快速搭建自己的视频热度分析项目。
1. 视频热度的答案不在榜单页:这套分析流水线给你完整链路
B 站排行榜页面只暴露题目、作者、观看和综合得分,真正能说明“用户为什么喜欢”的三连、分享、标签和分区数据,全部藏在视频详情页里。把这两层数据拼起来,再落到分区占比、平均播放量、三连偏好和热门标签四个维度上,才能回答“15-45 岁用户到底在为什么内容停留”这个问题。本文这套基于 Python 的 B 站视频热度分析方案,完整覆盖 Scrapy 数据抓取、Pandas 预处理、Pyecharts 可视化三个阶段,适合想系统做一次内容生态分析的产品、运营和数据新人。整条链路跑完只需要一个排行榜入口 URL,1300 行 13 列的数据就能支撑起这份报告。
2. Scrapy 数据抓取:排行榜页与详情页的字段映射设计
2.1 为什么选 Scrapy 而不是 requests + BeautifulSoup
单页抓取用 requests 完全够用,但一旦涉及“列表页进详情页”的两级页面跳转,requests 方案需要自己维护待抓取队列、去重集合、失败重试和请求频率控制,这些逻辑写到最后通常比业务代码还长。Scrapy 在框架层把这些问题都收编了:引擎负责调度、下载器负责并发请求、爬虫类只关心解析规则、管道负责数据落地。这个分层结构恰好匹配 B 站热度分析的两个抓取阶段——先抓排行榜页拿到视频 ID 和基础数据,再构造详情页 URL 补齐三连、分享和标签。
2.2 页面结构拆解:列表页拿 ID,详情页补三连
B 站排行榜页(/v/popular/rank/all)的 HTML 结构里,每个视频条目是一个li节点,其中包含视频标题、作者、观看量、评论数和综合得分。但播放页更关键的数据——点赞、投币、收藏、分享、标签——不在这个列表页上。
常见做法是先从列表页提取视频aid,拼出详情页 URLhttps://www.bilibili.com/video/aid,再从详情页的div标签里用 XPath 提取。
# items.py import scrapy class BiliHotItem(scrapy.Item): rank_tab = scrapy.Field() # 榜单分类:全站/动画/游戏/生活... video_id = scrapy.Field() # 视频 aid title = scrapy.Field() # 视频标题 author = scrapy.Field() # UP 主名称 category = scrapy.Field() # 视频分区 score = scrapy.Field() # 排行榜综合得分 view = scrapy.Field() # 观看数 danmaku = scrapy.Field() # 弹幕数 reply = scrapy.Field() # 回复数 like = scrapy.Field() # 点赞 coin = scrapy.Field() # 投币 favorite = scrapy.Field() # 收藏 share = scrapy.Field() # 分享 tag_name = scrapy.Field() # 视频标签,逗号分隔字段设计的原则只有一个:把列表页和详情页的字段分开建模,但合并到同一个 Item 中。这样管道写起来统一,后面 Pandas 读入时可以直接拿到完整的二维表。
2.3 爬虫主体:请求队列与 XPath 抽取逻辑
# spiders/bilibili_rank.py import scrapy from bilibili_hot.items import BiliHotItem class BilibiliRankSpider(scrapy.Spider): name = "bili_rank" def start_requests(self): tabs = ["all", "animation", "game", "life", "music"] for tab in tabs: url = f"https://www.bilibili.com/v/popular/rank/{tab}" yield scrapy.Request(url, callback=self.parse_list, meta={"tab": tab}) def parse_list(self, response): tab = response.meta["tab"] rank_items = response.xpath('//*[@id="app"]/div/div[2]/div[2]/ul/li') for item in rank_items: hot_item = BiliHotItem() hot_item["rank_tab"] = tab hot_item["video_id"] = item.xpath("./a/@href").re_first(r"(BV\w+)") hot_item["title"] = item.xpath(".//a[@class='title']/text()").get() hot_item["author"] = item.xpath(".//span[@class='data-box']/a/text()").get() hot_item["score"] = item.xpath(".//div[@class='pts']/div/text()").get() # 进入详情页继续取三连和标签 detail_url = f"https://www.bilibili.com/video/{hot_item['video_id']}" yield scrapy.Request(detail_url, callback=self.parse_detail, meta={"item": hot_item}) def parse_detail(self, response): hot_item = response.meta["item"] hot_item["view"] = response.xpath('//*[@class="view"]/text()').get() hot_item["danmaku"] = response.xpath('//*[@class="danmaku"]/text()').get() hot_item["like"] = response.xpath('//*[@class="like"]//text()').get() hot_item["coin"] = response.xpath('//*[@class="coin"]//text()').get() hot_item["favorite"] = response.xpath('//*[@class="favorite"]//text()').get() hot_item["share"] = response.xpath('//*[@class="share"]//text()').get() hot_item["tag_name"] = response.xpath('//*[@class="tag-list"]//a/text()').getall() yield hot_item这段代码把数据抓取拆成parse_list和parse_detail两个回调,充分利用了 Scrapy 的异步调度。yield scrapy.Request会把详情页请求交给引擎,引擎再调度给下载器,期间不会阻塞列表页的解析。有一点要注意:详情页里阅读数、弹幕这些字段有的带了“万”这样的单位后缀,Pandas 清洗时要统一处理,这一层先原样保留。
meta参数用于在详情页回调里拿到列表页已经解析好的 Item 数据,这是两级页面抓取最常用的传参方式。re_first(r"(BV\w+)")是 B 站链接里的视频编号,2019 年之后 B 站把 avid 全面切换成了 BV 号,这个正则能直接从href属性中抽出 BV 号用于拼详情页。
2.4 settings.py 中的并发与反爬参数
抓取 B 站这类平台,频率控制比 UA 伪装更重要。常见做法是控制延迟和并发,不要用默认的高并发参数:
# settings.py ROBOTSTXT_OBEY = False CONCURRENT_REQUESTS = 16 # 并发请求数,B站建议不超过 32 DOWNLOAD_DELAY = 1.2 # 请求间隔秒数,太密容易触发验证 COOKIES_ENABLED = True # 开启 cookies 保持会话状态 DEFAULT_REQUEST_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.bilibili.com/", "Accept-Language": "zh-CN,zh;q=0.9", }DOWNLOAD_DELAY是每次请求之间等待的时间,设为 1-2 秒对这个体量的抓取(约 1300 条数据)只需要几分钟,没必要冒更大风险。CONCURRENT_REQUESTS控制同时发出的请求数量,B 站的反爬对高频请求比较敏感,16 是个安全线。COOKIES_ENABLED开启后,Scrapy 会保留登录态,实测能降低详情页请求被拦截的概率。
抓完的数据落到管道里,建议直接用csv导出中间结果:
scrapy crawl bili_rank -o bili_raw.csv这样即使后续分析出错,也不需要重新跑一遍网络请求。
3. Pandas 数据预处理:重复、单位、标签三个坑一次填平
3.1 去掉全站榜重复条目
抓回来的数据中,rank_tab同时包含全站榜单和分区榜单。全站榜里的视频本身就来自各分区头部,如果不剔除,统计分区占比时会出现同一视频被重复计数的偏差。
import pandas as pd df = pd.read_csv("bili_raw.csv") print(df.shape) # (1300, 13) 原始规模 print(df["rank_tab"].value_counts()) # 检查各榜单数据量 df_without_all = df[~df["rank_tab"].isin(["全站"])] print(df_without_all.shape) # 剔除后规模这段代码用~df["rank_tab"].isin(["全站"])做布尔索引取反,把全站榜行整体过滤掉。isin接受一个列表,便于后续扩展过滤“全区榜”之类的其他榜单类型。实际操作时先跑一次value_counts()确认各榜单条数,防止因为榜单页改版导致某类数据缺失。
3.2 数值列中的“万”与空值处理
抓取阶段保留了页面上的原始文本,B 站的数据常见“1.2万”“3245”两种格式,还有“--”表示无效值。Pandas 的to_numeric遇到这些格式会直接转成 NaN,所以要在转换前做一次清洗:
def convert_count(value): if isinstance(value, str): value = value.replace(",", "") if "万" in value: return float(value.replace("万", "")) * 10000 if value in ("--", "", "None", "nan"): return None return pd.to_numeric(value, errors="coerce") num_cols = ["view", "danmaku", "like", "coin", "favorite", "share", "reply", "score"] for col in num_cols: df_without_all[col] = df_without_all[col].map(convert_count)errors="coerce"是 Pandas 最常用的类型容错策略,转不了的自动变成 NaN,后续统一用fillna(0)或者dropna()决策。对热度分析来说,三连和播放量缺失值用 0 填充是安全的,因为没拿到数据不等于用户没互动,但确实没有更好的估计方法时 0 是唯一不引入主观偏差的选择。
3.3 标签列拆分与出现次数统计
tag_name字段在抓取时是逗号分隔的字符串,一个视频可能有 3-5 个标签。做热门标签可视化前,需要把长字符串拆成多行:
df_tags = df_without_all.assign(tag=df_without_all["tag_name"].str.split(",")).explode("tag") df_tags["tag"] = df_tags["tag"].str.strip() tag_counts = df_tags["tag"].value_counts().head(20) print(tag_counts)assign+str.split+explode是 Pandas 处理“一列多值”的标准三步走。explode把列表中的每个元素拆成一行,同时复制其他所有字段。这样每个标签一行,value_counts()就直接得到出现次数。注意先strip()再统计,否则抓取时标签前后的空格会让同一个标签被计成两个。
如果不用explode,原论文里也描述过另一种做法:创建tag_df宽表,遍历tag_name给对应列赋值 1,再求和。两种方式都能出结果,但explode在内存和代码量上都更优,尤其是标签总量超过两百个的场景,宽表会生成大量零值列拖慢计算。
3.4 预处理后的数据集结构
| 字段 | 类型 | 用途 |
|---|---|---|
| rank_tab | str | 来源榜单,区分分区 |
| video_id | str | 去重主键 |
| category | str | 视频分区名 |
| view / danmaku | float | 播放与弹幕,衡量覆盖广度 |
| like / coin / favorite | float | 三连数据,衡量互动质量 |
| share | float | 分享,衡量传播意愿 |
| tag_name | str | 标签拆分前的原始串 |
清洗完成后,df_without_all可以导出为 csv 供可视化直接使用。这里有一个容易踩的细节:view如果是从详情页抓的数值,和排行榜页展示的数据可能有细微延时差,分析时统一以详情页为准即可。
4. Pyecharts 可视化:四种图表的配置要点与 PNG 输出
4.1 整体结构:从 Pandas 到图表实例
Pyecharts 的通用写法是:(Pie() .add(...) .set_global_opts(...)),链式调用把所有配置串起来,最后.render()输出 HTML。下面的代码覆盖分区占比、平均播放量、三连雷达图三个核心分析目标。
from pyecharts.charts import Pie, Bar, Radar, Line from pyecharts import options as opts # 4.1.1 分区占比:综合评分 top100 的 category 分布 top100 = df_without_all.sort_values("score", ascending=False).head(100) cat_stats = top100["category"].value_counts() pie = ( Pie() .add( series_name="分区占比", data_pair=[list(z) for z in zip(cat_stats.index, cat_stats.values)], radius=["30%", "70%"], # 环形饼图内外半径 ) .set_global_opts( title_opts=opts.TitleOpts(title="综合评分 Top100 分区占比"), legend_opts=opts.LegendOpts(orient="vertical", pos_top="15%"), ) .set_series_opts(label_opts=opts.LabelOpts(formatter="{b}: {d}%")) ) # 4.1.2 平均播放量:按分区聚合 play_mean = df_without_all.groupby("category")["view"].mean().sort_values(ascending=False) bar = ( Bar() .add_xaxis(play_mean.index.tolist()) .add_yaxis("平均播放量", [round(v, 0) for v in play_mean.values]) .set_global_opts( title_opts=opts.TitleOpts(title="各分区平均播放量"), yaxis_opts=opts.AxisOpts(name="播放量"), ) )radius=["30%", "70%"]让饼图变成环形,内半径 30% 外半径 70%,观感上比实心饼图清爽,而且中间留白可以放总样本量注释。formatter="{b}: {d}%"中{b}是类别名,{d}是百分比,这是 Pyecharts 内置的模板变量,不需要手动计算占比。groupby("category")["view"].mean()返回的是 Series,index.tolist()直接作为 X 轴类别,注意需要先按值排序再取tolist(),否则柱状图顺序是乱的。
4.2 三连情况的雷达图参数设计
三连分析要回答“哪些分区获得了用户的高质量互动”,标准做法是分别计算 top100 内各分区的点赞、投币、收藏均值,再画三张雷达图:
def build_radar(data_series, title): categories = data_series.index.tolist() values = [[round(v, 0)] for v in data_series.values] radar = ( Radar() .add_schema( schema=[opts.RadarIndicatorItem(name=str(cat), max_=float(data_series.max() * 1.2)) for cat in categories], splitarea_opt=opts.SplitAreaOpts(is_show=True), ) .add(series_name=title, data=values, color="#ff6b81") .set_global_opts(title_opts=opts.TitleOpts(title=title)) ) return radar like_radar = build_radar(top100.groupby("category")["like"].mean(), "点赞均值") coin_radar = build_radar(top100.groupby("category")["coin"].mean(), "投币均值") fav_radar = build_radar(top100.groupby("category")["favorite"].mean(), "收藏均值")雷达图的max_是每个维度坐标轴的最大值,这里用data_series.max() * 1.2留出 20% 余量,避免最大值顶点贴边影响读数。另一个细节是data必须是二维数组,即使每个分区只有一个数值,也要包成[[v]]的形式,否则 Pyecharts 会报维度错误。
实际跑下来能看到明显的差异:影视区的点赞均值最高,生活区的投币量最高,时尚区的收藏量最高。这说明不同分区用户的互动行为存在结构性差异,收藏偏多意味着内容具有“工具属性”,投币偏多则说明用户愿意用自己的“硬币”为创作者背书。
4.3 snapshot_selenium 把 HTML 转成 PNG
Pyecharts 默认输出 HTML,报告里贴图不方便。snapshot_selenium是官方配套的截图方案,原理是启动浏览器渲染 HTML 后截屏保存:
from pyecharts.render import make_snapshot from snapshot_selenium import snapshot make_snapshot(snapshot, pie.render("pie.html"), "pie.png") make_snapshot(snapshot, bar.render("bar.html"), "bar.png") make_snapshot(snapshot, like_radar.render("like.html"), "like.png")第一次运行会自动检测本地 Chrome 环境。Windows 上如果报浏览器驱动相关错误,通常是 SELENIUM 的 WebDriver 和 Chrome 版本不匹配,把 Chrome 升级到最新版并安装对应版本的chromedriver即可解决。批量转换时建议每个图之间加 1-2 秒停顿,避免浏览器进程堆积导致内存占用过高。
4.4 各区平均播放与热门标签的补充视图
各区平均播放量适合用折线图展示整体趋势,热门标签则用横向柱状图:
# 各区平均播放 zone_mean = df_without_all.groupby("category")["view"].mean().sort_values(ascending=False) line = ( Line() .add_xaxis(zone_mean.index.tolist()) .add_yaxis("平均播放", [round(v, 0) for v in zone_mean.values], is_smooth=True) .set_global_opts(title_opts=opts.TitleOpts(title="各区平均播放量")) ) # 热门标签 top20 横向柱状图 bar_h = ( Bar() .add_xaxis(tag_counts.index.tolist()[::-1]) .add_yaxis("标签出现次数", tag_counts.values[::-1].tolist()) .reversal_axis() # 转为横向条形图 .set_series_opts(label_opts=opts.LabelOpts(position="right")) )reversal_axis()是横向条形图的关键方法,配合label_opts.position="right"把数值标签放在条形右侧,阅读顺序更符合“从上往下看排名”的直觉。热门标签的结果显示“搞笑”依然占据第一梯队,但生活类标签明显增多,这和分区占比中生活区第一的结论形成了交叉验证。
5. 榜单分析的三个边界:结果验证、切片偏差与时效性
最后收在几个容易忽略的细节上。
第一个边界是结果验证。拿到 top100 分区占比后,不要急着下结论,先把结果和 B 站官方“每周必看”或热门榜做一个交叉比对。做法很简单:从报告中随机抽取 10 个视频,手动打开页面核对播放量、三连是否和报告一致。如果偏差超过 5%,优先检查清洗阶段是否漏掉了“万”字单位的转换;如果个别视频数据差一位数,多半是详情页请求被限流导致拿到了默认值。
第二个边界是切片偏差。原来的分析是“综合评分 top100”,但综合评分本身是 B 站未公开的加权函数,高评分不等于高播放。实际解读时要把分区占比、平均播放量、三连这三张图放在一起看:分区占比高说明头部内容多,平均播放量高说明整体流量大,三连高说明互动质量好,三者结论一致的话才能确认该分区确实是热度源。如果只看占比,喜剧类这种头部效应明显的分区会掩盖中部内容不足的问题。
第三个边界是时效性。B 站热榜小时级变化,抓下来的数据只是某个时间切面的快照。做分析时在文件命名里带上抓取时间,比如bili_raw_20250602_2000.csv,这样后续如果要做周环比或者月度趋势,还能拿旧快照重跑分析。爬虫脚本也可以加上Cron定时任务,每天固定两个时间点跑一次,数据积累两个月后就能做“用户兴趣迁移”这类更有深度的观察。
以上这套从 Scrapy 到 Pandas 再到 Pyecharts 的链路,完整跑一遍之后,得到的图表已经能支撑起一份可靠的视频热度分析报告。如果后续想扩大样本量,把列表页的分页参数p=2、p=3加进start_requests即可,商品逻辑不需要改动。
本文还有配套的精品资源,点击获取