☰
Python爬虫实战:动漫数据抓取、清洗与可视化全流程
2026/10/29 15:43:18 网站建设 项目流程

简介:这份资源面向Python爬虫与数据分析初学者,以B站番剧为实战场景,完整演示从数据采集到可视化呈现的项目流程。包内共23个文件,以10张png可视化结果图、3个ipynb交互式笔记本、2个xlsx数据表及若干xml配置与md说明文档为主,压缩包约3.58MB,结构清晰便于按模块查阅。已有2984人学习下载,适合作为爬虫入门的参考案例。资源先爬取总榜获取各番剧粗略信息与直达链接,再逐条访问详情页补充数据,随后完成数据清洗与处理,并围绕追番人数、评分等维度展开分析,最终以图表形式呈现历年动漫数据的变化趋势。配套readme对爬虫细节、数据处理、分析与可视化各环节均有说明,读者可据此理解请求构造、字段提取与结果解读的完整思路,并对照结果图复现分析过程。

1. 从一份“追番清单”说起:这个项目到底能干什么

前阵子有个做运营的朋友找我,说想看看某视频站上动漫区的热度分布,问我能不能“爬一下”。我第一反应是:这活儿不难,但要做成能反复跑、能出图、还能改的东西,就得有点工程结构了。这份「Python爬虫-动漫数据分析与可视化」资源,干的正是这件事——用 Python 把动漫区的公开列表数据抓下来,清洗成结构化表格,再做排行、类型分布、评分区间这类可视化。它适合两类人:一类是刚学完 requests 和 pandas、想找个完整链路练手的新手;另一类是想拿它当模板,换成别的榜单、别的字段做二次开发的老手。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序,把它拆开讲。

2. 技术栈与目录结构:一个爬虫项目该有的骨架

2.1 选型逻辑:为什么是 requests + pandas + matplotlib

这类“抓列表 → 存表格 → 出图”的项目,最忌讳一上来就上重型框架。我一般会按数据量和反爬强度来定:目标页面是服务端渲染的列表页,翻页靠 URL 参数,单页几十条,总量在几千条量级,这种场景用 requests 配 BeautifulSoup 完全够用,没必要上 Scrapy 的调度器和中间件,调试成本反而更高。数据落地用 pandas,是因为后面清洗、去重、分组统计几乎一行搞定;可视化用 matplotlib,中文显示配一下字体就行,不依赖浏览器环境。

真正需要提前想清楚的是三件事:请求头怎么伪装、翻页边界怎么判断、字段缺失怎么兜底。这三件事决定了脚本是“跑一次就废”还是“能反复跑”。资源里的实现思路也是围绕这三点展开的,下面结合目录结构看。

2.2 目录结构:模块职责怎么分

一个能维护的爬虫项目,目录不该是“一个 py 文件从头写到尾”。我拆过的同类项目,比较合理的组织是这样:

anime_spider/ ├── config.py # 请求头、基础URL、翻页上限、输出路径 ├── spider.py # 抓取层:发请求、解析列表、翻页控制 ├── parser.py # 解析层:把 HTML 节点转成 dict ├── storage.py # 存储层:写 CSV / 追加去重 ├── analysis.py # 分析层:pandas 清洗、分组统计 ├── visualize.py # 可视化层:柱状图、饼图、评分分布 ├── data/ │ └── anime.csv # 抓取结果 └── output/ └── *.png # 图表输出

这么分的核心目的是“抓取”和“解析”解耦。页面结构一变,只改 parser.py;想换存储格式,只动 storage.py;分析和可视化完全不碰网络层,本地有 CSV 就能跑。新手最容易犯的错是把 requests 调用、正则解析、画图全塞一个文件里,改一处牵全身,血泪经验。

2.3 依赖清单与版本约束

依赖不多,但版本要卡一下,尤其是 pandas 2.x 之后部分 API 有变动:

pip install requests beautifulsoup4 pandas matplotlib lxml

lxml是 BeautifulSoup 的解析器后端,比默认的 html.parser 快不少,页面结构不规范时容错也更好。matplotlib 画中文需要单独指定字体,否则图上全是方块,这个后面可视化那章会讲。pandas 建议 1.5 以上,groupby和value_counts的行为比较稳定。

提示:如果目标环境不能联网装包,提前把 wheel 包下好,别等到跑脚本时才发现缺 lxml。

3. 抓取与解析:一个列表页从请求到结构化字段

3.1 请求层:请求头、超时与重试

抓取层的第一原则是“像正常浏览器,但别太过”。请求头里 User-Agent 是必须的,Referer 视站点而定,Cookie 一般不需要——公开列表页通常不校验登录态。超时一定要设,不设的话某个请求卡住,整个脚本就挂在那儿。重试我一般手写一个简单的循环,不引第三方库,够用且可控。

