从脚本到系统:设计一个支持插件、限流、重试与监控的 Python 异步爬虫框架

从脚本到系统:设计一个支持插件、限流、重试与监控的 Python 异步爬虫框架

很多人第一次写 Python 爬虫,都是从几十行脚本开始的:requests.get()BeautifulSoupfor 循环、保存 CSV。它很快,也很有成就感。但真实项目往往不是"抓一个页面"这么简单,而是:目标站点很多、页面结构变化频繁、网络偶发失败、接口有访问频率限制、任务需要长期运行、线上出错要能追踪。

这时,问题就从"写一个爬虫"变成了"设计一个爬虫系统"。

本文面向既想打牢 Python 编程基础、又希望进入 Python 实战与工程化阶段的读者。我们将设计一个支持插件、限流、重试、监控的异步爬虫系统,用它回答一个高级 Python 工程师经常面对的问题:如何把混乱的抓取需求,组织成可扩展、可维护、可观测的工程体系。

在技术选型上,我们使用 asyncio 作为并发基础。Python 官方文档说明,asyncioasync/await 编写并发代码,并且是多个异步框架的基础;这非常适合网络 I/O 密集型场景。(Python documentation) HTTP 客户端可以选择 aiohttp,它的客户端请求支持异步上下文管理器,适合构建资源安全的异步请求流程。(AIOHTTP)


一、先定义边界:一个工程化爬虫系统应该解决什么?

一个可上线的异步爬虫系统,至少要回答六个问题:

text 复制代码
1. URL 从哪里来?
2. 请求如何并发执行?
3. 不同网站如何解析?
4. 失败如何重试?
5. 访问频率如何控制?
6. 系统状态如何监控?

如果把它画成流程图,大致是这样:
Seed URLs
Scheduler
Rate Limiter
Async Fetcher
Retry Policy
Plugin Parser
Pipeline Storage
Metrics Monitor

这个结构的核心思想是:抓取、解析、存储、监控分离

不要把所有逻辑写进一个 crawl.py,否则三天后你自己都会害怕打开它。


二、项目目录设计:先让系统有秩序

推荐目录如下:

text 复制代码
async_crawler/
├── crawler/
│   ├── core.py          # 爬虫主流程
│   ├── fetcher.py       # 异步请求
│   ├── limiter.py       # 限流器
│   ├── retry.py         # 重试策略
│   ├── plugin.py        # 插件协议与加载
│   ├── pipeline.py      # 数据处理与存储
│   └── metrics.py       # 监控指标
├── plugins/
│   ├── news.py
│   └── product.py
├── tests/
│   └── test_parser.py
└── main.py

目录不是形式主义。好的目录结构会告诉后来者:哪里放规则,哪里放变化,哪里是稳定核心。


三、基础能力:异步请求器 Fetcher

爬虫的第一层能力是请求页面。同步爬虫一次只能等待一个网络响应,而异步爬虫可以在等待 A 网站返回时,同时调度 B、C、D 请求。

python 复制代码
# crawler/fetcher.py
import aiohttp
import asyncio
from dataclasses import dataclass

@dataclass
class FetchResult:
    url: str
    status: int
    text: str

class AsyncFetcher:
    def __init__(self, timeout: int = 10):
        self.timeout = aiohttp.ClientTimeout(total=timeout)

    async def fetch(self, session: aiohttp.ClientSession, url: str) -> FetchResult:
        async with session.get(url, timeout=self.timeout) as resp:
            text = await resp.text(errors="ignore")
            return FetchResult(url=url, status=resp.status, text=text)

这里用了 async with,它不只是语法优雅,更重要的是能确保连接资源被正确释放。高级 Python 编程的一个重要习惯是:资源生命周期要显式管理


四、限流设计:别让并发变成攻击

很多新手误以为异步爬虫越快越好,这是危险的。成熟爬虫一定要有礼貌:设置合理 User-Agent、尊重目标站规则、遵守 robots.txt、限制并发、避免对目标服务造成压力。

一个简单的限流器可以基于 asyncio.Semaphore 实现:

