第35天晚上,我的爬虫终于跑通了。按照计划里的安排,我从一个公开的图书评分站点抓了约1200本书的基础信息:书名、作者、评分、评价人数、出版年份、定价、简介,最后落成了一份4万多字符的CSV文件。今天进入第36天,主题是爬虫与数据分析可视化(下)——把昨天囤下来的数据清洗干净,做一轮探索性分析,再用图表让这些数字开口说话。这篇文章适合已经能写出简单爬虫、但还没把数据“用完”的人参考,我会把清洗、分析与可视化的完整路径、每一段代码背后的取舍、以及实际跑出来的结果一起讲清楚。先说结论:爬虫只是把数据搬进家门,真正有价值的动作,全在今天的后半程。
1. 从“爬到数据”到“读出结论”,这一步常常被教程跳过去
网上绝大多数爬虫教程的终点,是控制台里打印出来的几行字典或者一张CSV截图。说句实在话,我之前也停在这个阶段很久。直到真拿着1200条记录去做分析,才发现“能跑”和“能用”之间的距离,差不多有一整个白天的工作量。
1.1 昨天抓回来的数据长什么样
先看一眼原始数据的样子,我截取了CSV的一部分:
| title | author | rating | vote_count | pub_date | price | intro |
|---|---|---|---|---|---|---|
| 一间只属于自己的房间 | 弗吉尼亚·伍尔夫 | 9.1分 | 2384评价 | 2019-08-01 | ¥42.00 | 女性写作… |
| 克拉拉与太阳 | 石黑一雄 | 8.1分 | 12345评价 | 2021-03-01 | 定价:79.00元 | 太阳即将… |
| 红楼梦 | 曹雪芹 | 9.6分 | 102938评价 | 2007/1/1 | 38.00 | 无简介 |
| 盗墓笔记 | 南派三叔 | 缺 | 缺 | 2020-02 | 59.0 | 空 |
乍一看似乎挺整齐,但只要开始处理,问题全出来了。“9.1分”“2384评价”这类带单位的字符串没法做数学计算;出版日期有“2019-08-01”也有“2007/1/1”,格式不统一;价格字段里混着人民币符号和“定价:”前缀;还有评分缺失、简介为空、因为翻页拼接导致的重复行。
这些脏数据不会自己变干净,如果不处理,后面画出来的图就是错的,结论也可能是反的。
1.2 为什么“能跑”的代码不等于“能用”的数据
我见过不少朋友爬完数据,直接df.head()看一眼就开画图,画完发现Y轴出现了一堆莫名其妙的字符,或者某个分组里数值大得离谱,最后只能回头补数据。这个来回折腾的时间,往往比正式清洗还长。
我现在的习惯是:爬虫写完先不急着分析,先给数据做一次“体检”。把每个字段的类型、空值数量、唯一值数量、异常值范围全部过一遍。宁可在这个阶段多花半小时,也不要让错误结论带着你多折腾好几天。因为可视化的本质是把数据里的信号放大,脏数据被放大之后,只会变成更大的噪音。
1.3 今天的交付物:四张图和一句话
为了避免陷入“为了画图而画图”,我在动手前先定了今天的交付物:
- 一张评分分布直方图,回答“这批书的评分集中在哪里”;
- 一张出版年份与平均评分的折线图,回答“近年的书评分走势如何”;
- 一张评价人数与评分的散点图,回答“口碑和讨论度是否互相促进”;
- 一张简介关键词词云,回答“这批书都在讲什么主题”。
最终用一句人话总结出来。后面每一步都围绕这四个问题展开,不贪多。这个“先定问题再定图表”的习惯,是我觉得今天最值得分享的一点。
2. 清洗才是数据可视化的真正起跑线
如果说爬虫是做运输,清洗就是做质检和分拣。货没分好,后面所有的加工都是白费功夫。我把今天清洗的过程拆成几个固定动作,每一个动作我都会解释为什么必须做。
2.1 动手之前,先留下原始数据副本
清洗的第一条铁律:永远不要直接在原始数据上改。我先做一次备份:
import pandas as pd df = pd.read_csv('books_raw.csv', encoding='utf-8-sig') df.to_csv('books_raw_backup.csv', index=False, encoding='utf-8-sig') raw = df.copy()理由很简单:清洗规则本身可能会写错,或者后面发现某个字段还需要原来的格式,没有备份就只能重新爬一遍。而重新爬的代价不只是时间,还可能因为站点数据已经更新,导致之后完全对不上账。
提示:备份这一步看着多余,但在数据流水线里,它相当于一个“后悔药”。我遇到过一次清洗正则写错,差点把200多行价格全改成空值,就是因为有备份才没重爬。
2.2 六个高频清洗动作,逐个拆解
针对这份数据,我依次做了以下操作。
去重。翻页拼接时同一本书可能被抓两遍,我用“书名+作者”作为组合主键去重:
raw.drop_duplicates(subset=['title', 'author'], inplace=True)丢弃关键字段为空的行。评分和评价人数是分析的基础,缺失了就没办法参与计算:
raw.dropna(subset=['rating', 'vote_count'], inplace=True)这里只丢弃关键的,简介为空的书反而不能丢——后面做词云时可以用NaN填充一个占位符。
清洗评分。原始值是“9.1分”这种字符串,需要去掉单位再转浮点:
raw['rating'] = raw['rating'].astype(str).str.replace('分', '', regex=False).astype(float)清洗评价人数。字段里混着“2384评价”“102938人”等写法,我用正则把非数字部分去掉:
raw['vote_count'] = raw['vote_count'].astype(str).str.replace(r'[评价人个]', '', regex=True).astype(int)统一出版日期。直接用pd.to_datetime统一格式,顺便提取年份字段,后面做趋势图时用:
raw['pub_date'] = pd.to_datetime(raw['pub_date'], errors='coerce') raw['pub_year'] = raw['pub_date'].dt.year提取价格数字。原来有“¥42.00”、“定价:79.00元”、“38.00”等多种写法,只要数字:
raw['price'] = raw['price'].astype(str).str.extract(r'(\d+\.?\d*)')[0].astype(float)最后用范围过滤把明显异常的数据剔除掉,比如评分大于10、出版年份早于1900或者晚于当前年份的记录:
raw = raw[raw['rating'].between(0, 10) & raw['pub_year'].between(1900, 2025)]2.3 清洗前后对比:从“字符碎片”到“整洁的DataFrame”
清洗完成后,我再出一次体检报告,和清洗前对比:
| 指标 | 清洗前 | 清洗后 |
|---|---|---|
| 总行数 | 1276 | 1188 |
| 重复行 | 31 | 0 |
| 评分缺失 | 23 | 0 |
| 评价人数缺失 | 17 | 0 |
| 评分类型 | 字符串 | float |
| 出版日期格式 | 混用3种 | 统一为datetime |
| 价格类型 | 混合文本 | float |
看到这个对比,我才有底气开始分析。清洗过程的每一步,本质上都是在和数据“核对口径”:评分的算法是什么、出版年份怎么定义、价格包不包括折扣。口径不一致的数据,画出来的图再漂亮也只是个空壳。
2.4 清洗要会“留痕”,别把过程都吞掉
我会在脚本里保存一份cleaning_log,记录每个动作删了多少行、改了多少字段。一方面是便于复查,另一方面是为了让流程可复现——昨天清洗的结果和明天的结果中间隔了网站数据更新,留痕才能追溯差异是从哪一步引入的。
cleaning_log = { 'raw_rows': len(df), 'after_dedupe': len(raw), 'after_dropna': len(raw), 'final_rows': len(raw), } print(cleaning_log)这一步很多人会忽略,但它对工程化的帮助极大。尤其是当你把爬虫改成定时任务后,留痕就是排查数据异常的“监控日志”。
3. 画图之前,先用EDA摸底,让数据自己报一遍家底
我见过太多人拿到干净数据就直接上图表。我的建议是别急,先用探索性数据分析(EDA)问几个基础问题:数据总量多少?分布偏不偏?哪些字段之间有关系?这就像去一家新餐馆,先看菜单找感觉,再决定点什么菜。
3.1 describe() 和 info(),两行代码读出基本面
df_clean = raw.copy() print(df_clean.info()) print(df_clean[['rating', 'vote_count', 'price', 'pub_year']].describe())输出的关键信息大概是这样的:
- 评分:均值约7.5,中位数约7.7,25%分位数在6.8左右;
- 评价人数:中位数只有200多人,但均值被少数几百本热门书拉到了好几千,右偏严重;
- 价格:大部分集中在30到80元,个别签名本或套装书价格异常高;
- 出版年份:从1980年代到2024年都有分布,最近十年占了多数。
读完这组数字我做的第一个判断是:分析时用中位数比均值更稳,因为评价人数这种长尾字段容易被极端值带偏。这个小细节后面会用到。
3.2 分组聚合,从单一记录看到时间截面
我想知道“这几年出品的书评分到底在涨还是跌”,所以按出版年份分组聚合:
year_stats = df_clean.groupby('pub_year').agg( avg_rating=('rating', 'mean'), book_count=('title', 'count') ).reset_index() print(year_stats.head(10))结果里能明显看到两个现象:一是样本量小的早期年份,评分波动特别大,比如1998年只有3本书,平均分却被其中一本9.4分拉得很高;二是2015年之后每年样本量都在50本以上,平均评分稳定在7.2到7.8之间,并且略有上升。
这个观察提示我,画折线图时必须把样本量一起展示,否则单看平均分很容易被极小样本误导。
3.3 相关系数:用数字确认直觉
我还想验证一个常见直觉:讨论度高的书,评分是不是也更高。直接看相关系数:
corr = df_clean[['rating', 'vote_count']].corr() print(corr)计算出来的皮尔逊相关系数大约在0.34左右。这个数值说明两者确实存在正相关,但强度不算高。很多口碑一般的书也可能因为营销或争议获得大量评价,所以不能画完散点图就下“评价越多书越好”的结论。
另外我还做了一组简单的价格区间交叉统计,结论很有意思:把价格分成“50元以下”“50到100元”“100元以上”三档之后,三组的平均评分几乎没有差别。这个发现提醒我,后面千万不要把价格画进“评分影响因素”的叙事里。
3.4 从EDA里挖出来的三条线索
经过前面几步,我手里攒了三条可以展开的线索:
- 评分整体右偏,8到9分之间的书最多,说明该站点读者打分偏宽容;
- 近年评分稳中有升,但高分书里重版经典的比例明显高于新书;
- 评价人数和评分只有弱正相关,话题热度不等于口碑。
后面所有的图表,都围绕这三条线索展开。先有线索再作图,图才有明确的“任务”,而不是为了凑页面。
4. 选对图表,才叫让数据“说话”
图表不是装饰品,它是在替数据充当表情和语气。同一个指标,折线图强调变化趋势,柱状图强调高低对比,饼图强调构成比例,选错图等于用错误的语气说话。
4.1 一个简单的选图决策表
我根据要回答的问题,做了一张选图对照表:
| 要回答的问题 | 推荐图表 | 注意点 |
|---|---|---|
| 评分集中在哪个区间 | 直方图 / 箱线图 | 组距不要太大,否则看不出分布细节 |
| 评分随时间怎么变化 | 折线图 | 样本量少的年份要标记出来 |
| 评价人数和评分是否相关 | 散点图 | 点重叠严重时加透明度和抖动 |
| 哪些作者的高分书最多 | 水平条形图 | 只取前N名,避免标签拥挤 |
| 简介里出现最多的词 | 词云 | 先去掉停用词,否则全是“本书”“作者” |
这张表帮我在画图前就把“语法”定好,代码只是填词。
4.2 Matplotlib把四张图快速画出来
用Matplotlib跑图,最关键的是先处理中文字体。中文环境默认字体经常是方框,必须先指定字体:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei'] plt.rcParams['axes.unicode_minus'] = False在Windows上这两个设置就够了,Linux服务器上如果还乱码,就需要安装文泉驿或Noto CJK字体。
第一张评分分布直方图:
fig, ax = plt.subplots(figsize=(10, 6)) ax.hist(df_clean['rating'], bins=20, color='#4C72B0', edgecolor='white') ax.set_title('全部书籍的评分分布') ax.set_xlabel('评分') ax.set_ylabel('书籍数量') plt.tight_layout() plt.savefig('rating_dist.png', dpi=150) plt.close()这里我特意加了plt.close()。在循环里连续画多张图时,不关闭当前画布会导致内存持续上涨,脚本一长就会卡死或导出错图。
第二张历年平均评分折线图,我把样本量少于10本的年份单独标成灰色点,避免读者误读那些波动:
fig, ax = plt.subplots(figsize=(12, 6)) valid = year_stats[year_stats['book_count'] >= 10] ax.plot(valid['pub_year'], valid['avg_rating'], marker='o', color='#C44E52') ax.set_title('历年平均评分走势(仅展示样本量>=10的年份)') ax.set_xlabel('出版年份') ax.set_ylabel('平均评分') plt.tight_layout() plt.savefig('year_rating.png', dpi=150) plt.close()第三张评价人数和评分的散点图,我加入透明度来解决点重叠问题:
fig, ax = plt.subplots(figsize=(10, 6)) ax.scatter(df_clean['vote_count'], df_clean['rating'], alpha=0.3, color='#55A868') ax.set_xscale('log') ax.set_title('评价人数与评分散点图(X轴取对数)') ax.set_xlabel('评价人数(对数坐标)') ax.set_ylabel('评分') plt.tight_layout() plt.savefig('vote_vs_rating.png', dpi=150) plt.close()注意我给评价人数加了对数坐标。因为这个字段从几十到十几万,跨度太大,直接线性坐标会让绝大多数点挤在左侧,组间关系根本看不出来。
4.3 用Pyecharts升级成交互式图表
静态图适合自己检查数据,但要给别人看、让读者自己“问数据”,交互图好用得多。Pyecharts基于ECharts,渲染成HTML后鼠标悬停就能看具体数值,还能缩放和切换图例,特别适合做汇报。
安装方式很简单:
pip install pyecharts把刚才的折线图改成交互式版本:
from pyecharts.charts import Line from pyecharts import options as opts line = Line() line.add_xaxis([str(y) for y in valid['pub_year']]) line.add_yaxis( "平均评分", [round(v, 2) for v in valid['avg_rating']], is_symbol_show=True, ) line.set_global_opts( title_opts=opts.TitleOpts(title="历年平均评分走势"), tooltip_opts=opts.TooltipOpts(trigger="axis"), ) line.render("year_rating.html")用Pyecharts有两个容易踩的坑。第一个是版本问题,0.5.x和1.x的API差异很大,网上很多老教程直接会报错,装完记得先看版本;第二个是图表默认在本地生成HTML文件,如果部署到服务器上,还需要把JS资源一并处理好,否则别人打开页面图表空白。我现在更偏向用官方CDN加载资源,省去一堆静态文件同步的麻烦。
4.4 从单图到“可视化大屏”的组装思路
单张图是“一句话”,大屏是“一篇报告”。网上热门的可视化大屏项目,本质就是在同一页面里组合多张ECharts图表,并配上筛选器和数据卡片。
我用一个很轻的方案就能拼出一个简易大屏:用Flask提供一个页面,页面上用多个<div>分别渲染不同的ECharts实例,再写几个JS回调做联动。Pyecharts本身支持把多个图表放到一个Page对象里快速生成:
from pyecharts.charts import Bar, Line from pyecharts import options as opts page = Page(layout=Page.SimplePageLayout) page.add( bar, line, scatter, ) page.render("dashboard.html")不过我更建议大家先不要追求“大屏效果”。把单张图画到能回答一个业务问题,比堆十个图表更有价值。我第一次做大屏时堆了十几张图,结果没有一张能讲清楚数据到底在表达什么。
5. 一条命令产出报告:把爬虫、分析、可视化流水线化
单次把所有步骤写在Jupyter里没问题,但如果你准备长期跟踪这份数据,就必须把流程固化下来。我给自己的要求是:以后每天更新数据,只跑一条命令,得到一份完整报告。
5.1 拆成四个函数,各司其职
我按职责把昨天和今天的代码整理成四个模块:
# pipeline.py import pandas as pd def crawl_to_raw(cache=True): if cache and os.path.exists('books_raw.csv'): return pd.read_csv('books_raw.csv', encoding='utf-8-sig') # 爬虫逻辑... 在这里调用昨天的抓取函数 df = fetch_books() df.to_csv('books_raw.csv', index=False, encoding='utf-8-sig') return df def clean_data(df): # 所有清洗动作 ... return df_clean def analyze_data(df_clean): # 分组、相关性等统计 ... return stats def visualize_data(df_clean, stats): # 生成所有图表 ... return output_paths if __name__ == '__main__': raw = crawl_to_raw() clean = clean_data(raw) stats = analyze_data(clean) visualize_data(clean, stats)模块化最大的好处是:图表参数想调整时,只动visualize_data;站点规则变了,只动crawl_to_raw;清洗规则出了问题,只动clean_data。互相不干扰,出问题也容易定位。
5.2 为什么我坚持要加缓存层
昨天完整爬一次大概需要40多分钟,因为我每抓几页就休息几秒,避免给站点造成压力。如果每次调整图表都要重新爬一遍,一天就得浪费大半天。
所以我加了缓存:第一次爬完把结果存成CSV,后面再跑就优先读本地文件,只有显式执行“全量刷新”时才重新抓取。时间就这样从40分钟降到了几秒。
提示:缓存虽然省时间,但也要记得给文件标上抓取日期。我习惯在文件名里带时间后缀,比如
books_raw_20250101.csv,避免隔了一个月还分不清哪份是新的。
5.3 定时更新的思路:尊重规则,控制频率
如果想做成周更或月更的自动化报告,可以用系统计划任务或APScheduler。核心原则是低频、礼貌:别人网站的数据是公开资源,但高频率抓取会给对方服务器带来压力。
我之前踩过一个坑:为了测试定时任务,把间隔设成了10分钟一次,结果第二天被目标站点限流,整批数据没抓全,还连累了后续分析。后来把频率降到了每天一次,数据一直很稳定。
5.4 实测一次全流程的耗时分布
我在本机跑了一次完整流水线,记录下来的耗时大致如下:
| 阶段 | 耗时 |
|---|---|
| 爬虫(全量抓取) | 约42分钟 |
| 爬虫(读缓存) | 0.8秒 |
| 清洗 | 3.2秒 |
| 分析聚合 | 0.6秒 |
| 生成四张图+一个HTML | 5.4秒 |
看到这个时间分布,你应该明白为什么我反复强调缓存。真正的工作重心在清洗和图表设计,而不在重复抓取上。
6. 复盘与踩坑:那些代码之外,真正决定成败的细节
今天一天下来,程序本身没出什么大问题,但我在中途还是踩了几个有意思的坑。它们都不算难,可每一个都能让人浪费一两个小时。
6.1 爬虫阶段埋下的雷,全在可视化阶段炸了
第一个雷是编码。抓取时页面声明的是UTF-8,但个别老页面用了GBK,混在同一个列表里,清洗时没发现,画词云时才看到一堆乱码。最后我只能重新写解码逻辑,把异常字符按errors='ignore'处理,再补抓一次。
第二个雷是重复。翻页时页面底部有推荐位,同一本书会出现在相邻两页,去重时如果只看书名不看作者,两个同名不同作者的书就会被误删。所以我的去重键必须用['title', 'author']组合。
第三个雷是空简介。做词云之前我偷懒没填充空值,结果词云里全是“nan”这个词,第一版图丑得没法看。处理方式是先用空字符串填充,再统一过滤停用词。
6.2 关于可视化的细节:字体、Y轴起点、配色
字体问题前面讲过,这里补充一个更隐蔽的坑:默认折线图如果Y轴从6.5开始画,视觉上会把0.3分的波动放大成“剧烈变化”。这种做法在汇报里其实很常见,但如果不加说明,就有点误导的嫌疑。
我的处理办法是:让坐标轴包含合理的下界,同时在标题里写明Y轴的截取范围。用更专业的说法,就是给读者足够的信息去理解图表的比例尺,而不是让读者被你“精心挑选”的视角蒙住。
配色方面,我选择的事色盲友好型配色:用蓝、红、绿三种颜色区分不同系列,避免红绿同时作为唯一区分维度。这个细节在公开分享时尤其重要,因为读者里可能有色觉障碍者。
6.3 让数据说话,不等于让数据替你撒谎
这是今天最想强调的一点。我这份数据只抓了该站点一个分类下的书,样本本身就有偏。比如点进去评价人数的分布,极端右偏意味着绝大多数书只有几十条评价,真正有统计意义的是那几百本“热门书”。
所以我在写分析结论时,特别注意措辞:
- 不说“全网高分书都是这个规律”,而是说“在这批样本中,评价人数与评分的相关系数约为0.34”;
- 不把价格和评分的关系说成因果,因为这只是简单的分组对比;
- 把样本范围、抓取时间和清洗规则一并写进报告备注里。
数据可视化最大的价值不是“证明你对了”,而是帮助你看清本来看不清的结构。一旦开始为了说服别人而挑选视角,图就不再是数据的嘴,而是你自己的嘴。
6.4 我今天最痛的一个坑和下一步计划
今天最痛的坑发生在最后一步:我在Pyecharts里把一个字段名写错了,导致渲染出的折线图只有第一个点。排查了十分钟才发现,是分组后列名被reset_index重置了,而我还用原来的列名去引用。这个小问题教会我一件事:可视化程序出问题时,先检查数据进图之前的样子,别急着怀疑图表库。
如果这篇文章让你走到这里,我的建议是:先克制住“再多画一张图”的冲动,挑一张今天最想讲清楚的问题图,把它打磨到能回应一个具体疑问。等这条链路跑顺了,再往大屏、自动化报告、数据库存储的方向扩。这套“爬虫+数据分析+可视化”的组合拳打完之后,再回头处理真实业务里的数据,你会更容易听见数据想说的那句话。