1. 项目概述与核心价值
最近在做一个社群运营分析的小工具,核心需求是从某个社交平台的群组里,把成员数据给“搬”出来。听起来简单,不就是爬虫嘛?但实际操作起来,发现页面数据是通过JavaScript动态加载的,直接
requests.get()
拿到的HTML里空空如也,关键的用户列表压根不在里面。这就不得不踏入“JS逆向”这个听起来有点技术门槛的领域了。这个项目,本质上就是一次典型的针对动态网页的数据采集实战,核心在于破解前端JavaScript的数据获取逻辑,并用Python将其复现。
这个过程对于数据分析师、运营人员或者任何需要从现代Web应用中获取结构化数据的朋友来说,价值巨大。现在越来越多的网站和应用为了性能和用户体验,都采用了前后端分离的架构,数据通过Ajax接口异步加载。如果你还停留在解析静态HTML的层面,很多数据源对你来说就是“隐形”的。通过这个项目,你将不仅学会如何用Python采集数据,更重要的是掌握一套分析、理解并模拟前端JavaScript行为的通用方法论。这能让你在面对各种加密参数、复杂认证的动态网站时,有路可循,而不是束手无策。
2. 逆向分析的核心思路与工具准备
逆向分析不是盲目地猜测,而是有策略地“侦察”和“推理”。我们的目标很明确:找到前端JavaScript是从哪个接口、以何种方式获取到群成员数据的。整个思路可以概括为“抓包定位 -> 静态分析 -> 动态调试 -> Python复现”。
2.1 核心工具链选择
工欲善其事,必先利其器。以下是经过多次实战检验的工具组合:
-
浏览器开发者工具 (Chrome DevTools)
:这是我们的主战场。尤其是
Network(网络)
面板和
Sources(源代码)
面板。Network面板用于监听所有网络请求,快速定位到目标数据接口;Sources面板用于设置断点,动态跟踪JavaScript的执行流程。
-
Python请求库:Requests 或 httpx
:
Requests
库经典、稳定,社区资源丰富,是大多数情况下的首选。
httpx
支持HTTP/2和异步,在处理大量并发请求时更有优势。本项目以
Requests
为例进行讲解。
-
解析与模拟库
:
-
json
: 目标接口返回的数据大概率是JSON格式,Python标准库的
json
模块足矣。
-
execjs
或
PyExecJS
: 如果逆向发现前端有复杂的本地加密(如用JS生成的签名、Token),需要直接在Python环境中执行JavaScript代码片段时使用。但我们的首要目标是
避免
走到这一步,而是通过分析,用Python原生代码模拟出关键逻辑。
-
re
(正则表达式): 有时密钥或参数会硬编码在JS文件里,用正则快速提取。
-
-
辅助分析工具
:
-
Charles / Fiddler / mitmproxy
: 高级抓包工具,可以拦截和修改HTTPS流量,对移动端APP或复杂场景的分析尤其有用。在浏览器DevTools搞不定时,它们是强力后备。
-
Pretty Print (美化)
: Network面板里看到的JS代码通常是压缩混淆过的,一行代码几万字符。点击代码区域左下角的
{}
按钮,可以格式化代码,让其变得可读。
-
2.2 关键分析流程拆解
开启记录
: 打开目标群组页面,先打开DevTools的Network面板,勾选
Preserve log
(保留日志),防止页面跳转时请求记录被清空。然后刷新页面。
筛选与定位
: 在Network面板的筛选栏,输入
XHR
或
Fetch
,过滤出Ajax请求。一边在页面上操作(如滚动加载更多成员),一边观察Network面板中新出现的请求。重点关注那些响应体(Preview)里包含类似
memberList
,
userInfo
,
data
等字段的请求。
请求剖析
: 点击找到的疑似接口,查看其
Headers
详情。你需要关注:
-
Request URL
: 接口地址,注意查询参数(
?
后面的部分)。
-
Request Method
: 通常是
GET
或
POST
。
-
Request Headers
: 重中之重!特别是
Cookie
,
Authorization
,
User-Agent
,
Referer
, 以及一些自定义的头部如
X-Sign
,
X-Timestamp
等。这些往往是服务端用于验证请求合法性的关键。
-
Query String Parameters / Form Data
: 请求携带的参数。同样,可能包含加密或动态生成的参数。
溯源与破解
: 这是逆向的核心。右键点击该请求,选择
Copy -> Copy as cURL
,然后到
https://curlconverter.com/
这类网站可以快速转为Python代码,但这只是第一步。你需要弄清楚那些可疑的参数(比如一个叫
_signature
的加密字符串)是怎么生成的。这时就要用到
Sources
面板进行搜索和断点调试了。
注意
: 不要一上来就试图理解整个压缩过的JS文件。先用搜索功能(Ctrl+Shift+F),在全站资源中搜索关键参数名,如
_signature
、
token
或接口URL的一部分,来定位生成这些参数的函数所在的具体JS文件。
3. 实战:一个简化的逆向案例拆解
假设我们通过Network面板,找到了一个获取群成员列表的接口:
GET https://api.example-group.com/member/list?groupId=123456&page=1&size=20&_t=1648886400000&_sign=abc123def456
3.1 参数分析
-
groupId
,
page
,
size
: 这些是业务参数,含义明确,我们直接构造即可。
-
_t
: 看起来是一个时间戳(13位毫秒时间戳)。在Python中可以用
int(time.time() * 1000)
轻松生成。
-
_sign
: 最可疑的参数,看起来是一个签名。签名的目的是防止参数被篡改,通常是使用所有参数(可能加上一个密钥)通过某种算法(如MD5, SHA1, HMAC-SHA256)计算得出的。
3.2 逆向签名生成逻辑
我们的目标是找到
_sign
的生成方式。
全局搜索
: 在Sources面板,按
Ctrl+Shift+F
,搜索
_sign
或
sign
。你可能会在某个JS文件中找到类似这样的代码片段(经过格式化后):
function generateSign(params) {
var key = "a_secret_key_here";
// 1. 参数排序
var sortedKeys = Object.keys(params).sort();
var strToSign = "";
for (var i = 0; i < sortedKeys.length; i++) {
var k = sortedKeys[i];
if (params[k] !== null && params[k] !== undefined) {
strToSign += k + "=" + params[k] + "&";
}
}
// 2. 拼接密钥
strToSign += "key=" + key;
// 3. MD5加密
var sign = md5(strToSign).toUpperCase();
return sign;
}
逻辑解读
: 这段代码揭示了一个常见的签名流程:
-
将请求参数(
groupId
,
page
,
size
,
_t
)按键名
升序排序
。
-
将它们拼接成
key1=value1&key2=value2&…
的格式。
-
在末尾拼接一个固定的
key=密钥
。
-
对整个拼接后的字符串进行
MD5
计算,并将结果转为大写。
验证与调试
: 在Sources面板找到这段代码,在
return sign;
这一行打上断点。然后回到页面触发请求(比如翻页)。当代码执行到断点时,查看
params
,
strToSign
,
sign
等变量的值,与你通过Network面板看到的实际请求参数和
_sign
值进行比对,确认逻辑是否正确。
3.3 Python代码复现
一旦分析清楚逻辑,用Python复现就非常直接了:
import hashlib
import time
import requests
def generate_sign(params, secret_key):
"""
模拟JS端的签名生成算法
:param params: 参数字典
:param secret_key: 从JS中分析出的密钥
:return: 签名字符串
"""
# 1. 参数排序
sorted_params = sorted(params.items(), key=lambda x: x[0])
# 2. 拼接键值对
str_to_sign = '&'.join([f'{k}={v}' for k, v in sorted_params if v is not None])
# 3. 拼接密钥
str_to_sign += f'&key={secret_key}'
# 4. MD5加密并大写
m = hashlib.md5()
m.update(str_to_sign.encode('utf-8'))
return m.hexdigest().upper()
# 使用示例
secret_key = "a_secret_key_here" # 这个密钥需要从JS代码中分析提取
group_id = "123456"
page = 1
size = 20
timestamp = int(time.time() * 1000)
# 构造参数字典
params = {
"groupId": group_id,
"page": page,
"size": size,
"_t": timestamp
}
# 生成签名
signature = generate_sign(params, secret_key)
# 将签名加入参数
params['_sign'] = signature
# 构造请求头,Cookie需要从登录后的浏览器中获取
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Cookie": "你的登录Cookie字符串", # 关键!如何获取见下文
"Referer": "https://xxx-group.com/group/123456" # 通常需要添加来源页
}
# 发送请求
url = "https://api.example-group.com/member/list"
response = requests.get(url, params=params, headers=headers)
if response.status_code == 200:
member_list = response.json()
print(f"获取到第{page}页,共{len(member_list.get('data', []))}条成员数据")
# 后续处理数据…
else:
print(f"请求失败,状态码:{response.status_code}")
print(response.text)
实操心得
: 密钥 (
secret_key
) 的查找是逆向中的难点。它可能是一个硬编码的字符串(如上例),也可能是一个经过简单变换(如Base64解码、反转)的字符串,甚至是从另一个接口获取的动态值。耐心地在JS代码中搜索
key
,
secret
,
salt
,
appSecret
等关键词,并结合断点查看运行时的值,是找到它的关键。
4. 核心环节:登录态维持与Cookie处理
上面代码中有一个关键注释:
Cookie
需要从登录后的浏览器中获取。对于需要登录才能访问的群组,如何让Python脚本“保持登录状态”是必须解决的问题。
4.1 手动获取Cookie(简单场景)
对于一次性或低频采集,最简单的方法是手动复制:
Application
面板(或
Storage
)。
Cookies
下找到目标网站的域名,右键点击某个Cookie(通常是包含
session
,
token
,
login
等字样的),选择
Copy Value
。
headers['Cookie']
中。
缺点
: Cookie会过期(Session Cookie在浏览器关闭后失效,持久Cookie也有有效期)。过期后需要重新手动获取。
4.2 模拟登录(自动化方案)
要实现全自动化,就必须用Python模拟登录过程,从登录接口获取有效的Cookie或Token。
分析登录请求
: 在Network面板,记录你手动输入用户名密码点击登录时发出的请求。它通常是一个
POST
请求,表单数据里包含用户名、密码(很可能是加密的),以及一些防CSRF的Token。
逆向密码加密
: 找到密码在提交前被哪个JS函数加密了。方法和逆向签名一样:在登录页面的JS中搜索
password
,
encrypt
,
md5
,
rsa
等关键词,打上断点,查看明文密码如何变成密文。
Python复现登录
: 用
requests.Session()
对象来保持会话。先访问登录页,获取必要的初始Token(如
csrf_token
),然后模拟加密过程生成密码密文,最后向登录接口提交数据。
保存会话
: 登录成功后,
session
对象会自动管理Cookies。后续访问群成员接口,直接使用这个
session
去请求即可。
import requests
from lxml import html # 用于解析HTML获取csrf token
def login(username, password):
session = requests.Session()
login_page_url = "https://example.com/login"
login_api_url = "https://example.com/api/login"
# 1. 获取登录页,提取csrf token (假设它在一个name为_csrf的input里)
resp = session.get(login_page_url)
tree = html.fromstring(resp.content)
csrf_token = tree.xpath('//input[@name="_csrf"]/@value')[0]
# 2. 模拟JS加密密码 (这里假设是简单的MD5,实际需要根据逆向结果修改)
# 假设逆向发现密码是: md5(md5(明文密码) + csrf_token)
import hashlib
first_md5 = hashlib.md5(password.encode()).hexdigest()
encrypted_pwd = hashlib.md5((first_md5 + csrf_token).encode()).hexdigest()
# 3. 构造登录数据
login_data = {
"username": username,
"password": encrypted_pwd, # 提交加密后的密码
"_csrf": csrf_token
}
# 4. 发送登录请求
login_resp = session.post(login_api_url, data=login_data)
if login_resp.status_code == 200 and login_resp.json().get('success'):
print("登录成功!")
return session # 返回携带登录态Cookie的session对象
else:
print("登录失败")
return None
# 使用
my_session = login("your_username", "your_password")
if my_session:
# 用这个session去请求需要登录的接口
# member_list_resp = my_session.get(member_api_url, params=…)
注意事项
: 模拟登录是逆向中最复杂的环节之一。网站可能采用更复杂的加密(如RSA非对称加密)、图形验证码、滑块验证等。遇到验证码时,可能需要引入图像识别库(如
ddddocr
用于简单验证码)或考虑使用付费打码平台。对于极其复杂的登录,有时“手动获取长期有效的Token”并定期更换,在成本效益上更划算。
5. 数据采集的工程化与优化
当核心接口打通后,我们需要考虑如何稳定、高效、友好地采集数据。
5.1 请求头伪装与反爬策略
现代网站都有基础的反爬机制。除了必不可少的
Cookie
,以下请求头也建议加上,让你的请求更像一个真实的浏览器:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br", # 注意requests会自动解压gzip/deflate,但不要写'br'除非你处理brotli
"Referer": "https://目标群组页面的URL",
"Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', # Chrome的UA客户端提示,动态变化
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"Windows"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"X-Requested-With": "XMLHttpRequest", # 表明是Ajax请求
}
使用
requests
时,
Accept-Encoding
最好不要包含
br
(Brotli),除非你额外安装
brotli
库并处理。
Sec-*
系列头是较新的浏览器特征,加上能提升伪装度,但并非所有网站都校验。
5.2 分页与速率控制
群成员数据通常是分页的。你需要循环请求,直到没有数据为止。
def fetch_all_members(group_id, session, start_page=1):
all_members = []
page = start_page
size = 50 # 每页数量,根据接口允许的最大值调整
secret_key = "your_secret_key"
while True:
params = {
"groupId": group_id,
"page": page,
"size": size,
"_t": int(time.time() * 1000)
}
params['_sign'] = generate_sign(params, secret_key)
resp = session.get(member_api_url, params=params, headers=headers)
if resp.status_code != 200:
print(f"第{page}页请求失败: {resp.status_code}")
break
data = resp.json()
current_page_members = data.get('data', [])
total = data.get('total', 0)
if not current_page_members: # 当前页无数据,结束
print(f"第{page}页无数据,采集结束。")
break
all_members.extend(current_page_members)
print(f"已采集第{page}页,共{len(current_page_members)}条,累计{len(all_members)}条")
# 简单判断是否还有下一页
if len(current_page_members) < size or (total and len(all_members) >= total):
break
page += 1
time.sleep(1.5) # 非常重要的延时,避免请求过快被封IP
return all_members
time.sleep()
是简单有效的速率控制。对于更复杂的场景,可以考虑使用
random.uniform(1, 3)
增加随机性,或者使用更高级的限流队列。
5.3 数据存储
采集到的数据(通常是JSON列表)可以保存为多种格式:
-
JSON文件
: 简单直接,
json.dump(all_members, open('members.json', 'w', encoding='utf-8'), ensure_ascii=False)
。
-
CSV文件
: 方便用Excel打开分析。使用
pandas
库非常便捷:
pd.DataFrame(all_members).to_csv('members.csv', index=False, encoding='utf-8-sig')
。
-
数据库(SQLite/MySQL)
: 适合数据量大或需要复杂查询的场景。使用
sqlite3
(内置)或
pymysql
/
sqlalchemy
库。
6. 常见问题与排查技巧实录
在实际操作中,你几乎一定会遇到各种问题。下面是一些典型问题及解决思路:
6.1 请求返回403/412错误
这通常是签名错误、Cookie失效或请求头不完整导致的。
-
排查签名
: 用Python生成的签名和浏览器实际请求的签名对比。确保参数顺序、拼接方式、密钥、编码完全一致。
特别注意
:JS中的
+
号拼接字符串,如果值不是字符串类型,可能会发生隐式转换。在Python中要确保所有参数值都是字符串后再拼接,或者完全模拟JS的类型处理逻辑。
-
排查Cookie
: 检查Cookie是否已过期。重新登录获取新的Cookie。确认Cookie的域(Domain)和路径(Path)是否正确。
-
排查请求头
: 使用
curlconverter
将浏览器的请求直接转为Python代码,对比和你自己构造的请求头差异,逐个补全。
6.2 数据接口找不到或参数不明
-
尝试搜索
: 在Network面板的搜索框(Filter)里,不要只搜
member
,可以尝试搜接口返回数据中特有的字段名,比如用户名的字段
nickName
,或者群ID
123456
。
-
查看Initator(发起者)
: 在Network面板点击请求,查看
Initiator
标签页,它会显示是哪个JS文件发起了这个请求,点击可以跳转到Sources面板的对应行,这是定位关键JS代码的捷径。
-
参数可能是全局变量
: 有些参数(如
_t
)可能是在页面加载时生成并存储在全局变量
window
对象里,或者来自上一个接口的响应。需要仔细梳理前端代码的数据流。
6.3 代码被混淆,难以阅读
这是常态。面对混淆代码(变量名都是a,b,c,d):
使用Pretty Print
: 先格式化。
搜索常量
: 搜索接口URL字符串、固定的密钥字符串、加密算法常量(如
0x67452301
可能是MD5的初始向量)。
关注函数调用
: 即使变量名混淆,函数调用如
Object.keys()
,
encodeURIComponent()
,
md5()
,
CryptoJS.AES.encrypt()
是清晰的,它们是理解逻辑的锚点。
动态调试
: 在疑似加密函数入口处打上断点,观察输入和输出,这是理解混淆代码最有效的方法。不要试图通读所有代码。
6.4 遇到WebSocket或SSE传输数据
有些现代应用使用WebSocket或Server-Sent Events (SSE) 进行实时数据传输。Network面板中会有
WS
或
EventStream
类型的请求。
-
对于WebSocket
: 可以使用
websockets
库进行连接和通信。你需要分析建立WebSocket连接时的握手请求(一个带有
Upgrade: websocket
头部的HTTP请求),以及后续发送和接收的消息格式。
-
对于SSE
: 本质上是一个长连接的HTTP流,可以使用
requests
以流模式 (
stream=True
) 读取,并解析
data:
开头的事件行。
6.5 关于封IP与验证码
如果请求频率过高,可能会触发IP限制或弹出验证码。
-
降低频率
: 增加
time.sleep()
的间隔,并加入随机延迟。
-
使用代理IP池
: 这是应对IP封锁的终极方案。可以使用一些代理服务,并在requests请求中通过
proxies
参数设置。
-
处理简单验证码
: 如果验证码是图片形式且比较简单,可以考虑使用OCR库(如
pytesseract
或更易用的
ddddocr
)进行识别。但这属于攻防对抗的范畴,需要谨慎评估法律和道德风险。
-
根本性解决方案
: 与网站所有者沟通,看是否有官方API可以提供数据。或者,你的采集行为是否在对方
robots.txt
协议允许范围内?始终将合规性放在首位。
逆向分析是一个需要耐心和细致观察的过程。每一次成功的逆向,都是对前端逻辑和网络协议一次深入的理解。从定位接口到破解参数,从模拟登录到处理反爬,每一步的突破都建立在扎实的基础和有条理的排查之上。掌握这套方法,你就能从大多数动态网站中获取到你需要的宝贵数据。