python 复制代码
# crawler/limiter.py
import asyncio
from collections import defaultdict

class DomainRateLimiter:
    def __init__(self, per_domain_limit: int = 3, delay: float = 0.5):
        self._locks = defaultdict(lambda: asyncio.Semaphore(per_domain_limit))
        self._delay = delay

    async def acquire(self, domain: str):
        sem = self._locks[domain]
        await sem.acquire()
        await asyncio.sleep(self._delay)
        return sem

使用时:

python 复制代码
from urllib.parse import urlparse

async def limited_fetch(fetcher, session, limiter, url):
    domain = urlparse(url).netloc
    sem = await limiter.acquire(domain)
    try:
        return await fetcher.fetch(session, url)
    finally:
        sem.release()

这段代码解决的是"每个域名最多同时几个请求"。真实项目中,还可以加令牌桶、滑动窗口、分布式限流等方案。


五、重试机制:失败不可怕,不可控才可怕

网络请求天然不稳定。DNS 抖动、连接超时、服务端 502、503、504 都可能发生。高级工程师不会简单地 except Exception: pass,而是设计明确的重试策略。

如果项目允许依赖第三方库,可以使用 Tenacity。Tenacity 是一个通用 Python 重试库,目标就是简化重试行为的添加。(Tenacity)

当然,我们也可以先实现一个轻量版本:

python 复制代码
# crawler/retry.py
import asyncio
import random

RETRY_STATUS = {429, 500, 502, 503, 504}

class RetryPolicy:
    def __init__(self, max_attempts: int = 3, base_delay: float = 0.5):
        self.max_attempts = max_attempts
        self.base_delay = base_delay

    async def run(self, coro_factory):
        last_error = None

        for attempt in range(1, self.max_attempts + 1):
            try:
                result = await coro_factory()
                if result.status not in RETRY_STATUS:
                    return result
                last_error = RuntimeError(f"retryable status: {result.status}")
            except Exception as exc:
                last_error = exc

            delay = self.base_delay * (2 ** (attempt - 1))
            jitter = random.uniform(0, 0.2)
            await asyncio.sleep(delay + jitter)

        raise last_error

这里用了指数退避和随机抖动。它的价值在于:失败后不要所有任务一起立刻重试,否则可能造成"雪崩式重试"。


六、插件系统:让变化关在笼子里

不同网站结构不同,解析规则也不同。如果把所有解析逻辑写进主流程,系统很快就会变成一锅粥。

我们可以定义插件协议:

python 复制代码
# crawler/plugin.py
from typing import Protocol, Iterable
from dataclasses import dataclass

@dataclass
class Item:
    source: str
    title: str
    url: str

class SpiderPlugin(Protocol):
    name: str

    def match(self, url: str) -> bool:
        ...

    def parse(self, url: str, html: str) -> Iterable[Item]:
        ...

新闻插件示例:

python 复制代码
# plugins/news.py
from bs4 import BeautifulSoup
from crawler.plugin import Item

class NewsPlugin:
    name = "news"

    def match(self, url: str) -> bool:
        return "news" in url

    def parse(self, url: str, html: str):
        soup = BeautifulSoup(html, "html.parser")
        for a in soup.select("a"):
            title = a.get_text(strip=True)
            href = a.get("href")
            if title and href:
                yield Item(source=self.name, title=title, url=href)

插件管理器:

python 复制代码
# crawler/plugin.py
class PluginManager:
    def __init__(self, plugins):
        self.plugins = plugins

    def select(self, url: str):
        for plugin in self.plugins:
            if plugin.match(url):
                return plugin
        return None

插件化的意义不是"看起来架构很高级",而是把变化隔离起来。目标站点结构变了,只改对应插件,不动核心调度器。


七、数据管道:不要边爬边乱存

抓到数据后,不建议直接在解析函数里写数据库。更好的方式是走统一 Pipeline:

python 复制代码
# crawler/pipeline.py
import json

class JsonLinePipeline:
    def __init__(self, path: str):
        self.path = path

    async def save_many(self, items):
        with open(self.path, "a", encoding="utf-8") as f:
            for item in items:
                f.write(json.dumps(item.__dict__, ensure_ascii=False) + "\n")

