欢迎光临
我们一直在努力

Python后端爬虫专题18:进程退出码是0,职位却少了一半——中间件、AutoThrottle与统计排障

Python后端爬虫专题18:进程退出码是0,职位却少了一半——中间件、AutoThrottle与统计排障

Scrapy运行统计诊断面板

上一篇练习完整答案

实际输出校验脚本可写成:

import json
from pathlib import Path

required = {
"external_id", "source_url", "title", "company", "city", "description",
"skills", "salary_min", "salary_max", "salary_months", "published_at",
}
rows = [
json.loads(line)
for line in Path("data/scrapy-jobs.jsonl").read_text(encoding="utf-8").splitlines()
]
assert len(rows) == 3
assert all(set(row) == required for row in rows)
assert all(isinstance(row["published_at"], str) for row in rows)
assert len({row["source_url"] for row in rows}) == 3

-o 会追加到已有 Feed,重复执行可能重复三行;-O 先覆盖,适合生成代表当前运行结果的全量文件。持续事件日志可以追加,但还需要 offset、事件 id 和消费者幂等。坏详情缺少 h1 时,parse_detail 抛 PageParseError,消息标明 title;让空标题继续会污染指纹、搜索和统计,且以后难以区分“真的无标题”与“选择器失效”。

值班现场:为什么 finished 不是成功

某次 Spider 日志最后写着 Spider closed (finished),运维只看到退出码 0;第二天产品发现职位从 3000 变成 1400。查看 Stats:response_status_count/429=820、retry/max_reached=400、item_scraped_count=1400。进程只是正常结束了调度生命周期,业务目标并未达成。

这类问题要沿中间件链排查。Downloader Middleware 在请求发出前和响应回来后工作,适合代理之外的通用请求头、重试、缓存和统计;Spider Middleware 位于 Engine 与 Spider 回调之间,处理输入响应和回调输出。不要把字段解析塞进下载中间件,否则页面业务规则会与传输机制耦合。

JobSpider 的六项运行护栏

ROBOTSTXT_OBEY=True 让 Scrapy 在访问前读取规则;AUTOTHROTTLE_ENABLED=True 根据延迟动态调整间隔;CONCURRENT_REQUESTS_PER_DOMAIN=2 给同域设置并发上限;DOWNLOAD_TIMEOUT=10 限制单请求占用;RETRY_HTTP_CODES 只包含可恢复的 429/5xx;FEED_EXPORT_ENCODING=utf-8 保证中文交付。

运行独立检查点:

cd project
.\\.venv\\Scripts\\python.exe –m pytest tests\\test_framework_adapters.py::test_scrapy_spider_enforces_polite_domain_runtime_settings –q

测试的意义不是证明 Scrapy 支持这些键,而是防止项目重构时误删自己的访问承诺。真正运行时还应查看 settings 优先级:命令行 -s 可能覆盖 Spider custom_settings,部署配置要有审查。

AutoThrottle 不是“自动越快越好”

它根据响应延迟与目标并发调整下载延迟:服务器变慢时放缓,稳定时逐步恢复。它不会无视 CONCURRENT_REQUESTS_PER_DOMAIN,也不替你决定合法频率。Target concurrency 是目标而非硬保证,非 200 响应不会促使提速,但仍应对 429 遵从 Retry-After 和业务停止线。

本地 TargetLab 很快,AutoThrottle 可能迅速缩短延迟;这不代表真实站点也能承受。课程可用 AUTOTHROTTLE_DEBUG=True 观察每个 slot 的 latency、delay 和 concurrency,但生产长期打开会产生很多日志。

一张够用的运行核对表

先看 finish_reason:finished、shutdown、closespider_* 含义不同;再比较请求、响应与 item 数;看 HTTP 状态分布,特别是 301/302、401/403、429、5xx;看 retry 次数与 max_reached;看 downloader exceptions 中 timeout、DNS、TLS;最后看 elapsed_time_seconds 和每分钟 item 吞吐。

