寒假过到第三周训练营群里突然弹出作业公告内容是做一个完整的“图书榜单采集与可视化分析”小项目。没有指定网站没有给代码框架只给了“爬虫 数据清洗 可视化”三个关键词要求在五天内提交一份可直接运行的代码和一篇分析报告。这种开放式作业比手把手带教的作业难不少难的不是写代码而是从选题到落地的整个流程设计。我花了四天多完成过程中踩了几个值得记录的坑把完整思路和关键代码整理成这篇笔记给同样在赶作业的朋友做个参考。作业要求不算复杂采集某一图书电商平台的热门榜单数据至少覆盖50本图书完成数据清洗后存入本地数据库再用图表展示价格分布、评分分布、出版年份趋势等信息最后给出一个两百字左右的结论。听上去只是几个脚本的活但真正动手后才发现每个环节都有选择题要做。1. 第三周作业是什么图书榜单采集与可视化分析项目拆解1.1 为什么第三周适合布置“数据链路完整”的小项目前两周的训练营作业分别是Python基础语法练习和单页静态页面爬取到了第三周突然跳到“采集清洗存储可视化”的全流程其实就是想逼学员把零散知识点串成一条完整的数据链路。图书榜单这个选题也选得聪明图书信息结构规整一般都有书名、作者、出版社、出版时间、价格、评分、评价数量等字段非常适合练习数据清洗榜单页面的翻页机制通常是URL参数变化难度不会大到劝退新手可视化的角度也多价格分布、出版年份趋势、出版社集中度都能讲出点内容。这类作业的核心难点并不在单一技能而是“字段的获取与对齐”。比如同一个电商页面上价格可能出现在两个位置一个是列表页的促销价一个是详情页的原价评分有的显示在列表页有的需要进入详情页才能拿到。不同的采集策略会直接决定后续数据清洗的工作量。所以拿到作业后我做的第一件事不是写代码而是花了一个多小时手工浏览页面确认“每个字段到底从哪个页面、哪个标签结构里取最可靠”。1.2 我确定的作业交付范围和验收标准项目的验收标准必须事先想清楚否则做起来容易收不住。我的范围是这样定的采集某电商平台“图书榜”前100本图书的基础信息字段限定为书名、作者、出版社、出版时间、价格、评分、评价数量、榜单排名共8个字段。数据清洗的目标是去重、补缺失、统一格式然后把数据存入SQLite数据库最后用图表做四个维度的可视化价格分布直方图、评分与评价数量的散点图、出版年份分布图、出版社Top10条形图。范围之外的东西不碰比如不做评论内容的情感分析、不做图书简介的文本挖掘因为那是第二周作业和后续课程会涉及的内容。这样定好边界之后每块工作的产出都是可验证的爬虫脚本输出多少行数据数据库里能查到多少条干净记录图表能对应生成几张答辩时有据可说。2. 环境准备与技术选型三周训练营的第三关打磨2.1 爬虫框架选型对比直接requests还是上Scrapy作业时间有限我一开始犹豫过要不要直接用Scrapy。Scrapy的优势是并发抓取、内置去重和管道处理可一旦用上它项目的复杂度和调试成本会同步上升对新手不太友好。对比下来我决定采用“requests BeautifulSoup pandas sqlite3”的组合这也是我认为训练营第三周最合理的选择理由有三点。第一整个项目只需要抓取20个左右的列表页和少量详情页总请求量不超过两百次requests的同步请求虽然慢但两分钟内能完成没必要引入异步框架。第二BeautifulSoup解析HTML的代码直观出错时容易定位Scrapy这套体系里有Selector、Item、Pipeline、Middleware等一堆概念为了一个小项目先学框架本身反而耽误时间。第三数据清洗和入库用pandas加sqlite3就能完美承接不需要额外引入重型ORM工具。2.2 环境准备里最容易忽略的细节环境准备除了常规的pip安装有几个细节直接决定后面能否顺利跑通。我在Windows环境下操作一开始没在意User-Agent的设置结果前两次请求直接被服务器拒绝。模拟浏览器请求头是最基本的反爬应对手段这不仅适用本次作业所有类似采集项目都需要注意。再就是pandas的版本和Python版本的匹配问题。我环境里Python是3.10版本pandas和numpy已经提前装好但某个同学用的Python版本较旧安装新版pandas时报了依赖冲突最后只能降级安装。建议作业开始前先在命令行里运行几条基本导入检查确认requests、bs4、pandas、matplotlib、sqlite3都能正常导入后再开始写业务代码。多数公开图书页面的数据属于公开信息采集频率低、间隔拉长不会对目标服务器造成压力。但务必遵守目标网站的robots协议不要采集个人隐私信息不要将数据用于商业用途作业完成后数据仅保存在本地。整个项目都应控制请求频率避免给目标服务器带来负担。2.3 目标页面结构分析列表页和详情页的字段分配动手写爬虫前我先人工访问榜单页面把页面的承载结构摸清。列表页每页共20本书每个项包含封面、书名、作者、出版社、出版时间、价格、评价数量等基础信息评分有些在列表页可见有些则被折叠需要进入详情页才能取到。经过测试列表页字段已经能覆盖作业所需的70%剩下20%左右的信息需要额外请求详情页补全另外还有大约10%的字段即便进了详情页也不稳定需要在清洗阶段做二次处理。我的策略是“以列表页为主详情页按需补抓”只有评分缺失时才构造详情页请求这样能把总请求量控制在一个相对可接受的范围内减少对目标站点的访问压力。3. 爬虫核心逻辑与代码实现从请求、解析到去重落库3.1 构造翻页URL与请求头榜单页面的翻页规律比较简单第一页URL不含page参数从第二页开始URL的query部分多了page2、page3这样的参数。经过测试最多能翻到20页。请求头我设置了User-Agent和RefererUser-Agent模拟正常浏览器Referer设置成榜单页首页这样服务器端看起来更像正常用户行为。import requests from bs4 import BeautifulSoup import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://example-book-site.com/top, Accept-Language: zh-CN,zh;q0.9, } def build_url(page): if page 1: return https://example-book-site.com/top return fhttps://example-book-site.com/top?page{page}请求间隔我设置成每次请求后sleep两秒二十个页面跑下来大约需要不到一分钟时间完全够用。3.2 列表页解析先拿到九成字段列表页的每个图书项可以看成一个卡片卡片内部分为左侧封面区、中间信息区、右下角价格区。我定位每个卡片容器后在容器内部继续用选择器提取字段这样做的好处是字段之间不会跨卡片串线。def parse_list_page(html, rank_start): soup BeautifulSoup(html, html.parser) items soup.select(div.book-item) book_list [] for idx, item in enumerate(items, start1): title_elem item.select_one(h3 a) author_elem item.select_one(p.author) pub_elem item.select_one(p.publisher) price_elem item.select_one(p.price span.amount) rating_elem item.select_one(span.rating_num) comments_elem item.select_one(span.comments-count) book_list.append({ rank: rank_start idx - 1, title: title_elem.text.strip() if title_elem else None, author: author_elem.text.strip() if author_elem else None, publisher: pub_elem.text.strip() if pub_elem else None, price: price_elem.text.strip() if price_elem else None, rating: rating_elem.text.strip() if rating_elem else None, comments: comments_elem.text.strip() if comments_elem else None, }) return book_list这里有两个容易踩的细节。第一个是价格字段的文本可能带有人民币符号和多余空格需要统一去掉第二个是评分字段有时候是“暂无评分”这样的字符串这个状态要直接标记为缺失值不要转成NaN因为后续清洗时需要区分“真缺失”和“假缺失”。def clean_text(text): if not text: return None text text.strip() text text.replace(¥, ).replace(元, ) return text3.3 详情页补抓策略按需请求而非全量请求详情页补抓的核心原则是“能不请求就不请求”。我解析完列表页后把所有评分为空或缺失的记录筛选出来构造一个待补全列表再从详情页重新提取评分、出版社、出版时间等字段。详情页的URL结构可以直接从列表页的书籍链接获得不需要额外搜索。def enrich_from_detail(session, book_url, current_book): resp session.get(book_url, headersHEADERS, timeout5) if resp.status_code ! 200: return current_book soup BeautifulSoup(resp.text, html.parser) rating_elem soup.select_one(div.rating_wrap span.rating_num) if rating_elem and not current_book.get(rating): current_book[rating] rating_elem.text.strip() return current_book这种方式能把请求数量控制在20到30次以内总耗时短被限流的风险也更低。所有请求完成后统一落库而不是每抓一本书就写一次数据库这样能加快整体速度。3.4 数据去重与排序两个容易被忽视的细节榜单数据在上午和下午各抓一次做对比验证时发现同一本书出现了两条记录。去重的逻辑不能只看书名因为重名书很多我用“书名作者”作为联合键来判断重复。具体实现是先把所有记录读入pandas的DataFrame再用subset指定这两列去除完全重复的记录。import pandas as pd df pd.read_csv(raw_books.csv, encodingutf-8) df_cleaned df.drop_duplicates(subset[title, author], keepfirst)排名排序也有讲究。第一页的排名是1到20第二页应该是21到40但由于某些书籍被推荐位占掉导致实际列表的数量可能不足20如果直接用“页数乘20加索引”会算错排名。我的做法是在翻页时维护一个全局累加器每解析出一条记录就自增一位这样排名顺序不会因为缺项而错位。4. 数据清洗与异常处理缺失值、重复项和单位统一4.1 清洗流程设计分阶段而不是一把梭数据清洗我分成了四个阶段格式统一、缺失值处理、数值转换、重复值处理。不要在一步里同时处理所有问题那样出了问题很难定位是哪一步代码造成的。格式统一阶段处理的是字符串里的空白字符、全角半角标点、价格单位混用等问题。比如有的价格是“39.80”有的可能是“39.8 元”还有的用了全角冒号。统一成浮点数才能做后续的直方图分析。数值转换阶段把“评分”这种字符串转成float“评价数量”转成int“价格”转成float。转换时要处理“暂无评分”、“None”、“空字符串”等异常输入我用了一个safe_convert函数转换失败就返回None而不是让程序崩溃。4.2 SQLite落库为什么作业阶段不选MySQL和CSV落库我选的SQLite而不是MySQL或者纯CSV文件。选SQLite的原因很简单零配置、单文件、随项目走。作业提交时直接把.db文件交上去对方在任何一台机器上都能用sqlite3命令查看不需要单独装数据库服务。MySQL对训练营作业来说过于重对一台普通电脑也会占用不必要的资源。建表语句我提前设计好字段类型标清楚书名和作者加上联合唯一索引。写完表后导入数据再次去重。import sqlite3 conn sqlite3.connect(books.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, rank INTEGER, title TEXT, author TEXT, publisher TEXT, publish_year INTEGER, price REAL, rating REAL, comments INTEGER, UNIQUE(title, author) ) ) conn.commit()4.3 数据质量验证清洗前后要有对照清洗前共采集134条记录清洗后剩余99条有效记录。缺少的35条主要是因详情页访问失败导致评分和出版社为空且无法通过其他渠道补齐。验证方式是对比最后一次入库后的记录总数和核心字段的缺失情况conn sqlite3.connect(books.db) df_final pd.read_sql_query( SELECT COUNT(*) as total, SUM(CASE WHEN price IS NULL THEN 1 ELSE 0 END) as missing_price, SUM(CASE WHEN rating IS NULL THEN 1 ELSE 0 END) as missing_rating, SUM(CASE WHEN publisher IS NULL THEN 1 ELSE 0 END) as missing_pub FROM books , conn) print(df_final)清洗后的数据仍然有少量书籍评分缺失我在可视化阶段把这些样本直接过滤掉而不是用中位数填充因为评分缺失和图书本身的质量无关强填会产生误导。5. 可视化分析与作业答辩图表怎么选、怎么讲才有说服力5.1 价格分布直方图解释为什么左偏而不该用平均值价格分布图用了直方图和箱线图两张图配合。绘制直方图后发现价格分布明显左偏大部分图书集中在20到60元之间少数精装书和套装书把均值拉高了。箱线图能直观看到中位数、四分位距和异常点的位置比单独一个均值更能说明问题。import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False fig, axes plt.subplots(1, 2, figsize(12, 4)) axes[0].hist(df_final[price].dropna(), bins20, edgecolorblack) axes[0].set_title(价格分布直方图) axes[0].set_xlabel(价格(元)) axes[0].set_ylabel(图书数量) axes[1].boxplot(df_final[price].dropna(), vertFalse) axes[1].set_title(价格箱线图) plt.tight_layout() plt.savefig(price_distribution.png, dpi150)这里有个讲解答辩的技巧不要只报数字要把“为什么会出现这个分布”讲清楚。我分析后给出的解释是榜单以大众畅销书为主定价策略相对统一精装典藏版和礼盒套装属于少数高价位异常值这正好解释了为什么平均价格高于中位数。5.2 评分与评价数量的关系先取对数再看趋势评分数值的范围在0到10之间评价数量的范围则很大几本书的评价数直接破万画散点图时图形都挤在一起看不出趋势。处理办法是把评价数量取对数再做散点图。对数变换之后横轴拉开数据点摊开才能看出评分越高的书评价数量确实会相对更多。import numpy as np df_scatter df_final.dropna(subset[rating, comments]) df_scatter df_scatter[df_scatter[comments] 0] plt.figure(figsize(8, 5)) plt.scatter(df_scatter[comments].apply(np.log10), df_scatter[rating], alpha0.6) plt.xlabel(评价数量(log10)) plt.ylabel(评分) plt.savefig(rating_comments_scatter.png, dpi150)答辩时我给的结论是评分在8.5分以上的书样本量更大、评价数分布更广说明高分评价集中度更高头部效应明显评分在7分左右的书评价数普遍不高说明口碑的传播度和评价数量确实存在正相关关系。5.3 出版社集中度Top10最能体现“数据洞察力”的图这个图是答辩时的亮点。统计出版社出现频次后发现排名前两位的出版社合计占了整个榜单接近四分之一榜单尾部还散落着大量只出现一次的出版社。用条形图展示出版社数量Top10后切入角度是“图书市场二八效应明显头部出版社在畅销榜里有更强的话语权”这个结论不复杂但能从数据里发现并准确表达出来作业就成功了大半。5.4 答辩讲图表的表达逻辑先现象、再原因、后建议答辩时我按“每张图30秒结论”来组织语言。标准结构是这样的先描述现象图表里能直接看到什么再推断原因结合图书市场、定价策略、读者偏好给出合理解释最后给一句“可操作”的延伸建议比如“从出版社集中度看新书若要进入畅销榜可以考虑与头部出版社合作”。不要试图解释每一张图的每一个点挑出三个核心发现并讲透比把所有图读一遍更有说服力。我实际讲了三个发现价格分布左偏、评分和评价数量正相关、出版社头部效应显著。每张图配一段三句话的解释答辩时间控制得很理想。6. 我的踩坑记录与这个项目还能往哪扩展6.1 踩坑一翻页参数检查不仔细导致第一页重复抓取项目初期我曾把第一页和“page1”当成两个不同的URL导致第一页数据被抓了两遍去重后又“少”了很多排名花了不少时间排查。教训是写翻页逻辑时先打印实际URL确认参数变化别凭直觉设计URL规则。6.2 踩坑二编码声明不一致导致中文字符乱码第一次解析列表页时页面返回的是gbk编码但requests的默认编码判断有偏差导致.text属性拿到乱码。处理方式很简单先确认页面meta标签里的charset然后在resp.encoding里手动指定编码。这个坑在中文网站采集项目里出现频率极高值得铭记。resp.encoding resp.apparent_encoding用apparent_encoding自动检测比手动指定稳妥但检测本身有一定耗时只在遇到乱码时使用更好。6.3 项目扩展方向同样的框架可以套用到别的选题做完这个作业后最大的体会是这套“列表页详情页按需补抓数据清洗入库可视化”的流程具备很强的复用性。比如把采集对象换成某平台的招聘信息榜单、游戏热销榜、影音排行等只需替换解析规则整体代码框架基本不变。后续想深挖的话在采集层可以接入代理池和断点续爬为更大规模的数据采集做基础准备。作业期限范围内的合理目标是把当前代码的健壮性提升到一个可交付水平。6.4 我的体会第三周作业真正磨的是什么这个项目最终我写了大约三百行代码真正难的其实不是那三百行而是中间踩坑时怎么判断问题出在哪个环节。第一周学语法、第二周学解析第三周就是在告诉所有人技术点要变成项目中间隔着需求拆解、异常处理、数据校验、结论表达几道坎。经历这道坎后后面再做类似的项目时就会自然地把流程拆成模块能复用旧代码的地方直接复用新的领域知识只需填补两三个函数其余逻辑一概不动。如果你也在这个阶段做类似的训练营作业我的建议是宁可把范围缩小也要把每块做扎实。少抓五十本书把清洗和图表这张“脸面”做好比贪多求全但图表粗糙有看点得多。这本作业交上去之后我给自己留了一个后续可扩展的空间在现有爬虫基础上增加计划任务每周自动跑一次数据生成周报趋势对比。这就是之前踩坑换来的经验再用一次的问题了。