真实项目可以替换成 MySQL、PostgreSQL、MongoDB、Kafka 或对象存储。核心原则是:解析只负责解析,存储只负责存储


八、监控:没有指标的系统等于盲飞

异步爬虫跑起来之后,你最关心的不是"它现在还活着吗",而是:

text 复制代码
每分钟抓取多少页面?
成功率是多少?
失败最多的是哪些状态码?
平均响应时间是多少?
队列是否积压?
插件解析失败率是多少?

Prometheus 是常见的监控方案,它提供多维数据模型、PromQL 查询语言和告警能力。(prometheus.io) 在 Python 中可以设计如下指标层:

python 复制代码
# crawler/metrics.py
from collections import Counter
import time

class Metrics:
    def __init__(self):
        self.counter = Counter()
        self.latencies = []

    def inc(self, name: str):
        self.counter[name] += 1

    def observe_latency(self, seconds: float):
        self.latencies.append(seconds)

    def report(self):
        avg = sum(self.latencies) / len(self.latencies) if self.latencies else 0
        return {
            "counts": dict(self.counter),
            "avg_latency": round(avg, 4),
        }

请求时打点:

python 复制代码
import time

async def monitored_fetch(fetcher, session, retry_policy, metrics, url):
    start = time.perf_counter()
    try:
        result = await retry_policy.run(lambda: fetcher.fetch(session, url))
        metrics.inc(f"status_{result.status}")
        return result
    except Exception:
        metrics.inc("failed")
        raise
    finally:
        metrics.observe_latency(time.perf_counter() - start)

很多工程事故不是因为没人写代码,而是系统出问题时没人知道问题在哪里。监控就是给系统装上仪表盘。


九、主流程:把组件组装成系统

现在,我们把 Fetcher、Limiter、Retry、Plugin、Pipeline、Metrics 组合起来:

python 复制代码
# crawler/core.py
import aiohttp
import asyncio

class AsyncCrawler:
    def __init__(self, urls, fetcher, limiter, retry_policy, plugins, pipeline, metrics):
        self.urls = urls
        self.fetcher = fetcher
        self.limiter = limiter
        self.retry_policy = retry_policy
        self.plugins = plugins
        self.pipeline = pipeline
        self.metrics = metrics

    async def crawl_one(self, session, url):
        result = await limited_fetch(self.fetcher, session, self.limiter, url)

        plugin = self.plugins.select(url)
        if not plugin:
            self.metrics.inc("no_plugin")
            return

        items = list(plugin.parse(url, result.text))
        await self.pipeline.save_many(items)

        self.metrics.inc("page_done")
        self.metrics.inc(f"items_{len(items)}")

    async def run(self):
        async with aiohttp.ClientSession(
            headers={"User-Agent": "AsyncCrawlerBot/1.0"}
        ) as session:
            tasks = [self.crawl_one(session, url) for url in self.urls]
            await asyncio.gather(*tasks, return_exceptions=True)

        print(self.metrics.report())

入口文件:

python 复制代码
# main.py
import asyncio
from crawler.fetcher import AsyncFetcher
from crawler.limiter import DomainRateLimiter
from crawler.retry import RetryPolicy
from crawler.plugin import PluginManager
from crawler.pipeline import JsonLinePipeline
from crawler.metrics import Metrics
from crawler.core import AsyncCrawler
from plugins.news import NewsPlugin

urls = [
    "https://example.com/news/1",
    "https://example.com/news/2",
]

crawler = AsyncCrawler(
    urls=urls,
    fetcher=AsyncFetcher(timeout=10),
    limiter=DomainRateLimiter(per_domain_limit=2, delay=1),
    retry_policy=RetryPolicy(max_attempts=3),
    plugins=PluginManager([NewsPlugin()]),
    pipeline=JsonLinePipeline("items.jsonl"),
    metrics=Metrics(),
)

asyncio.run(crawler.run())

这已经不是一个"脚本",而是一个具备工程骨架的小型系统。


十、最佳实践清单:让爬虫长期可维护

