“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参数是?page=1&size=50这样的结构,每页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/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } def fetch_page(url, retries=3): for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=10) 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(strip=True), }) return items列表页解析的核心是选对CSS选择器。我会先用浏览器开发者工具检查目标元素的class名,但绝对不会完全信任它——有些页面的class名是动态生成的,字符串里带随机后缀,那就改用更稳定的父级结构定位。另外,get_text(strip=True)这个参数很关键,不传的话文本两端全是换行和空格,后面清洗时还得再处理一遍。
详情页解析更麻烦,因为字段多且不确定。我的做法是写一个字典来定义字段名和对应的提取规则,然后循环遍历:
FIELD_RULES = { "title": ("h1.product-title", lambda e: e.get_text(strip=True)), "price": ("span.price-value", lambda e: float(e.get_text(strip=True).replace("¥", "").replace(",", ""))), "category": ("div.breadcrumb a:last-child", lambda e: e.get_text(strip=True)), "publish_date": ("div.publish-info span.date", lambda e: pd.to_datetime(e.get_text(strip=True))), "description": ("div.product-desc", lambda e: e.get_text(strip=True)[: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(subset="item_id", keep="first") # 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, errors="coerce") 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)}")这段代码有个小细节值得展开说说:errors="coerce"参数非常实用,遇到无法解析的日期不会被报错中断,而是转成NaN,最后统一过滤掉。整个清洗过程就是“能修的就修,修不了的就删”,核心逻辑就是别让一条脏记录污染最终分析结论。
3.2 入库设计:什么时候该分表,什么时候该简单
数据入库我用的是SQLite,表结构设计其实是有讲究的。我先建了一张raw_items原始表,字段就是网页上拿到的原文,不做任何修改;清洗之后的数据写入clean_items表,字段做了一定程度的规范化。有人觉得这多此一举,但我在实际工作中养成的一个习惯就是“永远保留一份原始数据”。
假设清洗逻辑出了bug,把数据修坏了,原始表还在,随时可以重新跑一遍清洗流程。如果只有清洗后的表,再有问题的数据源也只能干瞪眼。这个习惯在这次作业中也派上了用场——我中途改了一次清洗规则,直接对原始表重新处理,几分钟就搞定了,不用重新抓一遍网页。
import sqlite3 conn = sqlite3.connect("kcw.db") df.to_sql("clean_items", conn, if_exists="replace", index=False) # 建立必要的索引,便于后续分析查询 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("平均价格", ascending=False) print(summary.head(10).to_markdown())报告里除了数据结论,我还加了一块“数据采集合规声明”,写清楚数据来源、抓取方式、遵守Robots协议的情况和用途限制。这不是作业要求,但我觉得这个习惯值得保持——数据合规不是小事,从学生时代就建立正确意识,未来工作中会很受益。
5. 常见问题与排查实录
5.1 四个典型的翻车现场
整个项目做下来,我记录了几个典型的坑,基本覆盖了这类项目九成的问题。我整理成了一张排查表,每一条都是实测验证过的方案。
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 返回的HTML中找不到目标元素 | 数据是动态加载的 | 在浏览器开发者工具里的Network面板检查请求,看数据是不是通过AJAX接口返回的 |
| 请求频繁被拒绝 | 无请求头、频率过高 | 补全User-Agent和Accept,降低请求频率,增加指数退避重试逻辑 |
| 日期解析报错 | 格式含“年/月/日”或“2024.12.01” | 先统一分隔符再转时间,解析时加errors="coerce" |
| 图表中文显示成方块 | matplotlib默认字体不支持中文 | 显式指定中文字体,设置rcParams["font.sans-serif"] |
第一个问题我这次真遇到了。列表页HTML里能看到商品数据,但详情页的某些字段(比如库存量)怎么解析都是空的。用开发者工具看Network才发现,详情页的一部分信息是通过一个额外的JSON接口返回的。这种情况的解决方案很简单:找到JSON接口,直接请求并解析JSON数据,比解析HTML靠谱得多。
5.2 日志系统:排查问题的第一把手
这次作业我做得最对的一件事,是写了个简单的日志装饰器,把每个请求的URL、状态码、耗时全部记录下来。全程没有用复杂的loguru,就是标准的logging模块。但就是这个简单的日志文件,让我在写报告复盘时能精确知道哪个时间段请求了大量页面、哪个页面失败了几次、消耗了多少时间,这对分析“采集效率”和“稳定性”帮助非常大。
import logging logging.basicConfig( filename="kcw_crawler.log", level=logging.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名多了一个字符串,整个解析逻辑都可能失效。这个习惯帮我省下了不止一次返工的时间。做数据项目,稳比快重要,细心比技巧重要,这两句话是我在这次作业里最真实的收获。