上周凌晨两点监控告警弹了满屏红色采集任务成功率掉到 8%日志里清一色的 403。我的第一反应是机房 IP 被拉黑了赶紧切备用的代理出口连换三波结果还是 403。折腾到天亮才定位到根因——根本不是 IP 的事是站点在凌晨悄悄切换了域名请求打到了新的入口而请求头里携带的 token 还是旧域名下签发的服务端校验直接拒掉。那一晚之后我把403 处理和域名变更这两件事彻底做成了工程化方案。这篇就拿 Libvio 系站点当样本把完整的思路和踩坑过程摊开来讲。如果你维护的采集任务同样会被 403、域名漂移、登录态失效搞得焦头烂额那这篇文章应该能帮你省下好几个通宵。1. 先别急着怪 IP403 的真实长相与分类1.1 最常见的 403 不是封禁是你没带对证件爬虫工程师对 403 的第一反应通常是IP 被封了。这个直觉害了我很久。实际上403 只是一个 HTTP 状态码它只表达一件事服务器理解了你的请求但拒绝执行。拒绝的原因可以有一百种IP 信誉低只是其中之一。我梳理了一下在 Libvio 这类站点上实际遇到过的 403大致能分成七类类型典型特征常见的触发场景UA 拦截响应头里带Server: openresty请求头 User-Agent 是默认值没设置 UA 或 UA 明显是脚本标识Cookie 失效之前能正常某天开始部分接口 403登录态过期、会话被服务端主动清理Token 校验失败token exchange failed: token endpoint returned status 403token 过期或 token 与当前域名不匹配地域限制错误信息里带country关键词服务端按 IP 归属地做白名单Referer 校验直接请求资源地址 403带正确 Referer 就正常防外链、防盗链机制访问频率控制高频请求后触发可能伴随验证码单位时间请求数超过阈值WAF 网关拦截OpenResty默认页或者403 forbidden没有任何业务信息请求特征命中防火墙规则这里想给一个很反直觉的经验80% 的 403 跟 IP 没有半毛钱关系。尤其是当错误响应里还带着 JSON body 或明文的业务错误码时那基本可以确定是业务层逻辑拒绝了请求不是网络层拦截。这时候你疯狂换 IP除了把自己的代理预算烧光不会有任何作用。1.2 地域与网关类 403一个容易被误解的细节热搜词里有一条非常典型token exchange failed: token endpoint returned status 403 forbidden: country。这是 token 服务在做地域校验说明某些节点只允许特定国家的 IP 访问。这种 403 携带的错误信息非常明确一看就知道该换地域出口而不是换 UA 或清 Cookie。但很多时候错误响应是空的只有一个光秃秃的状态码。这种时候就必须靠请求-响应链路的旁路信息来做判断。我在实践里最常用的方法是同时采集请求发出节点的出口 IP、响应头里的Server字段、Via字段、以及响应 body 的长度和签名。Server: openresty往往意味着 Nginx/OpenResty 层就决定了你的请求命运还没到应用层。而如果响应头里出现了CF-Ray这类特征那说明请求在 CDN 边缘节点就被拦下了和源站逻辑无关——处理方式也应该完全不同。另一个容易踩坑的地方是本地代理链路本身出问题也可能伪装成 403。热搜里的unexpected status 403 forbidden: cc switch local proxy failed while handling就是典型的本地代理切换失败请求被某个中间节点丢弃后客户端把连接层错误翻译成了 403。这类情况本质上是代理客户端的 Bug 或配置错误你对着对端站点怎么排查都没用。我遇到这种情况会直接在内网搭一个最小复现环境用curl走同一个代理链路请求同一个 URL观察是否能稳定复现几秒钟就能把锅从源站和代理中间劈开。1.3 分清楚站方刻意为之和中间链路误伤这条经验非常值钱。面对 403首先要回答的问题不是怎么绕过而是谁产生这个 403。站在爬虫工程的角度我把 403 的来源分为三层源站主动拒绝站点自定义的拦截策略通常是业务规则比如地域白名单、登录态校验、风控策略。这类 403 响应速度通常很快往往在 50ms 以内因为不需要走复杂的业务逻辑。网关层拦截Nginx/OpenResty/WAF 在请求进入业务容器之前就拦掉了。这类 403 响应头里会有明显的网关特征响应时间也可能因为安全分析师处理而略有波动。链路中间人误伤代理节点故障、DNS 劫持、TLS 中间盒干扰。这类最隐蔽因为它根本不是对端返回的语义化 403只是连接层错误被客户端库封装成了 403 状态。分辨这三层的方法很简单抓包或者退一步用curl -v看请求链路。我从不会在没确认 403 来源之前就改代码。因为改错了方向比如源站风控导致的 403 被当成 IP 问题去换代理结果就是把代理池里的 IP 全部污染一遍反而加剧了封禁风险。这几类 403 肉眼可见地消耗了大量时间所以后来我把 403 的分类直接做成了采集框架的基础组件请求发出后先判断 403 的类型再决定走哪条补救路径。2. 域名频繁变更是常态要用工程手段去接住2.1 为什么这类站点要频繁换域名Libvio 这类站点有个非常鲜明的运营特征主域名活不过太久隔一段时间就会换。换域名的原因不只是配套服务的节点被关停更多的是为了保护业务连续性。提前储备一批备用域名一旦当前域名不可用就立刻切换同时通过发布页、公告、导航站将用户导流到新域名。这已经成了一整套成熟的运营机制。从爬虫运维的视角看这种机制带来的最大挑战是你无法通过一次代码上线就一劳永逸。如果你把域名硬编码在配置文件里那么平均每一到两个月就得人工改一次。在大规模采集中这个成本不可接受——你可以接受业务方换域名但不能接受你的程序因此静默失联。我常用的域名发布渠道有两种发布页和固定的 API 入口。很多站点会在一个固定的、相对稳定的地址上维护最新域名列表。这个地址本身就是最好的域名发现源。此外证书透明度日志CT 日志也能提前暴露一个站点准备使用的全新域名——当某个组织批量申请了多个相似域名时CT 日志里会有时间戳可循。这个思路在运营类站点上非常有效甚至能比正式切换提前数天发现候选域名。实测下来CT 日志 发布页双通道基本能做到域名切换后 10 分钟内自动跟进。2.2 域名发现的常态化机制域名频繁变更第一步要解决的是怎么及时发现。我把域名发现做成了独立进程不跟主采集进程耦合。每半小时跑一次按优先级依次检查站点对外公布的发布页解析页面里的入口链接和公告区提取新域名。备用入口 URL直接请求观察响应头里的跳转目标Location。证书透明度接口用已知域名字符串做模糊搜索筛出所有疑似域再通过请求探测确认有效性。历史 DNS 解析记录看最近是否有新增 A 记录对应的新域名。一个容易忽略的小细节是域名切换并不总是完全替代。有时候新旧域名会并行一段时间旧域名直接用 301/302 跳到新域名有时候则直接黑掉出 403 或者灰屏。所以域名探测必须区分可正常访问和可访问但跳转中两种情况在调度层的处理策略不一样。跳转中我可以直接跟随不可访问则立刻从可用域池移除并告警。下面是域名池的核心数据结构设计思路# 简化的域名状态模型 class DomainStatus: def __init__(self, domain): self.domain domain self.status probing # probing / active / cooldown / dead self.last_check None self.consecutive_failures 0 self.token_bucket None # 每个域名独立限速每个域名独立维护状态和限速器而不是全局共用一个 Token Bucket。这样当某一个域名被限制其他备用域名还能继续工作不会把整个采集任务拉死。这条设计在后面调度章节还会展开。2.3 域名池设计宁可先存起来也不要临时抱佛脚很多爬虫框架对域名的处理方式非常粗糙一个 config.py 里写着 BASE_URL一旦站点换域名就得改代码重新上线。这种做法的代价是从头部署一次流程加上权限申请几个小时就没了。业务方换域名的频率一旦提高你的工程交付节奏完全跟不上。我把域名池拆成了三个状态状态含义调度策略probing正在探测中不参与流量分配仅做健康检查active可正常访问参与流量分配按权重轮询dead连续 N 次失败不再分配流量告警通知active 和 probing 之间可以双向转换dead 则只能通过人工确认后恢复。域名池本身存在 DB 里同时支持通过配置文件热加载——不需要重启进程就能新增一个域名。实测中这个设计对运维的友好度提升是肉眼可见的站点换了域名之后我只需要往域名池里插一条记录采集进程下一次调度周期就能自动切换过去。另一个思路是宁可多存不要少存。域名池里多维护几个当前不在用的备用域名成本极低真正遇到主域名突然死掉时多一个备用域名就是多一条命。我把 CT 日志里发现的所有疑似域名都扔进池子里即使当时还没法访问只要能被探测到就保留probing状态。域名失效和重新上线的周期经常是波动的今天死的域名下个月可能又恢复解析这个过程完全是自动的。3. 合规不是一句口号是工程约束3.1 robots 协议与访问边界这一节标题里就带着合规二字那合规到底是什么意思在我近十年的爬虫工程实践里合规不是一纸声明更不是事后的免责话术它必须落到工程实现的每一个环节里。最基本的边界是robots.txt。Libvio 系的站点有没有 robots 协议有虽然内容不多但必须尊重。我在采集框架的启动阶段会拉取一次 robots.txt解析出Disallow路径前缀并把这个规则写到全局路由表里。任何请求 URL在发出之前都会先过一次路由表命中禁用路径则直接丢弃连请求都不会发出去。有些团队会纠结我爬的是公开页面robots.txt 不具法律效力可以不遵守。我的观点是你爬的确实是公开数据但遵守 robots 协议依然是整个工程长期稳定运行的必要条件。一个不尊重站方访问意愿的采集任务早晚会触发站点的风控系统结果就是封禁、验证码、法律函件三件套。与其说是道德选择不如说这是工程可持续性的技术选择。3.2 限速与退避把并发降下来反而更高效Libvio 系站点的服务器并不是高防配置高并发冲击很容易触发网关层的自动防护。早期我测试过用 20 并发去抓目录页跑了不到两分钟整个 IP 段就被封了那一次采集任务直接中断六个小时。后来我把并发降到 3每个请求之间加 2 到 5 秒的随机延迟反而稳定跑完了几十万个页面总耗时只多了不到一倍。这笔账算得非常清楚。具体实现上我建议用随机抖动 指数退避 节假日感知三层组合。随机抖动是固定的每次请求间隔在基础值上随机偏移 30% 到 50%避免形成周期性请求特征。指数退避用于处理异常状态一旦遇到 429 或部分类型 403则从 5 秒开始指数递增重试间隔最多退避到 10 分钟。节假日感知是针对影视线站点特有的——节假日期间访问量暴涨源站压力本来就大这时候你还在高频率采集等于往枪口上撞。我会在配置里维护一个简单的节假日调降策略表节假日当天并发减半延迟翻倍。3.3 数据边界什么能存什么不能存必须写死在代码里这一节可能是全文最容易被忽略但最重要的一节。Libvio 系站点是什么类型的站点是影视资源站。这类站点的页面里既有公开的元数据片名、分类、简介、封面、演员列表也有极可能涉及版权争议的资源链接和播放地址。爬虫可以碰哪些不可以碰哪些?我的原则非常明确可以抓列表页、详情页的公开元数据用于做公开信息聚合、行业研究、数据分析。不能批量抓视频文件本身、播放接口地址、付费内容、登录后才能看到的页面、绕过验证码或风控的受限内容。不可以做把自己抓取的内容二次打包分发或者用于任何侵权、盗版传播的用途。这些边界我会直接写进采集任务的代码里而不是靠团队成员的自觉。具体做法是在存储层做一个字段白名单详情页解析后只允许白名单内的字段入库其他字段一律丢弃。播放地址这类字段即使在页面里出现了也绝不入库。白名单机制从根本上杜绝了顺手把链接存下来的冲动。提示做影视类站点的数据研究最稳妥的边界就是只元数据、不碰资源本体。这个边界能守住你的爬虫就能处于一个相对安全的灰色区域内运作守不住那就是把整个团队置于风险之中。4. 一套可落地的 Python 采集工程骨架4.1 请求层httpx 连接复用 重试说到工程落地直接上代码思路。我用 Python 3.11 httpx主要原因有三个原生支持 HTTP/2、连接复用机制比 requests 更可控、异步与同步模式切换灵活。对于 Libvio 这类站点HTTP/2 的重要性在于它可以减少握手次数同时在 TLS 指纹层面比 requests 的默认指纹更像一个真实浏览器。请求层的核心设计是重试 分类处理。我使用tenacity库做重试控制但重试逻辑不是无脑重发三次就完事而是先判断 403 的类型再决定重试策略def classify_403(response: httpx.Response) - str: server response.headers.get(server, ).lower() if openresty in server: return gateway_block if cf-ray in response.headers: return cdn_block body response.text[:500] if country in body or region in body: return geo_block if token in body.lower(): return token_expired return unknown拿到分类标签之后重试逻辑就可以做到精细化geo_block直接换地域出口重试token_expired先去刷新 token 再重试一次gateway_block则降低当前域名的请求频率并等待退避而不是扎堆换 IP。连接复用方面我使用httpx.AsyncClient并显式设置了limits参数控制连接池大小。一个容易忽略的点默认的http2参数必须设为True否则刚升级 HTTP/2 的站点反而会退回 HTTP/1.1 导致请求特征异常。请求头里还需要固定Accept-Language、Accept-Encoding等字段且 UA 不要用网上泛滥的默认模板字符串自己拼一个带操作系统和浏览器的复合 UA直接写死在配置里。4.2 解析层应对 HTML 结构变化的策略Libvio 系站点的前端模板经常改版改版带来的典型问题不是请求失败,而是解析逻辑失效——页面返回 200但 CSS 选择器一个都匹配不到数据项全部为空。这种软失败比 403 更阴险它不会触发告警只会让你的数据库里悄悄多出一批空记录。我的解析层做两件事来对抗软失败多重选择器回退和结构指纹校验。SELECTORS { title: [ h1.video_title, .public-box h1, .movie-info h1, h1, ], cover: [ .screenshot img, .pic img, #pic img, ], } def parse_detail(doc): for selector in SELECTORS[title]: node doc.css(selector) if node: title node.attrib.get(alt) or node.text() if title: return title return None每一层选择器都按从具体到通用的顺序排列越靠前的选择器优先级越高。如果全部匹配失败返回 None这条记录会进入 low-quality 队列等待复查而不是直接入库。结构指纹校验更关键。每个解析任务都有一个期望字段集合详情页必须同时解析出标题、封面、分类、简介四项齐全才算有效记录。如果返回 200 但四项全空我会判定为页面结构已变化立刻把该页面的 HTML 全文和响应头存档——这类样本是后续改版后分析结构的最佳训练集。更重要的是这会触发一个告警通知维护人员上线调整选择器而不是让坏数据继续积累。4.3 调度层域名切换与请求路由调度机制是整个框架里和域名变更联系最紧密的环节。核心逻辑是一个采集任务不再绑定具体域名而是绑定任务域组。任务发出前调度层从活跃域名列表里选一个再把请求发过去。选中域名后请求的 base_url、cookie 作用域、token 作用域都会跟着切换。每个域名会维护一个健康分健康分受三个因素影响近 10 分钟内 403 占比、请求平均响应时间、最近一次成功解析的时间。健康分低于阈值时这个域名会被自动标记为冷却状态调度层会把它的流量降到 0并启动专门的探测进程去验证它是否恢复。冷却不是死刑很多情况下域名的封禁或故障是分钟级的只要探测进程确认恢复健康分会慢慢回升域名会自动回到流量池中。这里有一个坑必须说token 是有作用域绑定的不是所有域名通用的。我最早设计调度层时想当然地在所有域名之间共享同一份 token结果切到新域名后旧 token 被服务端拒收整批请求全部 400/403。后来我把 token 的存储结构改成了域名维度 有效时间 状态的独立记录每个域名有自己的 token 生命周期管理切换域名时自动从对应记录中加载有效的 token。4.4 监控与告警别让 403 悄悄蔓延没有监控的爬虫框架等于裸奔。我在这套工程里给 403 单独设计了监控指标因为 403 是最能反映采集健康状况的晴雨表。每 60 秒汇总一次指标按三个维度切分维度监控指标告警阈值域名维度该域名 403 占比超过 30% 持续 5 分钟状态码分布403 / 200 / 404 / 429 的实时占比403 占比超过 40%解析质量解析失败率页面 200 但字段为空超过 20%告警推送到 IM 群钉钉/飞书均支持自定义机器人并在 Webhook 里带上当前故障域的域名、403 分类标签、最近 5 条失败的请求摘要。这样值班同学不需要登录服务器就能先知道大致问题方向。域名无响应和 token 失效这两类高风险故障还会额外追加短信/电话级别的告警因为这类故障意味着整个入口挂了必须第一时间介入。5. 实测踩坑一次 403 定位的完整排查链路5.1 现象全线 403 但代理池正常回到开头说的那次事故。当晚的监控面板显示api.libvio.example这个域名的 403 率接近 100%其他备用域名正常。我当时的第一判断是只封了这一个域名的访问 IP于是切代理出口。切了三个出口之后故障依旧。这时候我注意到了第一次违和感如果只是 IP 封禁换了出口应该能恢复但无论换哪个出口都是 403,说明问题不在 IP 维度。5.2 逐层排查UA → Cookie → Token → 地域我的排查链路是有序的每层做完马上缩小范围UA 测试用 curl 带不同 UA 请求同一 URL均返回 403 空 body。排除 UA 因素。Cookie 测试清掉代码里的 Cookie用浏览器里复制的新 Cookie 请求依然 403。排除 Cookie 过期但这里有个误导信号——浏览器里打开详情页是正常的一度让我以为 IP 才是问题。Token 测试检查请求头发现业务代码里带了一个Authorization: Bearer xxx的 token。去掉这个头之后请求直接通过返回 200。此时局面反转问题恰恰出在带了一个过期的 token。服务器认为这个 token 不合法直接 403去掉 token走游客模式反而放行。地域验证继续读取响应头和应用日志确认服务端并没有针对出口 IP 做地域校验。地域因素排除。5.3 根因Token 与域名的绑定校验继续深挖那个 token 为什么过期。翻代码发现token 是在旧域名下通过登录接口获取的获取后存在一个全局缓存里根本没考虑 token 与域名的绑定关系。站点端切换域名之后旧 token 虽然还没到有效期但服务端在域名 token联合校验时发现不匹配一律返回 403。这解释了为什么换 IP 无效因为它根本不是 IP 层的问题。我把这个问题的完整证据链记录在排查文档里然后对采集框架做了三处整改:第一token 缓存维度改为域名 token并加上域名切换时的自动清理机制第二403 响应分类中增加token_expired标签遇到该标签不再重试旧请求而是直接重新获取 token 后重发第三增加了一条监控规则——任何域名 403 率超过 50% 时自动触发 token 刷新任务的调度而不是等值班同学手动介入。5.4 后续加固把一次事故变成永久免疫力那次事故之后我又补了几个前面没完全覆盖的细节。第一robots.txt的缓存周期缩短到 12 小时防止站点更新 robots 规则后我这边还在按老规则请求。第二DNS 解析的 TTL 不信任系统默认值在应用层做每日一次的强制刷新——域名切换往往伴随 DNS 变更系统级 DNS 缓存可能让你在切换到新域名后仍然访问旧 IP。第三把域名切换后 token 自动刷新这个动作做成显式的一步而不是依赖请求重试的副作用把刷新逻辑独立成一个可观测的服务刷新成功与否都要有日志和指标。这四条做完我后来再遇到 Libvio 系站点的域名切换和 403 波动基本都能在 10 分钟内自动恢复不需要半夜爬起来改代码。最后再分享一个经验采集工程里 70% 的故障都不是对方刻意防护导致的而是我们自己工程内部的状态管理出了问题——过期的 token、失效的域名引用、没有更新的解析规则。先把内部状态的自动化做好再去考虑外部对抗层面的技巧这个顺序一定不要搞反。对 403 和域名变更的处理本质上不是跟站点斗智斗勇而是把自己的工程系统做得更抗造。