import requests import time from config import BASE_URL, HEADERS, MAX_PAGE def fetch_page(page: int, retry: int = 3) -> str | None: """抓取单页 HTML,失败重试 retry 次""" url = f"{BASE_URL}?page={page}" for attempt in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) # 状态码非 200 直接进入重试 if resp.status_code == 200: resp.encoding = resp.apparent_encoding # 防中文乱码 return resp.text except requests.RequestException as e: print(f"第 {page} 页第 {attempt+1} 次失败: {e}") time.sleep(1 + attempt) # 退避,避免连续打 return None

这段的关键点有三个:timeout=10防止单请求挂死;resp.apparent_encoding是根据内容猜编码,比硬写 utf-8 稳,很多站点的响应头编码声明是错的;重试间隔用1 + attempt做简单退避,别用固定 sleep,连续失败时给服务器一点喘息。MAX_PAGE放在 config 里,是为了翻页边界可控——很多站点的页码超出范围会返回空列表或跳回首页,不设上限容易死循环。

3.2 解析层:把 HTML 节点转成 dict

解析层的职责单一:输入一段 HTML,输出一个字段齐全的 dict 列表。字段一般包括标题、类型、评分、播放量、链接。用 BeautifulSoup 的select比find_all链式调用可读性好,选择器写错时也容易定位。

from bs4 import BeautifulSoup def parse_list(html: str) -> list[dict]: """解析列表页,返回动漫条目列表""" soup = BeautifulSoup(html, "lxml") items = [] for node in soup.select("div.anime-item"): # 选择器按实际页面调整 title = node.select_one("h3.title") score = node.select_one("span.score") items.append({ "title": title.get_text(strip=True) if title else "", "score": float(score.get_text(strip=True)) if score else None, "link": node.select_one("a")["href"] if node.select_one("a") else "", }) return items

这里每个字段都做了“节点不存在就兜底”的处理,这是解析层最重要的习惯。页面里总有那么几条缺评分或缺链接的脏数据,不兜底的话get_text()直接抛 AttributeError,整个批次就断了。score转 float 时如果文本里带“分”字,要先replace("分", ""),这种细节得看实际页面。选择器div.anime-item是占位,实际要对着页面 DOM 改,改的时候用浏览器开发者工具复制选择器最省事。

3.3 翻页控制与去重存储

翻页有两种判断方式:按固定页数上限,或按“本页解析结果为空就停”。我一般两个都用——上限防死循环,空页防无效请求。存储层用追加模式写 CSV,同时维护一个已抓标题的集合做去重,避免重复跑时数据翻倍。

import csv, os from spider import fetch_page from parser import parse_list from config import MAX_PAGE, CSV_PATH def crawl_all(): seen = set() if os.path.exists(CSV_PATH): # 断点续跑:读已有标题 with open(CSV_PATH, encoding="utf-8") as f: seen = {row["title"] for row in csv.DictReader(f)} with open(CSV_PATH, "a", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=["title", "score", "link"]) if f.tell() == 0: writer.writeheader() for page in range(1, MAX_PAGE + 1): html = fetch_page(page) if not html: break rows = [r for r in parse_list(html) if r["title"] not in seen] if not rows: break # 空页或全重复,停止翻页 writer.writerows(rows) seen.update(r["title"] for r in rows) print(f"第 {page} 页写入 {len(rows)} 条")

f.tell() == 0判断文件是否为空,决定要不要写表头,这是追加模式下的常见写法。断点续跑靠启动时读已有标题进seen,这样中途断了重跑不会重复。break放在“本页无新数据”处,比单纯按页数停更稳——有些站点翻到后面会返回重复内容。

4. 数据分析与可视化:从 CSV 到能看的图

4.1 pandas 清洗:缺失值、类型与去重

抓下来的 CSV 不能直接画图,先过一遍清洗。评分列可能有空值,播放量如果是“1.2万”这种文本要先转数值,标题可能有重复。清洗逻辑集中在 analysis.py,本地有 CSV 就能反复跑。

import pandas as pd def load_clean(path: str) -> pd.DataFrame: df = pd.read_csv(path) df = df.drop_duplicates(subset="title") # 标题去重 df["score"] = pd.to_numeric(df["score"], errors="coerce") # 脏值转 NaN df = df.dropna(subset=["score"]) # 评分缺失的条目剔除 df = df[df["score"].between(0, 10)] # 过滤异常评分 return df

errors="coerce"是把无法转换的值变成 NaN 而不是抛异常,配合后面的dropna一起用,是清洗数值列的标准套路。between(0, 10)过滤掉明显异常的评分,比如解析错位抓到的年份数字。这几步做完,数据才具备统计意义。

4.2 统计口径:排行、类型分布、评分区间

统计部分要明确口径,不然图出来自己都解释不清。排行按评分降序取前 20;类型分布用value_counts;评分区间用pd.cut分箱。分箱的边界要写死,别用自动分箱,否则每次跑出来的区间不一样,图没法对比。

