ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HEAD请求预检与Cookie令牌复用:沃尔玛反爬实战解析

HEAD请求预检与Cookie令牌复用:沃尔玛反爬实战解析 说实话做爬虫这几年我很少遇到让我第一次觉得得换思路了的站点沃尔玛算一个。最早我写了个极其标准的requests爬虫换了个UA就去抓商品数据结果十分钟之内收到了二十几个403。打开响应体一看连个像样的报错都没给就一行Access Denied。当时我的第一反应是是不是我的IP被ban了换了代理再试还是一样。后来我把浏览器开发者工具里的完整请求头复制下来用同样的参数在Python里重新发起了一次请求结果依然被拦。就在那会儿我意识到这个坎的根本问题不在请求头伪装而在会话状态。这篇文章就用沃尔玛作为案例把我实际摸索出来的HEAD请求预检加Cookie令牌维持这套打法完整拆开讲清楚。它能解决什么问题呢——简单说就是让爬虫从裸奔路人变成带着合法会话进门的访客大幅降低请求被WAF拦截的概率。适合已经会写基础requests爬虫、但对反爬对抗机制理解还不够深的同行参考。1. 开局一次教科书级的403拦截1.1 我当时写的标准爬虫是怎么被识破的先还原一下第一版爬虫的样子非常简单粗暴import requests url https://www.walmart.com/ip/xxx headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)} resp requests.get(url, headersheaders, timeout10) print(resp.status_code) print(resp.text)这段代码几乎是所有爬虫入门教程里最常见的样子。如果我抓的是国内某些信息流网站可能问题不大但对于反爬做得比较认真的电商平台这种请求就好比你在门口大喊我是机器人WAF不拦你拦谁。问题不在于伪装的UA不够新而是缺失的请求头太多了。浏览器在发请求时会带上Accept、Accept-Language、Accept-Encoding、Sec-Fetch-Site、Sec-Fetch-Mode等一系列字段而且它们的顺序和值都遵循某种固定规律。爬虫只带一个UA就像穿了件外套但裤子鞋子还是原样站在风控系统眼里就是个不伦不类的访客。1.2 浏览器与脚本的差距到底在哪里我当时的排查过程是这样的先用Chrome正常打开沃尔玛商品页Network面板里看请求确实能正常拿到200。然后我用curl命令把完整请求头抄下来放到Python里原样发送结果返回还是403。这就很诡异了——请求头一模一样为什么浏览器能过requests就过不了后来我把浏览器关掉换了一个隐身窗口重新访问同一个商品页发现依然能正常打开。但如果直接用requests去请求同一个URL在没有任何前置请求的情况下就会撞上拦截。这说明服务器校验的不只是请求头还包括会话状态——也就是说浏览器访问页面之前其实已经通过首页或其他页面建立了一个合法身份。这个观察非常关键它推翻了我之前只要伪装够像就能绕过的认知。也是从这一刻开始我才认真去研究沃尔玛这类站点背后的风控链路才有了后面的HEAD请求预检和Cookie令牌维持方案。2. 沃尔玛反爬的三道闸门先搞清楚对面是什么路数2.1 第一道闸门User-Agent与基础请求头特征风控系统对请求的第一层校验几乎是静态的。UA只是一部分更完整的请求头指纹包括Accept、Accept-Language、Sec-Fetch-Site、Sec-Fetch-Mode、Sec-Fetch-Dest等。这些字段在真实浏览器里是有固定组合关系的而且值基本恒定。有一个细节容易被忽略requests库的默认UA是python-requests/2.x.x这个字符串本身就是最大的招黑体。网上一搜一大堆UA伪装代码但很多人只改了UA其他字段没动。比如你UA写着Chrome 120Accept却是*/*这两种特征放在一起本身就矛盾风控规则里如果有这种特征比对会直接标记。要快速拿到一套和浏览器完全一致的请求头我推荐一个办法Chrome开发者工具里右键请求选择Copy as cURL然后粘到终端里跑再用Python的requests适配。这样拿到的请求头是原汁原味的浏览器指纹比自己手写靠谱得多。2.2 第二道闸门行为指纹与请求频率模式请求头过了还有行为层的判断。人在浏览器里逛商品页是有节奏的打开一个页面停几秒看看图片、滚动一下、再点进下一个。每一步之间都有阅读时间。而脚本的行为是循环里发十个请求间隔0.1秒全部命中同一个URL模式。这种规律在风控系统里很容易被识别出来。除了频率还有一个容易忽略的特征是静态资源请求模式。真实浏览器打开一个页面时会同时发起对JS、CSS、图片、字体等大量静态资源的请求而脚本爬虫往往只请求HTML或API接口没有伴随的静态资源流量。通过分析单个页面请求的资源组成WAF就能推断出这是浏览器还是爬虫。换句话说单纯把请求间隔调慢并不能完全解决行为识别问题但如果你不调慢那就连第三道闸门长什么样都看不到——因为请求在第二道就被拦截了。2.3 第三道闸门Cookie令牌的会话绑定校验最核心也最容易被忽略的一层是Cookie令牌机制。沃尔玛这类大规模电商平台通常会在客户端首次访问站点时下发一组Cookie其中包含一个经过签名的令牌这个令牌和服务器的Session状态强绑定。后续每次数据请求服务端校验的是这个令牌是否存在、是否过期、是否与当前请求的IP、UA特征匹配。举个例子你就理解了你第一次用浏览器打开沃尔玛首页服务器在Response Header里返回Set-Cookie浏览器自动存下来。之后你访问商品页、加购物车、下单浏览器每次都会带上这个Cookie。服务器一看你的令牌合法IP和UA都和签发时一致Session状态也正常就放行。而如果直接用requests去请求一个深层页面没有前置的会话建立过程服务器看到的是一个没有合法令牌的陌生访客直接返回403。很多爬虫新手栽在这一层就是因为没意识到请求头伪装得再像没有合法的会话身份依然会被拦截。这也是为什么单纯的UA伪装解决不了沃尔玛这类站点的问题。3. HEAD请求的真正价值不取正文先谈会话3.1 HEAD请求是什么为什么常被忽略HTTP协议里定义了多个请求方法GET和POST大家最熟HEAD则常常挂在文档里没人看。HEAD和GET几乎一模一样唯一的区别是服务器在处理HEAD请求时只返回响应头不返回响应正文。也就是说你请求一个商品页拿到的是内容长度、内容类型、服务器信息、过期时间等元数据但拿不到商品标题、价格、库存这些实际内容。打个比方GET是进店里把整个货架都搬回家HEAD是站在门口看一眼店里有没有这个货。因为不传输正文HEAD请求比GET轻量得多速度也快得多。传统的用途主要是做资源健康检查、链接可用性扫描以及在下载大文件前先通过HEAD获取Content-Length做断点续传预检。很多写爬虫的人从入门到进阶都很少用它因为它拿不到能入库的数据。但在反爬对抗这个场景里HEAD请求有一个被严重低估的价值——它依然是一个合法的HTTP请求服务器在处理它时同样会经过会话校验逻辑同样会在响应头里下发Cookie。这意味着你可以用一个几乎没有数据成本的请求完成会话建立这一步然后再用GET去真正获取数据。3.2 用HEAD做预检把无效请求挡在门外批量爬取时最烦的情况是什么URL列表里混了一堆已下架、参数错误、重定向的链接每个都发GET请求去试浪费带宽、浪费时间还容易因为大量无效请求触发风控。HEAD请求在这个场景里就是完美的探路工具——先发一个HEAD根据状态码判断这个链接是有效还是无效再决定要不要发GET。我自己实际跑过的一个批量采集流程里URL列表有几千条其中大概有一成是无效链接。如果这些无效链接全部用GET打过去响应体虽然不大但架不住数量多很容易在短时间内拉高请求频率特征值。而用HEAD预检后无效链接会在第一轮就被过滤掉后续GET请求的数量大幅下降被风控标记的概率也相应降低。这算是HEAD预检的一个附加福利。它的核心价值还是下面要说的——会话初始化。3.3 用HEAD触发令牌下发轻量完成握手回到沃尔玛这个场景。正常的访问路径是浏览器先打开首页服务器下发Cookie然后浏览器带着Cookie去请求其他页面。爬虫如果想模拟这个路径最简单的办法是先GET一下首页把Cookie拿到手再去GET商品页。但直接GET首页有两个问题一是首页可能有好几百K的HTML纯浪费二是如果首页本身被加了防护或者页面结构特殊GET请求反而容易触发WAF的深度检查。用HEAD请求就能绕开这两个问题。HEAD请求到达服务器后服务器依然会执行会话创建逻辑在响应头里返回Set-Cookie。但因为它不返回正文WAF能做的内容级检查就少了很多。而且HEAD请求的报文格式和GET一致服务器通常不会特殊拒绝只是不发送message body而已。这样做的效果是你用一个几十毫秒、零正文的轻量请求就完成了新访客建立会话的全部流程。之后带Cookie去GET深层页面命中率会高很多。我先HEAD后GET不是先GET后HEAD这个顺序很重要——如果你先发了一堆GET再回头补HEAD那之前的GET请求已经暴露了没有会话状态的特征反爬系统早就把你标记了。4. Cookie令牌的完整生命周期从匿名到熟客4.1 第一次握手服务器在响应头里埋了哪些线索当你用Session对象发一个HEAD请求到首页时服务器会返回一组响应头其中最关键的就是Set-Cookie字段。我第一次把它打印出来的时候发现里面不止一个Cookie有session ID、有用户偏好设置还有一个看起来像加密串的令牌一串很长的字符。这个加密串就是前面说的会话绑定令牌。它的特点是由服务器端密钥、客户端IP、UA、时间戳、随机数等信息共同签名生成客户端无法伪造也无法解析出有用信息。服务器通过Session状态记住了这个令牌是合法的签发给某个特定用户。后续请求带上了它服务器就认为你是同一个用户回来了。实际DEBUG时候的方法很简单在preflight请求后把resp.headers打印出来观察Set-Cookie字段里到底有几个Cookie哪些是稳定的会话标识哪些是临时追踪参数。这些信息会指导你后续的请求头设计。不同站点的Cookie命名和组成差异很大但HTTP层面的交互逻辑是相通的。4.2 把Cookie留住Session与手动提取的正确用法requests库的Session对象在设计上就考虑到了Cookie管理的需求。它的内部维护了一个cookie jar容器每次请求响应里出现的Set-Cookie会自动存进去后续请求会自动在请求头里带上Cookie字段。这就省去了你手动拼Cookie字符串的麻烦。但Session能自动管理的只是Cookie字段本身。有些站点的令牌校验并不只依赖Cookie还会在响应头的自定义字段里返回一个token比如X-Request-Token、X-Token这类命名要求后续请求在自定义Header里显式携带。这种字段Session不会自动帮你去处理需要手动提取并塞进Session的headers里。我的代码里就写了这个逻辑preflight函数里在拿到响应头后先尝试提取自定义令牌如果存在就更新到Session的headers中。这个机制虽然简单但能把一些看起来已经拿到了Cookie却依然403的情况处理掉。4.3 令牌失效的几种常见表现与应对Cookie令牌不是永久的。不同站点的失效策略不同有的十分钟有的半小时有的只要会话不断就一直有效。失效后的表现也有差异我自己遇到过的有这几种返回403或401响应体里没有任何有用信息返回302跳转到验证码页或登录页返回200但内容是空数据或错误提示返回200但Response Header里出现refresh字段强制要求更新Cookie遇到这些情况别慌先确认是令牌过期还是请求头问题。一个实用的排查顺序是先打印resp.status_code和resp.url看看有没有发生重定向再检查resp.headers里有没有下发新的Set-Cookie最后对比一下自己本地保存的Cookie和服务器新下发的Cookie有没有差异。应对策略上我一般采取重试前先刷新的原则——每次请求失败后先做一次HEAD预检让服务器重新下发一组Cookie再带着新Cookie重试原请求。这个机制比失败后无脑重试靠谱得多因为它解决的是根因而不是表面现象。5. 完整代码实现HEAD预检 Cookie令牌复用的爬虫5.1 代码结构与模块说明下面这版代码是我在实际项目中反复迭代出来的一个稳定版本。整体用一个class封装好处是复用性高你只需要实例化一个对象传入目标URL列表然后调用run方法就能跑完整套流程。各模块的职责划分如下初始化模块创建Session、配置完整请求头预检模块发起HEAD请求、提取响应头、更新自定义令牌数据获取模块携带Cookie获取数据、失败自动刷新重试主流程模块控制请求顺序、频率和异常处理5.2 核心代码逐段讲解 沃尔玛反爬实战示例代码 核心思路HEAD请求预检 Cookie令牌维持 随机间隔 仅用于学习研究实际使用请遵守目标网站的服务条款与robots.txt约定 import requests import time import random import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) class WalmartCrawler: def __init__(self, base_url): self.base_url base_url self.session requests.Session() self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9, image/avif,image/webp,*/*;q0.8, Accept-Language: en-US,en;q0.9, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, Upgrade-Insecure-Requests: 1, Sec-Fetch-Site: same-origin, Sec-Fetch-Mode: navigate, Sec-Fetch-User: ?1, Sec-Fetch-Dest: document, }) def preflight(self): 第一次HEAD请求触发服务器下发Cookie与令牌 resp self.session.head(self.base_url, timeout10, allow_redirectsTrue) logging.info(HEAD预检状态码: %s, resp.status_code) for key, value in resp.headers.items(): logging.info(响应头 %s: %s, key, value) custom_token resp.headers.get(X-Request-Token) or resp.headers.get(X-Token) if custom_token: self.session.headers[X-Client-Token] custom_token logging.info(已提取并注入自定义令牌) return resp def fetch_data(self, url): 携带Cookie令牌获取数据 resp self.session.get(url, timeout15) logging.info(GET请求状态码: %s, resp.status_code) if resp.status_code in (403, 401): logging.warning(身份校验未通过先刷新令牌再重试) self.preflight() resp self.session.get(url, timeout15) resp.raise_for_status() return resp def run(self, urls): 主流程先HEAD预检再逐个获取数据带随机间隔控制节奏 self.preflight() time.sleep(random.uniform(1.5, 3.5)) for idx, url in enumerate(urls, start1): try: resp self.fetch_data(url) print(f第{idx}个请求完成状态码{resp.status_code}) except Exception as exc: logging.error(第%s个请求失败: %s, idx, exc) time.sleep(random.uniform(2.0, 5.0)) if __name__ __main__: # 替换为实际研究场景下的目标URL列表 target_urls [ https://www.example-store.com/api/product?id1001, https://www.example-store.com/api/product?id1002, ] crawler WalmartCrawler(https://www.example-store.com/) crawler.run(target_urls)几个要点拆开来说Session对象的必要性。它最大的价值在于自动维护Cookie状态。你不需要自己解析Set-Cookie、拼Cookie头、处理过期替换Session内部都帮你做了。在这个场景里HEAD预检返回时服务器下发的Cookie已经被Session自动保存后续GET请求会自动带上。Preflight里的自定义令牌提取。有些服务端不在Set-Cookie里放核心令牌而是通过在Response Header里返回自定义字段来完成令牌下发。这段代码用get方法尝试拿X-Request-Token或X-Token拿到后直接更新到Session的headers里。这个字段名因站点而异你需要根据实际响应头来调整。fetch_data里的重试机制。代码在收到403/401时会先调用preflight刷新令牌再重试一次原始请求。之所以敢重试是因为preflight已经帮你完成了一次会话刷新这次重试的身份状态和第一次完全不同成功率会高很多。随机间隔的意义。真实用户浏览网页相邻两个页面请求之间大概率有两到五秒的间隔而且这个间隔是波动的。用random.uniform(2.0, 5.0)生成随机延时不是为了完全模拟人类而是为了破除固定频率请求这个最明显的自动化特征。5.3 运行方式与预期输出运行代码前需要先安装依赖只用到requests一个第三方库其他都是Python标准库。pip install requests python walmart_crawler.pyPython版本建议3.8以上低版本对requests的兼容性虽然没问题但类型提示和日志格式化方面还是高版本用着顺手。运行后你会看到一行HEAD预检的状态码日志接着是响应头里各级字段的打印最后是每个目标URL请求的结果输出。如果前面的HEAD预检成功建立了会话后续GET请求的状态码通常会稳定在200如果看到403且日志里出现身份校验未通过先刷新令牌再重试说明preflight重试机制起作用了第二次请求大概率能恢复。5.4 HEAD不可用时的回退方案不是所有服务器都实现了HEAD方法有些反爬系统会直接对HEAD请求返回405 Method Not Allowed。遇到这种情况可以退一步用GET加stream模式只读取响应头resp self.session.get(url, streamTrue, timeout10) resp.close() # 只读header不读取body然后关闭连接这样做的本质依然是轻量握手——通过GET建立会话、读取响应头、拿到Set-Cookie但不下载正文。resp.close()执行后连接被释放响应体没有被读取流量消耗和一版HEAD请求差不了太多。代价是WAF可能针对GET路径做更多内容检查所以能用HEAD优先用HEAD否则再降级到GETstream方案。6. 实跑验证与踩坑记录6.1 加了HEAD预检之后的效果对比为了验证这套方法真的有效我做过一组对照实验。在同一个IP、同一批URL列表、同一个时间段内分别用三种方案去请求数据方案A直接用requests.get加一个简单UA方案B完整伪装浏览器请求头但跳过HEAD预检方案C完整伪装请求头加HEAD预检加Cookie复用加随机间隔结果非常说明问题方案首次请求结果连续20次请求成功率触发拦截所需请求数A简单UA4030%第1次就拦截B完整请求头部分200约40%约5-10次CHEAD预检Cookie20085%以上20次内未触发拦截方案B能拿到部分200说明完整请求头伪装确实有效但这种有效性不持久——因为每次请求都是新的陌生会话没有Cookie绑定关系服务器很快就会对高频无状态请求产生怀疑。方案C的稳定提升主要来自两点一是有HEAD预检完成的会话初始化二是请求间隔不再是零或固定1秒而是符合人类阅读节奏的随机值。6.2 四个高频踩坑点希望你别再走一遍坑一只伪装UA没伪装全套请求头。现象是请求能发出去但经常性403。排查时我一度以为是代理IP的问题换了好几个IP都不行。后来把所有请求头字段逐步加上去才确定问题出在不完整。核心原则是要么全抄浏览器的要么就把请求头做到尽量完整伪装一半比不伪装更容易触发风控的特征比对。坑二HEAD预检时被WAF针对。有些站点的WAF会对HEAD请求做额外处理返回405或者直接丢弃。解决路径是回退到GETstream方案。另外注意一点HEAD预检需要设置allow_redirectsTrue否则遇到302可能会漏掉Set-Cookie。坑三Cookie拿到手但过几分钟就失效。原因可能是会话超时时间比较短或者服务端定期做Session轮换。我的经验是别追求一次Cookie用到天荒地老而是在请求失败或间隔较长时间时主动做一次preflight刷新。把刷新逻辑写进重试机制里比自己手动维护Cookie过期时间靠谱。坑四睡眠时间设成了固定值。固定time.sleep(3)有一个隐藏风险如果服务器日志按每秒请求数做聚合统计固定间隔的请求会在统计周期内呈现完全一致的分布这也是自动化特征。改成随机间隔后请求分布在统计粒度上更接近真实用户行为。这也是我在代码里用random.uniform(2.0, 5.0)而不是固定休眠3秒的原因。6.3 一个被忽略的细节在请求失败时先看响应头请求返回403时大部分爬虫脚本只关注resp.status_code和resp.text很少人去看resp.headers。但实际上403响应头里可能藏着风控系统的下一步指令它可能设置了新的Cookie提示你下次请求带上这个也可能返回了Retry-After字段告诉你多久以后才能再来甚至可能修改了CSP头暗示你触发了某种浏览器特征校验。我之前排查一次间歇性403就是在403响应头里发现了服务端新下发的一个trace_id顺着这个字段去模拟完整的Cookie更新流程问题才彻底解决。所以我的建议是遇到异常状态码时至少打印一次resp.headers这个习惯能帮你少走很多弯路。写在最后的个人体会这套HEAD预检加Cookie令牌维护的思路我后来不仅用在了沃尔玛上也在其他几个有反爬协议保护的站点上验证过核心逻辑是通用的先用最小成本建立合法会话再带着会话身份去获取数据。整个过程的关键是顺序——先把身份问题解决掉再谈数据获取。爬虫和反爬之间的博弈永远在动态变化但HTTP会话交互的基本规则不会变服务器信任的是有状态、有节奏、有完整指纹的访客。做技术研究时尽量保持克制能用低频稳妥的方案解决问题就不必把服务器打到极限——稳定的数据管道比跑一次猛的一次性任务有价值得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进