(二十三)把调度与导出交给 Scrapy
1. 从当前程序对应到框架中的职责
我们已经自己写过请求、任务队列、去重、解析和保存。站点增加后,可以把固定的调度与下载职责交给框架,自己继续写“从响应产生哪些记录和请求”。Scrapy 中这些职责有对应位置:
| 当前程序中的动作 | Scrapy 中接手的组件 |
|---|---|
| 选择下一项请求、过滤重复 | Scheduler 与请求去重器 |
| 下载、重试、请求间隔 | Downloader 与下载中间件、设置 |
| 从目录发现详情和下一页 | Spider 的回调 |
| 检查并保存记录 | Item Pipeline;本例先用 Feed Export 输出 |
| 保存待调度请求与去重状态 | JOBDIR 中的任务状态 |
2. 用同一个站点写一个可运行 Spider
先把第三章的 yield 与上一章的异步等待接起来,观察一个异步生成器:
# 项目文件:demos/async_generator.py
# yield 保留逐项产生的方式,async for 在每次取得下一项时允许异步等待。
import asyncio
async def start_paths():
for number in [1, 2]:
await asyncio.sleep(0)
yield f"/catalog/?page={number}"
async def main():
async for path in start_paths():
print(path)
if __name__ == "__main__":
asyncio.run(main())输出第一页和第二页路径。async def 中出现 yield,调用后得到异步生成器;async for 逐次等待下一项,拿到 path 后执行循环体。它仍然逐项产生数据,区别在于下一项产生之前可以进行异步等待。
Scrapy 的 start 使用同样的方式交出起始 Request,框架负责异步迭代它。目录与详情的处理写成回调:
# 项目文件:crawler_project/spiders/books.py
# Spider 产生请求和记录;调度、下载、去重与导出交给 Scrapy 的组件。
import scrapy
from catalogcrawl.parsers import parse_detail
from catalogcrawl.urls import normalized_url
class BooksSpider(scrapy.Spider):
name = "reading_books"
custom_settings = {
"ROBOTSTXT_OBEY": True,
"CONCURRENT_REQUESTS_PER_DOMAIN": 2,
"DOWNLOAD_DELAY": 0.02,
"RANDOMIZE_DOWNLOAD_DELAY": False,
"FEED_EXPORT_ENCODING": "utf-8",
"TELNETCONSOLE_ENABLED": False,
}
def __init__(self, base_url="http://127.0.0.1:8766", **kwargs):
super().__init__(**kwargs)
self.base_url = base_url.rstrip("/")
async def start(self):
yield scrapy.Request(self.base_url + "/catalog/?page=1", callback=self.parse)
def parse(self, response):
for href in response.css("article.book-card a.detail::attr(href)").getall():
yield scrapy.Request(
normalized_url(response.url, href), callback=self.parse_book
)
next_href = response.css('a[rel~="next"]::attr(href)').get()
if next_href is not None:
yield scrapy.Request(
normalized_url(response.url, next_href), callback=self.parse
)
def parse_book(self, response):
item = parse_detail(response.text, response.url)
yield {
"source_id": item.source_id,
**item.as_record(),
"source_url": item.source_url,
}Spider 继承 Scrapy 提供的基类,复用第四章的重写与第三章的生成器。async def start 是异步生成器,yield 起始 Request;每次响应到达时,框架调用指定的 callback。parse 继续 yield 详情与下一页请求,parse_book 则 yield 一份字典记录。Scrapy 入门教程
response.css 使用 CSS 选择器,::attr(href) 取得属性;.getall() 交回全部字符串,.get() 交回第一项或 None。它不是 Beautiful Soup 的 Tag 接口,得到的 href 仍交给已经写过的链接补全函数。详情解析则直接复用 parse_detail。
请求队列会过滤相同 URL。输出的书籍字典包含 source_id,后续 Pipeline 可以继续按该编号检查记录重复、校验与入库。本章的完整采集器已经展示了这些动作,迁移时可以逐层比较行为,而不是一次改掉所有步骤。
3. 运行、输出与有序停止
# 安装 Scrapy 可选依赖,在另一个终端保持本机练习站运行。
uv sync --locked --extra scrapy
# 首轮将六本书写成 JSON Lines,每行一份记录。
uv run --extra scrapy scrapy runspider spiders/books.py -a base_url=http://127.0.0.1:8766 -O output/scrapy-books.jsonl -s JOBDIR=output/scrapy-job
-a 把字符串参数传给 Spider 构造函数,-O 覆盖指定导出文件。JOBDIR 保存本轮待调度请求、请求去重等状态。运行中按一次 Ctrl+C,让框架完成有序停止;之后用同一任务目录恢复,导出改用小写 -o 向原来的 JSON Lines 追加:
# 继续同一轮任务,并把新完成记录追加到已经保存的文件。
uv run --extra scrapy scrapy runspider spiders/books.py -a base_url=http://127.0.0.1:8766 -o output/scrapy-books.jsonl -s JOBDIR=output/scrapy-job
有序停止会把尚未处理的请求留在任务目录中;强行结束进程时,正在变化的任务文件可能没有写完。第十六节自己实现的事务进度,与这里框架保存的调度进度,可以按“什么已经提交、什么需要重做”逐项比较。JOBDIR 对应同一轮任务,继续时保持同一锁定环境;刷新一轮目录则使用新的任务目录和输出文件。Scrapy 暂停与恢复