Python 现代开发学习笔记

(十七)把发现、下载与资源管理接起来

1. 一页失败与一本失败分别交给对应层处理

runner.py 先检查 HTML 类型,再定义发现目录与读取单本的动作:

# 项目文件:crawler_project/src/catalogcrawl/runner.py
# 目录依赖下一页链接,先顺序发现;独立详情按小批并发,写入仍由当前调用层负责。
import asyncio
import logging
import math

import httpx

from .errors import CrawlError, ParseError
from .pacing import StartGate
from .parsers import parse_catalog, parse_detail
from .transport import get_response

logger = logging.getLogger(__name__)


def html_text(response):
    kind = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
    if kind not in {"text/html", "application/xhtml+xml"}:
        raise ParseError(f"{response.url}:期待 HTML,收到 {kind}")
    return response.text


async def discover_pages(client, state, gate, attempts):
    while (url := state.next_page()) is not None:
        response = await get_response(client, url, gate, attempts)
        links, next_url = parse_catalog(html_text(response), str(response.url))
        state.save_page(url, links, next_url)
        logger.info("目录已提交 url=%s cards=%s", url, len(links))


async def read_one(client, url, gate, attempts):
    try:
        response = await get_response(client, url, gate, attempts)
        return parse_detail(html_text(response), str(response.url))
    except CrawlError as error:
        # 预期的单项失败留待下一次启动;取消和程序错误继续向任务组传播。
        return error

目录页失败时还不知道后续有哪些任务,所以保留这页待处理并向入口反馈。详情已经有自己的任务行,预期的读取或解析失败可以交回为结果,继续收集其他书。CancelledError 继续向外传,取消不会被当作一本普通的失败记录吞掉。

2. 每批收尾后逐项提交

# 续写同一文件:crawler_project/src/catalogcrawl/runner.py
async def download_details(client, state, gate, concurrency, attempts, stop_after):
    failures, saved, after_seq = [], 0, 0
    while stop_after is None or saved < stop_after:
        limit = (
            concurrency if stop_after is None else min(concurrency, stop_after - saved)
        )
        batch = state.pending_jobs(after_seq, limit)
        if not batch:
            break
        # 本轮只处理每项一次;失败任务仍留在库里,供下次启动重新选择。
        after_seq = batch[-1]["seq"]
        async with asyncio.TaskGroup() as group:
            tasks = [
                group.create_task(read_one(client, row["url"], gate, attempts))
                for row in batch
            ]
        for row, task in zip(batch, tasks):
            result = task.result()
            if isinstance(result, CrawlError):
                failures.append({"url": row["url"], "error": str(result)})
                logger.error("详情仍待采集 url=%s error=%s", row["url"], result)
            else:
                state.complete_job(row["url"], result)
                saved += 1
                logger.info("详情已提交 id=%s url=%s", result.source_id, row["url"])
    return {"saved_this_run": saved, "failures": failures}

两项任务都进入 TaskGroup,预期的单项错误由 read_one 交回;程序错误仍然会触发任务组的取消与收尾。协调层再逐项查看结果:成功就提交书籍和完成标记,失败则记日志,并保留原来的待处理行。

stop_after 统计本次新保存的任务数量。设置为 2 时,只安排所需的这一批;本次暂停后,下一次启动重新选择剩余任务。某项失败时 saved 不增加,后面的批仍可继续取得其他记录。

3. 外层统一管理客户端与截止时间

# 续写同一文件:crawler_project/src/catalogcrawl/runner.py
# 先发现分页,再处理详情队列;同一个限速器和连接池供这些请求共同使用。
async def crawl(
    state,
    concurrency=2,
    interval=0.02,
    attempts=3,
    deadline=15,
    request_timeout=1,
    stop_after=None,
):
    if concurrency < 1 or attempts < 1:
        raise ValueError("并发数和尝试次数应至少为 1")
    if stop_after is not None and stop_after < 1:
        raise ValueError("暂停前本次保存的数量应至少为 1")
    if not all(
        math.isfinite(value) and value > 0 for value in (deadline, request_timeout)
    ):
        raise ValueError("超时应为正的有限秒数")
    gate = StartGate(interval)
    limits = httpx.Limits(
        max_connections=concurrency, max_keepalive_connections=concurrency
    )
    async with asyncio.timeout(deadline):
        async with httpx.AsyncClient(
            timeout=request_timeout,
            limits=limits,
            follow_redirects=True,
            trust_env=False,
            headers={"User-Agent": "ReadingNotes/1.0"},
        ) as client:
            await discover_pages(client, state, gate, attempts)
            result = await download_details(
                client, state, gate, concurrency, attempts, stop_after
            )
    return {**state.stats(), **result}

请求池、开始门、目录发现与详情下载都在同一批运行范围中。返回前先完成任务收尾,再关闭客户端;CrawlState 则由调用它的 with 负责。整批超时或取消发生时,本批尚未提交的项仍为待处理,先前已经提交的项继续保存在库中。

入口打开进度库,运行器统一管理客户端与任务批,只有协调层提交,退出时按内向外收尾

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