Python 现代开发学习笔记

(二十三)把调度与导出交给 Scrapy

1. 从当前程序对应到框架中的职责

我们已经自己写过请求、任务队列、去重、解析和保存。站点增加后,可以把固定的调度与下载职责交给框架,自己继续写“从响应产生哪些记录和请求”。Scrapy 中这些职责有对应位置:

当前程序中的动作 Scrapy 中接手的组件
选择下一项请求、过滤重复 Scheduler 与请求去重器
下载、重试、请求间隔 Downloader 与下载中间件、设置
从目录发现详情和下一页 Spider 的回调
检查并保存记录 Item Pipeline;本例先用 Feed Export 输出
保存待调度请求与去重状态 JOBDIR 中的任务状态
Spider从响应产生请求与记录,请求回到调度下载,记录进入保存,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 暂停与恢复

笔记正文与全部题解 · Ctrl / ⌘ K