爬虫 IP 被封的 7 个真实原因,以及逐条的解决路径

先区分两种”被封”

在排查之前先明确一件事:你遇到的是IP 被封,还是浏览器/客户端指纹被识别?两者的表现有时很像,但解决方向完全不同。

判断方法很简单:把同一个请求换成完全不同的出口 IP 再试一次。

  • 换 IP 后恢复正常 → 是 IP 层的问题,继续往下看
  • 换 IP 后依然被拦 → 是客户端层的问题(请求头、指纹、行为模式),换代理没用

下面的 7 条都属于第一类。

1. 单 IP 并发过高

这是最常见的原因。同一个出口 IP 在极短时间内发出几十个并发请求,在任何站点的访问日志里都是异常模式。

判断标准:把并发从 32 降到 4,成功率是否明显回升。如果回升,就是这个原因。

处理:降低单 IP 并发,用更多 IP 分散负载。注意降低并发不等于降低总吞吐——通过增加 IP 数量可以保持总吞吐不变。

2. 请求间隔过于规律

time.sleep(1) 看起来很温和,但一百个请求严格间隔一秒,在统计上是非常明显的机器特征。真实用户的访问间隔是随机分布的。

处理:加随机抖动,并且不要用均匀分布。

import random, time

# 均匀分布仍然过于规律,用带长尾的分布更接近真人
delay = random.lognormvariate(mu=0, sigma=0.8)   # 多数在 0.5–3 秒,偶尔更长
time.sleep(min(delay, 15))

对分页采集,不要按顺序一路抓到底。打乱页面顺序,效果比调整间隔更好。

3. 缺少必要的请求头

只发一个 User-Agent 是不够的。一个真实浏览器请求通常还带 AcceptAccept-LanguageAccept-EncodingReferersec-ch-ua 系列头。缺失这些的组合本身就是特征。

session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                  "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
    "Accept-Encoding": "gzip, deflate, br",
    "Upgrade-Insecure-Requests": "1",
})

Referer 尤其要注意:直接请求详情页却不带任何来源,是很明显的直接访问。合理的做法是先访问列表页,再带着列表页地址去请求详情页。

4. 会话没有正确轮换

用粘性会话做登录态任务时很容易犯这个错:整个采集过程共用一个 SESSION_ID,几百个请求全部走同一个出口 IP,等于用住宅代理的价格买了单 IP 的效果。

判断标准:打印每个请求实际使用的出口 IP,看分布是否合理。如果几百个请求只有一两个 IP,配置有问题。

处理:按任务粒度分配会话。列表页用轮换池,登录态任务用固定会话,不要混用。

5. 请求模式与页面结构不匹配

只请求详情页、从不请求列表页和静态资源;或者所有请求都在毫秒级完成;或者只抓某一个字段却下载了整个页面。这些都会形成异常画像。

处理:让请求序列更接近真实浏览路径。对关键任务,适当请求列表页和必要的资源,流量成本的小幅增加换来的是成功率的明显提升。

6. 目标站点的风控策略更新了

如果昨天还好好的,今天成功率突然腰斩,大概率不是你改了代码,而是对方调整了策略。这类变化通常表现为:同样的请求开始返回挑战页,或者返回 200 但内容为空。

处理:建立成功率监控,按小时或按天记录。发现突变时先确认是否只有自己在受影响(换一个完全不同的网络环境试同一个页面),再判断是否需要调整方案。

# 最小的成功率监控:按响应类型分类计数
from collections import Counter

stats = Counter()
def record(resp):
    body = resp.text[:4000].lower()
    if resp.status_code in (403, 429):
        stats["blocked"] += 1
    elif "captcha" in body or "验证码" in body:
        stats["challenged"] += 1
    else:
        stats["ok"] += 1

# 结束时打印,成功率低于阈值就告警
total = sum(stats.values())
print(dict(stats), f"success={stats['ok']/max(total,1):.1%}")

重点是把 challenged 单独计数。它常被忽略,因为状态码是 200,程序会当成成功,直到你发现入库的数据量对不上。

7. 目标站点的数据本身就有地域限制

有些内容只在特定国家可访问,用其他地区的出口 IP 会直接被拒或返回占位内容。这种情况下无论如何调优都没用,需要的是正确的出口地区,而不是更多的 IP。

判断标准:用目标国家的出口 IP 手动访问一次,看内容是否不同。

处理:在请求层面按目标地区选择出口,确保代理配置的地区与业务目标一致。

排查顺序

按下面顺序做,能最快定位:

  1. 换一个完全不同的 IP 重试 —— 区分 IP 层和客户端层
  2. 打印每个请求的实际出口 IP —— 确认代理是否按预期工作
  3. 把并发降到 4 —— 排除并发问题
  4. 加上完整的请求头 —— 排除请求头缺失
  5. 加随机间隔并打乱顺序 —— 排除规律性
  6. 检查目标内容是否有地域差异 —— 排除地区限制
  7. 建立成功率监控 —— 区分”你的问题”和”对方变了”

前两步能覆盖八成情况。剩下的大多数是第 6 条,需要换出口地区而不是换供应商。

取舍结论

维度结论
前提条件已确认换用完全不同的出口 IP 后依然被拦,说明问题在客户端层而非 IP 层。
核心取舍降低单 IP 并发、增加随机间隔都会牺牲吞吐,需要靠增加 IP 数量而不是提高单点压力来补回。
不适用场景目标内容本身有地域限制时,任何请求模式优化都无效,必须换出口地区。

一句话:“换 IP 对照测试”和”打印实际出口 IP”这两步能覆盖八成排查场景。

在你的目标站点上验证这套方案

文中的做法都可以用试用额度直接跑通。用你自己的目标站点测一轮,成功率数据比任何评测都可靠。