长期稳定跑网页监控:TLS 指纹、代理选路与请求节流的工程实践

写一个爬网页的脚本很容易,让它连续跑一年不出事很难。

网页监控和一次性爬取的区别就在这里:一次性任务失败了可以重跑,监控任务是长期驻留的------你要在别人的服务器上每隔几分钟出现一次,持续几个月。这种关系里,任何"莽"的做法最终都会被拉黑。

这篇文章讲的是怎么让监控链路长期稳定,同时不给对方站点添麻烦。前提说清楚:所有技术都建立在"你有权访问这个页面"之上------公开可访问的页面、你自己的站点、或者你有明确授权的对象。绕过付费墙、抓取登录后的他人数据、或者用技术手段规避明确的访问限制,不在本文讨论范围内,也不该做。


一、先做减法:大部分稳定性问题不需要技术手段

在上任何对抗性技术之前,先把这几件成本最低、收益最高的事做了。

1. 读 robots.txt,并且真的遵守

python 复制代码
from urllib.robotparser import RobotFileParser
from urllib.parse import urljoin, urlparse

def can_fetch(url: str, user_agent: str) -> bool:
    parts = urlparse(url)
    rp = RobotFileParser()
    rp.set_url(urljoin(f"{parts.scheme}://{parts.netloc}", "/robots.txt"))
    try:
        rp.read()
    except Exception:
        return True          # 拿不到 robots.txt 时按允许处理,但要记日志
    return rp.can_fetch(user_agent, url)

RobotFileParser 还提供 crawl_delay()request_rate(),很多站点会在 robots.txt 里明确写"每 10 秒一次"。照着它配你的检查频率------这是对方给的官方答案,比你猜任何数字都准。

2. User-Agent 里写清楚你是谁

python 复制代码
UA = "PriceWatchBot/1.0 (+https://example.com/bot; ops@example.com)"

留一个说明页 URL 和一个联系邮箱。这看起来像是自曝身份、给自己找麻烦,实际效果恰恰相反:运维在日志里看到一个流量稳定、频率克制、留了联系方式的 bot,通常不会封你;看到一个伪装成 Chrome、每秒打十几次的匿名请求,第一反应是加 WAF 规则。

我见过好几次这样的案例:站点方主动发邮件说"你抓的这个数据我们有 API,要不要用"。这比你在代理池上花的钱值多了。

3. 优先用 API 和条件请求

在写解析器之前先花十分钟找一找:对方有没有 RSS?有没有公开 API?有没有 sitemap.xml 带 lastmod

如果只能抓 HTML,至少把条件请求用起来:

python 复制代码
import httpx

def conditional_get(url: str, etag: str | None, last_mod: str | None):
    headers = {"User-Agent": UA}
    if etag:
        headers["If-None-Match"] = etag
    if last_mod:
        headers["If-Modified-Since"] = last_mod

    resp = httpx.get(url, headers=headers, timeout=20, follow_redirects=True)
    if resp.status_code == 304:
        return None, etag, last_mod          # 没变,零解析成本
    return (
        resp.content,
        resp.headers.get("ETag", etag),
        resp.headers.get("Last-Modified", last_mod),
    )

一个 304 响应通常不到 200 字节。如果目标站点正确实现了 ETag,你的带宽和它的计算量能降 95% 以上。这是双赢,也是最容易被跳过的一步。


二、请求节流:令牌桶 + 抖动 + 退避

监控系统最容易出的事故是"雪崩式重试"------一个站点挂了,你的 200 个监控项同时超时、同时重试,把对方刚恢复的服务再打挂一次。

按域名限速的令牌桶

python 复制代码
import asyncio
import time
from collections import defaultdict

class DomainLimiter:
    """每个域名独立限速,避免一个站点的配额被另一个吃掉"""

    def __init__(self, default_rate: float = 0.2):   # 默认每秒 0.2 次 = 5 秒一次
        self._rates: dict[str, float] = {}
        self._default = default_rate
        self._next_allowed: dict[str, float] = defaultdict(float)
        self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)

    def set_rate(self, domain: str, rate: float) -> None:
        self._rates[domain] = rate

    async def acquire(self, domain: str) -> None:
        async with self._locks[domain]:
            interval = 1.0 / self._rates.get(domain, self._default)
            now = time.monotonic()
            wait = self._next_allowed[domain] - now
            if wait > 0:
                await asyncio.sleep(wait)
            self._next_allowed[domain] = max(now, self._next_allowed[domain]) + interval

给调度加抖动

python 复制代码
import random

def next_run_at(base_interval: int, jitter_pct: float = 0.15) -> float:
    """base_interval 秒 ± 15% 抖动"""
    delta = base_interval * jitter_pct
    return time.time() + base_interval + random.uniform(-delta, delta)

不加抖动的后果是所有整点任务在 :00:00 齐发。抖动既能削平自己的峰值,也能避免在对方日志里形成过于规整的心跳特征。

指数退避 + 熔断

