简介:一份以哔哩哔哩番剧数据为案例的Python爬虫与数据分析可视化实战资源,面向希望通过项目上手爬虫和数据处理的初学者,也适合作为相关课程设计参考。项目从B站动漫总榜切入,先抓取番剧列表及直达链接,再访问详情页获取追番人数、观众评分等信息,形成历年番剧数据集,进而分析B站ACGN文化发展状况。压缩包共23个文件,包含3个Jupyter Notebook脚本、2个Excel数据集、10张PNG可视化结果图、XML工程配置及Markdown说明文档,整体仅3.58MB,目录清晰,按爬虫、数据预处理、分析等阶段组织。已有2981人学习参考。资源提供爬取代码、数据处理代码、分析代码以及处理前后的Excel数据,并有readme对爬虫细节和数据分析可视化流程进行详细介绍,可以帮助读者快速理解从请求解析、字段提取到数据清洗、绘图展示的完整思路,作为Python爬虫入门的参考资源非常合适。1. 这个Python项目要解决的并不是“爬”本身如果你只给一个requests.get()的结果做BeautifulSoup解析,最后打印几行数据,那算不上一个完整的爬虫项目。真正让爬虫项目产生复利的是数据链路:抓取、清洗、分析、可视化。这个以“B站动漫数据分析与可视化”为目标的Python项目,做的就是这件事:把B站动漫分区的番剧基础信息(评分、播放量、弹幕数、追番人数)抓下来,落成结构化表格,用pandas做统计分析,再用图表把结论呈现出来。适合已经写过多线程爬虫但没跑过完整数据处理闭环的开发者,也适合想用Python做作品集的初学者。2. 整体架构与技术选型:目录不是摆设,是数据链路的骨架2.1 目录结构:先搭出“抓取、存储、分析、展示”四段式bili_anime/ ├── config.py # 请求头、目标URL、限速参数 ├── spider/ │ ├── __init__.py │ ├── fetcher.py # 请求发送、重试、频率控制 │ └── parser.py # 把响应JSON提取成数据字典 ├── storage.py # CSV与SQLite两种落盘方式 ├── analysis.py # pandas清洗与聚合统计 ├── visualize.py # 生成图表 ├── main.py # 编排一次完整的数据任务 └── requirements.txt # requests pandas pyecharts第一眼看目录会奇怪:为什么把spider拆成fetcher和parser?因为这两个职责的变更频率不同。请求层要处理登录态、请求头、超时重试,而解析层只需要关心响应结构。如果有一天B站改API字段,你只需要动parser.py,不会波及请求逻辑。将存储独立成storage.py,则是为了给后续接入MySQL留空间,爬虫跑批后统一落库。analysis.py和visualize.py分离,是因为分析和图表的目标不一样:分析阶段可以反复调整统计口径,图表阶段则专注呈现。2.2 技术选型:为什么用requests而不是Scrapy,用pandas而不是纯Python这个项目没有上Scrapy,是刻意为之。数据量在千到万级别,单机、同步请求完全能承受;Scrapy是一个框架,引入后会带来项目分层和信号机制的学习成本,反而挡住核心逻辑。requests配合Session能维持登录态,对于需要模拟请求头的场景更轻便。用pandas做清洗和聚合,是因为字段缺失、类型转换这类脏活用纯Python写会非常啰嗦,DataFrame天然适合表格化数据,一行groupby就能完成分组统计。可视化方面选了pyecharts和matplotlib的组合。pyecharts生成的是带交互的HTML,用浏览器打开后可以缩放、悬停查看数值,适合做展示;matplotlib则用于静态图,方便直接嵌入文档。两者结合覆盖了大多数汇报场景。requirements.txt只需要固定四样:requests、pandas、pyecharts、matplotlib,再加一个Python内置的sqlite3就够了。2.3 配置管理:把“会变的东西”从代码中抽出来这里说的“会变的东西”是请求头、目标接口地址、页数范围。我一般会单独放在config.py,避免每次改参数都要翻主逻辑。# config.py BASE_URL = "https://api.bilibili.com/pgc/web/season/list?season_type=1&pn={page}&ps=20" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", "Referer": "https://www.bilibili.com/anime/", "Cookie": "" # 按需填入已登录的Cookie } MAX_PAGE = 10 # 最大爬取页数 RETRY_TIMES = 3 # 单页请求失败重试次数 TIMEOUT = 5 # 单次请求超时秒数 SLEEP_INTERVAL = 1.5 # 两次请求之间的间隔秒数BASE_URL里的{page}是占位符,之后用format填入真实页码。ps=20表示每页20条纪录,season_type=1代表动画番剧分区。HEADERS中重点看两个字段:User-Agent用来伪装成浏览器,Referer则避免部分接口校验来源。Cookie不是必填,但不登录时部分番剧的追番人数可能拿不到,所以我一般会填一个临时Cookie。MAX_PAGE控制爬取总量,防止把整个分区拉下来导致频控。SLEEP_INTERVAL是限速参数,后面请求函数会用到。这里有一个小细节:BASE_URL中的参数用字符串格式化而不是直接拼接,是因为pandas后续去重时会把整个URL作为数据来源标记,如果URL格式不统一,调试时会增加认知负担。翻页时用range(1, MAX_PAGE + 1),每页循环里调用BASE_URL.format(page=page),保证URL唯一。请求频率不能只靠sleep,还要在异常后二次重试,这部分留在3.1节展开。如果有心把它扩展成线程池爬取,需注意requests.Session不是线程安全的,每个线程最好创建自己的Session实例。最简单的办法是让fetcher每次请求时创建一个新Session,或者复制一个全局Session。这个项目没有上并发,因为数据分析阶段并不需要海量数据,控制频率比速度更重要。2.4 流程编排:main.py把模块按“拆开写、合着跑”组织起来# main.py from spider.fetcher import fetch_page from spider.parser import parse_anime_list from storage import save_to_csv from analysis import clean_data, analyze from visualize import render_charts def main(): all_data = [] for page in range(1, 6): response = fetch_page(page) items = parse_anime_list(response) all_data.extend(items) df = save_to_csv(all_data, "anime_raw.csv") df_clean = clean_data(df) stats = analyze(df_clean) render_charts(stats) if __name__ == "__main__": main()这段代码把流程分为5步:抓页面、解析、存原始数据、清洗分析、可视化。注意save_to_csv返回的是DataFrame,因为存档后马上要交给clean_data继续处理,从存储函数直接把载入的DataFrame返回能省一步read_csv。这是一个我在工程里常用的技巧:写出去的CSV,同时用pandas读回来,保证类型一致。main.py刻意不把请求参数写死,也可以从命令行读取页数,比如python main.py --pages 10,这样就不需要改代码。这种灵活性可以用argparse实现,但为了保持脚本简洁,我通常用环境变量或直接改config。注意all_data在内存中累积,几页数据没问题,如果全量抓取几万条,应该改成边爬边写,这个在5.5节会专门讨论。3. 核心模块拆解:从一次请求到一条番剧记录的完整链路3.1 请求层:fetcher.py的健壮性设计# spider/fetcher.py import time import random import requests from config import BASE_URL, HEADERS, RETRY_TIMES, TIMEOUT, SLEEP_INTERVAL def fetch_page(page: int, retry: int = RETRY_TIMES) -> dict: url = BASE_URL.format(page=page) for attempt in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT) if resp.status_code == 200: data = resp.json() if data.get("code") == 0: return data print(f"[warning] page {page} api code {data.get('code')}") except requests.RequestException as exc: print(f"[error] page {page} request failed: {exc}") time.sleep(SLEEP_INTERVAL + random.uniform(0, 0.5)) return {}逻辑:通过format填入页码;每次请求前设置timeout避免卡死;失败后记录状态码,并进入下一次重试;重试前休眠SLEEP_INTERVAL + random.uniform(0, 0.5)是为了让间隔带随机性,避免被识别成固定频率脚本。返回空字典表示这一页彻底失败。参数说明:retry默取config里的RETRY_TIMES,这样不用改函数签名就能全局调整。random.uniform会引入随机延迟。这里还比第一版多了一个关键检查:data.get("code") == 0。因为B站接口异常时HTTP状态码仍是200,但data里带错误码,不检查只靠状态码会把错误数据传给解析层。很多初学者在抓B站接口时会踩这个坑:看到200就放心解析,结果解出来全是空的。把code检查放在请求层,等于在所有解析逻辑之前加了一道闸门。如果返回的message里有“抱歉”之类的字眼,可以在fetch阶段直接打印出来,方便判断是Cookie过期还是风控触发。3.2 解析层:parser.py怎么从JSON里挑字段# spider/parser.py def parse_anime_list(response: dict) -> list: items = [] modules = response.get("data", {}).get("modules", []) for module in modules: if "list" not in module: continue for item in module["list"]: items.append({ "title": item.get("title", ""), "score": item.get("score", ""), "play_count": item.get("stat", {}).get("play", 0), "danmaku_count": item.get("stat", {}).get("danmaku", 0), "follow_count": item.get("stat", {}).get("follow", 0), "season_id": item.get("season_id"), }) return items说明:response结构是data.modules[].list[],每个module里可能有不同模块卡片,所以判断if "list" not in module跳过非列表模块。item里的stat是嵌套字典,包含play/danmaku/follow等数量。用get而不是[]是为了避免KeyError,缺失字段给默认值,后续清洗时再统一处理。season_id是唯一标识,后续二次开发用它关联评论接口。另外,在解析时不要贪心把所有字段都取出来。我第一次写这个项目时,试图同时抓取media_type、season_type、badge_info等字段,结果解析函数变得很复杂,后来发现分析阶段只用到6个核心字段,于是直接把parser简化。如果你要二次开发,建议先定义好分析目标,再决定抓哪些字段,避免数据冗余。这里还有一个细节:score字段可能是字符串“暂无评分”,play_count可能是带单位的字符串“1.2万”。我们先把原始值保留下来,在analysis.py里统一处理,不在parser里做类型转换。因为解析层只负责“提取”,类型转换属于“清洗”的职责,混在一起会让调试更困难。3.3 存储层:CSV与SQLite的取舍# storage.py import sqlite3 import pandas as pd def save_to_csv(data: list, filename: str = "anime_raw.csv") -> pd.DataFrame: df = pd.DataFrame(data) df.to_csv(filename, index=False, encoding="utf-8-sig") return df def save_to_sqlite(data: list, db_path: str = "bili_anime.db", table: str = "anime"): df = pd.DataFrame(data) conn = sqlite3.connect(db_path) df.to_sql(table, conn, if_exists="replace", index=False) conn.close() return df逻辑:save_to_csv使用utf-8-sig编码,保证Excel打开不乱码;save_to_sqlite使用if_exists="replace",重复跑脚本时不需要手动删表。两个函数都返回DataFrame,这样main.py可以省一次读盘。如果数据量大到无法全部写入内存,可以分批调用pandas.concat,但本项目按页抓取,全部数据在内存中仍可控。参数说明:filename是输出文件;db_path是SQLite路径;table是表名。实际使用中,我第一次跑项目用的是CSV,因为数据量小且方便用Excel快速检查。但到后期要跨天做增量对比时,SQLite更顺手,因为可以直接用SQL查字段。建议你优先用CSV跑通全流程,确认没有问题再切SQLite。3.4 登录态处理:Cookie过期会怎样影响数据爬虫写好后第一次跑很顺利,第二天再跑却发现部分字段全为空。常见原因是Cookie过期。B站部分接口需要登录Cookie才能返回关注数等字段。我一般会把Cookie单独放进config.py,并写一个简单的读取函数:# config.py def load_cookie(): with open("cookie.txt", "r", encoding="utf-8") as f: return f.read().strip() # spider/fetcher.py 中 HEADERS["Cookie"] = load_cookie()从项目中分离Cookie文件,是为了避免把个人Cookie提交到代码仓库。失效时只需替换文本文件,不用改代码。如果请求次数过多,Cookie还可能导致风控,这时优先检查返回体的code字段和message字段,别在请求层反复重试。4. 数据分析与可视化:让数值从表格进入图表4.1 数据清洗:字符串数字与缺失值的处理# analysis.py import pandas as pd def clean_data(df: pd.DataFrame) -> pd.DataFrame: df = df.drop_duplicates(subset=["season_id"]) df = df[df["score"].astype(str).str.replace("暂无评分", "") != ""] df["score"] = pd.to_numeric(df["score"], errors="coerce") def parse_count(value): if isinstance(value, str) and "万" in value: return float(value.replace("万", "")) * 10000 try: return float(value) except (TypeError, ValueError): return None for col in ["play_count", "danmaku_count", "follow_count"]: df[col] = df[col].map(parse_count) return df.dropna(subset=["score", "play_count"]).reset_index(drop=True)逻辑:先去重;再删掉“暂无评分”;把score由字符串转float;parse_count处理“1.2万”这种单位,统一换算成数字。dropna确保参与后续统计的记录都有score和play_count。errors="coerce"会把无法转换的文本变成NaN,这是一种宽容式解析。参数说明:subset指定去重字段;errors="coerce"是pandas Series.to_numeric的参数;map能应用自定义函数到整列。如果你拿到手的play_count是整数但单位已经是原始数值,parse_count也能正确返回float。这里最需要注意的是astype(str),因为原始score里有空单元格,直接.str访问会报警,先转成str最稳。4.2 统计维度:评分分布、播放量TOP、追番与播放的关系def analyze(df: pd.DataFrame) -> dict: stats = {} stats["score_bins"] = pd.cut(df["score"], bins=[0, 6, 7, 8, 9, 10]).value_counts() stats["top_play"] = df.nlargest(10, "play_count")[["title", "play_count"]] stats["corr"] = df[["score", "play_count", "follow_count"]].corr() return stats说明:pd.cut按评分区间切分,返回每个区间的条数,用于看评分分布形状。nlargest取播放量前10的番剧名。corr矩阵看三个连续变量的相关系数,通常可以发现播放量和追番数高度相关,而评分和播放量不一定强相关。比如跑完一次可能得到score与play_count相关系数只有0.3左右,说明高播放不必然意味着高评分,这类洞察才是这个项目的数据价值所在。如果你只想看单一维度,可以只保留score_bins或top_play。但建议三个都保留,因为展示时它们能互相佐证:评分分布展示整体梯度,播放量TOP展示头部效应,相关系数则回答“什么因素更影响热度”。4.3 可视化:静态图表的直白与交互图表的洞察# visualize.py import matplotlib.pyplot as plt from pyecharts.charts import Bar from pyecharts import options as opts def render_charts(stats: dict): plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False score_bins = stats["score_bins"] plt.bar(score_bins.index.astype(str), score_bins.values) plt.title("评分区间分布") plt.xticks(rotation=30) plt.savefig("score_dist.png", dpi=150) plt.close() bar = Bar() bar.add_xaxis(stats["top_play"]["title"].tolist()) bar.add_yaxis("播放量", stats["top_play"]["play_count"].tolist()) bar.set_global_opts(title_opts=opts.TitleOpts(title="播放量TOP10")) bar.render("top_play.html")说明:plt中设置SimHei字体解决中文乱码;pyecharts的Bar对象接受list,render会生成独立HTML文件。播放量单位已经换算成数字,所以y轴直接显示具体数值。如果需要单位换回“亿/万”,可以写一个format回调。参数说明:dpi=150控制png清晰度;rotation=30避免评分区间标签重叠;pyecharts的render自动生成html文件。如果你在macOS或Linux上跑,SimHei可能不存在,需要改成“PingFang SC”或“Noto Sans CJK SC”,否则标题会显示成方块。散点图也是常用视角,可以用plt.scatter(df["score"], df["play_count"])观察评分与播放量的关系,这里不再展开。如果你的数据量大,pyecharts支持DataFrame按列传入,也可以直接生成仪表盘。4.4 分析结果落盘:让图表和数据表可复现def export_stats(stats: dict, output: str = "stats.json"): import json with open(output, "w", encoding="utf-8") as f: json.dump(stats, f, ensure_ascii=False, indent=2, default=str)说明:这个函数把统计字典导出为JSON,二次开发时可让前端直接读取。default=str用来序列化pandas的索引对象和浮点类型。ensure_ascii=False保证中文可读。我一般会在跑完分析后执行一次export_stats,这样即使图表代码修改,统计结果仍然有据可查,不需要重新爬数据。5. 避坑与排查:写爬虫最容易踩的五个坑5.1 返回200但JSON里没有数据:业务状态码不等于HTTP状态码现象:请求状态明明是200,但response里code不是0,data为空列表。我第一次跑这个脚本的时候,打印了5页都是空列表,一度以为是字段名写错了。原因:接口自身的业务校验失败,常见于缺少Referer、Cookie失效,或者单IP请求过快。此时HTTP层没有异常,但接口返回了业务错误码。解决:在fetcher层检查code == 0,不为0则打印message并跳过。另外把Referer固定到动漫分区首页,Cookie放在config的独立文件里,能规避大部分校验问题。不要用status_code==200当作成功,这是一个很隐蔽的坑。5.2 运行两天后出现KeyError:字段名说变就变现象:报错KeyError: 'season_id'或者'stat',解析函数直接中断。原因:B站前端改版导致接口返回结构变化,比如说modules里嵌套层级变了,或者stat字段被改名。解决:解析时统一用dict.get并递归取默认值,比如item.get("stat", {}).get("play", 0)。同时建议把原始响应保存一份为raw_record.json,排查时直接对照真实JSON结构。如果你想减少这类断裂,可以只依赖稳定字段,像season_id、title这类字段基本不会动,而运营类字段则多做兜底。5.3 CSV用Excel打开中文乱码,且带“nan”字样现象:明明用pandas写入了中文,用Excel打开却乱码,或者空字段显示成“nan”。原因:to_csv默认编码是utf-8,Excel打开时按GBK解码,中文自然乱码。缺失值会以nan文本形式写入。解决:指定encoding="utf-8-sig",这个编码专门兼容Excel;写入前用fillna("")把NaN补成空字符串。如果你在存储后还要用pandas读回,read_csv时保持同样编码,再处理一次缺失值。5.4 matplotlib图表中文全是方框现象:标题和坐标轴文本显示成□□□,英文和数字正常。原因:系统没有匹配中文字体,matplotlib默认字体集合里没有SimHei。解决:设置plt.rcParams["font.sans-serif"] = ["SimHei"],并设置axes.unicode_minus=False。Windows下SimHei常见,macOS/Linux换成“PingFang SC”或“Noto Sans CJK SC”。如果换了字体还不行,可以用fc-list查看系统已安装字体,选一个中文字体名填进去。5.5 数据量一大,内存爆掉现象:爬到几万条数据时,脚本占用超过1GB,甚至卡死。原因:所有页面数据都append到list里,最后一次性构造DataFrame,内存里同时存在list副本和DataFrame副本。解决:边爬边写CSV,用mode="a"追加,或者用SQLite每页提交一次。pandas的to_csv追加模式需要传入header=False避免重复表头。如果你要分析全量数据,可以在爬取结束后用read_csv分块载入,或者直接在SQLite里做聚合,再导出统计结果。从那以后我每次处理超过5000条数据,都会强制走一遍“边爬边存”的流程,不再把内存当无底洞。6. 二次开发:把固定爬虫改成可扩展的数据管道改造这个项目的思路有很多方向。最简单的增量是把MAX_PAGE改成命令行参数,让批处理时不用改config;更实用的是把它从“一次性脚本”改成“可扩展管道”。你可以增加一个评论爬虫模块,复用fetcher的请求重试机制,用分析阶段拿到的season_id去请求该番剧的评论列表,分析弹幕情感倾向。也可以把存储层替换成MySQL,只动storage.py中的save_to_sqlite函数,改成to_sql连接MySQL。如果希望每天自动更新一次,在main.py里加一个循环,外层用系统cron或APScheduler定时触发即可。改造时我会用一个小工具函数来统一请求重试逻辑,避免每个新模块都重新写一遍。# utils.py import time def retry_request(func, max_retries=3): def wrapper(*args, **kwargs): for i in range(max_retries): result = func(*args, **kwargs) if result is not None and result.get("code") == 0: return result time.sleep(2) return None return wrapper验证方法我固定为三步:先抓单页,确认解析出的字段齐全;再抓十页,跑通清洗和可视化;最后全量,观察图表和统计结果是否符合常识。不要一开始就跑全量,否则字段解析错误会被放大成大量垃圾数据。从那以后,我每次改完新模块都强制走一遍“单页→十页→全量”的验证流程,不再直接跑全量。做爬虫分析项目的关键不是代码量,而是数据质量和迭代习惯。希望帮到你。本文还有配套的精品资源,点击获取
简介:一份以哔哩哔哩番剧数据为案例的Python爬虫与数据分析可视化实战资源,面向希望通过项目上手爬虫和数据处理的初学者,也适合作为相关课程设计参考。项目从B站动漫总榜切入,先抓取番剧列表及直达链接,再访问详情页获取追番人数、观众评分等信息,形成历年番剧数据集,进而分析B站ACGN文化发展状况。压缩包共23个文件,包含3个Jupyter Notebook脚本、2个Excel数据集、10张PNG可视化结果图、XML工程配置及Markdown说明文档,整体仅3.58MB,目录清晰,按爬虫、数据预处理、分析等阶段组织。已有2981人学习参考。资源提供爬取代码、数据处理代码、分析代码以及处理前后的Excel数据,并有readme对爬虫细节和数据分析可视化流程进行详细介绍,可以帮助读者快速理解从请求解析、字段提取到数据清洗、绘图展示的完整思路,作为Python爬虫入门的参考资源非常合适。1. 这个Python项目要解决的并不是“爬”本身如果你只给一个requests.get()的结果做BeautifulSoup解析,最后打印几行数据,那算不上一个完整的爬虫项目。真正让爬虫项目产生复利的是数据链路:抓取、清洗、分析、可视化。这个以“B站动漫数据分析与可视化”为目标的Python项目,做的就是这件事:把B站动漫分区的番剧基础信息(评分、播放量、弹幕数、追番人数)抓下来,落成结构化表格,用pandas做统计分析,再用图表把结论呈现出来。适合已经写过多线程爬虫但没跑过完整数据处理闭环的开发者,也适合想用Python做作品集的初学者。2. 整体架构与技术选型:目录不是摆设,是数据链路的骨架2.1 目录结构:先搭出“抓取、存储、分析、展示”四段式bili_anime/ ├── config.py # 请求头、目标URL、限速参数 ├── spider/ │ ├── __init__.py │ ├── fetcher.py # 请求发送、重试、频率控制 │ └── parser.py # 把响应JSON提取成数据字典 ├── storage.py # CSV与SQLite两种落盘方式 ├── analysis.py # pandas清洗与聚合统计 ├── visualize.py # 生成图表 ├── main.py # 编排一次完整的数据任务 └── requirements.txt # requests pandas pyecharts第一眼看目录会奇怪:为什么把spider拆成fetcher和parser?因为这两个职责的变更频率不同。请求层要处理登录态、请求头、超时重试,而解析层只需要关心响应结构。如果有一天B站改API字段,你只需要动parser.py,不会波及请求逻辑。将存储独立成storage.py,则是为了给后续接入MySQL留空间,爬虫跑批后统一落库。analysis.py和visualize.py分离,是因为分析和图表的目标不一样:分析阶段可以反复调整统计口径,图表阶段则专注呈现。2.2 技术选型:为什么用requests而不是Scrapy,用pandas而不是纯Python这个项目没有上Scrapy,是刻意为之。数据量在千到万级别,单机、同步请求完全能承受;Scrapy是一个框架,引入后会带来项目分层和信号机制的学习成本,反而挡住核心逻辑。requests配合Session能维持登录态,对于需要模拟请求头的场景更轻便。用pandas做清洗和聚合,是因为字段缺失、类型转换这类脏活用纯Python写会非常啰嗦,DataFrame天然适合表格化数据,一行groupby就能完成分组统计。可视化方面选了pyecharts和matplotlib的组合。pyecharts生成的是带交互的HTML,用浏览器打开后可以缩放、悬停查看数值,适合做展示;matplotlib则用于静态图,方便直接嵌入文档。两者结合覆盖了大多数汇报场景。requirements.txt只需要固定四样:requests、pandas、pyecharts、matplotlib,再加一个Python内置的sqlite3就够了。2.3 配置管理:把“会变的东西”从代码中抽出来这里说的“会变的东西”是请求头、目标接口地址、页数范围。我一般会单独放在config.py,避免每次改参数都要翻主逻辑。# config.py BASE_URL = "https://api.bilibili.com/pgc/web/season/list?season_type=1&pn={page}&ps=20" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", "Referer": "https://www.bilibili.com/anime/", "Cookie": "" # 按需填入已登录的Cookie } MAX_PAGE = 10 # 最大爬取页数 RETRY_TIMES = 3 # 单页请求失败重试次数 TIMEOUT = 5 # 单次请求超时秒数 SLEEP_INTERVAL = 1.5 # 两次请求之间的间隔秒数BASE_URL里的{page}是占位符,之后用format填入真实页码。ps=20表示每页20条纪录,season_type=1代表动画番剧分区。HEADERS中重点看两个字段:User-Agent用来伪装成浏览器,Referer则避免部分接口校验来源。Cookie不是必填,但不登录时部分番剧的追番人数可能拿不到,所以我一般会填一个临时Cookie。MAX_PAGE控制爬取总量,防止把整个分区拉下来导致频控。SLEEP_INTERVAL是限速参数,后面请求函数会用到。这里有一个小细节:BASE_URL中的参数用字符串格式化而不是直接拼接,是因为pandas后续去重时会把整个URL作为数据来源标记,如果URL格式不统一,调试时会增加认知负担。翻页时用range(1, MAX_PAGE + 1),每页循环里调用BASE_URL.format(page=page),保证URL唯一。请求频率不能只靠sleep,还要在异常后二次重试,这部分留在3.1节展开。如果有心把它扩展成线程池爬取,需注意requests.Session不是线程安全的,每个线程最好创建自己的Session实例。最简单的办法是让fetcher每次请求时创建一个新Session,或者复制一个全局Session。这个项目没有上并发,因为数据分析阶段并不需要海量数据,控制频率比速度更重要。2.4 流程编排:main.py把模块按“拆开写、合着跑”组织起来# main.py from spider.fetcher import fetch_page from spider.parser import parse_anime_list from storage import save_to_csv from analysis import clean_data, analyze from visualize import render_charts def main(): all_data = [] for page in range(1, 6): response = fetch_page(page) items = parse_anime_list(response) all_data.extend(items) df = save_to_csv(all_data, "anime_raw.csv") df_clean = clean_data(df) stats = analyze(df_clean) render_charts(stats) if __name__ == "__main__": main()这段代码把流程分为5步:抓页面、解析、存原始数据、清洗分析、可视化。注意save_to_csv返回的是DataFrame,因为存档后马上要交给clean_data继续处理,从存储函数直接把载入的DataFrame返回能省一步read_csv。这是一个我在工程里常用的技巧:写出去的CSV,同时用pandas读回来,保证类型一致。main.py刻意不把请求参数写死,也可以从命令行读取页数,比如python main.py --pages 10,这样就不需要改代码。这种灵活性可以用argparse实现,但为了保持脚本简洁,我通常用环境变量或直接改config。注意all_data在内存中累积,几页数据没问题,如果全量抓取几万条,应该改成边爬边写,这个在5.5节会专门讨论。3. 核心模块拆解:从一次请求到一条番剧记录的完整链路3.1 请求层:fetcher.py的健壮性设计# spider/fetcher.py import time import random import requests from config import BASE_URL, HEADERS, RETRY_TIMES, TIMEOUT, SLEEP_INTERVAL def fetch_page(page: int, retry: int = RETRY_TIMES) -> dict: url = BASE_URL.format(page=page) for attempt in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT) if resp.status_code == 200: data = resp.json() if data.get("code") == 0: return data print(f"[warning] page {page} api code {data.get('code')}") except requests.RequestException as exc: print(f"[error] page {page} request failed: {exc}") time.sleep(SLEEP_INTERVAL + random.uniform(0, 0.5)) return {}逻辑:通过format填入页码;每次请求前设置timeout避免卡死;失败后记录状态码,并进入下一次重试;重试前休眠SLEEP_INTERVAL + random.uniform(0, 0.5)是为了让间隔带随机性,避免被识别成固定频率脚本。返回空字典表示这一页彻底失败。参数说明:retry默取config里的RETRY_TIMES,这样不用改函数签名就能全局调整。random.uniform会引入随机延迟。这里还比第一版多了一个关键检查:data.get("code") == 0。因为B站接口异常时HTTP状态码仍是200,但data里带错误码,不检查只靠状态码会把错误数据传给解析层。很多初学者在抓B站接口时会踩这个坑:看到200就放心解析,结果解出来全是空的。把code检查放在请求层,等于在所有解析逻辑之前加了一道闸门。如果返回的message里有“抱歉”之类的字眼,可以在fetch阶段直接打印出来,方便判断是Cookie过期还是风控触发。3.2 解析层:parser.py怎么从JSON里挑字段# spider/parser.py def parse_anime_list(response: dict) -> list: items = [] modules = response.get("data", {}).get("modules", []) for module in modules: if "list" not in module: continue for item in module["list"]: items.append({ "title": item.get("title", ""), "score": item.get("score", ""), "play_count": item.get("stat", {}).get("play", 0), "danmaku_count": item.get("stat", {}).get("danmaku", 0), "follow_count": item.get("stat", {}).get("follow", 0), "season_id": item.get("season_id"), }) return items说明:response结构是data.modules[].list[],每个module里可能有不同模块卡片,所以判断if "list" not in module跳过非列表模块。item里的stat是嵌套字典,包含play/danmaku/follow等数量。用get而不是[]是为了避免KeyError,缺失字段给默认值,后续清洗时再统一处理。season_id是唯一标识,后续二次开发用它关联评论接口。另外,在解析时不要贪心把所有字段都取出来。我第一次写这个项目时,试图同时抓取media_type、season_type、badge_info等字段,结果解析函数变得很复杂,后来发现分析阶段只用到6个核心字段,于是直接把parser简化。如果你要二次开发,建议先定义好分析目标,再决定抓哪些字段,避免数据冗余。这里还有一个细节:score字段可能是字符串“暂无评分”,play_count可能是带单位的字符串“1.2万”。我们先把原始值保留下来,在analysis.py里统一处理,不在parser里做类型转换。因为解析层只负责“提取”,类型转换属于“清洗”的职责,混在一起会让调试更困难。3.3 存储层:CSV与SQLite的取舍# storage.py import sqlite3 import pandas as pd def save_to_csv(data: list, filename: str = "anime_raw.csv") -> pd.DataFrame: df = pd.DataFrame(data) df.to_csv(filename, index=False, encoding="utf-8-sig") return df def save_to_sqlite(data: list, db_path: str = "bili_anime.db", table: str = "anime"): df = pd.DataFrame(data) conn = sqlite3.connect(db_path) df.to_sql(table, conn, if_exists="replace", index=False) conn.close() return df逻辑:save_to_csv使用utf-8-sig编码,保证Excel打开不乱码;save_to_sqlite使用if_exists="replace",重复跑脚本时不需要手动删表。两个函数都返回DataFrame,这样main.py可以省一次读盘。如果数据量大到无法全部写入内存,可以分批调用pandas.concat,但本项目按页抓取,全部数据在内存中仍可控。参数说明:filename是输出文件;db_path是SQLite路径;table是表名。实际使用中,我第一次跑项目用的是CSV,因为数据量小且方便用Excel快速检查。但到后期要跨天做增量对比时,SQLite更顺手,因为可以直接用SQL查字段。建议你优先用CSV跑通全流程,确认没有问题再切SQLite。3.4 登录态处理:Cookie过期会怎样影响数据爬虫写好后第一次跑很顺利,第二天再跑却发现部分字段全为空。常见原因是Cookie过期。B站部分接口需要登录Cookie才能返回关注数等字段。我一般会把Cookie单独放进config.py,并写一个简单的读取函数:# config.py def load_cookie(): with open("cookie.txt", "r", encoding="utf-8") as f: return f.read().strip() # spider/fetcher.py 中 HEADERS["Cookie"] = load_cookie()从项目中分离Cookie文件,是为了避免把个人Cookie提交到代码仓库。失效时只需替换文本文件,不用改代码。如果请求次数过多,Cookie还可能导致风控,这时优先检查返回体的code字段和message字段,别在请求层反复重试。4. 数据分析与可视化:让数值从表格进入图表4.1 数据清洗:字符串数字与缺失值的处理# analysis.py import pandas as pd def clean_data(df: pd.DataFrame) -> pd.DataFrame: df = df.drop_duplicates(subset=["season_id"]) df = df[df["score"].astype(str).str.replace("暂无评分", "") != ""] df["score"] = pd.to_numeric(df["score"], errors="coerce") def parse_count(value): if isinstance(value, str) and "万" in value: return float(value.replace("万", "")) * 10000 try: return float(value) except (TypeError, ValueError): return None for col in ["play_count", "danmaku_count", "follow_count"]: df[col] = df[col].map(parse_count) return df.dropna(subset=["score", "play_count"]).reset_index(drop=True)逻辑:先去重;再删掉“暂无评分”;把score由字符串转float;parse_count处理“1.2万”这种单位,统一换算成数字。dropna确保参与后续统计的记录都有score和play_count。errors="coerce"会把无法转换的文本变成NaN,这是一种宽容式解析。参数说明:subset指定去重字段;errors="coerce"是pandas Series.to_numeric的参数;map能应用自定义函数到整列。如果你拿到手的play_count是整数但单位已经是原始数值,parse_count也能正确返回float。这里最需要注意的是astype(str),因为原始score里有空单元格,直接.str访问会报警,先转成str最稳。4.2 统计维度:评分分布、播放量TOP、追番与播放的关系def analyze(df: pd.DataFrame) -> dict: stats = {} stats["score_bins"] = pd.cut(df["score"], bins=[0, 6, 7, 8, 9, 10]).value_counts() stats["top_play"] = df.nlargest(10, "play_count")[["title", "play_count"]] stats["corr"] = df[["score", "play_count", "follow_count"]].corr() return stats说明:pd.cut按评分区间切分,返回每个区间的条数,用于看评分分布形状。nlargest取播放量前10的番剧名。corr矩阵看三个连续变量的相关系数,通常可以发现播放量和追番数高度相关,而评分和播放量不一定强相关。比如跑完一次可能得到score与play_count相关系数只有0.3左右,说明高播放不必然意味着高评分,这类洞察才是这个项目的数据价值所在。如果你只想看单一维度,可以只保留score_bins或top_play。但建议三个都保留,因为展示时它们能互相佐证:评分分布展示整体梯度,播放量TOP展示头部效应,相关系数则回答“什么因素更影响热度”。4.3 可视化:静态图表的直白与交互图表的洞察# visualize.py import matplotlib.pyplot as plt from pyecharts.charts import Bar from pyecharts import options as opts def render_charts(stats: dict): plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False score_bins = stats["score_bins"] plt.bar(score_bins.index.astype(str), score_bins.values) plt.title("评分区间分布") plt.xticks(rotation=30) plt.savefig("score_dist.png", dpi=150) plt.close() bar = Bar() bar.add_xaxis(stats["top_play"]["title"].tolist()) bar.add_yaxis("播放量", stats["top_play"]["play_count"].tolist()) bar.set_global_opts(title_opts=opts.TitleOpts(title="播放量TOP10")) bar.render("top_play.html")说明:plt中设置SimHei字体解决中文乱码;pyecharts的Bar对象接受list,render会生成独立HTML文件。播放量单位已经换算成数字,所以y轴直接显示具体数值。如果需要单位换回“亿/万”,可以写一个format回调。参数说明:dpi=150控制png清晰度;rotation=30避免评分区间标签重叠;pyecharts的render自动生成html文件。如果你在macOS或Linux上跑,SimHei可能不存在,需要改成“PingFang SC”或“Noto Sans CJK SC”,否则标题会显示成方块。散点图也是常用视角,可以用plt.scatter(df["score"], df["play_count"])观察评分与播放量的关系,这里不再展开。如果你的数据量大,pyecharts支持DataFrame按列传入,也可以直接生成仪表盘。4.4 分析结果落盘:让图表和数据表可复现def export_stats(stats: dict, output: str = "stats.json"): import json with open(output, "w", encoding="utf-8") as f: json.dump(stats, f, ensure_ascii=False, indent=2, default=str)说明:这个函数把统计字典导出为JSON,二次开发时可让前端直接读取。default=str用来序列化pandas的索引对象和浮点类型。ensure_ascii=False保证中文可读。我一般会在跑完分析后执行一次export_stats,这样即使图表代码修改,统计结果仍然有据可查,不需要重新爬数据。5. 避坑与排查:写爬虫最容易踩的五个坑5.1 返回200但JSON里没有数据:业务状态码不等于HTTP状态码现象:请求状态明明是200,但response里code不是0,data为空列表。我第一次跑这个脚本的时候,打印了5页都是空列表,一度以为是字段名写错了。原因:接口自身的业务校验失败,常见于缺少Referer、Cookie失效,或者单IP请求过快。此时HTTP层没有异常,但接口返回了业务错误码。解决:在fetcher层检查code == 0,不为0则打印message并跳过。另外把Referer固定到动漫分区首页,Cookie放在config的独立文件里,能规避大部分校验问题。不要用status_code==200当作成功,这是一个很隐蔽的坑。5.2 运行两天后出现KeyError:字段名说变就变现象:报错KeyError: 'season_id'或者'stat',解析函数直接中断。原因:B站前端改版导致接口返回结构变化,比如说modules里嵌套层级变了,或者stat字段被改名。解决:解析时统一用dict.get并递归取默认值,比如item.get("stat", {}).get("play", 0)。同时建议把原始响应保存一份为raw_record.json,排查时直接对照真实JSON结构。如果你想减少这类断裂,可以只依赖稳定字段,像season_id、title这类字段基本不会动,而运营类字段则多做兜底。5.3 CSV用Excel打开中文乱码,且带“nan”字样现象:明明用pandas写入了中文,用Excel打开却乱码,或者空字段显示成“nan”。原因:to_csv默认编码是utf-8,Excel打开时按GBK解码,中文自然乱码。缺失值会以nan文本形式写入。解决:指定encoding="utf-8-sig",这个编码专门兼容Excel;写入前用fillna("")把NaN补成空字符串。如果你在存储后还要用pandas读回,read_csv时保持同样编码,再处理一次缺失值。5.4 matplotlib图表中文全是方框现象:标题和坐标轴文本显示成□□□,英文和数字正常。原因:系统没有匹配中文字体,matplotlib默认字体集合里没有SimHei。解决:设置plt.rcParams["font.sans-serif"] = ["SimHei"],并设置axes.unicode_minus=False。Windows下SimHei常见,macOS/Linux换成“PingFang SC”或“Noto Sans CJK SC”。如果换了字体还不行,可以用fc-list查看系统已安装字体,选一个中文字体名填进去。5.5 数据量一大,内存爆掉现象:爬到几万条数据时,脚本占用超过1GB,甚至卡死。原因:所有页面数据都append到list里,最后一次性构造DataFrame,内存里同时存在list副本和DataFrame副本。解决:边爬边写CSV,用mode="a"追加,或者用SQLite每页提交一次。pandas的to_csv追加模式需要传入header=False避免重复表头。如果你要分析全量数据,可以在爬取结束后用read_csv分块载入,或者直接在SQLite里做聚合,再导出统计结果。从那以后我每次处理超过5000条数据,都会强制走一遍“边爬边存”的流程,不再把内存当无底洞。6. 二次开发:把固定爬虫改成可扩展的数据管道改造这个项目的思路有很多方向。最简单的增量是把MAX_PAGE改成命令行参数,让批处理时不用改config;更实用的是把它从“一次性脚本”改成“可扩展管道”。你可以增加一个评论爬虫模块,复用fetcher的请求重试机制,用分析阶段拿到的season_id去请求该番剧的评论列表,分析弹幕情感倾向。也可以把存储层替换成MySQL,只动storage.py中的save_to_sqlite函数,改成to_sql连接MySQL。如果希望每天自动更新一次,在main.py里加一个循环,外层用系统cron或APScheduler定时触发即可。改造时我会用一个小工具函数来统一请求重试逻辑,避免每个新模块都重新写一遍。# utils.py import time def retry_request(func, max_retries=3): def wrapper(*args, **kwargs): for i in range(max_retries): result = func(*args, **kwargs) if result is not None and result.get("code") == 0: return result time.sleep(2) return None return wrapper验证方法我固定为三步:先抓单页,确认解析出的字段齐全;再抓十页,跑通清洗和可视化;最后全量,观察图表和统计结果是否符合常识。不要一开始就跑全量,否则字段解析错误会被放大成大量垃圾数据。从那以后,我每次改完新模块都强制走一遍“单页→十页→全量”的验证流程,不再直接跑全量。做爬虫分析项目的关键不是代码量,而是数据质量和迭代习惯。希望帮到你。本文还有配套的精品资源,点击获取