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_id=1234567890。结果拿到手的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(源代码)面板,按
Ctrl+Shift+F进行全局搜索。搜索关键词可以是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中可能这样进行MD5:
function 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 = [ f"screen_{random.randint(1080, 3840)}x{random.randint(1920, 2160)}", f"lang_zh-CN", f"timezone_{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, k=10)) # 关键:组装原始字符串的顺序和格式必须与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;q=0.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, params=params, timeout=10) resp.raise_for_status() # 检查HTTP错误 data = resp.json() # 5. 检查API返回状态码(拼多多通常有自己的业务码) if data.get('error_code') != 0 or not data.get('result'): print(f"API返回错误: {data}") return {} return data.get('result', {}) except requests.exceptions.RequestException as e: print(f"网络请求失败: {e}") return {} except ValueError as e: print(f"JSON解析失败: {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( stop=stop_after_attempt(3), # 最多重试3次 wait=wait_exponential(multiplier=1, min=2, max=10), # 指数退避等待 retry=retry_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环境中被精确还原。
- 检查点1:时间戳。JS用的是客户端本地时间,Python的
算法识别错误:你以为的MD5可能其实是SHA1,或者中间经过了自定义的变换。使用在线工具或本地库,对中间字符串分别用不同算法计算,与JS计算的中间结果比对。
代码混淆导致逻辑丢失:混淆可能会把一些关键逻辑“折叠”或“隐藏”。尝试在浏览器中,在加密函数入口打上断点,然后单步执行(Step Into),观察每一步的变量变化。这能帮你还原被混淆掉的逻辑流。
6.2 爬虫运行时的稳定性问题
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 前几次成功,后续全部返回“访问频繁”或“参数无效” | 1. IP被限制。 2. 加密参数(如 anti_content)依赖的上下文(如Cookie中的token)已过期。3. 请求频率过高。 | 1. 切换代理IP测试。 2. 重新模拟一次完整的页面访问流程,获取新的Cookie和上下文信息。 3. 大幅降低请求频率,加入随机延迟。 |
| 直接返回空白页或状态码403/412 | 1. 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端的复杂加密?或者,所需的数据是否有其他更易获取的替代来源(如聚合数据平台)?评估投入产出比很重要。