ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

拼多多爬虫全攻略:anti-content 参数逆向与工程化采集实践

拼多多爬虫全攻略:anti-content 参数逆向与工程化采集实践 简介这份资源围绕拼多多(PDD)平台的anti-content参数加密机制展开面向具备一定Python基础、正在研究电商数据采集或反爬虫攻防的爬虫开发者。资源提供完整JS解密思路与全站抓取实现代码可帮助读者理解拼多多Web端请求参数的生成逻辑并掌握从URL收集、请求网页、解析内容到数据存储的完整爬虫流程。压缩包共45个文件包体约183KB以Python脚本、JS解密文件和项目配置文件为主——py文件负责抓取调度与数据解析js文件用于还原加密参数逻辑xml与json承担项目配置另附README说明文档帮助快速上手目录结构清晰便于按模块学习。该资源已有1400余人学习下载内容涵盖商品搜索、详情页等多个场景的抓取示例以及作者在反爬虫应对、参数逆向方面的实战思路适合需要突破反爬限制、希望系统掌握拼多多爬虫技术的开发者参考。1. 拼多多爬虫的核心门槛anti-content 参数与全站抓取思路做 pdd拼多多爬虫很多人在 js 解密这一步卡住。搜索接口、商品详情接口、店铺商品列表几乎每个核心数据接口都绑着一个叫 anti-content 的参数。这个参数不是静态 token而是一段高度混淆的 JS 在运行时动态生成的请求参数不同、时间戳不同结果就完全不一样。更麻烦的是它往往还依赖浏览器环境变量直接复制到 Python 里根本跑不通。这份资源解决的正是这一整条链路从抓包定位加密入口到把加密函数抠进本地 Node 环境再到用 Python 组装签名请求、按关键词拉搜索列表、进详情页拿 SKU最后落到数据库形成一套可复现的全站数据采集思路。适合两类人一类是写过 Python 爬虫但没碰过 JS 逆向想搞明白 anti-content 到底怎么产生的另一类是被拼多多风控拦住想看看现成方案怎么处理补环境、签名和频率控制。2. 定位 anti-content 生成逻辑从抓包到 JS 逆向的三步走2.1 先抓包确认哪些请求带 anti-content打开 Chrome DevTools 的 Network 面板勾上 Preserve log先清空缓存再进入拼多多搜索页触发一次关键词搜索。在请求列表里过滤 XHR你会看到一批以 api 开头的接口。逐条点开在 Headers 或 Query String Parameters 里找anti-content字段。这里有个容易被忽略的点不是所有请求都带这个参数只有返回业务数据的核心接口才带这一点能帮你判断加密逻辑到底覆盖在哪一层。请求类型是否带 anti-content说明商品搜索接口是参与签名还带 anti-timestamp商品详情接口是签名逻辑与搜索接口可能不同静态资源 / 图片否不走业务签名登录接口视情况加密逻辑独立不在本次范围内确认参数位置后换个关键词再触发一次搜索对比两次请求里的anti-content值。你会发现两次结果完全不同这说明它里面有动态因子。最明显的动态因子是时间戳通常配套的anti-timestamp参数就是参与签名的时间基准。这个时间戳的作用是防重放服务端拿到请求后会先检查时间戳是否在合理窗口内再用它和请求参数一起重建签名做比对。所以本地算签名时时间戳必须和请求里带的是同一个稍后集成阶段我会反复强调这点。2.2 从调用栈里找加密入口区分还原与黑匣子拿到一条带 anti-content 的请求后在 Network 面板里右键这条请求选择 Copy → Copy as cURL粘贴到编辑器里。然后切到 Sources 面板按 CtrlShiftF 全局搜索anti-content。大部分情况下你能直接搜到一处给这个字段赋值的语句格式类似params[anti-content] xxx()。如果搜不到不要硬搜完整字符串改搜anti-或content因为混淆后的 JS 经常把字符串拆开拼接或者直接用十六进制编码。搜到赋值语句后我的习惯是先不急着读加密算法本身而是打断点看调用栈。在赋值那一行打断点重新触发一次搜索断点命中后看右侧 Call Stack 面板从栈里能找到加密函数的调用来源。顺着调用栈往上翻你会看到一个清晰的混淆边界边界之外是正常业务代码边界之内是加密逻辑。这一步是 js 逆向实战里最重要的判断点你选择还原整个算法还是把混淆模块当黑匣子调用。对 anti-content 这种高频变化、且内部依赖大量浏览器 API 的参数黑匣子方案更省时间资源包的思路也是按这个方向组织的。如果断点调试不方便还可以用 hook 的方式抓参数。在 Console 里对可疑对象做包装打印入参和返回值。常见做法是 hookJSON.stringify因为加密前往往会把参数对象序列化// hook_anti.js —— 在浏览器 DevTools Console 里运行 const rawStringify JSON.stringify; JSON.stringify function (obj) { if (obj typeof obj object obj[anti-content]) { console.log(anti-content value:, obj[anti-content]); } return rawStringify.apply(this, arguments); };这段代码的逻辑很简单在加密函数调用JSON.stringify之前先检查对象里是否带 anti-content如果带了就打印出来。它的作用是帮你省掉反复打断点、看调用栈的时间。注意hook 目标不一定是JSON.stringify也可能是Object.assign或某个数组方法具体取决于加密函数内部怎么处理参数。先打印调用日志再决定从哪个点切入比一上来就啃混淆代码效率高得多。2.3 补环境把浏览器加密函数搬进 Node 跑通从浏览器里抠出来的加密函数直接放到 Node 里跑通常会报错因为代码里调用了window、navigator、document这些浏览器专属对象。这时候要做的是补环境在 Node 脚本顶部手动构造一个最小可用 stub让加密函数以为自己在浏览器里。常见做法是// sign_stub.js —— 补环境最小示例 global.window {}; global.navigator { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 }; global.document { cookie: , getElementById: () null, createElement: () ({ getContext: () null, toDataURL: () }) }; global.localStorage { getItem: () null, setItem: () {} }; // 抠出来的加密 IIFE 从这里开始粘贴 // const anti_content generateAntiContent(inputParams); // module.exports { generateAntiContent };stub 的逻辑是缺什么补什么不需要百分百还原浏览器。关键在navigator.userAgent必须和你后面请求头里用的 UA 保持一致如果两边不一致算出来的 anti-content 和请求头对不上服务端签名校验会直接失败。document.createElement(canvas)这行值得单独说如果加密函数内部调用 canvas 做指纹最简单的做法是返回一个空上下文让脚本不报错就行但如果指纹参与了签名空上下文算出来的值和浏览器里不一样必须把浏览器里的真实指纹取出来传进去。判断标准很简单先跑一遍看输出和抓包里的值是否一致。如果加密代码是 webpack 打包出来的你还会遇到模块加载器。这类代码特征是开头有一堆数字和数组然后通过某个_0x开头的函数来加载模块。抠代码时不要把整个 bundle 都搬过来只需要找到模块加载器函数把依赖的模块按 id 逐个加载进去。这一步比较绕资源包里带的补环境模板会给你一个可以直接改的骨架比自己从零开始省很多时间。跑通后立刻做一次验证用抓包里的同一组参数和时间戳喂给本地函数输出应该和抓包里的 anti-content 完全一致不一致就回头看 stub 里漏了哪个环境变量。3. Python 侧集成PyExecJS 与 subprocess 的取舍请求头怎么组装3.1 两种 JS 调用方式我为什么默认选 subprocesspython 爬虫做数据收集核心流程跑通后接下来就是把 JS 加密能力接进来。最常见的两个方案是 PyExecJS 和 subprocess 调 Node。PyExecJS 接入确实快几行代码就能跑但实际采集时有两个坑一是每次调用都会重新初始化 JS 运行时单次耗时高QPS 一上来就成瓶颈二是它自动选择的运行时不稳定有时候落到 QuickJS有时候落到 JavaScriptCore不同运行时对 ES6 语法的支持程度不一样同一个加密脚本换台机器可能就报错。所以我更推荐用 subprocess 调 NodeJavaScript 环境完全由你控制Python 侧只负责传参数和收结果出了问题也好定位。requests 爬虫的其他部分保持简单签名逻辑独立出来后续要改成常驻进程也顺理成章。先写 Node 侧的签名脚本它从 stdin 读 JSON往 stdout 写结果// sign.js —— 接收 JSON 输入返回 anti-content const fs require(fs); // 这里 require 你从浏览器里抠出来的加密模块 const { generateAntiContent } require(./anti_core); let input ; process.stdin.on(data, (chunk) { input chunk; }); process.stdin.on(end, () { try { const params JSON.parse(input); const antiContent generateAntiContent(params); process.stdout.write(JSON.stringify({ anti_content: antiContent })); } catch (e) { process.stderr.write(e.stack || e.message); process.exit(1); } });Python 侧封装一个签名函数import subprocess import json def sign(params: dict) - str: proc subprocess.run( [node, sign.js], inputjson.dumps(params, ensure_asciiFalse), capture_outputTrue, textTrue, encodingutf-8, timeout5, ) if proc.returncode ! 0: raise RuntimeError(fsign failed: {proc.stderr}) return json.loads(proc.stdout)[anti_content]这里有两个参数值得说明。ensure_asciiFalse是必须的否则中文关键词会被转成 \uXXXXJS 那边解析出来是原文但参与签名时字符串已经变了会导致签名结果和服务端不一致。timeout5也是必填的防加密脚本死循环把整个爬虫卡死。用 stdin/stdout 而不是命令行参数传值是因为命令行参数有长度限制而且 URL 编码后的特殊字符在 shell 里容易出幺蛾子标准输入输出流是最稳的通道。3.2 组装带签名请求参数顺序、UA 与 Referer签名函数就位后组装请求反而容易翻车。拼多多的常见签名逻辑是把请求参数里除了 anti-content 之外的字段按固定顺序拼接成一个字符串参与加密。也就是说你加到请求里的参数必须在签名前就已经确定签名后再把 anti-content 追加进去。import requests import time def build_search_params(keyword: str, page: int) - dict: return { keyword: keyword, page: page, page_size: 20, anti-timestamp: int(time.time() * 1000), } def fetch_search(keyword: str): params build_search_params(keyword, 1) params[anti-content] sign(params) # sign 是上一节封装的函数 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://mobile.yangkeduo.com/, Accept: application/json, } resp requests.get(SEARCH_API, paramsparams, headersheaders, timeout10) return resp.json()注意调用sign(params)时的参数顺序要和加密函数里拼接的顺序一致。为了避免 Python dict 顺序问题我一般会在 Node 侧对Object.keys(params)做一次排序后再拼接这样 Python 这里无论怎么定义 dict 都不会影响签名结果。User-Agent和Referer也都是参与校验的因素必须和补环境时填写的 UA 保持一致。SEARCH_API的具体地址以你自己抓包结果为准资源包里保留了接口占位符改一下就能用。3.3 返回体判断200 不等于拿到数据很多新手卡在这请求返回 200但程序拿到的不是商品列表而是风控提示。拼多多的接口返回体里业务状态码和 HTTP 状态码是分开的必须先判断业务码。常见结构如下返回字段含义error_code / errorCode0 表示成功非 0 表示业务失败error_msg失败原因例如「参数错误」「请求频繁」result / items正常情况下的商品数据写一个判读函数把风控场景单独抛出来class AntiBotTriggered(Exception): pass def parse_search_resp(data: dict): code data.get(error_code) or data.get(errorCode) if code 0: result data.get(result) or {} return result.get(items, []) if code in (10001, 10002): # 风控相关错误码以抓包实际返回为准 raise AntiBotTriggered(data.get(error_msg, triggered)) raise RuntimeError(data.get(error_msg, unknown error))把风控场景单独抛异常是为了在采集主流程里统一做退避处理。别看到 200 就狂欢业务码才是真正的信号。还有一类情况是接口返回 HTML 而不是 JSON这种通常是 IP 被拉黑直接进验证码流程了后面避坑章节会细说。4. 全站抓取代码思路三级队列、SQLAlchemy 入库与单机限速4.1 三级任务队列搜索词、商品列表、详情页分层全站抓取不是把搜索、详情、店铺列表全写在一个函数里。拆开任务分三层第一层是搜索关键词种子第二层是商品 ID 列表第三层是商品详情页。每层对应不同的接口、不同的签名参数、不同的频率控制策略拆开后也便于单独重试和暂停。用 queue 模块就能搭一个单机版本from queue import Queue seed_queue Queue() # 关键词种子 list_queue Queue() # 商品 ID detail_queue Queue() # 详情页任务 def list_worker(): while True: seed seed_queue.get() for page in range(1, MAX_PAGE 1): items fetch_search(seed, page) # 返回商品 ID 列表 for item in items: list_queue.put(item[goods_id]) seed_queue.task_done() def detail_worker(): while True: goods_id list_queue.get() detail fetch_detail(goods_id) save_product(detail) list_queue.task_done()分工明确list_worker 只负责拿 IDdetail_worker 只负责拿详情两个 worker 可以开成独立线程。商品 ID 是天然的去重键也是重试的最小单位。MAX_PAGE不要写死太大拼多多的网页端搜索接口翻到 20 到 30 页以后基本会被风控拦强行越界只会让 IP 提前被封。4.2 SQLAlchemy 建表与 upsert 去重数据收集落库我倾向于直接用 SQLAlchemy好处是换数据库不用改业务代码。建一张 product 表字段按商品维度设计from sqlalchemy import create_engine, Column, String, Integer, Text from sqlalchemy.orm import declarative_base Base declarative_base() class Product(Base): __tablename__ product goods_id Column(String(64), primary_keyTrue) title Column(String(512)) price_min Column(Integer) price_max Column(Integer) sales Column(Integer) detail_json Column(Text) engine create_engine(sqlite:///pdd.db) Base.metadata.create_all(engine)goods_id作为主键天然去重。拼多多商品是区间价一个商品可能有多个 SKU所以价格分别存最小值与最大值。写入时用数据库原生 upsert不要先查再插并发场景下先查再插会撞主键from sqlalchemy.dialects.sqlite import insert def save_product(session, data: dict): stmt insert(Product).values( goods_iddata[goods_id], titledata[title], price_mindata[price_min], price_maxdata[price_max], salesdata[sales], detail_jsonjson.dumps(data.get(detail, {}), ensure_asciiFalse), ).on_conflict_do_update( index_elements[Product.goods_id], set_{ title: data[title], price_min: data[price_min], price_max: data[price_max], sales: data[sales], detail_json: json.dumps(data.get(detail, {}), ensure_asciiFalse), }, ) session.execute(stmt) session.commit()SQLite 用on_conflict_do_update如果换 MySQL改成insert().on_duplicate_key_update逻辑一样。upsert 的核心是列表页的数据先入库建基线详情页数据回来后再覆盖更新主键不变不会产生垃圾重复行。4.3 单机限速再谈分布式爬虫怎么扩单机跑通后再谈分布式否则你连是签名问题还是队列问题都分不清。频率控制是拼多多采集的生命线固定间隔反而不安全因为人为操作不可能每次都精确间隔。我给每个请求之间加随机睡眠对风控异常做指数退避import random import time def rate_limited_request(func, *args, **kwargs): time.sleep(random.uniform(1.2, 2.8)) # 随机停 1~3 秒 for attempt in range(3): try: return func(*args, **kwargs) except AntiBotTriggered: wait 2 ** attempt * 5 print(f触发风控等待 {wait}s 后重试) time.sleep(wait) raise RuntimeError(三次重试仍然被风控拦截)随机睡眠的意义在于时间分布更接近人工操作。指数退避是对抗风控的基础手段第一次失败等 5 秒第二次等 10 秒第三次等 20 秒。如果三次还不行别硬撞停一段时间再继续。至于分布式爬虫等单机逻辑稳定后把queue.Queue换成 Redis 列表把去重逻辑换成 Redis set多台机器各跑几个 worker 就行。但要注意分布式只解决并行问题签名服务如果还是单点QPS 一高多台机器的请求全卡在签名上所以第六章我会把签名封装成常驻服务来解锁这个瓶颈。5. 避坑排查anti-content 最容易踩的五个坑的现象与解决5.1 本地签名有效换到服务器就 401现象同一个 JS 加密脚本本地 Node 算出的 anti-content 能正常请求把整套工程搬到云服务器上同样的参数同样的代码接口返回 401 或参数校验失败。原因加密函数内部大概率读了navigator.userAgent或document.cookie参与签名。服务器上跑的 UA 和本地不一致服务端校验签名时发现签名环境和请求头对不上。解决把参与签名的环境变量固定住。补环境脚本里写死的 UA 必须和请求头里的 UA 保持一致cookie 也统一从一个常量读而不是从运行环境动态取。5.2 改了参数顺序签名全部失效现象抓包时 anti-content 有效复制到代码里发现同一个值时灵时不灵排查半天发现是参数 dict 的 key 顺序变了。原因拼多多这类的签名逻辑普遍是把参与签名的字段按固定顺序拼成字符串再加密。Python 3.7 之前的 dict 不保证顺序换了顺序签名输入就变了服务端重建签名时自然对不上。解决在 sign 函数内部对字段做排序后再传给 JS或者直接让 Node 侧按Object.keys排序拼接让签名结果与 Python 侧字典定义顺序无关。5.3 补环境时把整个 window 塞进去反而报错现象补环境为了图省事直接global.window global结果加密脚本执行时报一堆奇怪的 TypeError。原因浏览器 window 对象上有大量不可枚举属性混淆代码里如果检测到 API 不存在或行为不一致会走不同分支。把 global 直接赋给 window很多 API 的类型和行为对不上反而触发更隐蔽的报错。解决用最小 stub 按需补齐。先用 try/catch 配合日志追踪实际调了哪些 API逐个补补到跑通为止。不用说完全还原浏览器环境加密函数需要的入口就那几个补多了反而引入新的不确定性。5.4 PyExecJS 中文乱码签名结果错乱现象用 PyExecJS 调加密脚本传入中文关键词后签名结果明显不对日志里中文变成一串乱码。原因PyExecJS 在 Windows 等环境下默认编码处理不可靠子进程输出被按错误编码解码导致拼接进加密函数的字符串已经损坏。加密算法本身没问题是调用层的编码坑。解决换 subprocess Node 的方案Python 侧显式传encodingutf-8。这也是第三章推荐 subprocess 的原因之一省掉这类莫名其妙的环境问题。5.5 高频请求后返回验证码字段现象前面请求都正常跑到第 2000 条左右接口返回的 JSON 里不再有商品列表而是出现 risk、captcha、verify 相关字段或者直接返回一个 HTML 验证页。原因IP 维度或设备维度触发了风控频率已经超过阈值。这时候再调签名毫无意义因为请求根本进不到业务逻辑层就被拦截了。解决两层应对。先降 QPS请求间隔拉长到 5 秒以上如果还触发换代理 IP代理还不管用就停下来等风控解除。切忌同一个 IP 加并发硬撞那样只会让封禁时间翻倍。提示补环境的原则是缺啥补啥不要追求完整还原浏览器宁可多打日志也别靠猜。6. 把签名封装成常驻 Node 子进程突破 QPS 瓶颈的进阶技巧用 subprocess 每次起 Node 进程在 QPS 低的时候没什么问题但把采集速度提上来后你会发现大量时间耗在「启动 Node → 加载加密模块 → 解析混淆代码」这个过程上。一个更稳的做法是让 Python 用Popen起一个常驻的 Node 进程通过 stdin 写请求、stdout 读结果一进一出循环利用省掉重复启动的开销。Python 侧封装一个常驻签名客户端import json import subprocess import threading class SignDaemon: def __init__(self): self.proc subprocess.Popen( [node, sign_server.js], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue, encodingutf-8, ) self._lock threading.Lock() def sign(self, params: dict) - str: with self._lock: line json.dumps(params, ensure_asciiFalse) \n self.proc.stdin.write(line) self.proc.stdin.flush() out self.proc.stdout.readline() return json.loads(out)[anti_content]Node 侧用readline逐行处理// sign_server.js —— 常驻进程版 const readline require(readline); const { generateAntiContent } require(./anti_core); const rl readline.createInterface({ input: process.stdin }); rl.on(line, (line) { const params JSON.parse(line); const antiContent generateAntiContent(params); process.stdout.write(JSON.stringify({ anti_content: antiContent }) \n); });改成常驻进程后第一件事不是直接上真实接口而是本地压测。用 1000 次循环连续调sign()观察平均耗时、有没有stdout错位、有没有进程挂掉。压测通过后再用真实搜索接口连续跑 20 次确认签名都能通过校验。这里有个细节self._lock必须加我第一次做这个改造时忘了加锁两个线程同时在写 stdinNode 侧读到的 JSON 被切成两半反爬没拦住倒是自己先把自己拦了。从那以后凡是涉及子进程长连接我第一件事就是加锁第二件事就是本地压测 1000 次再上真实接口这个流程救了我好几次。希望帮到你。本文还有配套的精品资源点击获取
返回列表