1. 尊重规则与边界

爬虫不是绕过限制的工具。正式项目应遵守目标网站服务条款、robots.txt、版权规则和访问频率要求。

2. 限流优先于重试

很多失败来自访问太猛。先降低压力,再谈重试。

3. 解析逻辑插件化

每个站点一个插件,每个插件有单元测试。页面结构变化时,故障范围可控。

4. 日志与指标分层

日志回答"发生了什么",指标回答"整体是否健康"。

5. 数据落盘使用 JSONL

JSONL 一行一条记录,适合追加写入、断点恢复和后续批处理。

6. 给任务加唯一 ID

URL、插件名、时间戳、重试次数都应该进入上下文,便于排查问题。

7. 测试解析器,而不是测试网络

单元测试中保存 HTML 样本,测试 parse() 输出是否稳定。

python 复制代码
def test_news_plugin_parse():
    html = '<a href="/a">标题 A</a>'
    plugin = NewsPlugin()
    items = list(plugin.parse("https://example.com/news", html))

    assert len(items) == 1
    assert items[0].title == "标题 A"

十一、从入门到高级:这套系统背后的 Python 能力

这个项目几乎串起了 Python 教程中的核心知识:

能力 在项目中的体现
列表、字典、集合 URL 队列、状态码统计、插件注册
函数与异常 请求封装、失败处理
类与面向对象 Fetcher、Limiter、Plugin、Pipeline
装饰器与上下文管理器 监控、资源释放
生成器 解析结果流式产出
异步编程 高并发网络 I/O
模块化设计 各组件职责分离
单元测试 保障插件稳定性

这也是 Python 的魅力所在:它既能让初学者快速写出第一个程序,也能支撑复杂系统的工程化演进。


十二、未来扩展:从单机爬虫到平台化系统

当业务增长后,可以继续演进:

text 复制代码
单机队列       -> Redis/Kafka 分布式队列
JSONL 文件     -> PostgreSQL/Elasticsearch/对象存储
简单 Metrics   -> Prometheus + Grafana
手动运行       -> Airflow/Prefect 定时调度
静态插件       -> 动态插件加载与版本管理
本地部署       -> Docker + Kubernetes

也可以引入 FastAPI 做任务管理接口,或用 Streamlit 做内部监控面板。FastAPI 官方定位是基于标准 Python 类型提示构建现代、高性能 API;Streamlit 文档则强调可以用纯 Python 构建和分享数据应用,非常适合内部数据工具与监控看板。(FastAPI)


结语:高级不是更快,而是更稳

一个初级爬虫解决"我能不能抓到";一个工程化爬虫解决"我能不能长期、稳定、可控、可追踪地抓到"。

Python 高级工程师的价值,不在于能不能写出最炫的异步代码,而在于能不能把真实世界的不确定性收进系统设计里:网络会失败,所以有重试;目标站有压力,所以有限流;规则会变化,所以有插件;线上会出问题,所以有监控;团队会协作,所以有模块边界和测试。

这就是 Python 实战最迷人的地方:你写的不只是代码,而是一套让混乱变得有秩序的能力。

相关推荐
deepin_sir9 小时前
02 - 第一个 Python 程序
开发语言·python
徐先生 @_@|||9 小时前
pycharm/IDEA + markdown + 图床(PicList)
ide·python·pycharm·intellij-idea
半壶清水9 小时前
一次处理挖矿木马的记录,从流量异常到揪出 XMRig 的过程
网络·安全·病毒
ZHW_AI课题组9 小时前
基于PCA与HOG特征融合的热轧钢带缺陷检测
人工智能·python·机器学习
MediaTea9 小时前
DL:扩散模型的基本原理与 PyTorch 实现
人工智能·pytorch·python·深度学习·机器学习
programhelp_9 小时前
Ramp OA 四关全过,CodeSignal OOD 完整复盘
linux·前端·python
IT大白鼠9 小时前
2008年YouTube全球劫持事件:BGP协议脆弱性与互联网基础设施安全反思
网络·安全
Chasing__Dreams9 小时前
大模型应用开发--0--知识点
python