目录
- 一、分析(目标+抓包+定位+参数分析合并)
- [二、复现与验证(JS 复现)](#二、复现与验证(JS 复现))
- [三、Python实现(execjs版 + 纯Python版合并)](#三、Python实现(execjs版 + 纯Python版合并))
- 四、编码流向详解
- 五、总结
免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、robots 协议、用户协议以及获得合法授权的前提下进行学习和实验。请勿将本文中的方法、脚本或思路用于未授权访问、批量采集、账号撞库、绕过风控、破坏验证码体系、规避平台限制、侵犯数据权益、商业化滥用或影响线上系统稳定性的行为。对于真实网站案例,读者不应直接复制代码对线上服务进行高频请求或非授权调用。若相关网站、产品方、权利方或平台认为本文内容存在不适宜公开展示之处,可通过评论区、私信或作者主页提供的联系方式联系我;核实后将及时删除、替换或调整相关内容。读者因不当使用本文内容造成的任何法律责任、业务风险或经济损失,均由使用者自行承担,与作者无关。
一、分析(目标+抓包+定位+参数分析合并)
需求:抓取新华社时政新闻频道的数据,提取新闻的标题、发布时间和评论数量
目标地址:https://xhpfmapi.xinhuaxmt.com/vh512/account/25295
F12 打开开发者工具,向下滚动触发翻页,在 Network 面板的 Fetch/XHR 过滤中找到数据接口:
text
POST https://xhpfmapi.xinhuaxmt.com/v600/core/columnnewslist
观察请求体,是一个 JSON 但值是密文:
json
{"param":"5sXqBDO69b5TT0a7L7xiUJdbRZNoUSCsfPVxCjDxf.....Ms198hb7ETuo2IkXpkVGkgUhEdL6Vw=="}
响应同样返回密文。右键请求 → Copy as cURL(bash) → 粘贴到 curlconverter.com 转成 Python 代码执行,确认能拿到响应(虽然是密文)。
精简请求头 :逐步删除 Cookie 和其他请求头字段,发现只保留 User-Agent 就能正常请求。说明服务端没有做严格的请求头校验,我们只需要关注两件事:加密请求体、解密响应体。
Ctrl+Shift+F 全局搜索。直接搜 param 结果太多(几百个匹配),根据经验改为搜索 param: 精确匹配赋值位置

定位到 JS 文件:
python
app.bb596773ec5533767418.js
# 完整链接
https://xhpfmapi.xinhuaxmt.com/vh512static/static/js/app.bb596773ec5533767418.js
找到关键代码段:
javascript
r.a.post(t, {
param: (n = i,
s = p.TripleDES.encrypt(n, v, {
mode: p.mode.ECB,
padding: p.pad.Pkcs7
}),
c = p.enc.Base64.stringify(s.ciphertext),
// 开发者留下的调试日志,直接告诉我们这是加密过程
window.xyJSBridge && window.xyJSBridge.output && window.xyJSBridge.output("原文:" + n + ";加密后:" + c),
c)
})
// t的值: "https://xhpfmapi.xinhuaxmt.com/v600/core/columnnewslist"
在此处打断点,翻页触发请求,断点命中。观察调用栈中的接口路径确实是 /v600/core/columnnewslist,确认定位正确。
明文内容(变量 n):
json
{"cid":"25295","pn":1,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}
{"cid":"25295","pn":5,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}
{"cid":"25295","pn":6,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}
{"cid":"25295","pn":7,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}
多次翻页对比:只有 pn(页码)在变化,其他字段固定不变。
密钥来源 (变量 v):当前函数作用域内找不到 v 的定义,往上层闭包查找,在当前函数上方发现:
javascript
v = p.enc.Utf8.parse(p.MD5("Xinhuamm@2018").toString())
密钥是固定字符串 "Xinhuamm@2018" 经 MD5 哈希后转为 UTF-8 字节序列。
算法识别 :变量 p 就是 CryptoJS 库(展开对象能看到 AES、DES、TripleDES、MD5、SHA 等完整的加密算法集合)。加密方式确认为 3DES + ECB 模式 + PKCS7 填充。
解密函数:在加密代码正下方找到对应的解密逻辑:
javascript
.then(function(t) {
return null == t.data || 1 == t.data || "number" == typeof t.data ? t : (t.data = (e = t.data,
p.TripleDES.decrypt({
ciphertext: p.enc.Base64.parse(e)
}, v, {
mode: p.mode.ECB,
padding: p.pad.Pkcs7
}).toString(p.enc.Utf8))
同密钥、同模式,方向相反。
二、复现与验证(JS 复现)
标准 CryptoJS 无魔改,直接引库复现。验证时需要对比本地生成的密钥 v 的 words 数组和浏览器中的是否一致,排除魔改可能:
javascript
// xinhuaxmt.js
let CryptoJS = require('../../CryptoJS')
// 密钥:固定字符串 MD5 后转 UTF-8 字节
let v = CryptoJS.enc.Utf8.parse(CryptoJS.MD5("Xinhuamm@2018").toString())
// 验证:在浏览器控制台打印 v.words 和本地对比,确认一致
// 加密:构造请求体
function encrypt(n) {
let s = CryptoJS.TripleDES.encrypt(n, v, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
})
return CryptoJS.enc.Base64.stringify(s.ciphertext)
}
// 解密:解析响应体
function decrypt(resData) {
return CryptoJS.TripleDES.decrypt({
ciphertext: CryptoJS.enc.Base64.parse(resData)
}, v, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
}).toString(CryptoJS.enc.Utf8)
}
验证结果:
javascript
console.log(encrypt('{"cid":"25295","pn":4,"clientVer":"8.8.2","clientLable":"h5","source":0,"userID":""}'));
// 本地加密: 5sXqBDO69b5TT0a7L7xiUKi4bBT5kPjkfPVxCjDxfO5jvVL+d4qPHBkytYh+1OwPNYlfqRSL3kuMgZ9tXc3nsJIbMs198hb7ETuo2IkXpkVGkgUhEdL6Vw==
// 浏览器: 5sXqBDO69b5TT0a7L7xiUKi4bBT5kPjkfPVxCjDxfO5jvVL+d4qPHBkytYh+1OwPNYlfqRSL3kuMgZ9tXc3nsJIbMs198hb7ETuo2IkXpkVGkgUhEdL6Vw==
// 完全一致 ✓
// 解密调用 decrypt 函数即可
三、Python实现(execjs版 + 纯Python版合并)
采用面向对象封装 + 线程池并发的方式,方便后续复用和扩展:
python
import json
import subprocess
from functools import partial
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
# execjs 在 Windows 下的编码修复
# 原因: execjs 内部用 subprocess.Popen 调用 Node.js,但不指定 encoding 参数
# Windows 默认用 GBK 解码 Node.js 的 UTF-8 输出,导致中文乱码或解析失败
# 这行代码让所有 Popen 调用默认使用 UTF-8 编码
subprocess.Popen = partial(subprocess.Popen, encoding='utf-8')
import execjs
class XinhuaSpider:
"""新华社客户端爬虫"""
API_URL = 'https://xhpfmapi.xinhuaxmt.com/v600/core/columnnewslist'
def __init__(self, cid='25295', js_path='xinhuaxmt.js'):
self.cid = cid
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36',
})
# 加载 JS 加解密模块
with open(js_path, 'r', encoding='utf-8') as f:
self.ctx = execjs.compile(f.read())
def _build_plaintext(self, page):
"""构造加密前的明文 JSON"""
return json.dumps({
"cid": self.cid,
"pn": page,
"clientVer": "8.8.2",
"clientLable": "h5",
"source": 0,
"userID": ""
})
def fetch_page(self, page):
"""请求单页数据并解密"""
plaintext = self._build_plaintext(page)
param = self.ctx.call('encrypt', plaintext)
resp = self.session.post(self.API_URL, json={'param': param})
encrypted_data = resp.json().get('data', '')
if not encrypted_data:
return []
decrypted = self.ctx.call('decrypt', encrypted_data.strip())
news_list = json.loads(decrypted).get('newsList', [])
return [
{
'标题': item.get('topic'),
'发布日期': item.get('releasedate'),
'评论数量': item.get('commentCount'),
}
for item in news_list
]
def run(self, pages=5, max_workers=3):
"""并发采集多页数据"""
all_news = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(self.fetch_page, p): p for p in range(1, pages + 1)}
for future in as_completed(futures):
page = futures[future]
try:
result = future.result()
all_news.extend(result)
print(f'[Page {page}] 获取 {len(result)} 条新闻')
except Exception as e:
print(f'[Page {page}] 请求失败: {e}')
return all_news
if __name__ == '__main__':
spider = XinhuaSpider()
news = spider.run(pages=3, max_workers=3)
for _ in news:
print(_)
关于并发数的说明 :max_workers=3 是比较保守的设置。新华社这个接口没有明显的频率限制,但作为官方媒体平台,不建议开太高的并发,3-5 个线程足够用。
既然已经确认是标准 3DES 且无魔改,完全可以用 Python 的 pycryptodome 库直接实现,不需要调用 Node.js。性能更好,部署也更简单。
python
import json
import hashlib
import base64
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from Crypto.Cipher import DES3
from Crypto.Util.Padding import pad, unpad
class XinhuaCrypto:
"""新华社 3DES 加解密器"""
def __init__(self, secret='Xinhuamm@2018'):
# 密钥生成过程:
# 1. MD5("Xinhuamm@2018") → 32位十六进制字符串
# 2. 将这个字符串当作 UTF-8 文本 → 32 字节
# 3. 3DES 需要 24 字节密钥,CryptoJS 自动截取前 24 字节
md5_hex = hashlib.md5(secret.encode('utf-8')).hexdigest()
self.key = md5_hex.encode('utf-8')[:24]
def encrypt(self, plaintext):
"""加密明文 → Base64 密文"""
cipher = DES3.new(self.key, DES3.MODE_ECB)
padded = pad(plaintext.encode('utf-8'), DES3.block_size)
encrypted = cipher.encrypt(padded)
return base64.b64encode(encrypted).decode('utf-8')
def decrypt(self, ciphertext):
"""Base64 密文 → 明文"""
cipher = DES3.new(self.key, DES3.MODE_ECB)
encrypted = base64.b64decode(ciphertext)
decrypted = unpad(cipher.decrypt(encrypted), DES3.block_size)
return decrypted.decode('utf-8')
class XinhuaSpider:
"""新华社客户端爬虫(纯 Python 版)"""
API_URL = 'https://xhpfmapi.xinhuaxmt.com/v600/core/columnnewslist'
def __init__(self, cid='25295'):
self.cid = cid
self.crypto = XinhuaCrypto()
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36',
})
def fetch_page(self, page):
"""请求单页数据"""
plaintext = json.dumps({
"cid": self.cid,
"pn": page,
"clientVer": "8.8.2",
"clientLable": "h5",
"source": 0,
"userID": ""
})
param = self.crypto.encrypt(plaintext)
resp = self.session.post(self.API_URL, json={'param': param})
encrypted_data = resp.json().get('data', '')
if not encrypted_data:
return []
decrypted = self.crypto.decrypt(encrypted_data.strip())
news_list = json.loads(decrypted).get('newsList', [])
return [
{
'标题': item.get('topic'),
'发布日期': item.get('releasedate'),
'评论数量': item.get('commentCount'),
}
for item in news_list
]
def run(self, pages=5, max_workers=3):
"""并发采集"""
all_news = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(self.fetch_page, p): p for p in range(1, pages + 1)}
for future in as_completed(futures):
page = futures[future]
try:
result = future.result()
all_news.extend(result)
print(f'[Page {page}] 获取 {len(result)} 条新闻')
except Exception as e:
print(f'[Page {page}] 请求失败: {e}')
return all_news
if __name__ == '__main__':
spider = XinhuaSpider()
news = spider.run(pages=3)
for item in news:
print(item)
纯 Python 版 vs JS 调用版对比:
| JS 调用版(execjs) | 纯 Python 版(pycryptodome) | |
|---|---|---|
| 依赖 | Node.js + CryptoJS | 仅 pycryptodome |
| 性能 | 每次调用启动进程,较慢 | 纯内存计算,快 |
| 部署 | 需要安装 Node.js | pip install 即可 |
| 适用场景 | 算法有魔改、不确定实现细节时先用 JS 验证 | 确认是标准算法后切换到纯 Python |
什么时候用哪个:先用 JS 版验证结果正确,确认无魔改后再改写成纯 Python 版。如果发现纯 Python 版输出和 JS 版不一致,说明 CryptoJS 有魔改,老老实实用 JS 版。
四、编码流向详解
Python 加解密代码中频繁出现 .encode()、.decode()、base64.b64encode()、base64.b64decode(),容易混淆。这里把整个数据流向画清楚。
加密方向(明文 → 可传输的密文字符串):
text
"hello" ← str,人能读的字符串
↓ .encode('utf-8')
b"hello" ← bytes,加密算法只能处理字节
↓ pad(..., block_size)
b"hello\x03\x03\x03" ← bytes,PKCS7 填充到 8 字节整数倍
↓ cipher.encrypt(...)
b"\x8a\x3f\x7b..." ← bytes,加密后的原始字节(乱码,不可读,不可传输)
↓ base64.b64encode(...)
b"ij97..." ← bytes,Base64 编码后(全是 ASCII 字符,可读)
↓ .decode('utf-8')
"ij97..." ← str,可以放进 JSON 传输了
解密方向(密文字符串 → 明文):
text
"ij97..." ← str,从响应 JSON 中拿到的密文
↓ base64.b64decode(...) (b64decode 直接接受 str,不需要先 encode)
b"\x8a\x3f\x7b..." ← bytes,还原成加密后的原始字节
↓ cipher.decrypt(...)
b"hello\x03\x03\x03" ← bytes,解密后带填充
↓ unpad(..., block_size)
b"hello" ← bytes,去掉填充
↓ .decode('utf-8')
"hello" ← str,人能读的明文
核心规律:
- 加密算法只吃 bytes、只吐 bytes
- Base64 是
"二进制乱码 ↔ 可读 ASCII 文本的桥梁(因为 HTTP/JSON 不能直接传二进制) - 整个链路:人话 → 字节 → 加密字节 → Base64 文本 → 传输 → Base64 文本 → 加密字节 → 字节 → 人话
容易混淆的点:.decode('utf-8') vs base64.b64decode()
这两个名字里都有 "decode",但完全是两回事:
| 写法 | 做了什么 | 内容是否变化 |
|---|---|---|
.decode('utf-8') |
Python 类型转换:bytes → str | 内容不变,只是换了个容器 |
base64.b64decode(...) |
Base64 算法逆操作:ASCII 文本 → 原始二进制 | 内容变了 |
python
# .decode('utf-8') 不改变内容,只改变类型
b"5sXqBDO69b5..." # bytes 类型
"5sXqBDO69b5..." # str 类型,内容完全一样
# 为什么需要这一步?因为 JSON 只接受 str,不接受 bytes:
requests.post(url, json={'param': b"5sXq..."}) # 报错
requests.post(url, json={'param': "5sXq..."}) # 正常
Base64 输出的字符全部是 ASCII 范围内的(A-Z a-z 0-9 + / =),而 ASCII 是 UTF-8 的子集。所以 .decode('utf-8') 在这里没有做任何编码转换,只是把数据从 bytes 容器倒进 str 容器------就像把水从玻璃杯倒进塑料杯,水还是那个水。
五、总结
总结:
| 环节 | 要点 |
|---|---|
| 抓包 | POST 请求,请求体和响应体都是密文,请求头无特殊校验 |
| 定位 | 搜索 param: 精确定位赋值点,比搜 param 高效得多 |
| 算法 | 3DES + ECB + PKCS7,标准 CryptoJS 实现无魔改 |
| 密钥 | 固定字符串 "Xinhuamm@2018" 经 MD5 后作为密钥,藏在闭包上层 |
| 验证 | 对比密钥的 words 数组和加密输出,确认无魔改 |
| 复现 | 直接引用 CryptoJS 库,不需要手动扣算法实现 |
本案例的核心收获:
- 遇到标准加密库(CryptoJS / JSEncrypt 等)时,不需要手动扣算法实现。识别出算法类型和参数(密钥、模式、填充方式),直接引用同一个库调用即可。
- 精力应该花在
"定位加密位置"和"找密钥"上,而不是重新实现算法。 - 搜索关键词时加上冒号、等号等赋值符号(如
param:),能大幅减少无关结果。 - 密钥不一定在当前函数内,要有意识地往闭包上层找。