拼多多爬虫:anti-content签名逆向与全站抓取实战
简介面向爬虫逆向与电商数据采集开发者这份资料围绕拼多多anti-content参数展开提供了JS解密与全站抓取代码思路。资源包共45个文件压缩后约183KB核心代码以16个Python脚本和7个JS文件为主另有pyc、json、xml等运行配置与缓存文件适合已有Python爬虫基础、希望突破Web端反爬限制的读者。内容以真实采集流程为线索从搜索场景的anti-content参数定位开始梳理了JS加载、补环境、动态调试等环节并给出多个入口脚本和JS处理模块方便对照理解参数生成与请求构造过程。包内还包含爬虫框架配置、README说明和项目结构文件便于直接复现或改造到自己的数据抓取任务中。已有1425人学习或下载该资源对于关注拼多多数据采集、JS逆向与自动化抓取的开发者来说是一个结构紧凑、可直接落地的参考包。1. pdd 爬虫的密钥在 JS 里anti-content 到底是什么做 pdd拼多多爬虫的人大概率都见过这种场面requests 把请求发出去返回的 JSON 里既不是商品列表也不是错误说明而是errorCode4001或者一串含糊的request illegal。问题不在 URL、不在 UA而在请求体里多出来的那个参数——anti-content。这个参数不是后端随机生成的而是前端 JS 在页面运行时动态算出来的签名每次请求不一样换一个参数值、换一个字段顺序结果就完全不同。抓包截下来的 anti-content 基本没法重放复用必须把生成它的那段 JS 抠出来在本地用同样的逻辑算出来再拼进请求里。本篇文章要解决的就是这条完整链路怎么定位 anti-content 的生成入口、怎么把加密函数搬到本地执行、怎么围绕它搭一套可持续抓取搜索、商品、评价等全站数据的代码思路以及最容易被忽略的几个风控和签名坑。适合已经能跑通普通 requests 抓取、但被前端签名卡住的人。2. 从抓包到断点定位 anti-content 的生成入口2.1 先抓一条带 anti-content 的搜索请求做 JS 逆向的第一步永远是抓包不是翻源码。建议直接用电脑浏览器打开拼多多 H5 端按 F12 切到 Network 面板勾选 Preserve log然后在搜索框输入一个词并点搜索。此时页面会发起一堆请求真正包含商品数据的往往集中在/proxy/api/开头的接口里比如搜索接口通常长这样https://mobile.yangkeduo.com/proxy/api/search?pdduidxxxxxx在这个请求的 Payload或者叫 Request Body里能看到一个对象其中必然带着anti-content字段值是一长串看起来像十六进制的东西长度可能是 32、64 或者更长取决于算法。同时留意以下字段page1size10query手机sort_typedefaultanti-contentxxxxxxxx参数说明page和size是分页参数query是搜索词这些内容就是你后端请求要拼的参数。anti-content的值不是跳转链接里带过来的也不是 Cookie 里现成的它是一个由前端脚本用请求参数现场计算的结果。判断依据很简单把请求复制成 cURL稍微改一下page的值再重放返回结果会变成非法请求——说明签名跟参数内容绑定不能只保留旧签名。2.2 在 DevTools 里搜参数名找到赋值现场拿到请求样本后切到 Sources 面板按 CtrlShiftF 打开全局搜索输入anti-content。这个操作会扫所有已加载的 JS 文件定位到这个参数名第一次出现的位置。注意搜索时建议同时搜anti_content下划线写法因为 JS 里变量命名可能有驼峰、下划线、中划线三种写法后端接收时统一转成了anti-content前端代码里大概率是anti_content或antiContent。常见情况是搜索到的第一个命中点出现在某个 chunk 文件里代码大致长这样var s { page: e.page, size: e.size, query: e.query, sort_type: e.sort_type }; s.anti_content Object(d.a)(JSON.stringify(s));这里的Object(d.a)(...)就是真正的签名函数。d.a是一个被 webpack 打包过的模块导出具体函数名在压缩代码里已经被简化不能直接看出算法。此时不要急着把整个文件抠下来先在上面的赋值那一行打一个断点刷新页面再触发一次搜索让断点停在签名前。停住后在 Console 里手动执行JSON.stringify(s)记下这次序列化的结果它就是等一下本地复算时要比对的标准输入。再在 Console 里直接调用Object(d.a)(JSON.stringify(s))把得到的值和 DevTools Network 面板里的 anti-content 值对比一致就说明定位准确。2.3 顺着调用栈走找盐、找算法、找入参断点停住后右侧 Call Stack 里能看到Object(d.a)是从哪个函数被调出来的。点进d.a的源码位置观察它接收了什么参数、后面又拼了什么内容。常见的套路有三种直接对JSON.stringify(s)做 MD5 或 SHA 系列的哈希然后可能再拼一段固定盐值对序列化后的字符串做一次 Base64 编码再在末尾追加上时间戳后二次哈希入参加上一个 Cookie 里的值比如api_uid、webp或某种设备标识形成用户绑定关系。怎么判断是哪种看函数体的字符特征搜代码里有md5、sha、hex、digest这类词或看它是否调用了window上的一个全局对象。更直接的办法是在那行调用代码上右键选“Add script to ignore list”以外的另一种操作——在函数底部打到 return执行到 return 时用鼠标悬浮变量快速确认它返回的是纯字符串结果还是依赖了window.navigator.userAgent这类的环境信息。这一步原则上不要追求看懂全部代码只需要记录三条信息这个函数接收什么入参、有没有拼外部变量Cookie、时间戳、页面环境、输出是什么编码的字符串。这三条信息足够支撑后面的本地复算。3. 把解密逻辑搬到 Node 里执行补环境与最小调用3.1 提取加密函数把 chunk 抠出来定位完成之后接下来就是把生成 anti-content 的 JS 代码提取到本地。做法是切到 Sources 面板找到所在文件在文件内容上右键选择 Save As保存为一个.js文件。但这个文件往往有几千行因为它是 webpack 打包后的一个 chunk包含了大量跟签名无关的业务逻辑。不要整文件塞进 Node 里跑会报一堆window is not defined的错而且很不好排查。正确做法是先找出关键模块的导出方式。在断点处执行Object.keys(d)返回结果里通常有a、b、c等几个导出项其中d.a就是签名函数。再执行d.a.toString()这一步很关键它会把加密函数源码直接以字符串形式打印到 Console 里。把这个函数体复制出来它可能长这样function(t) { var e function(t) { var e arguments.length 1 void 0 ! arguments[1] ? arguments[1] : {} , r Object.keys(t).sort() , n {}; return r.forEach(function(r) { n[r] t[r] }), JSON.stringify(n) }(t); return (0, c.a)(e _pdd_f1scenesearch) }观察这个函数它先对入参做了一次 key 排序后重新序列化再拼接了一个固定字符串_pdd_f1scenesearch最后调用c.a做了最终哈希。因此至少要提取两个部分外层这个排序拼接逻辑以及c.a对应的哈希函数。这里遇到的现象很常见c.a在同一个 chunk 文件里直接把它源码也打印出来。如果c.a内又调用了e.t()之类的二级函数就继续顺着往下打印一直打到原生方法为止。多数情况最终会落到 MD5、SHA1、SHA256 或者它们的魔改版本。魔改版本的表现是原生哈希算法被改了初始向量 IV或者对输入字符串做了字节级替换所以网上搜出来的标准 MD5 代码对不上。3.2 用 Node 跑通最小签名补 window 环境把函数提取出来后在本地建一个独立目录结构如下pdd-crawler/ ├── sign/ │ ├── algs.js # 提取出来的哈希算法 │ ├── sign.js # 组装 anti-content 的入口 │ └── test.js # 本地验证脚本sign.js的内容思路是导出一个纯函数输入为请求参数对象输出为 anti-content 字符串形式类似这样// sign/sign.js // 本地复算 anti-content 的入口抽离环境依赖 const { hash } require(./algs); function generateAntiContent(params) { // 注意这里的 Object.keys().sort() 排序逻辑必须和浏览器一致 const sortedKeys Object.keys(params).sort(); const sortedParams {}; for (const key of sortedKeys) { sortedParams[key] params[key]; } const plainText JSON.stringify(sortedParams) _pdd_f1scenesearch; return hash(plainText); } module.exports { generateAntiContent };逻辑说明generateAntiContent接收的params是请求参数的普通对象函数内先按 key 排序再序列化然后拼接盐值字符串最后调用hash得到最终签名。参数说明排序用的是默认的字典序这是很多 JS 里Object.keys().sort()的默认行为千万别改成自定义排序否则结果立刻不一致。test.js里放抓包拿到的真实请求参数算一次签名再和真实的 anti-content 做对比node test.js返回一致说明最小签名已经跑通。不一致的情况下优先排查两件事第一hash函数里的 IV 和填充方式是不是原生哈希第二盐值字符串是否拼错了位置有的算法是先加盐再哈希有的是哈希后再拼盐做二次哈希打印出代码里每个字符串拼接处逐一核对即可。还有一种情况是函数依赖了window.navigator.userAgent、document.cookie这类环境变量直接跑会报错。解决办法不是在 Node 里补全套浏览器环境而是看依赖的变量到底参与不参与签名计算。调试方法在函数内打印navigator.userAgent的值手动把这个值作为参数传进去替代对全局变量的读取。大部分 PDD 搜索接口的签名不依赖 UA依赖的是 Cookie 和固定的盐值所以这一步要实测。提示优先尝试vm模块执行原始 chunk 代码是比较省事的做法但调试成本高我一般推荐先做纯函数提取跑通后再考虑用 vm 跑完整 chunk。两种路径选一种即可不用都做。3.3 Python 侧调 Nodeexecjs 方案与失败时的备选签名逻辑在 Node 里跑通之后Python 侧最常见做法是用execjs调用 Node 运行时直接复用上面的 JavaScript 代码。整个过程建议写成一个独立的签名服务模块而不是每次请求时都重新加载 JS 文件。import execjs import os # 加载签名模块模块内导出 generateAntiContent with open(os.path.join(sign, sign_bundle.js), r, encodingutf-8) as f: JS_CODE f.read() ctx execjs.compile(JS_CODE) def get_anti_content(params: dict) - str: # 调用 Node 侧生成的函数 return ctx.call(generateAntiContent, params)逻辑说明execjs.compile会把 JavaScript 源码编译进一个运行时上下文之后每次调用都在同一个上下文里执行省去反复读文件的开销。参数说明params必须与浏览器端传入签名的对象保持一致包括字段名、字段值类型例如数字、字符串、布尔值的区别会影响序列化结果。get_anti_content返回的字符串直接放进请求体里的anti-content字段即可。execjs 有三个高频坑需要注意。第一个是运行时选择本机一定要装 Node.js默认的 JavaScriptCore 和 PhantomJS 在跑现代 ES6 语法时容易报错建议显式指定execjs.get().name # 确认输出是 Node.js (V8)第二个是超时问题。正常签名计算不超过 50ms如果加了 RPC 调用或依赖了较大的 chunk可能耗时较长需要设置超时。第三个是上下文变量污染JS 里如果用了Math.random或时间戳参与签名那同一个请求每次生成的签名可能都不同这其实是正常的只是会影响你对比验证时的注意力。备选方案是如果提取出的哈希算法是标准 MD5/SHA 系列魔改可以直接用 Python 的hashlib复刻不经过 Node。比如魔改点只在拼接盐值上那 Python 侧只需要算一次标准哈希即可。完整 chunk 提取方案跑不通时我一般先试这条路代码少、容易排查但前提是你必须确认算法没有被魔改到字节级。4. 全站抓取的代码思路从签名到落库4.1 分接口梳理 anti-content 覆盖范围PDD 的 H5 端接口并不全依赖 anti-content但搜索、商品详情、评价列表这几个核心接口基本都带这个参数。全站抓取的代码思路第一步不是写爬虫而是先把接口清单列出来确认每个接口的入参规格和签名规则。以搜索和商品链路为例常用接口如下表功能模块接口路径示意关键入参是否需要 anti-content搜索/proxy/api/searchquery、page、size、sort_type是商品详情/proxy/api/goodsgoods_id、pdduid是评价列表/proxy/api/reviewsgoods_id、page、size是店铺信息/proxy/api/mallmall_id部分情况需要轮播商品/proxy/api/carousel无否表格里的接口路径代表这一类接口的命名规律实际项目里路径会带上渠道参数。注意观察所需签名这一列并不是所有接口都走同一套generateAntiContent有些接口的参数排序规则可能不同盐值字符串也可能不同。所以代码里应该把签名逻辑按接口分类而不是全局共用一个函数。代码目录结构建议这样设计pdd-crawler/ ├── sign/ # 签名模块按接口分类 │ ├── search.js │ ├── goods.js │ └── reviews.js ├── crawlers/ # 抓取模块 │ ├── search_crawler.py │ ├── goods_crawler.py │ └── base.py ├── storage/ # 存储模块 │ └── models.py └── config.py抓取模块的基类可以封装统一的请求逻辑把签名生成、Cookie 管理、重试策略都收敛在一个地方。以搜索为例import requests import time from sign import get_anti_content class SearchCrawler: def __init__(self, cookies: dict): self.session requests.Session() self.session.cookies.update(cookies) self.session.headers.update({ User-Agent: ..., Referer: https://mobile.yangkeduo.com/, }) def fetch_page(self, query: str, page: int, size: int): params { page: page, size: size, query: query, sort_type: default, } anti get_anti_content(params) params[anti-content] anti resp self.session.get(https://mobile.yangkeduo.com/proxy/api/search, paramsparams, timeout10) data resp.json() if data.get(errorCode) ! 0: # 非 0 的 errorCode 必须单独处理很多是风控信号 return None return data逻辑说明每次请求前实时调用get_anti_content计算签名再把签名放进查询参数里发起请求。参数说明User-Agent和Referer一定要配置齐全很多风控逻辑先校验请求头再校验签名缺一个字段页面行为和接口行为都会变化。注意errorCode的判断这是抓包时最容易忽略的地方——HTTP 状态码 200 不代表数据正常真正的状态码在 JSON 体里。4.2 请求调度登录态、Cookie、UA 与限速全站抓取不是一次性跑完所有接口就结束而是要持续采集新增商品、评价和价格变动。调度上要解决三个问题登录态怎么维护、Cookie 怎么轮换、每秒最多能发多少个请求。登录态这块H5 端接口的鉴权主要靠 Cookie 里的几个标识字段加pdduid查询参数。保持登录状态不能只靠复制 Cookie要先确认签名生成时是否读取了 Cookie 内容。如果读取了说明签名和登录态绑定换账号就要重新提取该账号的签名逻辑如果没有读取可以只维护一份公共 Cookie。UA 建议准备一个池子但不要频繁切。风控系统会看 UA 的变化频率与请求频率之间的关系同一个 UA 每天固定 1000 次请求比每 10 次请求换一个 UA 要安全得多。限速是很多爬虫翻车的主要原因。PDD 的搜索接口高频请求的阈值并不高同 IP 下每秒超过 2 次搜索请求持续几分钟后就会触发验证码。保险的设置如下# 请求间隔控制搜索接口是重接口必须慢 SEARCH_INTERVAL 1.5 # 搜索请求间隔 1.5 秒 DETAIL_INTERVAL 0.8 # 详情页间隔 0.8 秒 MAX_RETRY 3 # 单个请求失败最多重试 3 次 def fetch_with_retry(func, *args, **kwargs): for attempt in range(MAX_RETRY): try: return func(*args, **kwargs) except requests.exceptions.Timeout: time.sleep(2 * (attempt 1)) except Exception: time.sleep(1) return None逻辑说明fetch_with_retry是一个通用包装器所有请求都走这个入口失败后按指数退避重试。参数说明SEARCH_INTERVAL控制的是两次搜索请求之间的间隔这个值不是随便定的至少从 1.5 秒起步跑一晚不出验证码再逐步降DETAIL_INTERVAL可以略快因为商品详情页接口风控阈值通常高于搜索接口。这里强调一点请求调度里最值得投资的不是代理池而是每个请求的间隔稳定性。抖动大比整体频率高更容易触发风控所以建议用固定间隔加少量随机扰动例如1.5 random.uniform(0, 0.5)。4.3 SQLAlchemy 落库别把数据存成 JSON 文件全站抓取到的数据结构不复杂但量大、更新频繁用 JSON 文件存会越来越难维护。这里用 SQLAlchemy 定义 ORM 模型把搜索商品、商品详情、评价分开建表方便增量更新和去重。from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, UniqueConstraint from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from datetime import datetime Base declarative_base() class SearchGoods(Base): 搜索商品表一个商品多次出现在搜索结果中时只保留最新一条 __tablename__ search_goods id Column(Integer, primary_keyTrue, autoincrementTrue) goods_id Column(String(64), nullableFalse) goods_name Column(String(512), default) price Column(Float, default0.0) sales Column(Integer, default0) query Column(String(64), nullableFalse) page Column(Integer, default0) created_at Column(DateTime, defaultdatetime.now) __table_args__ (UniqueConstraint(goods_id, query, nameuq_goods_query),) engine create_engine(mysqlpymysql://user:passlocalhost:3306/pdd?charsetutf8mb4) Session sessionmaker(bindengine) Base.metadata.create_all(engine)逻辑说明这张表保存每次搜索抓取到的商品信息用goods_id query做唯一约束同一商品在同一搜索词下重复出现时只保留最新记录。参数说明price使用Float因为拼多多前端返回的价格很多是带小数点的字符串Python 侧先float()再入库更稳query字段用于区分不同搜索词下同一商品的价格差异如果不留这个字段后续分析关键词价格走势会很吃力。入库时用merge代替add可以省掉先查后插的麻烦from sqlalchemy.orm import sessionmaker def save_search_goods(session, items, query): for it in items: row SearchGoods( goods_idit[goods_id], goods_nameit[goods_name], pricefloat(it[price]), salesint(it[sales]), queryquery, pageit[page], ) session.merge(row) # 存在则更新不存在则插入 session.commit()参数说明session.merge(row)是 SQLAlchemy 里最实用的一个方法它按唯一约束判断记录是否存在存在就更新不存在就插入。注意必须在SearchGoods类里定义好UniqueConstraint否则merge会退化为简单插入重复数据会不断累积。评价表和商品详情表的结构类似不再展开。建表之后建议再加一个简单采集日志表记录每次任务跑了多少请求、成功几条、失败几条、耗时多久。日志表会让后面排查问题轻松很多没有日志的爬虫在跑了一周之后基本就是黑匣子。5. anti-content 解密与抓取避坑现象、原因、处理5.1 签名对不上请求体顺序变了现象本地用抓包的参数算出 anti-content第三方请求工具里直接替换进去返回errorCode4001。原因JS 里的签名函数对入参做了Object.keys().sort()排序后再序列化。你本地传参时如果先存 dict 再转 JSON字段顺序可能和浏览器不一致。还有一种是入参里有字段没传全比如漏了sort_type签名结果当然对不上。处理不要自己拼字符串把抓包时的完整参数原样复制到 Python dict 里再调用签名函数生成。每次新增字段时先用抓包样本做回归对比别直接上线。签名对不上时第一步永远不是怀疑算法而是逐字段核对入参。5.2 拿旧签名重放过期与非法请求现象把几分钟前抓包里的 anti-content 拿出来直接当固定参数用前几次可能成功一段时间后返回request illegal。原因anti-content 的入参里可能包含当前时间戳或者签名本身绑定了一段限时的服务端 token过期之后签名失效。这是最容易踩的一个坑会让人误以为签名算法不稳定。处理每个请求都现场算签名不做缓存。假如你的调度器把一组 URL 放进队列里慢慢消费务必在请求发出那一刻调用签名函数不要在入队时就算好。拿旧签名重放做测试只能验证签名绑定关系不能作为生产方案。5.3 Node 里跑 JS 报错环境分支走错现象同样的 JS 代码在浏览器里执行正常放进 Node 里跑就报Cannot read property xxx of undefined或者算出来的签名跟抓包不一致。原因JS 函数里可能有一个环境判断比如typeof window ! undefined时走一条分支Node 环境下走了另一条分支。两条分支计算方式可能完全不同返回结果也不同。处理打印函数内部typeof window、typeof document、typeof navigator的值比对浏览器与 Node 的差异。最简单的方案是提取纯函数部分绕过环境判断不用补全套环境。我之前遇到过一种情况加密函数里调用了new Uint8Array()来构造字节数组Node 里一样有问题却出在TextEncoder的编码差异上最终是显式传入utf-8参数解决的。5.4 请求一多就出滑块被风控盯上了现象前几百个请求都正常把线程数从 1 加到 4 之后返回的内容变成一段 HTML 验证页或者接口直接返回errorCode4004。原因同一 IP 的请求频率超过了阈值或者请求指纹与浏览器行为差距过大。滑块页面的出现说明已经进入风控流程不是签名问题。处理先降线程数回到单线程跑半天观察。然后检查 Cookie 里的api_uid和pdduid是否一致有的风控逻辑会校验客户端标识。最有效的规避手段是慢请求和稳定间隔优先于任何代理方案。验证码出现后没必要硬解换一个时间段继续跑或者降低每天的采集总量。5.5 返回 200 但不是真数据错误码在 JSON 里现象请求没有超时HTTP 状态码是 200解析出的 JSON 里errorCode却是 4004 或者 5000页面数据空白。原因PDD 接口的正常与异常状态都通过 JSON 里的errorCode表达404、500这类标准 HTTP 错误码反而不常见。如果代码里只判断resp.status_code 200会把风控响应当正常数据处理导致落库一堆空值。处理每个接口的解析函数必须检查errorCode。建议封装一个统一的响应校验函数def check_response(data): code data.get(errorCode) if code ! 0: raise RuntimeError(ferrorCode{code}, msg{data.get(errorMsg, )}) if not data.get(result): raise RuntimeError(result 为空可能是数据字段变化) return data[result]参数说明errorCode为 0 时表示正常非 0 时抛异常进入重试流程。加一个result非空判断防止接口字段改名后静默失败。这类校验写清楚后续维护成本会低很多。6. 进阶技巧用一次成功请求验证你的签名是否真生效跑通单个搜索接口后很多人下一个动作就是直接开全站抓取但这会浪费一次很好的验证机会——你并没有确认签名到底是真的参与服务端校验还是恰好被忽略了。验证方式很简单找一条真实抓包记录把原始请求的anti-content替换掉替换成你自己写的一个错误字符串然后重新发请求。如果返回错误码说明服务端确实校验签名你的签名逻辑是有效的如果返回正常数据说明这个接口的签名只是前端摆设后端没校验后续可以省去签名步骤。替换测试时需要注意anti-content值里不要带空格、换行路径参数和请求体参数都要仔细核对。对于搜索接口签名值和请求参数绑定替换后大概率报 4001如果某个小接口替换后依然返回正常数据记下来这个接口后续抓取时可以不走签名流程降低依赖。验证通过后接下来值得花时间的是签名性能优化。在实际抓取中搜索、详情、评价三个接口的签名调用频率会很高每次调 Node 再回传有毫秒级开销线程多了之后开销会被放大。我的做法是给签名模块加一个简单缓存缓存键是参数对象的序列化结果缓存有效期控制在 60 秒以内。注意有效期不能太长因为签名里可能带时间戳过期签名会导致请求失败。合理配置下缓存命中率能达到 80% 左右整体请求速度能提升一半。最后一条经验全站抓取前先把单接口的日采集量验证跑出来。比如搜索接口连续跑 12 小时记录触发验证码的次数、错误码分布、数据完整率用这份数据反过来调整限速参数。不要等到整个采集框架搭完再暴露风控问题那时候定位问题成本就高多了。选定一个慢但稳定的节奏再逐步提高线程数每一步调整后都要观察至少 30 分钟。这样才能在拼多多复杂的风控体系下长期稳定采集。希望这些经验能帮你少走弯路。本文还有配套的精品资源点击获取