业务侧还要记录发现详情数、解析成功数、入库 created/updated/unchanged 和错误样本 URL。Scrapy 原生 Stats 不理解“职位更新”,所以适配层或 Pipeline 应补业务计数。技术指标正常而业务发现数突然归零,往往是选择器或登录页面问题。

为什么不对所有状态都重试

400 表示请求不符合合同,重发相同内容仍错;401/403 需要凭据或授权处理;404 多数是资源已经删除;429 与 5xx 才可能在退避后恢复。无限重试会拖住 slot、放大对方故障,并让任务永不结束。重试耗尽必须进入报告,不能被最后的 finished 掩盖。

RetryMiddleware 与 AutoThrottle 也不是一回事:前者决定失败响应是否再次调度,后者控制请求节奏。若服务返回 Retry-After,需要确认当前 Scrapy 版本/自定义中间件是否按预期处理,不能只因为启用了 AutoThrottle 就假定严格等待该秒数。

事故处理动作

看到 429 激增,先降低并发/频率并暂停扩容,不换代理绕过;看到大量 200 但 item 降低,抽取快照比较 DOM;看到 302 增加,检查是否跳到登录或同意页面;看到 403,核对授权而非伪装浏览器;看到单个 5xx,有限重试并记录。处理后用固定 Fixture 回归,再小流量恢复。

本篇完整 Spider 配置

完整模块的代码量不大,但每个 setting 都对应上面的运行决策。实际团队可把跨 Spider 默认值放 settings.py,把来源特例保留在 custom_settings,并记录为什么特例存在。

"""让 Scrapy 的调度能力复用 JobRadar 已验证的解析合同。"""

from collections.abc import Iterable
from urllib.parse import urlsplit

import scrapy
from scrapy.http import Response

from jobradar.parsing import parse_detail, parse_listing

class JobSpider(scrapy.Spider):
"""采集 TargetLab 列表与详情;item 字段与 JobItem 完全一致。"""

name = "jobradar_jobs"
custom_settings = {
"ROBOTSTXT_OBEY": True,
"AUTOTHROTTLE_ENABLED": True,
"CONCURRENT_REQUESTS_PER_DOMAIN": 2,
"DOWNLOAD_TIMEOUT": 10,
"RETRY_HTTP_CODES": [429, 500, 502, 503, 504],
"FEED_EXPORT_ENCODING": "utf-8",
}

def __init__(self, seed_url: str, *args: object, **kwargs: object) –> None:
super().__init__(*args, **kwargs)
parts = urlsplit(seed_url)
if parts.scheme not in {"http", "https"} or not parts.hostname:
raise ValueError("seed_url must be an absolute HTTP(S) URL")
self.start_urls = [seed_url]
self.allowed_domains = [parts.hostname]

def parse(self, response: Response, **kwargs: object) –> Iterable[scrapy.Request]:
listing = parse_listing(response.text, response.url)
for detail_url in listing.detail_urls:
yield scrapy.Request(detail_url, callback=self.parse_job)
if listing.next_url:
yield scrapy.Request(listing.next_url, callback=self.parse)

def parse_job(self, response: Response) –> Iterable[dict[str, object]]:
item = parse_detail(response.text, response.url)
yield item.model_dump(mode="json")

本篇课后练习

  • 给出 429=300、retry/max_reached=120、item_scraped=800、finish_reason=finished 的完整故障判断与处置顺序。
  • 启动 TargetLab 后运行 Spider,保存末尾 Stats,找出请求数、200 数、item 数、耗时和 finish_reason。
  • 说明 Downloader Middleware 与 Item Pipeline 分别适合做什么,并举一个放错层会导致的问题。下一篇会处理普通 HTTP 响应中根本没有职位 DOM 的动态页面。
  • 赞(0)
    未经允许不得转载:171主机测评 » Python后端爬虫专题18:进程退出码是0,职位却少了一半——中间件、AutoThrottle与统计排障
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址