从 Scrapy 迁移到 Crawlee Python:保留字段契约,用双跑结果决定切换
用同一商品采集任务对照迁移 Scrapy 与 Crawlee Python,保留业务键和清洗规则,检查覆盖率、字段差异与回滚条件。
迁移爬虫最容易出错的地方,不是把 parse() 换成另一个回调,而是新程序能运行、字段却悄悄变了:价格从金额变成展示字符串,去重键从商品编号变成 URL,失败页面又被当作商品下架。
本教程让 Scrapy 和 Crawlee Python 分别采集 Books to Scrape 的前两页商品,导出相同字段,再按 UPC 对账。新程序先写隔离文件,旧程序继续承担正式任务;只有范围、字段和失败处理都满足约定,才切换调度。
这里迁移静态 HTML 采集,不安装浏览器。已有 Scrapy 基础的读者可以重点看组件对应关系、共用清洗规则和最后的差异比较。
准备输入地址
先在浏览器找到下列页面并确认内容,再从地址栏复制完整地址。服务或仓库地址从相应账户设置、官方项目的接入说明或克隆菜单取得。不要从示例输出中反推真实地址。
BOOKS_PAGE_1_URL:Books to Scrape 总目录 page-1.html 页地址。BOOKS_PAGE_2_URL:同目录 page-2.html 页地址。
在执行后续命令的同一个 Bash/WSL 终端中设置:
export BOOKS_PAGE_1_URL='<paste-the-actual-books-page-1-url>'
export BOOKS_PAGE_2_URL='<paste-the-actual-books-page-2-url>'下文 JSON 或任务参数中的 <变量名> 是待替换标记,提交前填写刚复制的实际值;JSON 不会自动展开环境变量。结果示例中的 example.16yun.cn 只表示来源字段的形状,不是本文部署的演示站。
先冻结迁移契约
| 项目 | 本次约定 |
|---|---|
| 输入范围 | catalogue/page-1.html 与 page-2.html,及它们直接链接的商品详情 |
| 业务键 | 详情表格中的 UPC,保持字符串 |
| 标题 | 详情 h1 的完整文本,不使用列表截断标题 |
| 价格 | GBP 整数便士 price_minor;保留 price_display 便于核查 |
| 来源 | 详情页 URL,作为证据字段,不替代 UPC |
| 失败 | 保留错误或日志;缺失商品不能解释为下架 |
| 正式数据 | 双跑阶段均不修改正式数据库 |
练习站当前结构每页 20 件,两页的基准范围为 40 件商品。实际使用时,范围与预期数量应来自你冻结的输入清单;站点更新后要重新确认,不能机械认定 40 就永远正确。
安装两个实现的依赖
使用 Python 3.12,在新目录创建环境:
python -m venv .venv
source .venv/bin/activate
python -m pip install "Scrapy==2.13.4" "crawlee[parsel]==1.11.1"Windows PowerShell 激活命令为 .venv\Scripts\Activate.ps1。目录内将有三个实现文件和一个比较文件;下面给出全部内容,不需要额外项目模板。
把原 Pipeline 中的业务规则独立出来
保存为 contract.py:
import os
from decimal import Decimal
START_URLS = [
os.environ["BOOKS_PAGE_1_URL"],
os.environ["BOOKS_PAGE_2_URL"],
]
def normalize(selector, source_url):
title = selector.css(".product_main h1::text").get()
price = selector.css(".product_main .price_color::text").get()
upc = selector.xpath(
"//table//tr[th[normalize-space()='UPC']]/td/text()"
).get()
if not title or not price or not upc:
raise ValueError("missing_required_product_field")
title, price, upc = title.strip(), price.strip(), upc.strip()
if not title or not upc or not price.startswith("£"):
raise ValueError("invalid_product_field")
cents = Decimal(price[1:]) * 100
if not cents.is_finite() or cents < 0 or cents != cents.to_integral_value():
raise ValueError("invalid_gbp_amount")
return {
"upc": upc,
"title": title,
"price_minor": int(cents),
"currency": "GBP",
"price_display": price,
"source_url": source_url,
}Scrapy 的 Response 和 Crawlee 的 Parsel selector 都提供这里使用的 css()、xpath() 接口,因此可以复用同一清洗函数。真实项目中,应从原 Pipeline 提取已有规则,保留原来的异常、单位、空值与主键语义,而不是借迁移机会重新定义字段。
复用函数有一个限制:它可能让两个实现共享同一个错误。后面的对账能发现覆盖范围与运行差异,但不能替代人工核对页面价格、单位与 UPC。
保留一个可以单独运行的 Scrapy 基线
保存为 old_spider.py:
import scrapy
from contract import START_URLS, normalize
class ProductsSpider(scrapy.Spider):
name = "migration_baseline"
allowed_domains = ["books.toscrape.com"]
start_urls = START_URLS
custom_settings = {
"CONCURRENT_REQUESTS": 2,
"DOWNLOAD_DELAY": 0.5,
"RETRY_TIMES": 2,
"FEED_EXPORT_ENCODING": "utf-8",
}
def parse(self, response):
links = response.css("article.product_pod h3 a::attr(href)").getall()
if not links:
raise ValueError("list_has_no_product_links")
for href in links:
yield response.follow(href, callback=self.parse_detail)
def parse_detail(self, response):
yield normalize(response, response.url)执行:
scrapy runspider old_spider.py -O old-products.jsonl 2> old-run.log-O 覆盖指定的练习输出文件,每次比较前应归档旧批次。检查日志中的异常与重试耗尽记录。Spider 关闭和命令结束都不意味着范围完整,后面还要检查输出。
这段基线不是要求重写现有 Spider。真实迁移应保留现有实现作为基线,固定相同输入,并使用隔离输出替代正式数据库写入。
用 Crawlee 路由重建列表与详情流程
保存为 new_crawler.py:
import asyncio
import json
from pathlib import Path
from urllib.parse import urljoin
from crawlee import ConcurrencySettings
from crawlee.crawlers import BasicCrawlingContext, ParselCrawler, ParselCrawlingContext
from contract import START_URLS, normalize
def save_jsonl(path, rows):
Path(path).write_text(
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
async def main():
products = {}
failures = []
list_urls = set()
discovered = set()
crawler = ParselCrawler(
max_requests_per_crawl=60,
max_request_retries=2,
concurrency_settings=ConcurrencySettings(
min_concurrency=1, max_concurrency=2
),
)
@crawler.router.default_handler
async def listing(context: ParselCrawlingContext):
if context.request.url not in START_URLS:
raise ValueError("unexpected_list_url")
links = context.selector.css(
"article.product_pod h3 a::attr(href)"
).getall()
if not links:
raise ValueError("list_has_no_product_links")
discovered.update(urljoin(context.request.url, href) for href in links)
await context.enqueue_links(
selector="article.product_pod h3 a", label="DETAIL"
)
list_urls.add(context.request.url)
@crawler.router.handler("DETAIL")
async def detail(context: ParselCrawlingContext):
record = normalize(context.selector, context.request.url)
previous = products.get(record["upc"])
if previous is not None and previous != record:
raise ValueError("conflicting_duplicate_upc")
products[record["upc"]] = record
@crawler.failed_request_handler
async def failed(context: BasicCrawlingContext, error: Exception):
failures.append({
"url": context.request.url,
"label": context.request.label,
"error_type": type(error).__name__,
"error": str(error),
})
try:
await crawler.run(START_URLS)
finally:
save_jsonl("new-products.jsonl", [
products[key] for key in sorted(products)
])
Path("new-errors.json").write_text(
json.dumps(failures, ensure_ascii=False, indent=2),
encoding="utf-8",
)
actual = {row["source_url"] for row in products.values()}
if failures or list_urls != set(START_URLS) or actual != discovered:
raise RuntimeError("incomplete_migration_batch_check_outputs")
if __name__ == "__main__":
asyncio.run(main())用一批独立的 Crawlee 存储执行:
CRAWLEE_STORAGE_DIR=./storage-migration-01 python new_crawler.pyPowerShell 先执行 $env:CRAWLEE_STORAGE_DIR="./storage-migration-01",再执行 Python 命令。每次独立比较使用新的存储目录,避免旧的已处理请求影响新批次;若要验证恢复能力,应作为另一个明确场景,不能把恢复结果混进首次迁移对账。
这里用字典收集 40 条记录,结束时统一导出,适合有限范围的迁移样本。大规模正式任务应改用已有事务写入与 UPC 幂等更新规则,不能把这个小样本的内存策略直接扩大到全站。
对照组件,而不是只替换名字
| Scrapy 组件 | 这里的迁移方式 | 还要确认的差异 |
|---|---|---|
| parse / parse_detail | 默认列表路由 / DETAIL 路由 | 标签是否在入队时保留 |
| response.follow | enqueue_links | 范围与 URL 去重规则是否一致 |
| Item Pipeline | normalize + 结果写入 | 校验失败是重试、隔离还是终止 |
| Feed export | 显式 JSONL 导出 | 编码、覆盖行为、字段类型 |
| Downloader middleware | 按目标功能重新配置 | 代理、Cookie、重定向不能直接复制类 |
| JOBDIR / 请求状态 | Crawlee 存储 | 不能复用 Scrapy 队列文件 |
本例两边都配置最多两次重试,但这不代表所有失败的重试语义一致。Scrapy 下载重试与回调异常处理、Crawlee 请求处理失败重试之间存在区别。生产迁移必须用你关心的故障场景确认,而不是仅比较参数数值。
按业务键比较两份输出
保存为 compare.py:
import json
from pathlib import Path
def load(path):
rows = [
json.loads(line)
for line in Path(path).read_text(encoding="utf-8").splitlines()
if line.strip()
]
result = {}
for row in rows:
key = row["upc"]
if key in result:
raise ValueError("duplicate_upc_in_" + path + ":" + key)
if type(row["price_minor"]) is not int or row["currency"] != "GBP":
raise ValueError("schema_mismatch_in_" + path)
result[key] = row
return result
old = load("old-products.jsonl")
new = load("new-products.jsonl")
missing = sorted(old.keys() - new.keys())
extra = sorted(new.keys() - old.keys())
changed = {
key: {
field: {"old": old[key].get(field), "new": new[key].get(field)}
for field in sorted(old[key].keys() | new[key].keys())
if old[key].get(field) != new[key].get(field)
}
for key in sorted(old.keys() & new.keys())
if old[key] != new[key]
}
report = {
"old_count": len(old), "new_count": len(new),
"missing": missing, "extra": extra, "changed": changed,
}
Path("comparison.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(json.dumps(report, ensure_ascii=False, indent=2))
if len(old) != 40 or len(new) != 40 or missing or extra or changed:
raise SystemExit("migration_comparison_failed")执行 python compare.py。在站点内容未变化、两边完整采集约定范围的前提下,目标结果是两个 count 均为 40,missing、extra 和 changed 为空。这是验收条件,不是预先保证的运行结果。
随后人工打开至少一件商品,核对完整标题、UPC、GBP 单位和价格;再检查 old-run.log 与 new-errors.json。共享清洗函数意味着“完全一致”仍有可能是共同出错。
出现差异时如何处理
| 差异 | 常见原因 | 下一步 |
|---|---|---|
| 新版数量少 | 列表路由遗漏、入队标签错误、旧存储跳过请求、详情失败 | 对比 discovered 与实际 URL,检查错误文件和批次目录 |
| 两边都不足 40 | 输入范围或页面结构变化、共同选择器错误 | 先核对两页实际商品链接,不能据此放行迁移 |
| 同 UPC 标题不同 | 一边取列表标题,一边取详情标题 | 固定取详情 h1,重新双跑 |
| 价格差 100 倍 | 元与分混用、字符串隐式转换 | 固定整数便士,人工核查一件商品 |
| 只有 source_url 不同 | 重定向、协议、规范化策略不同 | 明确来源字段含义,再决定是否允许差异 |
| 重复 UPC | 不同 URL 指向同商品,或解析错取到固定编号 | 核对详情;不要用覆盖字典隐藏内容冲突 |
什么时候可以切换,怎样回滚
先选择一个固定范围,让新旧实现写不同输出。检查字段契约、范围、失败情况和人工抽样后,再让新版处理一个小批次的正式调度。保留旧任务入口、配置和上一次可用版本。
正式写入仍使用已有业务键和事务规则。不要清空历史表后导入新版,也不要把一次缺失直接变成删除。若新版发生字段变化、覆盖率下降或失败率异常,暂停新版调度,恢复旧任务入口,并按批次定位新版已经写入的记录。回滚应恢复调度和可追溯的数据版本,而不是假定删除新目录就能撤销数据库变化。
继续迁移其他站点时,逐项扩展输入、认证、代理、动态页面和持久化场景;每项都有自己的对账与回滚条件。迁移完成的判断依据是原有业务行为被保留,而不是项目已经换了依赖。
参考 Scrapy 教程、Scrapy Feed exports 和 Crawlee Python 快速开始。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。