简介一份以哔哩哔哩番剧数据为案例的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_type1pn{page}ps20 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填入真实页码。ps20表示每页20条纪录season_type1代表动画番剧分区。HEADERS中重点看两个字段User-Agent用来伪装成浏览器Referer则避免部分接口校验来源。Cookie不是必填但不登录时部分番剧的追番人数可能拿不到所以我一般会填一个临时Cookie。MAX_PAGE控制爬取总量防止把整个分区拉下来导致频控。SLEEP_INTERVAL是限速参数后面请求函数会用到。这里有一个小细节BASE_URL中的参数用字符串格式化而不是直接拼接是因为pandas后续去重时会把整个URL作为数据来源标记如果URL格式不统一调试时会增加认知负担。翻页时用range(1, MAX_PAGE 1)每页循环里调用BASE_URL.format(pagepage)保证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(pagepage) for attempt in range(retry): try: resp requests.get(url, headersHEADERS, timeoutTIMEOUT) 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, indexFalse, encodingutf-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_existsreplace, indexFalse) conn.close() return df逻辑save_to_csv使用utf-8-sig编码保证Excel打开不乱码save_to_sqlite使用if_existsreplace重复跑脚本时不需要手动删表。两个函数都返回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, encodingutf-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], errorscoerce) 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(dropTrue)逻辑先去重再删掉“暂无评分”把score由字符串转floatparse_count处理“1.2万”这种单位统一换算成数字。dropna确保参与后续统计的记录都有score和play_count。errorscoerce会把无法转换的文本变成NaN这是一种宽容式解析。参数说明subset指定去重字段errorscoerce是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(rotation30) plt.savefig(score_dist.png, dpi150) 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_optsopts.TitleOpts(title播放量TOP10)) bar.render(top_play.html)说明plt中设置SimHei字体解决中文乱码pyecharts的Bar对象接受listrender会生成独立HTML文件。播放量单位已经换算成数字所以y轴直接显示具体数值。如果需要单位换回“亿/万”可以写一个format回调。参数说明dpi150控制png清晰度rotation30避免评分区间标签重叠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, encodingutf-8) as f: json.dump(stats, f, ensure_asciiFalse, indent2, defaultstr)说明这个函数把统计字典导出为JSON二次开发时可让前端直接读取。defaultstr用来序列化pandas的索引对象和浮点类型。ensure_asciiFalse保证中文可读。我一般会在跑完分析后执行一次export_stats这样即使图表代码修改统计结果仍然有据可查不需要重新爬数据。5. 避坑与排查写爬虫最容易踩的五个坑5.1 返回200但JSON里没有数据业务状态码不等于HTTP状态码现象请求状态明明是200但response里code不是0data为空列表。我第一次跑这个脚本的时候打印了5页都是空列表一度以为是字段名写错了。原因接口自身的业务校验失败常见于缺少Referer、Cookie失效或者单IP请求过快。此时HTTP层没有异常但接口返回了业务错误码。解决在fetcher层检查code 0不为0则打印message并跳过。另外把Referer固定到动漫分区首页Cookie放在config的独立文件里能规避大部分校验问题。不要用status_code200当作成功这是一个很隐蔽的坑。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-8Excel打开时按GBK解码中文自然乱码。缺失值会以nan文本形式写入。解决指定encodingutf-8-sig这个编码专门兼容Excel写入前用fillna()把NaN补成空字符串。如果你在存储后还要用pandas读回read_csv时保持同样编码再处理一次缺失值。5.4 matplotlib图表中文全是方框现象标题和坐标轴文本显示成□□□英文和数字正常。原因系统没有匹配中文字体matplotlib默认字体集合里没有SimHei。解决设置plt.rcParams[font.sans-serif] [SimHei]并设置axes.unicode_minusFalse。Windows下SimHei常见macOS/Linux换成“PingFang SC”或“Noto Sans CJK SC”。如果换了字体还不行可以用fc-list查看系统已安装字体选一个中文字体名填进去。5.5 数据量一大内存爆掉现象爬到几万条数据时脚本占用超过1GB甚至卡死。原因所有页面数据都append到list里最后一次性构造DataFrame内存里同时存在list副本和DataFrame副本。解决边爬边写CSV用modea追加或者用SQLite每页提交一次。pandas的to_csv追加模式需要传入headerFalse避免重复表头。如果你要分析全量数据可以在爬取结束后用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_retries3): 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验证方法我固定为三步先抓单页确认解析出的字段齐全再抓十页跑通清洗和可视化最后全量观察图表和统计结果是否符合常识。不要一开始就跑全量否则字段解析错误会被放大成大量垃圾数据。从那以后我每次改完新模块都强制走一遍“单页→十页→全量”的验证流程不再直接跑全量。做爬虫分析项目的关键不是代码量而是数据质量和迭代习惯。希望帮到你。本文还有配套的精品资源点击获取