python 复制代码
class Backoff:
    def __init__(self, base: float = 60, cap: float = 3600, threshold: int = 5):
        self.base, self.cap, self.threshold = base, cap, threshold
        self.failures = 0

    def on_success(self) -> None:
        self.failures = 0

    def on_failure(self) -> float:
        self.failures += 1
        delay = min(self.cap, self.base * (2 ** (self.failures - 1)))
        return delay * random.uniform(0.5, 1.0)      # full jitter

    @property
    def tripped(self) -> bool:
        return self.failures >= self.threshold

连续失败 5 次就熔断,停掉这个监控项并告警"目标不可达",而不是继续每分钟撞一次墙。熔断状态本身就是一条有价值的信息------目标站点宕机、改版、或者把你拉黑了,这三件事你都应该第一时间知道。


三、TLS 指纹:为什么"UA 伪装"经常不管用

这是很多人第一次做长期监控时的困惑:请求头我一个字段不落地照抄 Chrome,为什么还是被识别成机器人?

原因在 TLS 握手层。

JA3 是什么

TLS ClientHello 里包含一组有序信息:TLS 版本、加密套件列表及顺序、扩展列表及顺序、椭圆曲线、曲线格式。JA3 指纹就是把这些字段按固定格式拼接后取 MD5。

关键在于不同 TLS 实现产生的指纹天然不同

客户端 底层 TLS 库 指纹特征
Chrome BoringSSL 带 GREASE 值,扩展顺序每次随机化
Firefox NSS 加密套件顺序与 Chrome 明显不同
Python requests / httpx OpenSSL 套件列表更短,无 GREASE
Go net/http Go crypto/tls 又是另一组特征

所以当你的请求头写着"我是 Chrome 131",而 TLS 握手明明白白是 OpenSSL 的形状,这个矛盾本身就是最强的机器人信号------比你老老实实用默认 UA 还显眼。

两条路

路线 A:不伪装。 用真实的 UA 声明自己是 bot(见前面第一节)。这是我推荐的默认选择,尤其是监控公开信息、频率又不高的场景。指纹不匹配的问题根本不存在,因为你没在装。

路线 B:让 TLS 层和 HTTP 层保持一致。 如果确实需要以浏览器身份访问(比如对方只对浏览器返回完整内容),那就整套对齐,而不是只改 UA:

python 复制代码
# curl_cffi 基于 curl-impersonate,TLS 握手与真实浏览器一致
from curl_cffi import requests as cffi_requests

resp = cffi_requests.get(
    "https://example.com",
    impersonate="chrome131",
    timeout=20,
)

或者干脆用真实浏览器(Playwright / Puppeteer)------指纹问题自动消失,代价是内存和 CPU 涨一个数量级。

关于验证码,说明一句:如果一个站点弹出了 CAPTCHA 或人机验证,那是站点方在明确表达"这里不欢迎自动化访问"。正确的处理是停下来告警、让人工介入或去找对方要 API,而不是想办法解掉它。技术上能不能做是一回事,该不该做是另一回事------绕过人机验证在很多地区的服务条款和法律框架下都有明确风险。


四、代理与出口地域:不是为了隐身,是为了拿对内容

代理在监控场景里最常被误解为"换 IP 防封"。实际上更重要的用途是拿到正确的内容

地域差异是真实的业务需求

同一个 URL,从不同地区访问返回的内容可能完全不同:

  • 电商价格和币种随 IP 归属地变化
  • 新闻和流媒体做地域内容分发
  • 政府/监管类站点对境外 IP 限流
  • 航司票价按出发地市场定价

如果你在北京的服务器上监控一个美国电商的价格,你监控的其实是"该站点对中国 IP 展示的价格"------这可能根本不是你想要的数字。监控项的配置里应该有"出口地域"这个字段,和 URL、选择器同等重要。

住宅 IP vs 数据中心 IP

数据中心 IP(AWS、阿里云的出口)在很多 WAF 的默认规则里权重就是负的。这不是针对你,是统计上的合理策略。

选型上的实际考虑:

  • 自建/云服务器直连:最便宜,适合监控对你友好的站点、自家站点、政府公开信息。
  • 数据中心代理池:便宜,适合需要多地域但目标站点不严格的场景。
  • 住宅代理:贵一个数量级,只在确实需要时用。注意选合规的供应商------有些住宅代理池的 IP 来源是灰色的(比如捆绑在免费 App 里的 SDK),用了会有连带风险。

粘性会话很关键

python 复制代码
# 大多数代理服务用 username 里的字段控制会话粘性
proxy_user = f"{account}-country-us-session-{monitor_id}"
proxies = {"all://": f"http://{proxy_user}:{password}@gate.provider.com:7000"}

同一个监控项应该长期用同一个出口。每次换 IP 意味着每次可能拿到不同的 A/B 分桶、不同的地域内容、不同的推荐位------这些差异会直接伪装成"页面变更",产生一堆你查不出原因的误报。IP 稳定性对降噪的贡献,往往比你精心调的正则还大。

失败要能归因

python 复制代码
from dataclasses import dataclass

@dataclass
class FetchResult:
    ok: bool
    status: int | None
    body: bytes | None
    exit_region: str
    proxy_id: str | None
    elapsed_ms: int
    error: str | None = None