def summarize(df: pd.DataFrame): top20 = df.nlargest(20, "score")[["title", "score"]] type_dist = df["type"].value_counts().head(10) if "type" in df else None bins = [0, 6, 7, 8, 9, 10] labels = ["<6", "6-7", "7-8", "8-9", "9-10"] score_dist = pd.cut(df["score"], bins=bins, labels=labels).value_counts().sort_index() return top20, type_dist, score_dist

nlargest比sort_values().head()在数据量大时略快,语义也更清楚。pd.cut的bins左开右闭,边界值归到右侧区间,这个细节在写图注时要说明,不然读者会对不上数。

4.3 matplotlib 出图:中文字体与布局

可视化层最容易翻车的是中文显示。matplotlib 默认字体不含中文,图上的标题全是方块。解决办法是显式指定系统里已有的中文字体,并关掉负号显示问题。

import matplotlib.pyplot as plt import matplotlib matplotlib.rcParams["font.sans-serif"] = ["SimHei"] # 按系统实际字体改 matplotlib.rcParams["axes.unicode_minus"] = False # 负号正常显示 def plot_top20(top20): plt.figure(figsize=(10, 6)) plt.barh(top20["title"][::-1], top20["score"][::-1]) # 反转使最高分在顶部 plt.xlabel("评分") plt.title("动漫评分 Top20") plt.tight_layout() plt.savefig("output/top20.png", dpi=150) plt.close()

[::-1]反转是为了让最高分排在横向柱状图顶部,符合阅读习惯。tight_layout()防止标签被裁掉,dpi=150保证导出图清晰。plt.close()一定要加,批量出图时不关 figure 会内存泄漏,跑到后面越来越慢。

5. 避坑与排查:跑不通时先看这几条

5.1 抓回来是空列表

现象:脚本不报错,但 CSV 里一条数据都没有。原因多半是选择器写错,或者页面是前端渲染的,requests 拿到的 HTML 里根本没有数据节点。解决:先把resp.text存成文件,用浏览器打开看有没有目标内容;没有就说明是动态渲染,得换思路(找接口或换工具),别在 BeautifulSoup 上死磕。

5.2 中文全是乱码

现象:标题列显示成æµ·è´¼ç这类字符。原因是响应编码判断错误。解决:用resp.apparent_encoding覆盖resp.encoding,或者在open()写文件时显式指定encoding="utf-8"。两处都要检查,只改一处往往还是乱。

5.3 翻页死循环或提前停

现象:脚本一直跑到页数上限,或者第二页就停了。原因:站点对超范围页码返回首页内容(导致一直有数据),或对连续请求做了限流返回空页。解决:加“本页标题与上一页重复率过高就停”的判断,同时把请求间隔调到 1 秒以上,别用并发硬冲。

5.4 图表中文变方块

现象:图能出,但标题和坐标轴全是方框。原因:matplotlib 没找到指定字体。解决:先确认系统里装了什么中文字体(Linux 下常见的是文泉驿),把font.sans-serif改成实际存在的字体名;找不到就装一个,别硬写 SimHei。

5.5 重复跑数据翻倍

现象:第二次运行后 CSV 行数变成两倍。原因:追加模式没做去重。解决:启动时把已有标题读进集合,写入前过滤;或者每次跑之前先备份旧文件、重新生成。我一般选前者,断点续跑也靠它。

6. 二次开发:换站点、加字段、做增量

拿到这份资源后,最实际的改造方向是“换一个榜单”。核心改动集中在三处:config.py 里的BASE_URL和HEADERS、parser.py 里的选择器和字段映射、analysis.py 里的统计口径。换站点时先手动请求一页,把 HTML 存下来对着改选择器,比边跑边调试快得多。字段扩展也类似——想加“更新时间”,就在 parser 的 dict 里加一个键,storage 的fieldnames同步加上,pandas 那边自然多一列。

增量更新是另一个高频需求。我的做法是给每条记录加一个抓取日期列,每次跑只保留最新日期的数据参与分析,历史数据留档。这样既能看趋势,又不会因为重复抓取把统计搞乱。验证改造是否成功,我一般跑三步:先小页数(MAX_PAGE=2)验证解析,再全量跑验证存储,最后本地读 CSV 跑分析和出图,确认图表数字和 CSV 对得上。

改造点改动文件验证方式
换目标站点config.py、parser.py单页解析条数与页面实际一致
新增字段parser.py、storage.pyCSV 表头与数据列对齐
改统计口径analysis.py分组总数等于清洗后行数
增量更新storage.py、analysis.py同一条目只保留最新记录

有个坑我踩过不止一次:改完 parser 忘了同步fieldnames,结果DictWriter遇到多余键直接抛异常,或者少写列导致后面 pandas 读出来错位。从那以后我每次改字段都强制走一遍“小页数试跑 → 打开 CSV 看表头 → 再全量”的流程,宁可多花两分钟,也不返工。希望这份拆解能帮你把这个爬虫模板真正跑起来、改成自己的。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询