写一个爬网页的脚本很容易,让它连续跑一年不出事很难。
网页监控和一次性爬取的区别就在这里:一次性任务失败了可以重跑,监控任务是长期驻留的------你要在别人的服务器上每隔几分钟出现一次,持续几个月。这种关系里,任何"莽"的做法最终都会被拉黑。
这篇文章讲的是怎么让监控链路长期稳定,同时不给对方站点添麻烦。前提说清楚:所有技术都建立在"你有权访问这个页面"之上------公开可访问的页面、你自己的站点、或者你有明确授权的对象。绕过付费墙、抓取登录后的他人数据、或者用技术手段规避明确的访问限制,不在本文讨论范围内,也不该做。
一、先做减法:大部分稳定性问题不需要技术手段
在上任何对抗性技术之前,先把这几件成本最低、收益最高的事做了。
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_region 与 proxy_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 提供托管版本,出口地域和抓取稳定性由服务端负责。
参考
- RFC 9309 --- Robots Exclusion Protocol:https://www.rfc-editor.org/rfc/rfc9309.html
- MDN,HTTP 条件请求:https://developer.mozilla.org/docs/Web/HTTP/Conditional_requests
- Salesforce JA3 TLS 指纹方法:https://github.com/salesforce/ja3
- Playwright 网络拦截文档:https://playwright.dev/python/docs/network