把出口信息记进抓取结果里。等你某天发现"这个站点最近误报特别多",一查发现全是某个代理节点返回的验证码页------没有这几个字段,这个问题能查一整天。


五、JS 渲染:什么时候必须上无头浏览器

先做个判断,别默认上浏览器:

python 复制代码
def needs_browser(html: bytes, selector_text: str) -> bool:
    """静态 HTML 里找不到目标内容,才需要渲染"""
    return selector_text.encode() not in html

很多"必须 JS 渲染"的页面其实不必须。SPA 的数据通常来自一个 XHR 接口,直接抓那个接口既快又稳,返回的还是结构化 JSON------比解析渲染后的 DOM 好一百倍。打开 DevTools 的 Network 面板,按 Fetch/XHR 过滤,花五分钟找一找,经常有惊喜。

确实需要渲染时,把成本压下来:

python 复制代码
from playwright.sync_api import sync_playwright

BLOCK_TYPES = {"image", "media", "font", "stylesheet"}

def render(url: str, selector: str, timeout_ms: int = 15000) -> str:
    with sync_playwright() as p:
        browser = p.chromium.launch(args=["--disable-dev-shm-usage"])
        ctx = browser.new_context(user_agent=UA)
        # 拦掉不影响内容的资源,能省 60%+ 的流量和时间
        ctx.route("**/*", lambda route: (
            route.abort() if route.request.resource_type in BLOCK_TYPES
            else route.continue_()
        ))
        page = ctx.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
        page.wait_for_selector(selector, timeout=timeout_ms)
        html = page.inner_html(selector)
        browser.close()
        return html

几个要点:

  • wait_until="domcontentloaded" 而不是 networkidle------后者在带轮询的页面上永远等不到。
  • wait_for_selector 等具体元素,比 sleep(5) 既快又可靠。
  • 拦截图片/字体/CSS,内容型监控完全不需要它们。
  • 浏览器实例复用(同一个 browser 开多个 context),冷启动开销很可观。

六、一张排查表

线上跑久了,"这次到底是真变更还是抓取问题"会成为最高频的问题。建议按这个顺序查:

现象 大概率原因 怎么确认
内容突然变空 选择器失效 / 页面改版 存原始快照,人工打开对比
每次检查都报变更 页面有动态片段 连续抓 3 次自比,差异即噪音
间歇性大幅变更 代理换了地域 / A/B 分桶 检查 exit_regionproxy_id
突然全部 403 触发了 WAF 规则 看响应体是不是挑战页,降频重试
只有部分时段失败 对方限流窗口 按小时统计成功率

前提是你得把原始响应存下来。只存 diff 结果的系统,出了问题就是黑盒。快照存储可以压缩(同一页面前后两次 gzip 后差别很小,用增量存储更省),但不要不存。


写在最后

做长期稳定的网页监控,真正的技术难点分布很反直觉:

  • 花在降噪和归因上的时间,比花在"对抗"上的多得多。
  • 收益最高的三件事是:条件请求、按域名限速、稳定出口 IP。这三件都不需要任何对抗性技术。
  • 大部分"被封"的案例,根因是频率没控制住,不是指纹没伪装好。

如果只是想快速跑起来,pip install pagewatch 是个开源的起点(MIT 协议),选择器、正则降噪、退避重试和 webhook 告警都是现成的,数据存本地 JSON:

bash 复制代码
pagewatch add https://example.com/pricing --selector ".price" --interval 30m
pagewatch watch          # 守护进程模式

如果不想自己维护代理和多地域出口,PageWatch.tech 提供托管版本,出口地域和抓取稳定性由服务端负责。


参考

相关推荐
m沐沐1 小时前
【计算机视觉】OpenCV 物体跟踪——原理、算法与CSRT跟踪器实战
人工智能·python·深度学习·opencv·算法·计算机视觉·人脸识别
AAA@峥1 小时前
Ceph 集群配置管理完整指南
运维·数据库·分布式·ceph
白狐_7981 小时前
【408计算机网络|第03章·上|408-CN-03A】数据链路层(上):组帧、差错控制与可靠传输
网络·网络协议·计算机网络
爱吃龙利鱼1 小时前
K8s 运维体系搭建:全组件监控与日志收集实战(victoria-metrics-k8s-stack 0.87.0)
运维·容器·kubernetes
weixin_446729161 小时前
Nginx简单学习与了解
运维·nginx
大数据魔法师2 小时前
AI Agent - OpenAI从零开始完整学习教程(零基础入门+实战落地)
python
老赵的博客2 小时前
工控机之udp远程控制
网络·网络协议·udp
怪奇云呼军2 小时前
闪电智能 Voice Agent 怎样根据语速、停顿和追问方式切换话术?策略引擎拆解
开发语言·人工智能·网络协议·算法·语音识别·web app
努力努力再努力wz2 小时前
【分布式系统与 RPC 框架系列】从单机瓶颈到远程调用:一文理解分布式架构与 RPC 原理
linux·网络·c++·分布式·网络协议·rpc·架构