“KCW 12.24作业”这串字符在我这行当里一眼就能看出门道——KCW多半是某门课的内部代号12.24是截止日。这是一份让我印象挺深的课程大作业内容是做一个完整的数据采集与分析小项目从零开始把数据抓下来、洗干净、存进库、再画出图表写成报告。我用这篇文章完整复盘一遍当时从拿到需求到最终交付的过程包括需求拆解、技术选型、编码实现以及一堆只有踩过坑才会知道的细节。对正在鼓捣类似课程作业、或者想自己动手做一次“数据采集到分析”全流程的读者来说这套思路可以直接抄作业。先说结论这门课的作业要求听起来不多——选择一个公开数据源抓取不少于5000条有效记录做清洗入库输出分析报告。但真正动手做的时候每一步都有讲究哪一步没处理好后面全得返工。我这次大概花了四个整天前半天在拆需求后半天交付报告中间的活全砸在数据清洗和反反复复的调试上。整个过程走完我觉得最有价值的不是作业拿了多少分而是捋清了一条“从原始数据到可读结论”的完整链路。1. 内容和架构的整体设计思路1.1 先把需求拆透再动手写代码很多同学拿到作业第一反应是“赶紧找个网站开爬”我上次也这么干过结果爬到一半发现目标网站结构变了、字段对不上、数据量凑不够彻底抓瞎。这次我强迫自己先花半个下午把需求拆成一张清单逐条对号入座。作业要求拆下来是这么几块可用公开数据源、不少于5000条有效记录、字段要规整、数据要入库、要出可视化分析和结论。于是我对照清单做了三件事。第一筛选数据源要求页面结构稳定、允许非商业用途抓取、数据更新频率不高——这种数据源最适合练手不容易爬到一半就失效。第二规划字段清单至少包括时间、分类、数值、文本四类基础数据这样后面做清洗和分析才有发挥空间。第三确定交付形态代码脚本、SQLite数据库文件、分析图表、PDF报告四样缺一不可。我选的是一类公开的商品信息平台数据结构上像列表页加详情页列表页有名称、价格、分类、发布时间详情页能拿到更细的描述字段。选它的原因很简单字段类型丰富数值、时间、文本都有数量足够而且页面规律性很强适合用requests加BeautifulSoup做静态解析。当然如果目标平台有公开API那优先用API代码量和稳定性都会好很多。至于那些需要登录、需要处理复杂动态加载、或者条款里明令禁止抓取的站点我直接绕开课程作业求的是稳不是秀肌肉。1.2 工具选型的取舍逻辑整个项目的技术栈我定得很保守Python 3 requests BeautifulSoup pandas sqlite3 matplotlib。没有用Scrapy没有用Selenium也没有用任何重型框架。原因很简单这作业数据量不大并发需求几乎为零用重型框架反而把简单问题复杂化。我自己在实际项目中经常用Scrapy但这次是课程交付代码要自己能讲清楚每一个环节轻量库的组合更合适。数据库选了SQLite而不是MySQL这条选择被很多人问过。我的理由有两个。一是零配置、单文件交作业时把kcw.db文件一起交上去就可以不需要对方再装数据库服务。二是数据量在几十万条以下时SQLite的读写性能完全够用没必要为了形式主义引入MySQL。当然我也在报告里注明了一句如果后续数据量到百万级且有并发读写需求迁移到MySQL或PostgreSQL是顺理成章的事。可视化和报告生成这块工具倒是换过一轮。一开始直接用matplotlib画图发现中文标签全成了方块折腾字体设置花了不少时间。后面固定用了一套中文字体配置方案包括指定SimHei字体和手动注册字体文件这才算彻底解决。报告部分用了最普通的Word排版图文混排结论部分用表格列数据清清爽爽。2. 数据采集的实现与细节打磨2.1 请求策略频率控制是第一原则采集脚本的第一步是分析目标页面的URL规律。我观察下来列表页的URL参数是?page1size50这样的结构每页50条那我只需要循环请求到第100多页就能凑够5000条以上。但我坚决没用并发也不开多线程就是一个for循环挨个请求每请求一次睡上0.5到1秒。这段代码的核心就是请求模块import requests import time from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, } def fetch_page(url, retries3): for attempt in range(retries): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text elif resp.status_code in (403, 429): time.sleep(5 * (attempt 1)) continue except requests.exceptions.RequestException as e: time.sleep(2 * (attempt 1)) continue return None这里有两个容易被忽略的细节。第一是timeout必须设不然某个请求卡住会让整个脚本永久挂起。第二是失败重试时的退避策略我按5秒 * 尝试次数递增避免在被限制的情况下还疯狂重试导致问题加剧。如果连续三次拿不到数据就记录下来跳过这一页等全部跑完后单独补抓。注意很多初学爬虫的人容易掉进一个误区觉得“爬得快写得好”这是大错特错的。对目标站点保持礼貌控制请求频率既是合规要求也是确保数据稳定的前提。设置随机延时0.6到1.2秒其实效果更好我这里用固定0.8秒是图省事。2.2 解析要写在ply解析函数里别一把梭解析这块我吃过亏。最开始我把所有解析逻辑全写在主循环里页数一多有个别页面结构出现小差异就整个崩掉。后来我改成按函数拆解——一个函数负责解析列表页拿到条目链接另一个函数负责解析详情页拿到完整字段出错时日志里能直接定位到具体函数排查效率高得多。def parse_list_page(html): soup BeautifulSoup(html, lxml) items [] for card in soup.select(div.item-card): link card.find(a, class_item-link) title card.find(h3, class_item-title) if link and title: items.append({ url: link.get(href), title: title.get_text(stripTrue), }) return items列表页解析的核心是选对CSS选择器。我会先用浏览器开发者工具检查目标元素的class名但绝对不会完全信任它——有些页面的class名是动态生成的字符串里带随机后缀那就改用更稳定的父级结构定位。另外get_text(stripTrue)这个参数很关键不传的话文本两端全是换行和空格后面清洗时还得再处理一遍。详情页解析更麻烦因为字段多且不确定。我的做法是写一个字典来定义字段名和对应的提取规则然后循环遍历FIELD_RULES { title: (h1.product-title, lambda e: e.get_text(stripTrue)), price: (span.price-value, lambda e: float(e.get_text(stripTrue).replace(¥, ).replace(,, ))), category: (div.breadcrumb a:last-child, lambda e: e.get_text(stripTrue)), publish_date: (div.publish-info span.date, lambda e: pd.to_datetime(e.get_text(stripTrue))), description: (div.product-desc, lambda e: e.get_text(stripTrue)[:500]), } def parse_detail_page(html): soup BeautifulSoup(html, lxml) data {} for field, (selector, transform) in FIELD_RULES.items(): node soup.select_one(selector) if node: try: data[field] transform(node) except Exception: data[field] None return data这里有个取舍某个字段解析失败时我选择置为None而不是抛异常终止脚本。因为课程作业要求的是尽可能多的有效记录个别字段缺失可以通过后面的清洗步骤补全或标记让整批数据作废就太亏了。数据采集合规方面多说一句我在脚本里加了一个robots.txt获取逻辑抓取前先看一眼页面是否明确禁止爬取并且在请求头里带了联系方式说明用途。这是技术之外的习惯但对长期做数据采集的人来说非常加分。3. 数据清洗与入库的关键环节3.1 脏数据的真实情况比想象中多很多人以为从网页上抓下来的数据就是干净规整的表实际操作一下就知道了——完全不是。我这次抓了5200多条原始记录清洗完只留下5100多条可用去掉的近百条就是典型的脏数据。清洗时我把问题分成了四类每一类都有对应的处理策略。脏数据类别具体表现处理策略重复记录同一商品被抓了两次按唯一标识字段去重保留完整度高的那条空值描述字段为空、价格缺失分类处理关键字段为空则删除次要字段为空则填充“暂无”格式混乱日期格式混有“2024/12/01”和“2024年12月1日”统一用正则标准化后转datetime类型异常值价格为0、发布时间在未来的阈值过滤超出合理范围则标记或删除以价格清洗为例原始数据里有的价格是字符串“¥1,299.00”有的是浮点数值还混进了一个“0”。我用正则r[0-9,]\.?[0-9]*把纯数字部分提取出来再统一转成浮点型然后按 0的阈值过滤。发布时间更坑同一批数据里出现了“2024-12-01”“2024/12/01”“2024.12.01”三种格式清洗的第一步是统一替换分隔符第二步才是用pandas.to_datetime做类型转换。清洗脚本这部分我贴出来做个示例import pandas as pd import re df pd.read_sql(SELECT * FROM raw_items, conn) # 1. 去重按item_id去重保留第一条 df df.drop_duplicates(subsetitem_id, keepfirst) # 2. 清洗价格提取数字并转为float def clean_price(val): if pd.isna(val): return None match re.search(r[0-9,]\.?[0-9]*, str(val)) if not match: return None return float(match.group().replace(,, )) if match.group() ! 0 else None df[price] df[price].apply(clean_price) # 3. 标准化日期 def clean_date(val): if pd.isna(val): return None val str(val).replace(年, -).replace(月, -).replace(日, ) val re.sub(r[\./], -, val) return pd.to_datetime(val, errorscoerce) df[publish_date] df[publish_date].apply(clean_date) # 4. 删除关键字段为空的记录 df df.dropna(subset[item_id, title, price]) df df[df[price] 0] df df[df[publish_date] pd.Timestamp.now()] print(f清洗后剩余记录数{len(df)})这段代码有个小细节值得展开说说errorscoerce参数非常实用遇到无法解析的日期不会被报错中断而是转成NaN最后统一过滤掉。整个清洗过程就是“能修的就修修不了的就删”核心逻辑就是别让一条脏记录污染最终分析结论。3.2 入库设计什么时候该分表什么时候该简单数据入库我用的是SQLite表结构设计其实是有讲究的。我先建了一张raw_items原始表字段就是网页上拿到的原文不做任何修改清洗之后的数据写入clean_items表字段做了一定程度的规范化。有人觉得这多此一举但我在实际工作中养成的一个习惯就是“永远保留一份原始数据”。假设清洗逻辑出了bug把数据修坏了原始表还在随时可以重新跑一遍清洗流程。如果只有清洗后的表再有问题的数据源也只能干瞪眼。这个习惯在这次作业中也派上了用场——我中途改了一次清洗规则直接对原始表重新处理几分钟就搞定了不用重新抓一遍网页。import sqlite3 conn sqlite3.connect(kcw.db) df.to_sql(clean_items, conn, if_existsreplace, indexFalse) # 建立必要的索引便于后续分析查询 conn.execute(CREATE INDEX IF NOT EXISTS idx_category ON clean_items(category)) conn.execute(CREATE INDEX IF NOT EXISTS idx_price ON clean_items(price)) conn.execute(CREATE INDEX IF NOT EXISTS idx_date ON clean_items(publish_date)) conn.commit() conn.close()索引这块很多同学容易忽略但其实特别重要。虽然几千条数据查起来并不慢但建立索引本身是对“数据分析师思维”的训练——后续你面对几十万条数据时没有索引的查询会慢到让人怀疑人生。我在category、price、publish_date三个字段上建了索引后续做分析查询时明显能感受到区别。关于SQLite和MySQL的取舍前面提过一嘴这里再说细一点。如果你的作业明确要求用MySQL那就在本机用Docker起一个MySQL容器或者用云上的免费数据库实例建表导入操作上也不复杂。本质区别只在于两点数据量是否超过百万级、是否需要多人同时连接读写。课程作业这两种场景基本都碰不上所以SQLite是最优解。4. 分析结论与可视化呈现4.1 从数据里提炼有价值的问题清洗入库之后分析才是真正有意思的部分。面对5100多条商品记录我给自己提了三个问题价格分布整体是什么形态不同分类的商品价格差异大吗发布时间和价格之间有没有关联这三个问题分别对应了分布分析、对比分析和相关性分析基本把这次数据能讲出来的点都覆盖了。先看价格分布。用matplotlib画直方图横轴是价格分段纵轴是商品数量。结果非常典型——长尾分布大部分商品集中在低价区间少量高价商品把均值拉高了很多。这里有个关键的统计学细节看分布的时候一定要区分均值和分位数。我这次数据的均值被少数几个高价商品拉到了远超中位数的水平如果只看均值会被误导。所以我报告里画的是直方图加中位数标注线一眼就能看清楚大部分商品的实际价格区间。价格与发布时间的关系用的是散点图。本来预期时间越近价格越高结果完全不是那么回事两个变量之间没有明显相关性。这个“没有结论”的结论其实也是分析的一部分我在报告里如实写了出来并且简单分析了一下可能的原因——这个平台的商品价格更多是由成本驱动而不是时间驱动。分类对比分析用了分组汇总的方式按category分组计算平均价格和数量然后按平均价格排序输出前10个类目。这块用了pandas的groupby再配合一条SQL做验证两边结果一致才放心写进报告。4.2 中文字体问题一个让无数人栽跟头的坑可视化方面我最想吐槽的是matplotlib的中文显示问题。辛辛苦苦画好的图打开一看全是一个个小方块那种崩溃感相信很多人都体会过。问题本质是matplotlib默认字体不支持中文解决方案是明确指定中文字体。我用的是SimHei但更重要的是把字体路径写对。import matplotlib.pyplot as plt import matplotlib matplotlib.rcParams[font.sans-serif] [SimHei] matplotlib.rcParams[axes.unicode_minus] False如果是Mac系统需要改成[PingFang SC]或者[Arial Unicode MS]Linux系统可以换成[Noto Sans CJK SC]。axes.unicode_minus这行很多人不知道不设置的话图中坐标轴的负号会显示成乱码方块。还有一个小技巧如果你用的是Jupyter Notebook除了上面的配置还可以在画图前加一行plt.rcParams[figure.dpi] 100避免高清屏上图形模糊。4.3 报告输出的组织和排版规范最后是报告输出。我用Word写了一份图文并茂的PDF版报告结构大概是项目背景与目标、数据源说明、采集与清洗流程、数据分析、结论与不足。图和表的编号是必须的——图1、图2表1、表2正文中要有引用比如“如图1所示”。这是学术写作和工程文档的基本规范也是作业评分的重要加分点。我这里用pandas直接把分组统计结果输出成了Markdown表格再复制到Word里转成Word表格summary df.groupby(category).agg( 商品数量(title, count), 平均价格(price, mean), 最低价格(price, min), 最高价格(price, max) ).sort_values(平均价格, ascendingFalse) print(summary.head(10).to_markdown())报告里除了数据结论我还加了一块“数据采集合规声明”写清楚数据来源、抓取方式、遵守Robots协议的情况和用途限制。这不是作业要求但我觉得这个习惯值得保持——数据合规不是小事从学生时代就建立正确意识未来工作中会很受益。5. 常见问题与排查实录5.1 四个典型的翻车现场整个项目做下来我记录了几个典型的坑基本覆盖了这类项目九成的问题。我整理成了一张排查表每一条都是实测验证过的方案。问题现象可能原因排查思路与解决方案返回的HTML中找不到目标元素数据是动态加载的在浏览器开发者工具里的Network面板检查请求看数据是不是通过AJAX接口返回的请求频繁被拒绝无请求头、频率过高补全User-Agent和Accept降低请求频率增加指数退避重试逻辑日期解析报错格式含“年/月/日”或“2024.12.01”先统一分隔符再转时间解析时加errorscoerce图表中文显示成方块matplotlib默认字体不支持中文显式指定中文字体设置rcParams[font.sans-serif]第一个问题我这次真遇到了。列表页HTML里能看到商品数据但详情页的某些字段比如库存量怎么解析都是空的。用开发者工具看Network才发现详情页的一部分信息是通过一个额外的JSON接口返回的。这种情况的解决方案很简单找到JSON接口直接请求并解析JSON数据比解析HTML靠谱得多。5.2 日志系统排查问题的第一把手这次作业我做得最对的一件事是写了个简单的日志装饰器把每个请求的URL、状态码、耗时全部记录下来。全程没有用复杂的loguru就是标准的logging模块。但就是这个简单的日志文件让我在写报告复盘时能精确知道哪个时间段请求了大量页面、哪个页面失败了几次、消耗了多少时间这对分析“采集效率”和“稳定性”帮助非常大。import logging logging.basicConfig( filenamekcw_crawler.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, datefmt%Y-%m-%d %H:%M:%S ) logger logging.getLogger(kcw)这里有个细节日志级别我用了INFO。很多人喜欢把级别调成DEBUG结果每一条请求都输出一大串真正想找的报错反而被淹没了。我的习惯是DEBUG只在本地小范围调试时用跑全量任务时用INFO就够了。5.3 增量更新的设计思路这次作业的采集是一次性的但我在报告里额外写了增量更新的设计思路因为这是实际工作中必然要面对的问题。简单来说数据库里加一个crawled_at时间戳字段每次采集只处理新发布的数据然后通过INSERT OR IGNORE或ON CONFLICT DO UPDATE进行upsert操作。这样既能让数据保持最新又不用重复抓全量数据。6. 一些心得与后续可以怎么扩展在做这个作业的过程中我最大的一个体会是写爬虫代码往往只占整个项目20%的工作量剩下的时间基本都在处理数据异常、调整格式、排查问题。这个比例其实才是真实的工程状态——再炫酷的采集技术最终落到报告里的也只有那几行数字和结论。如果时间充裕这个项目还能往几个方向扩展。第一个方向是加一个定时调度用系统的cron或者更轻量的schedule库每天跑一次增量采集让数据库持续滚动更新这样数据结构就变成了一个迷你数据仓库后续可以做的分析会多出很多。第二个方向是把可视化搬到Web端用Flask搭一个简单的服务让数据以交互式图表的形式呈现技术含量和展示效果都会上一个台阶。第三个方向是加一层更严格的数据质量监控比如每次采集完成后自动对比关键字段的统计量发现异常就发提醒——这套东西在真实业务里几乎就是标配了。最后再说一个很朴素的技巧用在我踩了好几次坑之后总结出的经验里每次运行脚本前先看一眼目标网站的页面结构有没有变化再跑数据。哪怕只是class名多了一个字符串整个解析逻辑都可能失效。这个习惯帮我省下了不止一次返工的时间。做数据项目稳比快重要细心比技巧重要这两句话是我在这次作业里最真实的收获。