Python爬虫JS逆向实战:破解拼多多商品数据加密抓取 1. 项目概述当爬虫遇到拼多多的“铜墙铁壁”做数据抓取的朋友尤其是用Python写爬虫的这两年应该都听过一个词叫“JS逆向”。这玩意儿以前可能只是进阶技能但现在特别是面对像拼多多这样的大型电商平台它几乎成了绕不开的必修课。为什么因为平台为了保护自己的商品数据、价格信息、用户评论这些核心资产早就筑起了层层防线。你直接用requests库去请求一个商品页面返回的HTML里大概率空空如也核心数据全是通过JavaScript动态加载和渲染的。更“绝”的是很多关键接口的请求参数比如那个关键的anti_content都是前端JS经过一系列复杂加密计算生成的。你不把这段JS逻辑搞清楚、逆向出来你的爬虫连门都进不去。这个项目我们就拿“拼多多商品详情页数据抓取”这个非常具体且典型的场景开刀。目标很明确绕过平台的前端加密稳定地获取到商品标题、价格、销量、规格、评价等结构化数据。这不仅仅是一个爬虫脚本更是一个完整的JS逆向实战案例分析。我们会从最基础的请求分析开始一步步拆解拼多多前端是如何“藏”数据的如何“造”参数的最后用Python完整复现这套逻辑。无论你是刚接触JS逆向感到一头雾水的新手还是想找个硬骨头练练手的老鸟相信这个从零到一的拆解过程都能给你带来实实在在的启发。毕竟搞定拼多多市面上大多数电商平台的类似防护思路你基本也就摸清门道了。2. 核心思路与逆向工程入口选择2.1 从现象到本质常规爬虫为何失效很多新手第一步会尝试用requests直接GET拼多多的商品链接比如https://mobile.yangkeduo.com/goods.html?goods_id1234567890。结果拿到手的HTML用BeautifulSoup或者lxml一解析傻眼了商品价格、标题、SKU信息全都不在预期的HTML标签里可能只有一个空的div或者一堆看不懂的脚本引用。这就是典型的“前端动态渲染”。数据是通过额外的JavaScript请求通常是XHR或Fetch从后端API获取然后由前端框架如Vue、React或原生JS填充到页面中的。所以我们的第一要务不是解析初始HTML而是找到那个真正携带数据的“数据接口”。打开浏览器的开发者工具F12切换到Network网络面板刷新商品页面。在纷繁复杂的请求中我们需要寻找那些返回了结构化数据通常是JSON格式的请求。通过查看Preview预览或Response响应内容很容易找到类似api.pinduoduo.com或mms.pinduoduo.com域名的请求其响应体里就包含了我们需要的商品信息。然而当你兴冲冲地准备用Python模拟这个请求时第二个拦路虎出现了请求参数加密。你会发现这个数据接口的URL或者请求头Headers里带着一串长得离谱、看起来像乱码的参数比如anti_content或者请求头里有一个自定义的Anti-Content字段。直接复制你浏览器里的这个参数去请求第一次可能成功但过几分钟或者换一个IP马上就失效了。这说明这个参数是动态生成的而且很可能与时间、设备指纹、页面上下文等因子绑定。注意这里千万不要去尝试寻找所谓的“固定参数”或“万能密钥”。现代反爬机制的核心就是“一次一密”每个有效参数都有极短的生命周期且与本次会话强相关。试图绕过生成逻辑直接复用参数是条死胡同。2.2 逆向入口定位关键参数生成逻辑追踪既然参数是动态生成的那生成它的“工厂”就在前端JavaScript代码里。我们的逆向工程就是要找到这个“工厂”理解它的“生产线”算法然后用Python重建一条生产线。具体操作上在开发者工具的Network面板找到那个携带anti_content等加密参数的数据请求。右键点击该请求选择“Copy” - “Copy as cURL (bash)”。这能帮你快速在Python里构建一个初始请求框架但关键是要研究这个参数从哪里来。通常有两个主要方向搜索全局JS文件在开发者工具的Sources源代码面板按CtrlShiftF进行全局搜索。搜索关键词可以是anti_content、antiContent或者该参数名的一部分。这能快速定位到定义或生成该参数的JavaScript代码段。XHR/Fetch断点在Sources面板找到“XHR/Fetch Breakpoints”区域添加一个包含部分接口URL的断点如*api.pinduoduo.com*。然后刷新页面当浏览器发起对该接口的请求时执行会自动暂停。此时调用栈Call Stack会清晰地展示出是哪个JS函数发起了这个请求以及参数是在调用链的哪一层被添加进去的。这是最精准的逆向入口定位方法。以我的经验拼多多的加密逻辑通常被封装在某个经过混淆Obfuscated的、体积较大的vendor.js或app.[hash].js文件中。代码可能被压缩、变量名被替换成无意义的单字母但核心的算法结构如MD5、SHA、AES、RSA的调用或自定义的位运算是改变不了的。我们的任务就是在这团“乱麻”中理出加密的主流程。3. 核心加密逻辑分析与Python复现3.1 反混淆与关键代码提取找到疑似加密函数的位置后面对混淆的代码直接阅读是低效的。我们可以借助浏览器控制台Console的一些技巧。 首先尝试将整个JS文件美化Pretty Print通常开发者工具源代码面板左下角有{}图标。然后在关键函数附近设置断点或者直接在控制台通过函数名如果还能辨认尝试调用观察其输入输出。 更有效的方法是将关键的、独立的加密函数片段提取出来。比如你发现了一个名为function g(t) { ... }的函数它接收一个字符串返回一个看起来像anti_content的加密结果。你可以尝试将这个函数及其所有依赖的内部函数、外部变量上文中定义过的一起复制到一个单独的JavaScript文件中。接下来我们需要一个能执行这段JS的环境来验证和调试。Node.js是最佳选择。在本地新建一个.js文件粘贴提取的代码。你可能会遇到一些浏览器环境特有的对象如window、document未定义报错。这时需要分析如果这些对象只是用于获取一些固定值如navigator.userAgent我们可以直接在Node.js环境中用常量模拟。如果这些对象是加密算法的一部分概率极低则需要寻找其Polyfill或分析其实际作用后用其他方式实现。通过反复调试、补充缺失的变量或函数最终目标是在Node.js中仅凭我们提取的JS代码输入一个已知的明文能输出与浏览器中生成的、完全一致的密文。这一步验证成功就说明我们完整剥离了加密逻辑。3.2 算法识别与Python翻译加密逻辑的核心无非几种哈希MD5, SHA、对称加密AES、非对称加密RSA、Base64编码、以及自定义的字符串拼接和位运算。在提取的JS代码中注意识别以下特征看到CryptoJS.MD5(...)、require(crypto).createHash(md5)那就是MD5。看到CryptoJS.AES.encrypt(...)、cipher.update(...)很可能就是AES。看到JSEncrypt、setPublicKey、encrypt那就是RSA。看到btoa、atob或Buffer.from(...).toString(base64)就是Base64。我们的任务是将这些JS逻辑“翻译”成Python。Python有强大的密码学库hashlib,pycryptodome(或crypto)以及标准库base64。一个典型的翻译难点示例JS中可能这样进行MD5function sign(t) { return CryptoJS.MD5(t a_secret_salt).toString().toUpperCase(); }Python中对应import hashlib def sign(t: str) - str: s t a_secret_salt # 注意编码MD5操作的是字节 md5_hash hashlib.md5(s.encode(utf-8)).hexdigest() return md5_hash.upper() # 对应toUpperCase()更复杂的情况可能是自定义的字符映射表、或者对加密结果进行的二次处理如截取特定长度、反转字符串等。这些都需要仔细对照JS和Python的输出进行逐行比对和调试。这里强烈建议使用差分调试法准备相同的输入分别运行你的Python代码和Node.js中的原JS代码对比每一步的中间结果直到最终输出一致。3.3 参数组装与完整请求链构建搞定了核心加密函数比如generate_anti_content(raw_data)不代表就能直接用了。你需要知道这个raw_data加密函数的输入是什么。它通常是一个由多个字段拼接而成的字符串这些字段可能包括时间戳精确到毫秒随机数Nonce用户标识可能来自Cookie或LocalStorage设备指纹信息如屏幕分辨率、浏览器插件列表的哈希值等这些信息在第一次访问页面时就被收集并固化在本地当前页面的URL或商品ID你需要仔细追溯加密函数被调用时的上下文找到这些原始数据是如何收集和组装的。在Python中我们就需要模拟这个过程生成当前时间戳。生成一个随机字符串。从首次访问页面时获取的Cookie或HTML中提取设备指纹信息这可能又需要一次初始请求和解析。按照JS中发现的顺序和分隔符可能是、|或直接拼接将这些字段组合成原始字符串。将原始字符串送入我们复现好的加密函数得到anti_content。将anti_content作为参数与其他必要参数如商品IDgoods_id一起构造最终的API请求。4. 完整爬虫架构与代码实现4.1 工程结构设计一个健壮的爬虫不应该把所有逻辑堆在一个文件里。建议按功能模块拆分pdd_crawler/ ├── core/ │ ├── __init__.py │ ├── decryptor.py # 核心加密算法复现 │ └── request_builder.py # 请求参数构造器 ├── spiders/ │ ├── __init__.py │ └── goods_spider.py # 商品爬虫主逻辑 ├── utils/ │ ├── __init__.py │ ├── logger.py # 日志配置 │ └── tools.py # 通用工具函数 └── main.py # 程序入口4.2 核心解密模块实现示例假设我们逆向出的加密是一个自定义的哈希加盐算法decryptor.py可能长这样import hashlib import time import random import string from typing import Dict, Any class PDDDecryptor: 拼多多核心参数加密生成器 def __init__(self, device_fp: str None): 初始化可能需要一些从首次请求中获取的固定参数如设备指纹。 :param device_fp: 设备指纹字符串 self.device_fp device_fp or self._generate_default_fp() staticmethod def _generate_default_fp() - str: 模拟生成一个默认设备指纹实际应从首次访问页面提取 # 这里是一个简化示例真实情况复杂得多 parts [ fscreen_{random.randint(1080, 3840)}x{random.randint(1920, 2160)}, flang_zh-CN, ftimezone_{random.randint(-12, 12)}, ] return |.join(parts) def _custom_hash(self, input_str: str) - str: 复现JS中的自定义哈希函数示例为MD5加盐变形 # 假设JS中是 CryptoJS.MD5(input_str PDD_SALT_2023).toString().substr(8, 16).toUpperCase() salt PDD_SALT_2023 s input_str salt md5_full hashlib.md5(s.encode(utf-8)).hexdigest().upper() # 截取第8位开始的16个字符模拟substr return md5_full[8:24] def generate_anti_content(self, goods_id: str) - str: 生成 anti_content 参数 timestamp int(time.time() * 1000) # 毫秒时间戳 nonce .join(random.choices(string.ascii_letters string.digits, k10)) # 关键组装原始字符串的顺序和格式必须与JS完全一致 # 假设JS中是 {timestamp}|{nonce}|{goods_id}|{device_fp} raw_data f{timestamp}|{nonce}|{goods_id}|{self.device_fp} encrypted self._custom_hash(raw_data) return encrypted4.3 请求构建与爬虫主体request_builder.py负责组装最终的请求import requests from .decryptor import PDDDecryptor class PDDRequestBuilder: def __init__(self): self.decryptor PDDDecryptor() self.session requests.Session() # 设置通用请求头模拟浏览器 self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9, Referer: https://mobile.yangkeduo.com/, }) def get_goods_detail(self, goods_id: str) - Dict[str, Any]: 获取商品详情数据 # 1. 生成加密参数 anti_content self.decryptor.generate_anti_content(goods_id) # 2. 构建请求参数 params { goods_id: goods_id, anti_content: anti_content, _: str(int(time.time() * 1000)), # 防止缓存的时间戳参数 # ... 可能还有其他固定参数 } # 3. 目标API地址需要从网络请求中分析得出 api_url https://api.pinduoduo.com/api/router # 或者可能是 https://mms.pinduoduo.com/sydney/api/goodsDetail # 4. 发送请求 try: # 注意可能是GET也可能是POST需要根据实际分析确定 resp self.session.get(api_url, paramsparams, timeout10) resp.raise_for_status() # 检查HTTP错误 data resp.json() # 5. 检查API返回状态码拼多多通常有自己的业务码 if data.get(error_code) ! 0 or not data.get(result): print(fAPI返回错误: {data}) return {} return data.get(result, {}) except requests.exceptions.RequestException as e: print(f网络请求失败: {e}) return {} except ValueError as e: print(fJSON解析失败: {e}, 响应文本: {resp.text[:200]}) return {}4.4 主爬虫逻辑与数据解析goods_spider.py调用上述模块并解析返回的复杂JSON数据from core.request_builder import PDDRequestBuilder class GoodsSpider: def __init__(self): self.client PDDRequestBuilder() def crawl(self, goods_id: str) - Dict[str, Any]: 爬取指定商品ID的详情 raw_data self.client.get_goods_detail(goods_id) if not raw_data: return {} # 解析数据这里结构需要根据实际API响应调整 goods_info raw_data.get(goods, {}) sku_list raw_data.get(skus, []) parsed_data { goods_id: goods_id, title: goods_info.get(goods_name), price: self._parse_price(goods_info), # 价格可能在不同字段 sales: goods_info.get(sales), # 销量 description: goods_info.get(goods_desc), specs: self._parse_specs(sku_list), # ... 其他字段 } return parsed_data def _parse_price(self, goods_info: Dict) - float: 价格解析示例实际可能更复杂如区间价、券后价 # 例如最小价格可能在 min_group_price 字段单位为分 min_price goods_info.get(min_group_price) if min_price: return float(min_price) / 100 # 转换为元 return 0.0 def _parse_specs(self, sku_list: List[Dict]]) - List[Dict]: 解析SKU规格 specs [] for sku in sku_list: spec { spec_id: sku.get(sku_id), price: float(sku.get(group_price, 0)) / 100, stock: sku.get(quantity), spec_text: sku.get(specs), # 规格描述如“黑色XL” } specs.append(spec) return specs5. 反反爬策略与稳定性保障5.1 请求频率与IP管理即使破解了加密疯狂请求也会导致IP被封。必须实施速率限制。import time from functools import wraps def rate_limited(max_per_minute): 装饰器限制函数调用频率 min_interval 60.0 / max_per_minute def decorator(func): last_called [0.0] wraps(func) def wrapper(*args, **kwargs): elapsed time.time() - last_called[0] left_to_wait min_interval - elapsed if left_to_wait 0: time.sleep(left_to_wait) ret func(*args, **kwargs) last_called[0] time.time() return ret return wrapper return decorator # 使用限制每分钟最多20次请求 rate_limited(20) def crawl_goods_page(self, goods_id): # ... 爬取逻辑对于大规模抓取必须使用IP代理池。建议使用可靠的付费代理服务并在每次请求时随机切换。在PDDRequestBuilder的session中配置代理并处理代理失效的轮换逻辑。5.2 请求特征模拟与动态适配平台不仅看IP还分析请求头、Cookie序列、TLS指纹等。请求头尽可能完整地复制浏览器请求的所有Headers特别是Accept,Accept-Encoding,Accept-Language,Connection,Upgrade-Insecure-Requests等。Cookie管理使用requests.Session()自动管理Cookie。首次访问可能需要先GET一下首页获取初始Cookie其中可能包含重要标识。定期检查Cookie是否过期。TLS指纹一些高级反爬会检测客户端的TLS指纹如JA3。requests库的指纹比较容易被识别。可以考虑使用httpx或curl_cffi库它们能更好地模拟浏览器TLS指纹。动态参数更新观察anti_content的生成是否依赖某些会过期的Token可能在Cookie或初次请求的响应中。爬虫需要定期例如每抓取100个商品重新访问一次首页刷新这些Token。5.3 错误处理与重试机制网络爬虫必须健壮要预料到各种失败并优雅处理。from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type import requests class GoodsSpider: retry( stopstop_after_attempt(3), # 最多重试3次 waitwait_exponential(multiplier1, min2, max10), # 指数退避等待 retryretry_if_exception_type((requests.exceptions.ConnectionError, requests.exceptions.Timeout)) ) def crawl_with_retry(self, goods_id): return self.crawl(goods_id)对于业务逻辑错误如返回“参数错误”、“访问频繁”应在解析响应后判断并触发不同的处理流程如等待更长时间、更换代理、重新获取加密参数等。6. 常见问题排查与实战心得6.1 逆向与复现过程中的典型问题加密结果不一致这是最常遇到的问题。99%的原因在于输入不一致。检查点1时间戳。JS用的是客户端本地时间Python的time.time()返回的是系统时间。确保单位一致秒还是毫秒并考虑时区问题JS的Date.now()通常是本地时间但拼多多后端可能统一用UTC时间戳需要验证。一个技巧是在浏览器控制台打印出加密函数的输入字符串然后在Python中完全复制这个字符串进行加密看结果是否一致。检查点2随机数。如果加密输入包含随机数你需要确保在调试阶段JS和Python使用相同的随机数种子或者直接硬编码一个值进行比对。检查点3编码。字符串拼接时空格、换行符、不可见字符都可能不同。在JS和Python中分别将待加密的原始字符串用encodeURIComponent和urllib.parse.quote处理后再对比或者直接打印它们的字节表示Buffer.from(str).toString(hex)in JS,str.encode(utf-8).hex()in Python。检查点4依赖的全局变量。你提取的JS函数可能依赖了外部定义的全局变量如window.__NUXT__里的某个值。这些值必须在Node.js环境中被精确还原。算法识别错误你以为的MD5可能其实是SHA1或者中间经过了自定义的变换。使用在线工具或本地库对中间字符串分别用不同算法计算与JS计算的中间结果比对。代码混淆导致逻辑丢失混淆可能会把一些关键逻辑“折叠”或“隐藏”。尝试在浏览器中在加密函数入口打上断点然后单步执行Step Into观察每一步的变量变化。这能帮你还原被混淆掉的逻辑流。6.2 爬虫运行时的稳定性问题问题现象可能原因排查与解决思路前几次成功后续全部返回“访问频繁”或“参数无效”1. IP被限制。2. 加密参数如anti_content依赖的上下文如Cookie中的token已过期。3. 请求频率过高。1. 切换代理IP测试。2. 重新模拟一次完整的页面访问流程获取新的Cookie和上下文信息。3. 大幅降低请求频率加入随机延迟。直接返回空白页或状态码403/4121. TLS指纹被识别。2. 请求头缺失或异常。3. 代理IP质量太差数据中心IP被重点关照。1. 尝试使用curl_cffi或httpx替换requests。2. 使用浏览器开发者工具“Copy as cURL”功能将请求头完整复制过来。3. 更换高质量住宅代理IP。数据返回成功但字段为空或为默认值1. 请求的参数不全导致后端返回了“降级”数据非详情页数据。2. 解析JSON的路径不对。1. 对比浏览器成功请求和自己脚本请求的所有参数URL参数、Form Data、Headers找出差异。2. 将API返回的完整JSON保存下来仔细检查数据结构。加密参数生成速度慢影响效率Python实现的加密算法特别是涉及大量循环或复杂位运算可能比JS原生慢。1. 使用PyPy解释器运行对纯Python计算有加速效果。2. 将最耗时的计算部分用Cython重写或调用C扩展。3. 检查是否有不必要的重复计算进行缓存。6.3 个人实操心得与建议工具链是生产力不要只用浏览器开发者工具。搭配使用抓包工具如Charles、Fiddler Everywhere可以更清晰地看到请求/响应序列。使用Node.js配合VS Code调试提取的JS代码效率远高于在浏览器控制台里折腾。保持耐心注重细节JS逆向是个细活差一个字符结果就天壤之别。养成随时在JS和Python两端打印、对比中间变量的习惯。使用console.log和print进行“printf调试”非常有效。理解业务而不仅是技术多思考“为什么平台要在这里加密这个参数”理解其背后的业务逻辑如防刷、追踪用户会话能帮助你更快地定位关键代码。比如与商品ID、时间戳强相关的参数很可能在生成订单、验证库存等关键链路上。尊重规则控制尺度技术是用来学习和解决问题的不是用来搞破坏的。务必控制抓取频率不要对目标服务器造成压力。明确数据的用途遵守相关法律法规和平台协议。代码要模块化便于维护平台的加密逻辑不是一成不变的可能一周甚至一天就变。将加密部分独立成模块当发现爬虫失效时能快速定位是加密算法变了还是请求参数结构变了然后针对性更新。同时良好的日志记录记录下失败的请求和响应是快速排错的关键。不要死磕一个点如果某个加密逻辑极其复杂耗费数天毫无进展不妨换个思路。比如是否有可能通过分析App的接口来规避Web端的复杂加密或者所需的数据是否有其他更易获取的替代来源如聚合数据平台评估投入产出比很重要。