数据抓取

如何识别一个站点正在给你返回伪造或被封的内容

危险的抓取失败不是你看得见的 403,而是那个塞满垃圾的 200 OK。如何检测软封锁、蜜罐和被投毒的数据。

Chris Collins

Chris Collins

2026年7月31日 · 1 分钟阅读

每个人都会为之做准备的抓取失败,是那个显而易见的:一个 403、一个 429、一条超时的连接。你能看见它、统计它、重试它。而真正毒害你数据集的失败正相反:一个 200 OK,看起来完全成功,却不含任何你想要的东西。一张伪装成正常响应的封锁页。一个挑战屏。一具空壳。一页专门为了给机器人喂坏信息而伪造出来的数据。

一个报错的抓取器令人烦。一个用垃圾”成功”的抓取器则很危险,因为你要等到坏数据已经进了你的数据仓库、进了一份报告、或进了一个模型,才会发觉。这是抓取器为什么被封的另一半:现代反爬系统越来越倾向于安静地欺骗、而不是大声地拒绝,正因为一次安静的失败对它们比一次可见的失败更有价值。下面就是如何把它抓出来。

状态码不是真相

第一个要戒掉的习惯,是把 HTTP 状态码当成成功信号来信任。一个 200 意味着服务器发了一个响应,而不是它发了你所请求的那个响应。站点常规性地用 200 返回封锁页、CAPTCHA 和同意墙,因为这样能让天真的抓取器高高兴兴、安安静静,同时什么也不给它们。把状态码当作一个弱信号,并在每一个请求上都校验响应体。

值得给这些具有欺骗性的 200 分类命名,因为每一类都有不同的破绽:

  • 软封锁与插页。 一张用 200 返回的挑战页或”验证你是人类”页,往往比真页面小得多。
  • 挑战与 CAPTCHA 屏。 就摆在本该是内容的地方内联返回。与反爬防御如何演变相关。
  • 被降级或被剥离的内容。 一堵登录墙、一个”请启用 JavaScript”的存根,或者一具本该渲染出数据的空骨架。
  • 限速软失败。 一旦你越过一个阈值,返回的是陈旧的、缓存的或空的结果,而不是一个错误。
  • 错误的地理或个性化。 面向错误国家、错误货币或未登录状态的正确页面——技术上有效,却静悄悄地没用。
  • 蜜罐与被投毒的数据。 故意喂给机器人的内容:假价格、陷阱链接,或看起来合情合理却带着不可能数值的记录。

校验内容,而不只是投递

最有效的防御,是在每个响应上做一次内容断言:一个廉价的检查,确认页面包含一个真页面必须包含的东西。选一个真结果永远有、而封锁页永远没有的不变量——一个特定元素、一个必填字段、一个最小的合理长度——若它缺失,就让该请求失败。

def is_valid_product_page(html, parsed):
# 真的产品页永远有这些。封锁页一个都没有。
if len(html) < 2000: # 封锁页通常很小
return False
if parsed.select_one("h1.product-title") is None:
return False # 锚点元素不见了
if parsed.select_one('[data-price]') is None:
return False # 我们来找的那个字段缺失
return True

关键转变在于:一个缺失的锚点元素是一次失败,而不是一个空结果。如果你所依赖的选择器不在,别记一条空行然后继续,把该响应当作一次软封锁、并用一个新鲜身份重试它——就像你对待一个 403 那样。记空白,正是一次封锁如何在无声中变成成千上万条、直到很久以后才有人注意到的空行。

盯住你响应的形状,而不只是单个响应

单个检查能抓住明显的垃圾。分布式检查能抓住微妙的漂移,而它们正是把一条稳健的管线和一条脆弱的管线区分开来的东西。

  • 响应大小。 封锁页和挑战页往往又小又统一。一个批次里平均页面大小的骤然坍塌,或一堆响应聚集在某个精确字节数上,就是一个封锁签名——哪怕每一个都返回 200
  • 响应哈希。 对每个响应体的归一化版本做哈希。当同一个哈希突然在许多不同 URL 上重复,你就是在被一遍遍地喂同一张封锁页,而不是真实、多样的内容。
  • 字段填充率。 追踪每个字段实际被填充的记录百分比。一个昨天 98% 填充、今天 4% 填充的字段,并不是变得更罕见了——是你开始拿到被剥离的页面了。
  • 每主机成功率。 某个特定目标上的下跌、而别的保持平稳,就是那个目标在改变对你的姿态,值得在它浪费掉一整轮之前抓住。这和在大规模监控一条抓取管线时要紧的那个”按目标”信号是同一个。

