2026/10/11 9:34:24

拼多多 anti-content 参数逆向与爬虫实战:从抓包到规模化抓取

拼多多 anti-content 参数逆向与爬虫实战:从抓包到规模化抓取 简介这是一份面向Python爬虫进阶学习者与逆向爱好者的拼多多PDD数据采集实战资料聚焦anti-content参数解密与全站抓取思路适合已掌握Requests、Scrapy基础、希望突破JS加密与反爬限制的中高级开发者。压缩包共45个文件约183KB以16个py脚本、10个pyc字节码、7个js逆向文件为主辅以xml、json配置与说明文档涵盖请求构造、参数生成、数据解析与存储等环节。资源围绕anti-content加密参数的JS还原与Python复现展开并给出搜索、商品等页面的抓取代码组织方式可帮助读者理解加密逻辑、搭建可复用的采集流程。目前已有1427人学习下载适合作为反爬对抗与JS逆向的练手参考。1. 拼多多 anti-content 参数到底是什么一次抓包后的三个反直觉结论第一次抓拼多多商品列表的人大概率会卡在同一个地方请求 URL 拼对了headers 也照抄了返回的却是{success:false,errorCode:40002}或者干脆一个空壳。翻遍 Network 面板你会发现每个search、goods_detail、recommend接口的请求头里都躺着一个叫anti-content的字段长度几百到上千字符不等每次刷新都在变。这就是拼多多爬虫绕不开的第一道门。anti-content不是普通的签名它是一段经过混淆和加密的上下文载荷由页面里的 JS 在发请求前动态生成服务端会校验它的合法性、时效性和与当前账号/设备/接口的绑定关系。它解决的问题是确认这次请求来自真实浏览器环境而不是脚本拼出来的。适合谁看做电商比价、选品监控、价格追踪的 Python 爬虫工程师以及任何需要稳定拿到拼多多公开商品数据的人。下面按「它是什么 → 怎么定位生成逻辑 → 怎么在本地复现 → 怎么规模化抓取 → 坑在哪」的顺序讲透。2. 定位 anti-content 的生成入口从请求头反查到 JS 函数2.1 用请求发起栈锁定加密函数打开 Chrome DevTools切到 Network勾选Fetch/XHR随便触发一次商品搜索。找到带anti-content的请求右键 →Copy→Copy as cURL先确认这个字段确实在 headers 里。然后切到 Sources 面板在右侧XHR/fetch Breakpoints里添加一个断点条件填请求 URL 里包含anti_content或接口路径关键字。刷新页面断点命中后看 Call Stack。你要找的是「谁往 headers 里塞了这个值」。常见做法是在XMLHttpRequest.prototype.setRequestHeader或fetch被重写的地方下钩子。更直接的办法在 Console 里执行下面这段拦截所有 header 写入把调用栈打出来。// 在页面 Console 中执行拦截 setRequestHeader定位 anti-content 写入点 const raw XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader function (key, value) { if (key.toLowerCase() anti-content) { // 打印调用栈顺着栈找到生成函数 console.log(anti-content 写入值长度:, value.length); console.trace(调用栈); } return raw.apply(this, arguments); };逻辑说明这段代码没有改任何业务逻辑只是给原生方法加了一层观察。console.trace会输出完整的调用链你顺着栈帧往上翻通常两三层就能看到一个名字被混淆成_0x开头的函数那就是生成入口。参数说明key.toLowerCase()做大小写归一因为不同接口可能写成Anti-Content或anti-content这也是热词里「js 忽略大小写」在实战里的典型用法。2.2 把混淆代码还原成可读逻辑定位到函数后把对应的 JS 文件下载下来。拼多多的前端 JS 一般经过 webpack 打包 变量名混淆直接读很痛苦。常见做法是用 AST 工具做常量折叠和字符串还原或者用浏览器自带的 Pretty Print 先格式化。我一般会先搜几个特征字符串比如anti-content、_nano、md5、timestamp快速判断它用了哪些基础算法。还原后的逻辑通常长这样收集一批环境参数时间戳、随机数、页面 URL、cookie 里的某些字段、navigator 信息拼成一个字符串再做一次或多次哈希/加密最后 Base64 编码。你要做的是把这段逻辑用 Python 或 Node 重写而不是去硬解密文——因为密钥和盐值往往就藏在同一段 JS 里。提示不要试图用纯 Python 去执行混淆后的 JS兼容性和性能都很差。主流方案是 Node 起一个本地服务Python 通过 HTTP 调用或者用 PyExecJS / mini-racer 这类桥接库。2.3 判断是纯算法还是带环境检测这一步决定你后面走哪条路。把还原出的参数列表逐个删掉再跑看服务端是否还认。如果只依赖时间戳和几个固定字段那就是纯算法补全逻辑即可。如果删掉navigator、screen、canvas指纹后立刻失败说明它带环境检测这时候纯算法补环境成本极高更稳的做法是直接用无头浏览器或补环境框架。判断方法在 Node 里跑你的复现逻辑把生成的anti-content拿去请求连续请求 20 次。如果前几次成功后面开始失败多半是时间戳窗口或请求频率问题如果一次都不成功回去检查是不是漏了某个环境字段。3. 用 Node 复现 anti-content 生成最小可跑通的代码结构3.1 搭建本地签名服务把还原后的 JS 逻辑封装成一个 Node 脚本对外暴露一个 HTTP 接口输入是接口路径和业务参数输出是anti-content字符串。这样 Python 爬虫只管发请求签名交给 Node职责清晰。// sign_server.js —— 本地 anti-content 签名服务 const http require(http); const crypto require(crypto); // 从还原的 JS 中提取的核心生成逻辑示意结构字段名以实际还原为准 function genAntiContent(path, params) { const ts Date.now(); const nonce Math.random().toString(36).slice(2, 10); // 拼接顺序很关键顺序错了服务端直接拒绝 const raw ${path}|${ts}|${nonce}|${JSON.stringify(params)}; // 实际算法可能是 md5 / sha256 / 自定义异或以还原结果为准 const hash crypto.createHash(md5).update(raw).digest(hex); const payload { ts, nonce, hash }; return Buffer.from(JSON.stringify(payload)).toString(base64); } const server http.createServer((req, res) { let body ; req.on(data, chunk body chunk); req.on(end, () { const { path, params } JSON.parse(body || {}); const anti genAntiContent(path, params); res.writeHead(200, { Content-Type: application/json }); res.end(JSON.stringify({ anti_content: anti })); }); }); server.listen(3000, () console.log(sign server on 3000));逻辑说明genAntiContent里的拼接顺序、哈希算法、编码方式必须和还原出的 JS 完全一致任何一处不同都会导致校验失败。参数说明ts是毫秒时间戳服务端一般允许几十秒的偏差nonce是随机串防止重放path是接口路径说明签名和具体接口绑定不能跨接口复用。Buffer.from(...).toString(base64)是常见的外层编码实际可能是 URL-safe Base64 或十六进制。3.2 Python 侧调用与请求组装Python 这边用requests发请求签名通过本地服务拿。注意 headers 里的cookie、user-agent、referer要和签名时用的环境保持一致否则服务端会判定环境不匹配。# pdd_spider.py —— 调用本地签名服务抓取商品列表 import requests, json, time SIGN_URL http://127.0.0.1:3000 def get_anti_content(path, params): resp requests.post(SIGN_URL, json{path: path, params: params}, timeout5) return resp.json()[anti_content] def fetch_goods(keyword, page1): path /proxy/api/search params {keyword: keyword, page: page, size: 20} anti get_anti_content(path, params) headers { anti-content: anti, user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., referer: https://mobile.yangkeduo.com/, cookie: 你的有效 cookie, } url https://mobile.yangkeduo.com/proxy/api/search resp requests.get(url, paramsparams, headersheaders, timeout10) return resp.json() if __name__ __main__: for p in range(1, 4): data fetch_goods(耳机, p) print(json.dumps(data, ensure_asciiFalse)[:300]) time.sleep(2) # 控制频率别把账号打挂逻辑说明get_anti_content把路径和参数传给 Node 服务拿到签名后塞进 headers。参数说明timeout设 5 到 10 秒避免签名服务卡死拖垮爬虫time.sleep(2)是经验值太快会触发风控。cookie必须是从真实浏览器里拿的有效会话空 cookie 或过期 cookie 会直接返回登录失效。3.3 验证签名是否真的生效跑通后不要急着上量先做单接口验证。连续请求同一个接口 10 次记录返回码。如果 10 次全成功说明签名逻辑对了如果中间夹杂失败看失败返回的errorCode。40002 通常是签名不合法40001 是登录态问题频率相关的错误码一般带frequent字样。这一步是后面规模化的前提签名不稳抓取量越大翻车越快。4. 全站抓取的调度设计从单接口到多品类并发4.1 接口分层与优先级拼多多的接口大致分三类搜索类关键词搜索、类目搜索、详情类商品详情、SKU、评价、推荐类首页推荐、猜你喜欢。搜索类用来发现商品 ID详情类用来补全字段推荐类用来扩量。抓取顺序应该是先搜索拿 ID 列表去重后进队列再由详情 worker 消费。不要一上来就并发打详情ID 都没拿全并发没意义。4.2 用队列做解耦和限速单机跑的时候用queue.Queue或 Redis 做任务队列就够了。生产者和消费者分离生产者负责调搜索接口拿 ID消费者负责调详情接口。限速放在消费者侧用令牌桶控制 QPS。# scheduler.py —— 简易生产者消费者调度 import queue, threading, time, requests task_q queue.Queue(maxsize1000) seen set() def producer(keywords): for kw in keywords: for page in range(1, 6): ids search_ids(kw, page) # 内部调用签名服务 for gid in ids: if gid not in seen: seen.add(gid) task_q.put(gid) time.sleep(1) def consumer(worker_id): while True: gid task_q.get() try: detail fetch_detail(gid) save(detail) except Exception as e: print(fworker{worker_id} 失败 {gid}: {e}) finally: task_q.task_done() time.sleep(0.5) # 单 worker 限速 threads [threading.Thread(targetconsumer, args(i,), daemonTrue) for i in range(3)] for t in threads: t.start() producer([耳机, 充电宝, 数据线]) task_q.join()逻辑说明seen集合做去重避免同一商品被反复抓。参数说明maxsize1000防止内存爆掉range(1, 6)控制每个关键词翻 5 页翻太多页后面的结果相关性低且更容易触发风控time.sleep(0.5)是单 worker 的节流3 个 worker 合计约 6 QPS这个量级在带有效 cookie 的情况下相对安全。4.3 分布式扩展时的注意点单机跑稳之后想上分布式核心是把seen去重和限速从进程内挪到 Redis。用SETNX做去重用 Redis 的过期键做滑动窗口限速。签名服务可以独立部署成一个内网接口多个爬虫节点共用。注意分布式下每个节点最好绑定独立的 cookie 池所有节点共用一个账号风控会很快找上门。5. 避坑与排查anti-content 抓取里最容易翻车的 5 个点现象本地签名服务返回的 anti-content 拿去请求一直 40002。原因拼接顺序或哈希算法和线上 JS 不一致最常见的是字段顺序、时间戳精度秒 vs 毫秒、编码方式Base64 vs hex对不上。 解决回到浏览器在生成函数入口和出口各打一个断点把输入参数和输出值完整抄下来用同样的输入喂给你的 Node 函数逐字节比对输出。差一个字符都不行。现象前 20 次请求成功之后全部失败。原因触发了频率风控或时间戳窗口校验。服务端会记录同一 cookie/设备在短时间内的请求密度。 解决降低 QPS给每个账号加冷却时间检查时间戳是不是用了本地时间且偏差过大和服务端时间对齐到秒级以内。现象换了 cookie 后签名突然失效。原因anti-content 的生成逻辑里绑定了 cookie 中的某些字段如PDDAccessToken或设备标识cookie 和签名必须同源。 解决签名服务和请求必须使用同一份 cookie不要 A 账号签名、B 账号请求。把 cookie 和签名服务做成一一对应的会话。现象Node 服务跑一段时间内存暴涨。原因混淆代码里可能有全局缓存或闭包持有大量对象长时间运行不释放。 解决给签名服务加进程守护定期重启或者用--max-old-space-size限制堆大小配合 PM2 做自动拉起。现象详情接口能通搜索接口一直空。原因不同接口的 anti-content 生成参数不同搜索接口可能额外校验referer或页面来源参数。 解决不要用一个通用签名打所有接口按接口路径分别还原生成逻辑至少确认path字段参与签名。6. 进阶把签名逻辑做成可维护的模块而不是一次性脚本走到这一步你已经能跑通单接口和简单并发了。但真正决定这套方案能不能长期用的是签名逻辑的可维护性。混淆代码会更新算法会变如果你的签名逻辑散落在爬虫各处每次更新都是一场灾难。我的习惯是把它收敛成一个独立模块对外只暴露一个函数内部实现随便换。具体做法建一个sign/目录里面放adapter.js对接还原后的 JS、server.jsHTTP 服务、test.js回归测试。每次线上算法更新只改adapter.js然后用test.js跑一遍固定输入输出比对。下面是一个回归测试的骨架。// test.js —— 签名回归测试固定输入比对输出 const { genAntiContent } require(./adapter); const cases [ { path: /proxy/api/search, params: { keyword: test, page: 1 }, expectLen: 200 }, { path: /proxy/api/goods, params: { goods_id: 123456 }, expectLen: 180 }, ]; let pass 0; for (const c of cases) { const out genAntiContent(c.path, c.params); // 长度只能做粗校验精确校验要存历史快照 if (out.length c.expectLen * 0.8) { pass; console.log(PASS ${c.path} len${out.length}); } else { console.log(FAIL ${c.path} len${out.length}); } } console.log(通过 ${pass}/${cases.length});逻辑说明长度校验只能发现明显异常更严格的做法是把每次成功请求的anti-content存一份快照算法更新后拿同样的输入重新生成比对是否一致。参数说明expectLen是经验长度实际以历史成功样本为准。这个测试跑在 CI 里算法一变立刻报警比线上抓取失败了再回头查要省事得多。还有一个容易被忽略的点cookie 池的维护。签名再稳cookie 失效一样抓不到数据。我一般会用一个独立的健康检查任务每隔几分钟拿池子里的 cookie 请求一个轻量接口失败的标记下线成功的续期。cookie 池和签名服务配合才是完整的可用方案。最后说个血泪经验不要追求「一次还原永久可用」。拼多多的前端会不定期更新签名逻辑跟着变是常态。把还原、验证、替换的流程标准化比死磕某一次还原结果重要得多。我现在的习惯是每周跑一次回归测试发现失败就当天处理不让问题堆积。希望帮到你。本文还有配套的精品资源点击获取