Scrapy 入门可以按三个练习推进:解析一页、跟随下一页、统一清洗后导出。请求去重与数据去重要分别处理,前者避免重复下载,后者避免同一条业务记录被保存多次。

当你的 Python 小脚本开始混入分页、重试、输出文件和重复数据判断,主循环会越来越长。Scrapy 的价值是让调度、下载与数据处理分工明确,但它不会自动替你定义“哪两条记录算重复”。本文用本地两页课程列表完成一个可复现项目,演示三个练习如何接起来。编程狮使用 Python 3.10 以上、Scrapy 2.13 以上的 start 接口,页面由本机服务提供,便于观察行为,也避免把学习压力施加给外部站点。
一、先搭本地两页样本,理解请求和记录的区别
创建项目后,你会看到 settings.py、pipelines.py 和 spiders 目录。Spider 负责发现请求并提取数据,Pipeline 负责统一处理记录,导出器负责写出结果。项目根目录的 scrapy.cfg 帮助命令定位设置。
在空目录运行以下命令。python 指已安装 Scrapy 的解释器;Windows 可使用虚拟环境中的 python.exe,macOS 或 Linux 使用相应环境的 python。
python -m pip install "scrapy>=2.13,<3"
python -m scrapy startproject course_demo
在 course_demo 目录中新建 pages 文件夹,分别保存 page1.html 和 page2.html:
<!-- page1.html -->
<ul><li class="course" data-id="a"> Python 入门 </li></ul>
<a class="next" href="page2.html">下一页</a>
<!-- page2.html -->
<ul>
<li class="course" data-id="a">Python 入门</li>
<li class="course" data-id="b">JavaScript 实践</li>
</ul>
两个文件故意共享编号 a。即使两个页面各只下载一次,记录 a 仍会被提取两次,这正是后续数据去重需要解决的问题。先确认你能解释这个差异,再接入更多页面。
二、Scrapy 分页请求由回调产生,不要手写同步循环
在项目的 course_demo/spiders 下保存 courses.py。启动本地页面服务后,Spider 会从第一页开始解析,再从下一页链接产生第二个请求。
import scrapy
class CoursesSpider(scrapy.Spider):
name = "courses"
allowed_domains = ["127.0.0.1"]
async def start(self):
yield scrapy.Request("http://127.0.0.1:8765/page1.html")
def parse(self, response):
for node in response.css("li.course"):
yield {
"id": node.attrib.get("data-id", ""),
"title": node.xpath("string(.)").get(),
"source": response.url,
}
next_page = response.css("a.next::attr(href)").get()
if next_page:
yield response.follow(next_page, callback=self.parse)
response.follow 会以当前响应地址解析相对链接。parse 既可以产生记录,也可以产生新的请求,它们会进入不同处理路径;不是 Python for 循环直接等待下一页下载结束。
在一个终端从项目根运行本地服务,另一个终端执行爬虫。第一个终端需要保持运行,完成练习后用 Ctrl+C 停止:
python -m http.server 8765 --bind 127.0.0.1 --directory pages
分页链接缺失时不再提交请求,任务自然结束。真实网站还应确认下一页没有指向站外或回到当前页,不要为了“强制继续”随意打开 dont_filter。请求调度可以自动去重,但分页规则本身写错仍需修复。相关 Python 类与生成器概念可在 Python3 教程 中补齐。
三、用 Pipeline 清洗和去重,确定保留哪一条
将项目生成的 pipelines.py 替换为以下内容,保留模块所在位置不变。Pipeline 按编号去重,并拒绝缺少编号或标题的记录。
from scrapy.exceptions import DropItem
class CleanCoursePipeline:
def __init__(self):
self.seen = set()
def process_item(self, item, spider):
item["id"] = str(item.get("id", "")).strip()
item["title"] = " ".join((item.get("title") or "").split())
if not item["id"] or not item["title"]:
raise DropItem("课程缺少编号或标题")
if item["id"] in self.seen:
raise DropItem(f"重复课程编号:{item['id']}")
self.seen.add(item["id"])
return item
这套规则保留最先进入 Pipeline 的同编号记录。示例串行抓取,顺序容易理解;如果以后提高并发,“先到”不一定等于“网站顺序最前”。需要按更新时间保留最新记录时,应比较字段或在数据库中执行相应更新,不能只靠集合。
内存集合只覆盖本次进程。重新启动程序会重新建立集合,历史结果不会自动成为去重依据。如果要求跨天去重,要把业务编号存入可持续查询的存储,并设计更新策略。请求指纹去重、业务主键去重和数据库唯一约束,各解决一个层面的问题。
你可以把第二页的编号 b 改成空字符串,再运行一次,观察日志中记录被丢弃的原因。这样验证的是异常路径,不只是看到一个输出文件就宣布处理成功。
四、配置限速与导出,再检查记录数是否符合预期
在 settings.py 中加入或修改以下配置。演示选择串行请求、固定三秒间隔,关闭随机延迟,方便观察两次请求不会紧挨着发出。AutoThrottle 可以在响应变慢时增加等待。
ITEM_PIPELINES = {"course_demo.pipelines.CleanCoursePipeline": 300}
ROBOTSTXT_OBEY = True
CONCURRENT_REQUESTS = 1
CONCURRENT_REQUESTS_PER_DOMAIN = 1
DOWNLOAD_DELAY = 3
RANDOMIZE_DOWNLOAD_DELAY = False
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 3
AUTOTHROTTLE_MAX_DELAY = 60
FEED_EXPORT_ENCODING = "utf-8"
TELNETCONSOLE_ENABLED = False
AutoThrottle 不是绕过站点限制的工具,仍应遵守站点约定。本文本地服务没有 robots.txt,可能出现对应的缺失日志;接入真实网站时,要读取并遵守有效规则。抓取失败不要用增加并发来“试着恢复”,应检查响应状态和重试原因。
从项目根导出一个 JSON 文件:
python -m scrapy crawl courses -O courses.json
大写 -O 会覆盖同名输出文件,因此首次练习应选择新的文件名或先备份;小写 -o 是追加语义,反复追加 JSON 可能不符合你对单个完整文档的预期。需要持续追加时,可以明确选用逐行记录的格式。
预期结果为两条课程,标题分别是 Python 入门和 JavaScript 实践,编号分别为 a、b。检查日志中的抓取、丢弃和导出记录,确认重复 a 被识别,而不是因为第二页根本没请求到才得到一个“没有重复”的结果。遇到文件阅读问题,可以配合 JSON 教程 理解输出结构。

