资讯动态

用测试驱动开发打造健壮的Python爬虫:解析、清洗、入库全流程

发布时间:2026/9/8 6:00:01 来源:尧图企业网站定制
Python爬虫学着学着你会发现一个很奇怪的现象很多教程都在教你怎么发请求、怎么写解析器、怎么把数据入库却极少有人认真讲“怎么证明你的爬虫是对的”。解析器写完了没有样本验证清洗函数靠肉眼观察数据入库之后才发现字段对不上。这一篇我聊测试驱动爬虫用解析器、清洗、入库三类测试把爬虫项目从“能跑就行”变成“敢改敢维护”。零基础刚学完Python基础、想往爬虫方向进阶的同学或者已经写过几个爬虫但总觉得代码脆弱的初学者都可以照着这篇把工程化补上。1. 爬虫不写测试省下的时间最后都要加倍还回去1.1 为什么爬虫项目普遍跳过测试先聊一个很现实的问题为什么爬虫项目的测试总是被跳过我见过太多初学者包括当年的自己的逻辑是爬虫就是个一次性脚本跑完拿数据就完事了写测试不是浪费时间吗但实际情况是爬虫是最容易出现“悄然变坏”的项目类型。网络请求失败会抛异常你能立刻发现但页面结构变了解析代码可能依然正常运行只不过select出来的结果全是None入库之后全是空字符串。这种静默失败最危险。我总结了三个最典型的翻车场景每一个都靠人眼盯不出来页面结构变了。网站改版、某个class名调整、布局换了你的解析函数表面上还在运行但返回的字段全是空值。等你跑完整个任务、开始分析数据时才察觉甚至说不清是哪一天开始错的。脏数据没有兜底。价格字段有时候是“1,299.00”有时候是“价格面议”有时候干脆是个空标签。如果清洗逻辑没有处理边界入库的数据就是垃圾场。重复入库。爬虫任务通常不是跑一次就结束的定时任务、断点续爬、调试时反复运行同一个URL可能被解析很多次。如果入库逻辑没有去重数据库会越跑越脏。这三个问题靠人眼盯肯定盯不过来。测试就是那个替你把关的“机器人”任何一次解析结果异常、清洗结果越界、入库行为不对它都能第一时间报警。1.2 解析器、清洗、入库三条检查线各管一摊我在爬虫项目里只写三类测试正好对应数据链路中三个最容易出错的位置解析器测试输入一段HTML断言解析结果是不是期望的结构化数据。它守护的是“网页转字典”这一步。清洗测试输入一个原始字符串断言清洗后的值、类型、格式都正确。它守护的是“字典转干净字典”这一步。入库测试把干净数据交给存储层断言数据库里实际发生了什么。它守护的是“干净字典落库”这一步。三条线是递进关系解析器错了后面全错解析器对了但清洗错了入库也是错的前两步都对但入库逻辑有bug数据照样出问题。三层都测才算完整覆盖。1.3 爬虫场景下的TDD流程要怎么变TDD的核心流程大家可能听过红写一个失败的测试到绿用最简实现让测试通过再到重构在测试保护下改进代码。但在爬虫场景里这个流程要做一点调整你没法先写测试去测一个还没出现的网站但你可以先把一小段页面样本固定下来然后让解析代码去适配这段样本并用测试保证“这段样本的解析结果永远符合预期”。换句话说爬虫里的测试驱动本质上是让“历史页面”成为你的回归基线。网站改版不可怕可怕的是改版之后你完全不知道哪里坏了。测试存在改版影响一眼就能扫出来。2. 可被测试的工程骨架从分层设计到固定HTML样本2.1 分层设计每层只干一件事很多零基础同学写爬虫喜欢把所有逻辑堆在一个脚本里发请求、解析、清洗、存库全在一起。好处是“看起来简单”坏处是一旦想写测试根本无从下手。要让代码可测试第一步就是拆分层级。我常用的项目结构长这样book_spider/ ├── spider/ │ ├── __init__.py │ ├── parser.py # 解析HTML - dict │ ├── cleaner.py # 清洗dict - 干净dict │ └── storage.py # 入库逻辑 ├── tests/ │ ├── __init__.py │ ├── fixtures/ # 存HTML样本 │ │ └── book_detail.html │ ├── test_parser.py │ ├── test_cleaner.py │ └── test_storage.py └── requirements.txt为什么这么分原则只有一个请求、解析、清洗、存储每一层只干一件事层与层之间通过普通数据结构比如字典传递。这样测解析器时不需要真的发网络请求测清洗函数时不需要关心页面长什么样测入库时更不需要连生产数据库。每一层都能独立验证互不牵连。2.2 最小依赖清单与pytest环境依赖方面其实很简单核心就两个pytest负责测试beautifulsoup4负责解析HTML。requests看你的抓取需求sqlite3是标准库不需要额外安装。requests2.32.3 beautifulsoup44.12.3 pytest8.3.2版本号建议锁死。Python项目最怕的就是“昨天还能跑今天升级依赖后挂了”锁版本可以把这个风险降到最低。安装完依赖后在项目根目录运行pytest tests/ -vpytest会自动发现tests目录下所有test_*.py文件。如果你用的是VS Code装好Python插件后可以直接点击测试函数旁边的Run按钮也挺方便。2.3 把页面存成fixture让测试确定下来这里有个非常关键的实操细节测试解析器时千万别在代码里实时请求网站。原因有两个第一网站不是你的随时可能改版、限流、甚至关站测试会变得不稳定第二爬虫测试要的是“确定性输入”线上页面每次返回的内容都不一样没法做精确断言。正确的做法是把一个正常的页面HTML保存下来放进tests/fixtures目录。我通常会写一个加载fixture的小函数from pathlib import Path FIXTURES_DIR Path(__file__).parent / fixtures def load_fixture(name: str) - str: return (FIXTURES_DIR / name).read_text(encodingutf-8)这样测试永远基于一份固定的HTML只要这份HTML不变测试结果完全可预测。你甚至可以把这份HTML提交到Git仓库项目组里任何人拉下来都能跑。2.4 先跑通一个“链路验证”测试在正式用例之前我会先写一个最傻的验证测试确保fixture能被加载、pytest环境没问题def test_fixture_can_be_loaded(): html load_fixture(book_detail.html) assert Python编程 in html命令行执行pytest tests/ -v看到绿色passed说明测试链路已经通了。这步的价值是把“测试基础设施”先搭起来后面再往上加真实用例出了问题你也能确定是测试代码的问题还是爬虫代码的问题。3. 解析器测试把不稳定的网页变成确定性的断言3.1 解析器是第一个暴露风险的环节解析器是爬虫项目里最脆弱的环节。网络请求失败会抛异常你立刻能发现但页面结构变了解析代码可能依然“正常运行”只不过返回的字段全是None或空字符串。这种静默失败是最危险的。所以解析器测试的目标就是给“HTML转字典”这一步加一个强约束。只要页面上该有的信息没被正确解析测试立刻挂掉让你第一时间知道改版了。3.2 正常用例断言解析结果是结构化字典拿一个书籍详情页举例。假设要解析书名、价格、作者三个字段。第一个测试跑的是“一切正常”的情况from spider.parser import parse_book_detail def test_parse_book_detail_normal(): html html body h1 classtitlePython编程从入门到实践/h1 span classprice89.00/span div classauthorEric Matthes/div /body /html result parse_book_detail(html) assert result[title] Python编程从入门到实践 assert result[price] 89.00 assert result[author] Eric Matthes这个测试没过说明解析函数有问题过了我们继续加边界条件。3.3 边界用例缺失字段与空白文本页面并不是永远规规矩矩的。有的书没有作者信息有的标题前后带着换行和空格有的价格被套在一个很深的div结构里。这些都应该写进测试def test_parse_book_detail_missing_author(): html html body h1 classtitle Python编程从入门到实践 /h1 span classprice89.00/span /body /html result parse_book_detail(html) assert result[title] Python编程从入门到实践 assert result[author] def test_parse_book_detail_nested_price(): html html body h1 classtitle深入理解计算机系统/h1 div classprice-wrapper span classprice139.00/span /div /body /html result parse_book_detail(html) assert result[price] 139.00看明白了吗先写失败用例再去实现解析函数这正是测试驱动的“红”阶段。3.4 实现时的容错写法与选择器稳定性配合上面测试的解析实现我推荐“能找到就返回找不到就返回空字符串”的容错风格from bs4 import BeautifulSoup def parse_book_detail(html: str) - dict: soup BeautifulSoup(html, html.parser) title soup.select_one(h1.title) price soup.select_one(span.price) author soup.select_one(div.author) return { title: title.get_text(stripTrue) if title else , price: price.get_text(stripTrue) if price else , author: author.get_text(stripTrue) if author else , }每个字段都做了None判断配合get_text(stripTrue)把首尾空白去掉。很多人刚学BeautifulSoup时习惯直接soup.find(h1).text一旦找不到节点就抛AttributeError整个爬虫直接崩。容错写法虽然多打几行字但健壮性完全不同。选择器是另一个容易踩坑的地方。比如页面class名从price变成price highlightselect_one(span.price)依然能匹配到因为class属性包含price。但如果你写成select_one(span[classprice])这种属性精确选择就会挂。所以写选择器时我会先去浏览器开发者工具里复制真实节点出来确认class完整值再写测试。选择器宁可写得宽一点也别写得窄到只能匹配当前一版页面。测试的价值就在这里你改了选择器跑一遍测试就知道有没有影响其他用例。4. 清洗测试边界条件才是脏数据治理的核心4.1 清洗函数必须是纯函数解析完成后拿到的字段还是“原始状态”价格是“1,299.00”这种字符串日期是“2024年12月1日”描述文本里可能还带着HTML标签。清洗层的任务就是把五花八门的raw值变成规整的、可直接入库的数据。这里有一个关键设计原则清洗函数必须是“纯函数”。所谓纯函数就是同样的输入永远得到同样的输出不依赖外部状态不修改传入参数。好处是测试极其好写——不用准备数据库、不用发网络请求丢一个字符串进去断言返回值即可。4.2 参数化测试一组用例覆盖价格、日期、文本pytest有个非常好用的功能叫参数化特别适合清洗场景。拿价格清洗举例import pytest from spider.cleaner import clean_price pytest.mark.parametrize(raw,expected, [ (1,299.00, 1299.0), (89.00, 89.0), ($1,234.56, 1234.56), (价格999, 999.0), (, None), (免费, None), (暂无报价, None), ]) def test_clean_price(raw, expected): assert clean_price(raw) expected这段代码的价值非常大你扫一眼用例列表就知道清洗函数支持哪些格式、遇到什么情况返回None。以后需求变了比如要支持欧元直接加一行参数继续跑谁也没法偷偷改坏你之前调好的逻辑。对应的实现可以这样写import re def clean_price(raw_price: str): if not raw_price: return None match re.search(r\d(?:,\d{3})*(?:\.\d)?, raw_price) if not match: return None return float(match.group(0).replace(,, ))4.3 从脏数据里总结边界清单实际项目里我会把每遇到一种新脏数据都写成一条测试。下面是一份我常用的清洗层边界清单空字符串或None统一返回None或默认值绝不抛异常。逗号和空格分隔价格里的千分位逗号、空格都要移除后再转数值。中文标点日期里的“年、月、日”、价格里的“”符号需要剥离。单位混杂“1.2万”“1,000”“约500”这类模糊表达要么定义规则转为数值要么返回None。标签残留描述字段从网页抓下来带着a、br标签需要strip_html。与其在代码里靠感觉处理不如把这些全部列成测试用例。每遇到一种新脏数据先加用例让它失败再改代码让它通过——这就是测试驱动清洗函数的方式。4.4 日期清洗示例再补一个日期清洗的常见例子目标是支持“2024年12月1日”这种中文格式输出“2024-12-01”import re def clean_date(raw: str): if not raw: return None match re.search(r(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日, raw) if not match: return None year, month, day match.groups() return f{year}-{int(month):02d}-{int(day):02d}测试用例pytest.mark.parametrize(raw,expected, [ (2024年12月1日, 2024-12-01), (发布于2024年1月5日, 2024-01-05), (, None), ]) def test_clean_date(raw, expected): assert clean_date(raw) expected注意如果网站上给的是“2024-12-01”这种标准ISO格式目前这个实现会返回None。这是合理的测试会明确记录这个函数支持什么、不支持什么。后面真要支持ISO格式再加一条用例改动也会很安全。5. 入库测试用内存SQLite检验重复写入与字段正确性5.1 入库层最常见的三类错误解析和清洗都过了数据终于到了入库这一步。很多人觉得存数据库还不简单实际上没有测试保护的入库逻辑问题一点不比前两层少同一个URL爬了两次数据库里出现两条几乎一样的记录。表结构和字段对不上数据插进去后列错位。类型没转换好把字符串塞进数值字段有的库直接报错。多任务并发时连接没处理好数据互相覆盖。入库测试要回答的问题是数据交给存储层之后数据库的状态是否符合预期重复写入会不会产生脏数据5.2 fixture构建内存SQLite与一次性表结构我的建议是测试环境用SQLite的内存模式:memory:不碰真实数据。原因很实际测试需要快速、可重复、不污染数据。内存SQLite连文件都不用创建每个用例结束后自动消失互不干扰。import sqlite3 import pytest from spider.storage import create_schema, save_book pytest.fixture def conn(): conn sqlite3.connect(:memory:) create_schema(conn) yield conn conn.close() def test_save_book_creates_one_row(conn): save_book(conn, { title: Python编程从入门到实践, price: 89.0, author: Eric Matthes, url: http://books.example.com/1, }) rows conn.execute(SELECT title, price, author FROM books).fetchall() assert len(rows) 1 assert rows[0] (Python编程从入门到实践, 89.0, Eric Matthes)注意这里的关键pytest的fixture在每个测试前创建表结构、结束后关闭连接这是测试隔离的基础。如果多个测试共享同一个数据库数据就会互相干扰断言就很难写了。表结构我用URL唯一约束def create_schema(conn): conn.execute( CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT UNIQUE, title TEXT, price REAL, author TEXT ) ) conn.commit()5.3 幂等写入与UPSERT测试生产环境里爬虫任务经常需要重复执行今天跑完明天再跑调试时也可能跑好几遍。如果入库逻辑不处理重复数据库很快就会堆满垃圾。所以我会专门为“幂等性”写一条测试def test_save_same_book_twice_only_stores_once(conn): book { title: 测试书, price: 10.0, author: 张三, url: http://books.example.com/unique, } save_book(conn, book) save_book(conn, book) count conn.execute(SELECT COUNT(*) FROM books).fetchone()[0] assert count 1如果你用的是普通INSERT第二次写入会抛IntegrityError因为url重复测试会红。更合理的做法是用“存在则跳过、不存在则插入”的幂等写入def save_book(conn, book): conn.execute( INSERT OR IGNORE INTO books (url, title, price, author) VALUES (?, ?, ?, ?) , (book[url], book[title], book[price], book[author])) conn.commit()如果你希望重复爬取时更新标题、价格等字段就要用UPSERT语义存在则更新不存在则插入def upsert_book(conn, book): conn.execute( INSERT INTO books (url, title, price, author) VALUES (?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET title excluded.title, price excluded.price, author excluded.author , (book[url], book[title], book[price], book[author])) conn.commit()对应的测试就要断言第一次插入一条第二次更新同一条而不新增并且价格字段被更新。5.4 测试隔离绝不碰生产数据再补一点实战经验在测试里尽量别碰真实数据库。我见过有人在开发库上直接跑测试跑完了还得手动清数据每次都心惊胆战。SQLite内存库零成本最适合做测试。如果项目用的是MySQL或PostgreSQL测试环境也要单独建schema或库在fixture里做事务回滚或清表。pytest里更稳健的做法是这样pytest.fixture def db_conn(): conn sqlite3.connect(:memory:, check_same_threadFalse) create_schema(conn) yield conn conn.rollback() conn.close()每个用例的数据都在内存库里测试结束后直接丢库互不影响。这样无论你跑多少次测试都不需要担心误删生产数据。6. 红-绿-重构一次小型TDD实战演示6.1 从ImportError开始先写一条失败测试很多零基础同学听说TDD觉得“先写测试再写代码”很反直觉。我用一个特别小的例子演示一下你会发现这套流程其实很顺。需求写一个函数从礼品卡页面解析面额字符串“$50”为数字50。第一步不写实现先写测试from spider.parser import parse_gift_card_amount def test_parse_gift_card_amount(): assert parse_gift_card_amount($50) 50此时我连parse_gift_card_amount函数都没建。运行pytest它会报ImportError测试失败。这就是“红”。6.2 最小实现让测试转绿第二步写一个最笨的实现让测试通过def parse_gift_card_amount(raw: str): return int(raw.replace($, ))运行测试通过了这就是“绿”。有人觉得这一步实现得太简单没错TDD的哲学就是在测试约束下用最小代价搞定功能别提前设计一堆用不上的东西。6.3 补充边界用例倒逼实现进化第三步回到测试里补充边界用例pytest.mark.parametrize(raw,expected, [ ($50, 50), ($1,000, 1000), ($50.5, 50.5), (, None), ]) def test_parse_gift_card_amount(raw, expected): assert parse_gift_card_amount(raw) expected第二条用例“$1,000”就会失败说明当前实现没处理千分位逗号。这时候再去扩展实现def parse_gift_card_amount(raw: str): if not raw: return None cleaned raw.replace($, ).replace(,, ).strip() try: return float(cleaned) except ValueError: return None所有用例都通过。整个过程下来你既拥有了一个被测试保护的函数又让实现被需求逐步驱动着长出来而不是一开始就凭感觉写一大堆代码。这套节奏用在正式爬虫项目里体会会更明显。6.4 日常跑测试的节奏与应对红测试的心态项目搭好之后我日常的节奏是爬虫代码有改动先跑pytest tests/ -v看是否全绿。出现红的时候第一反应不是“改代码把测试弄绿”而是先想清楚是测试预期过时了还是代码改坏了两者处理方式完全不同。网站改版了先打开fixtures里的HTML样本看哪些字段变了先改测试再改代码顺序不能反。这里有个很容易搞反的地方很多人看到测试红了就赶紧删用例说“这个场景不重要”。我的建议是别急先搞清楚为什么红。测试红了恰恰说明页面样本、解析逻辑、预期结果三者之间有冲突这正是调试的好时机。直接删用例等于关掉警报器风险只会更大。最后分享一个我自己的习惯每次页面改版我会把抓到的HTML另存一份到tests/fixtures文件名带上日期。这些HTML样本比任何文档都真实地记录了网站的变化轨迹。测试驱动爬虫这套做法前期会多花一点时间写用例但回报是长期的——你的爬虫不再是“跑完就扔”的一次性脚本而是可以放心维护、随时修改的工程。这个转变才是爬虫入门到进阶真正的分水岭。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价