这些都不需要机器学习。它们是计数器和简单基线,而它们正是”在头一百个请求里就注意到一次封锁”和”在一百万个请求之后才注意到”之间的差别。

已知的封锁页签名

维护一个小小的短语与标记库——那些只会出现在封锁页、挑战页或错误页上的东西——并对任何包含它们的响应打标,不论状态码如何。

BLOCK_MARKERS = (
"verify you are human",
"unusual traffic",
"access denied",
"enable javascript to continue",
"request blocked",
)
def looks_blocked(text):
low = text.lower()
return any(marker in low for marker in BLOCK_MARKERS)

让它保持短小而具体,好让它不会对恰好谈论这些话题的真实内容误报,并在你遇到新防御时让它成长。一张你能认出的挑战页,就是一张你可以重试穿过、而不是把它存下来的挑战页。

蜜罐与被投毒的数据

最阴险的一类,是被设计成看起来真实的内容。这里有两道防御要紧。

第一,别跟随陷阱链接。蜜罐链接通常用 display:nonevisibility:hidden、零尺寸、屏幕外定位或 aria-hidden 对人类隐藏,存在的唯一目的就是抓住一个跟随每一个锚点的机器人。一个尊重可见性、并忽略人类永远点不到的链接的爬虫,能绕开其中大多数。

第二,对数据本身做合理性检查。被投毒的记录被造出来是为了通过一个天真的解析器,但往往会违反基本的领域规则:为零或高得离谱的价格、未来的日期、不可能存在的数量、超出任何合理范围的数值。对着你的领域真正允许的范围去校验,并把违反它们的记录隔离起来,而不是因为 HTML 解析得干净就信任它们。

金丝雀请求

最可靠的早期预警是一个金丝雀:周期性地抓取一个你已经知道其正确内容的页面,并断言它仍然吻合。当你的金丝雀开始返回一张封锁页或错误的数据,你就知道目标翻脸了——立刻且明确,无需从数据质量的缓慢下滑中去推断。按目标、按地理分别跑金丝雀,因为一个站点可能封掉一个国家的出口 IP、却对另一个国家毫不动手。

代理在哪里发挥作用

干净的 IP 会减少你一开始被软封锁的频率——一个有良好信誉的池能顺畅通过,而被标记的地址则会被悄悄喂上一张挑战页。这降低了你必须去抓的欺骗性响应的比率,但并不消除去抓它们的必要,因为封锁在设计上正变得越来越隐形,没有哪个 IP 能免疫。两者协同工作:检测告诉你一个响应是一次软封锁,而一个轮换的住宅池给你一个新鲜身份去重试它——就像你对待一次硬失败那样。把每一次检测到的软封锁回灌进你的重试和轮换逻辑(粘性 vs 轮换),并把某个目标上持续的封锁率飙升当作一个退避的信号、而不是猛击的信号(避免被封负责任地抓取都适用)。

底线

在响应证明自己之前,假定它在撒谎。在每个请求上对着一个真页面永远拥有的不变量校验内容,并把一个缺失的锚点当作一次要重试的失败、而不是一条要存下来的空行。盯住响应大小、哈希和填充率的分布,好让一个返回 200 的封锁仍然作为一个异常浮现出来。维护一份已知封锁页的简短签名清单,拒绝跟随隐藏的陷阱链接,对着领域规则给数据做合理性检查,并跑金丝雀,好让你在一个目标翻脸的那一刻就知道。

做到这些,那个无声的失败——那个把一个数据集悄无声息地腐蚀好几周的失败——就不再无声。把它与一个干净的住宅池搭配,好让你一开始就更少被软封锁,而按 GB 计费的套餐让你可以对着自己的目标验证这一套,无需预先承诺。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始