总结
Scrapy 网络爬虫的三个练习要分别验收:Spider 能提取当前页,分页回调能发现下一页,Pipeline 能清洗并拒绝重复记录。最后的文件只是这个过程的结果,不应该成为唯一成功标准。
本例两页有三条原始记录,导出后有两条有效记录。你可以继续加入一页循环链接或一条缺标题数据,观察请求去重与记录丢弃的差别。掌握这个小项目之后,再扩展持久化去重、错误重试和详情页,代码职责会更清楚,问题也更容易定位。
延伸学习
- 通过 Python Scrapy 网络爬虫入门课程 继续实践。
- 用 HTML 教程 练习定位页面元素与属性。
- 阅读 常用 Python 爬虫库汇总,了解不同采集工具的分工。
常见问题
Q:为什么重复页面没抓,重复数据还是出现了?
A:请求去重比较的是请求身份,数据去重比较的是业务记录。同一课程可能出现在不同页面,应在 Pipeline 或存储层根据业务编号处理。
Q:断点继续能保证数据不重复吗?
A:不能自动保证所有业务条件。请求状态恢复与记录持久化是不同问题,应同时考虑已经保存的数据、恢复后的请求和存储层唯一约束。
Q:可以在 parse 里调用 time.sleep 吗?
A:不建议用它限速。它会阻塞处理线程,影响其他任务。应使用下载延迟、并发配置和框架的节流机制,让调度器管理等待。

免费 AI IDE



