简介:面向Python爬虫与数据分析初学者的B站动漫数据项目,以哔哩哔哩番剧为对象,演示从总榜爬取动漫列表、访问详情页抓取追番人数与评分,再到数据清洗、统计分析与可视化的完整流程,适合作为爬虫与数据分析方向的入门实战参考。压缩包共23个文件,体积约3.58MB,包含3个交互式笔记代码文件、2份表格数据、10张可视化图片、1份说明文档及少量配置类文件,覆盖爬取、预处理、分析三条主要环节,能直观看到不同步骤的产出。目前已有2978人学习下载。借助这套资料,读者可同时获得可运行代码、真实抓取数据和成图结果,结合文档中对请求解析、字段抽取、数据清洗、图表绘制等细节的介绍,能较快理解一类番剧数据项目的实现思路,节省自行搭建和排错的时间。
1. 先想清楚:B站动漫爬虫项目到底要抓哪些数据
想给B站动漫做数据分析,第一个能落地的选题就是爬番剧列表:标题、评分、播放量、追番人数、平台归属和动漫类型,一个周末能出结果。别被“爬虫”两个字吓住,这个项目的难点根本不在反爬,而在数据清洗和口径选择——B站接口返回的score是一个嵌套字典,play字段可能是字符串,评分0的番剧到底算“没开分”还是“真烂”,这些才是真正花时间的地方。适合三种人:第一次写完整爬虫的新手、想把数据分析技能用在真实场景的学生、想观察二次元内容走势的从业者。下面我把从请求到可视化的完整路径拆给你,照着跑,晚上就能看到第一张B站动漫数据图表。
2. 用requests+xpath抓B站动漫数据:请求、解析与翻页参数
2.1 为什么选requests+xpath而不是scrapy
说到爬虫,很多人的第一反应是scrapy,但遇到这个项目我一般会劝新手先别上框架。scrapy的学习曲线体现在项目结构、Item Pipeline、Middleware 这些概念上,而你要抓的不过是B站动漫索引这一个站点、几十个字段、几百条数据。用requests加xpath,三步就能跑通:发请求、拿响应、解析字段。等量级大到要天天跑、要防封、要分布式采集时,再换scrapy也不迟。
| 方案 | 适用场景 | 上手成本 | 主要坑点 |
|---|---|---|---|
| requests + xpath | 小批量、单站点、字段结构稳定 | 低 | 重试和频率得自己管 |
| scrapy | 大规模、多站点、需要长期维护 | 中 | 框架概念多,调试成本高 |
| playwright | 页面要执行JS才能渲染 | 高 | 内存占用大,运行慢 |
选择逻辑很简单:B站动漫索引页在服务端渲染了大部分列表内容,HTML里能直接拿到标题和链接;同时它的JSON接口也能返回格式化的字段。用requests就是把网络爬虫原理里的“请求—响应—解析”三步走完,xpath负责从HTML静态结构里把节点摘出来。接口变动时还能切到JSON方案兜底,两条路都不丢。
2.2 抓取热播番剧列表:请求接口的完整代码与参数说明
我先给出用JSON接口抓取番剧列表的完整写法。B站有一个半开放的番剧索引接口,返回的是结构清晰的JSON,比从HTML里用xpath抠文本要稳。第一次请求时先把响应打出来看结构,这步很重要,后面所有清洗逻辑都建立在字段名正确的前提上。
import requests from requests.adapters import HTTPAdapter import time session = requests.Session() session.mount('https://', HTTPAdapter(max_retries=2)) session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/122.0 Safari/537.36', 'Referer': 'https://www.bilibili.com/anime/', 'Origin': 'https://www.bilibili.com', }) API_URL = 'https://api.bilibili.com/pgc/season/index/result' params = { 'season_type': '1', # 1 代表番剧 'type': 'sort', # 排序请求 'sort_type': '2', # 2 按追番人数排序 'page': 1, 'pagesize': 20, # 每页返回条数 } resp = session.get(API_URL, params=params, timeout=10) resp.raise_for_status() data = resp.json() # 先看返回结构,确认字段名再写后续逻辑 print(data['code'], data['message']) print(data['data']['list'][0])这段代码里有三个地方新手容易忽略。第一,session.mount配合HTTPAdapter可以把超时重试交给底层处理,不用自己写try except套循环;第二,User-Agent和Referer是B站服务端识别请求来源的重要依据,少了Referer很容易拿到412或403响应;第三,params里的season_type和sort_type决定你抓的是“番剧”还是“电影”,是“按播放量排”还是“按追番数排”。先打印第一页数据,确认score是字典还是数字,确认platform存在与否,再往下写。
2.3 翻页、请求头与超时重试:稳定采集三件套
单页数据撑不起分析,翻页才是采集的主体。翻页时有个血泪经验:不要for page in range(1, 100)一口气跑完,控制请求频率不是玄学,是给服务端留响应时间,也是给自己留后退的余地。每请求一页sleep 0.5到1秒,看起来慢,实际上20页只需要几十秒,远比触发风控后换IP来得省事。
def fetch_anime_page(page: int): params['page'] = page resp = session.get(API_URL, params=params, timeout=10) resp.raise_for_status() payload = resp.json() if payload['code'] != 0: # 接口返回异常时,先停下来看 message,不要盲目重试 print(f'page {page} return code: {payload["code"]}, msg: {payload["message"]}') return [] items = payload['data']['list'] rows = [] for item in items: rows.append({ 'title': item.get('title'), 'score': item.get('score'), # 这里可能是 dict,后面再拆 'play': item.get('play'), 'follow': item.get('follow'), 'type_name': item.get('type_name'), 'platform': item.get('platform'), }) return rows all_rows = [] for page in range(1, 11): # 先抓前10页,200条 rows = fetch_anime_page(page) print(f'page {page}: got {len(rows)} rows') all_rows.extend(rows) time.sleep(1) # 请求间隔,控制频率代码逻辑不复杂,但有两个设计取舍值得讲。第一,fetch函数内部先判断payload['code'],code不等于0时直接返回空列表并打印message。B站索引接口在请求过密时会返回风控提示,把code打印出来能帮你区分“我方请求有问题”和“服务端拒绝服务”,避免在错误方向上加戏。第二,rows里统一用item.get()而不是item[],这样字段缺失时返回None而不是抛KeyError,后面pandas清洗会省很多事。如果你遇到接口完全不可用,另一个常见做法是用requests抓HTML再用lxml的etree和xpath解析列表节点,核心区别只是在解析层,请求层的session逻辑可以原样复用。
3. 数据清洗与存储:把JSON响应整理成能分析的DataFrame
3.1 原始字段里哪些能用、哪些要拆开用
抓回来的原始数据是列表套字典的结构,直接塞进DataFrame之前,先看清每个字段的“真实身份”。我在第一次跑这个项目时犯过一个典型错误:天真地以为score是一列数字,结果DataFrame里那一列全是字典对象,排序直接翻车。B站接口里的score实际是嵌套结构,包含score和user_count两个子字段,必须提前拆开。
| 原始字段 | 实际类型 | 分析用途 | 清洗动作 |
|---|---|---|---|
| title | str | 番剧名称,主键 | 去重、去空格 |
| play | int 或 str | 播放量 | 转数值,处理非数字字符 |
| follow | int | 追番人数 | 转数值 |
| score | dict 或 int | 评分和评分人数 | 拆出score子字段 |
| type_name | str | 动漫类型,如“搞笑”“战斗” | 分组聚合 |
| platform | str | 播出平台 | 统计平台分布 |
拆字段的操作其实在爬虫环节就能做。每一条item经过清洗后只保留分析需要的维度,不要贪多。尤其是platform这个字段,部分番剧是B站独播,部分是多平台联播,后面做类型分析时它是个很容易被忽略的维度。数据是黑匣子,你存进去什么,分析时就只能看到什么,提前把字段拆干净比事后补救便宜。
3.2 空值、字符串数字和评分0:pandas清洗三板斧
数据落地到pandas后,第一件事不是画图,是看df.info()和df.isna().sum()。这两个命令能告诉你哪些列有空值、哪些列类型不对。play和follow是数字,但如果接口在某些情况下返回了带逗号的字符串,排序时就会按字典序排,1万播放会被排在2千后面。
import pandas as pd df = pd.DataFrame(all_rows) print(df.info()) # 字符串数字统一转成数值,转不动的置为 NaN df['play'] = pd.to_numeric(df['play'], errors='coerce') df['follow'] = pd.to_numeric(df['follow'], errors='coerce') # score 是字典的,拆出真正分数;直接是数值的保留 def extract_score(value): if isinstance(value, dict): return value.get('score') return value df['score'] = df['score'].apply(extract_score) df['score'] = pd.to_numeric(df['score'], errors='coerce') # 去掉标题为空的记录,保留数据完整的行用于评分分析 df_dedup = df.drop_duplicates(subset=['title']) df_dedup = df_dedup.dropna(subset=['title', 'play']) print(df_dedup.head()) print(df_dedup.isna().sum())这段清洗有两个参数值得讲清楚。pd.to_numeric里的errors='coerce',作用是把无法解析的字符串置成NaN而不是抛异常,这是处理脏数据的标准姿势,你永远不知道接口里会出现什么奇怪的垃圾值。score的提取用isinstance判断类型,兼容“接口返回字典”和“已经优化成数值”两种情况,避免接口一变代码就报废。drop_duplicates和dropna的目的是保证后续排序、聚类的数据干净,标题去重按番剧名,播放量缺失的行直接丢弃,因为排行分析里缺播放量就没有意义。
3.3 存CSV还是SQLite:按你的分析目标选
清洗完的数据总要落盘。两种选择:CSV适合一次性的分析流程,打开即用,Excel也能看;SQLite适合你后面要反复查、要做条件筛选的场景。如果你只是写完这篇就交差,CSV就够了;如果你想在周末里反复调整分析逻辑,SQLite的体验会好很多,尤其数据量超过几千条时,SQL的聚合效率远超一遍遍跑pandas。
# 方案一:CSV,注意编码要用 utf-8-sig df_dedup.to_csv('bilibili_anime.csv', index=False, encoding='utf-8-sig') # 方案二:SQLite,方便后续 SQL 查询 import sqlite3 conn = sqlite3.connect('bilibili_anime.db') df_dedup.to_sql('anime', conn, if_exists='replace', index=False) conn.close()这两个参数是踩坑后才懂的:to_csv的encoding='utf-8-sig',专门解决Excel打开中文乱码的问题,稍后避坑章节会细说;to_sql的if_exists='replace',解决重复跑脚本时表已存在的问题,replace会直接重建表,适合分析阶段的反复迭代。至于列类型,pandas会自动推断,int列存成INTEGER,float列存成REAL,不需要手工建表,省一步是一步。
4. 数据分析:播放量、评分和类型分布怎么算
4.1 播放量TOP10与追番人数排行
数据干净了,分析才有底气。第一个必出的分析是播放量排行,找头部番剧。这里有个细节:nlargest比sort_values().head()更直接,它在底层做了优化,而且只对单列做排序,不会打乱其他列的顺序。
top10_play = df_dedup.nlargest(10, 'play')[['title', 'play', 'follow', 'score']].copy() print(top10_play.to_string(index=False)) top10_follow = df_dedup.nlargest(10, 'follow')[['title', 'follow', 'play', 'score']].copy() print(top10_follow.to_string(index=False))排行表出来后,第一个结论往往就藏在对比里。你会发现播放量最高的番剧不一定追番人数最多:有的番剧是“路人盘大”,点开看一眼但不追番;有的番剧是“粉丝粘性高”,追番人数远超播放量。这两个指标一对比,就能看出内容的出圈属性和粉丝属性。分析时不要只看绝对数,把play和follow都打印出来,再算一列“播放追番比”,这个比值是内容运营里很常见的健康度指标。
4.2 评分分布、类型结构与数据口径
评分是这个项目里最容易翻车的维度。我的做法是先把评分切成区间,看整体分布,再按动漫类型分组看均值。pd.cut的bins参数决定着分的颗粒度,B站番剧评分普遍偏高,8分以下并不多见,所以bins要按7.0到10.0的区间切,切5分以下没有实际区分度。
# 评分分箱,看分布 bins = [0, 6, 7, 8, 9, 10] labels = ['0-6', '6-7', '7-8', '8-9', '9-10'] df_dedup['score_bin'] = pd.cut(df_dedup['score'], bins=bins, labels=labels, right=False) score_dist = df_dedup['score_bin'].value_counts().sort_index() print(score_dist) # 按动漫类型统计播放量,groupby 后 agg 一次算三个指标 type_stats = ( df_dedup.groupby('type_name')['play'] .agg(['sum', 'mean', 'count']) .sort_values('sum', ascending=False) ) print(type_stats.head(10))这段分析有两条逻辑要说明。第一,pd.cut的right=False表示区间左闭右开,8会进到8-9而不是7-8,这个参数不设的话默认是右闭,边界数据会分到意外的桶里。第二,groupby之后用agg一次算总和、均值、条数,sum体现类型总盘,mean体现单部平均水平,count体现作品数量,三个数字并排看才能判断一个类型是“大而稀”还是“小而精”。至于波动大的分析结论,先确认你的数据口径是“当前累计播放量”还是“某时间段增量”——B站公开接口返回的是累计值,说“播放量”之前心里得有数。
4.3 把分析结论收敛成可视化指标清单
分析做得再深,最后都是为了输出。我一般会在动手画图前先列一张指标和图表类型的对照表,想清楚“谁要看什么”。
| 要回答的问题 | 核心指标 | 推荐图表 |
|---|---|---|
| 哪些番剧播放最猛 | play | 柱状图TOP10 |
| 追番粘性和播放量是否一致 | play/follow比 | 散点图 |
| 评分高的番剧集中在哪个类型 | score均值 + type_name | 箱线图或柱状图 |
| B站独播还是多平台联播占主流 | platform 占比 | 饼图 |
这张表的价值在于让你不盲目堆图表。数据分析的方法不是图越多越好,而是每个图都要对应一个决策或一个判断。播放量TOP10用柱状图,平台分布用饼图,评分分布用箱线图。下一步画图时就有明确目标了,尤其是做可视化大屏时,指标清单能帮你挡住“再加个图会显得很厉害”的冲动。
5. 避坑:B站爬虫里的403、字段变动和编码问题排查
5.1 请求被拒403:UA、Referer与请求频率
现象:前两页抓得好好的,第三页开始返回403,随后连第一页都打不开。
原因:请求频率太快,加上请求头里Referer缺失或错误。B站服务端会把“无Referer的Python请求”和“高频同IP请求”都识别为异常访问。
解决:给session配齐浏览器级的User-Agent和Referer,每页间隔加到1秒以上。请求节奏上做退避而不是硬碰硬,遇到403先停30秒再继续,比一直重试有效。另外一个常用做法是把页码顺序打乱,尤其抓几十页时,从中间页开始抓能降低连续翻页的行为特征。
5.2 xpath解析返回空列表:先看响应再排查
现象:写好的xpath表达式在浏览器里能定位到节点,代码里运行却一直返回空列表。
原因:B站的动漫索引页有些板块走接口后在客户端渲染,HTML源码里根本没有你要的节点;还有一种是class名带了动态变化的hash后缀,每次请求都不一样。
解决:遇到空列表,第一件事不是改xpath,而是打印resp.text前500个字符,看响应体里到底有什么。如果确定是接口渲染,直接换JSON接口方案;如果只是class动态变化,用xpath的contains函数模糊匹配,比如//p[contains(@class,'score')],而不是写死完整class名。
5.3 评分全是0或NaN:取字段与类型转换的坑
现象:清洗完的DataFrame里score列要么是0,要么是NaN,没有一个正常分数。
原因:接口返回的score是字典,直接当成数值读出来自然是错;另外部分未开分或评分人数太少的番剧,接口给的score就是0。二者混在一起,如果不处理,分析时会把“没评分”和“低分差评”混为一谈。
解决:在爬虫解析层就用isinstance判断,字典就取出score子字段,数值就原样保留。分析时用布尔掩码区分两类数据:df[df['score'] > 0]做评分分析,df[df['score'].isna()]单独看未开分作品占比。这个区分直接影响结论的准确性。
5.4 CSV用Excel打开乱码:编码选utf-8-sig
现象:CSV文件用Notepad看正常,用Excel打开中文全是乱码。
原因:Excel打开CSV时默认按GBK解码,而pandas默认用utf-8写入,编解码不一致就乱了。
解决:写入参数改成encoding='utf-8-sig'。utf-8-sig会在文件开头写入BOM标记,Excel就能自动识别为UTF-8编码。这个问题几乎每个入门者都会遇到,早改早省心。
5.5 翻页超过50页就没有数据:接口页数上限
现象:循环翻到第50页左右,返回的list为空,但code还是0,程序没有报错。
原因:B站索引接口对单次排序条件下能翻的页数有限制,超过深度后返回空列表;同时连续高频翻页也会触发限流。
解决:不要执着于“一页页翻到底”。要么增大pagesize到50,减少请求次数;要么换一个排序条件从另一个维度重新抓,比如先按追番人数抓前50页,再按播放量抓前50页。数据采集讲究用不同路径拼全景,单一路径是有边界的。
6. 用pyecharts做可视化落地:从单图到HTML看板
6.1 汇报场景用柱状图起手
pyecharts是ECharts的Python封装,画完直接生成HTML文件,浏览器就能打开,不需要搭建前端环境。对数据分析与可视化这个场景来说,这是最省事的落地方式。柱状图是汇报场景的默认起手式,因为排行信息的传达效率最高。
from pyecharts.charts import Bar from pyecharts import options as opts top10 = df_dedup.nlargest(10, 'play') bar = Bar() bar.add_xaxis(top10['title'].tolist()) bar.add_yaxis( '播放量', top10['play'].tolist(), label_opts=opts.LabelOpts(position='right'), ) bar.set_global_opts( title_opts=opts.TitleOpts(title='B站新番播放量TOP10'), yaxis_opts=opts.AxisOpts(name='播放量'), ) bar.set_series_opts(itemstyle_opts=opts.ItemStyleOpts(color='#fb7299')) bar.render('bilibili_anime_top10_play.html')有两点细节我是吃过亏后养成的习惯。add_xaxis和add_yaxis都接收list类型,pandas的Series要先tolist(),不然图表上会出现索引错位;set_global_opts里的yaxis_opts用来改坐标轴名称,不要省,后期看图的同事不会知道你脑子里想的是播放量。颜色改成B站主色 #fb7299,汇报时观感会好很多。
6.2 多图合成一个HTML看板
单张图只能回答问题的一个侧面,把柱状图、饼图、表格拼进同一个HTML文件,就形成了最简版可视化大屏。pyecharts的Page组件可以串起多个图表,配套的SimplePageLayout会自动做上下布局,不需要写HTML模板。
from pyecharts.charts import Bar, Pie, Page import pandas as pd platform_stats = df_dedup['platform'].value_counts() pie = Pie() pie.add_xaxis(platform_stats.index.tolist()) pie.add_yaxis('平台占比', platform_stats.values.tolist()) pie.set_global_opts(title_opts=opts.TitleOpts(title='播出平台分布')) page = Page(layout=Page.SimplePageLayout) page.add(bar, pie) page.render('bilibili_anime_dashboard.html')Page组件会把bar和pie两个图表对象依次渲染进一个HTML文档里,layout参数控制排列方式。做这种组合看板时,我的教训是图表数量控制在三到四个以内,指标清单里没有对应分析结论的图不要加。爬虫和数据清洗占掉了这个项目七成的时间,可视化的价值在于让分析结论一眼被看懂,而不是把图表堆成花架子。
第一次跑通后,你把pagesize改大、把season_type换成别的分类,整套流程可以原样复用到B站电影和纪录片板块。数据分析需要学哪些,最重要的不是工具数量,而是从抓取到出结论这整条链路你都亲手走过一遍,后面换任何数据源都只是微调参数的事。希望帮到你。
本文还有配套的精品资源,点击获取