免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、robots 协议、用户协议以及获得合法授权的前提下进行学习和实验。请勿将本文中的方法、脚本或思路用于未授权访问、批量采集、账号撞库、绕过风控、破坏验证码体系、规避平台限制、侵犯数据权益、商业化滥用或影响线上系统稳定性的行为。对于真实网站案例,读者不应直接复制代码对线上服务进行高频请求或非授权调用。若相关网站、产品方、权利方或平台认为本文内容存在不适宜公开展示之处,可通过评论区、私信或作者主页提供的联系方式联系我;核实后将及时删除、替换或调整相关内容。读者因不当使用本文内容造成的任何法律责任、业务风险或经济损失,均由使用者自行承担,与作者无关。
一、分析
目标地址:https://www.birdreport.cn/home/activity/page.html
本案例目标是抓取页面中 "记录查询" 表格数据,字段包括:报告编号、观测时间、观测地点、记录用户、鸟种数量、浏览数。 进入页面后,表格会自动加载第一页数据。打开 DevTools,切到 Network → Fetch/XHR,刷新页面或点击分页按钮,可以看到记录查询接口:
POST https://api.birdreport.cn/front/activity/search
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
观察发现三个问题:
-
请求体是密文:一段 Base64 字符串,不是明文参数
-
请求头有动态参数:
sign、timestamp、requestId每次请求都不同requestId: 0e29693240cbd44a3949bf347ca493bd sign: 5c0fa2318787c31fd9ec4ca900dd6c9b timestamp: 1782114136000 requestId: 33cfa60dd69ded410fd8f88d638714ec sign: ec5cd2fee8d78443d7c1605e6573acfb timestamp: 1782114160000 requestId: f31f75a289f6f845418b0af86939de7a sign: 0fcb490373e6cf1b4e9b2e2afe7bca00 timestamp: 1782114161000 -
响应体也是密文:
data字段是一段加密的 Base64 字符串
定位加密入口:搜索 requestId(比 sign 更特殊,结果更少),可以看到其中一处结果是 var requestId = getUuid();。这行代码和请求头里的 requestId 高度相关,优先点进去看上下文:
进入后发现它位于 jqueryAjax.js 的 $.ajaxSetup 中,也就是 jQuery 的全局 AJAX 发送前拦截器:
$.ajaxSetup({
beforeSend: function(xhr, options) {
var timestamp = Date.parse(new Date);
var requestId = getUuid();
var data = JSON.stringify(sort_ASCII(dataTojson(options.data || "{}")));
options.data = encrypt.encryptLong(data);
var sign = MD5(data + requestId + timestamp);
xhr.setRequestHeader("timestamp", timestamp);
xhr.setRequestHeader("requestId", requestId);
xhr.setRequestHeader("sign", sign)
}
})
为了确认翻页请求是否真的经过这段逻辑,在 var requestId = getUuid(); 这一行下断点,然后回到页面点击分页。断点命中后,说明当前接口请求在发送前确实会进入这个 beforeSend 拦截器:
命中断点后,先看下面三行 xhr.setRequestHeader:timestamp、requestId、sign 这三个动态请求头就是在这里设置的。需要进一步确认的话,可以放开断点后回到 Network 面板,对比最近一次请求头里的值和断点处变量值是否一致。这里已经能确认它们来自同一段逻辑,接下来直接拆代码。timestamp 来自 Date.parse(new Date),本质上就是毫秒时间戳;requestId 来自自定义函数 getUuid()。如果要在本地复现请求,就需要先还原 getUuid() 的生成逻辑。单步进入函数后可以看到,它只依赖局部变量和 Math.random() 等浏览器基础 API,没有额外上下文依赖,因此可以直接拷贝到本地 birdreport.js 中测试:
function getUuid() {
var s = [];
var hexDigits = "0123456789abcdef";
for (var i = 0; i < 32; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 16), 1)
}
s[14] = "4";
s[19] = hexDigits.substr(s[19] & 3 | 8, 1);
s[8] = s[13] = s[18] = s[23];
var uuid = s.join("");
return uuid
}
在本地简单调用 console.log(getUuid());,可以正常生成 32 位字符串:
继续往下看 var data = JSON.stringify(sort_ASCII(dataTojson(options.data || "{}")));。先在断点处观察 options.data,当前翻页请求里的值是 page=5&limit=20,也就是普通的表单参数字符串。把整段表达式放到 Console 中执行:JSON.stringify(sort_ASCII(dataTojson(options.data || "{}"))),可以看到它会把原始参数转换成一个 JSON 字符串:
这里真正需要还原的是两个自定义函数:dataTojson 和 sort_ASCII。JSON.stringify 是标准 API,浏览器和 Node.js 中都可以直接使用。单步进入这两个函数后可以看到,它们也没有额外依赖,直接复制到本地 birdreport.js 即可:
function dataTojson(data) {
var arr = [];
var res = {};
arr = data.split("&");
for (var i = 0; i < arr.length; i++) {
if (arr[i].indexOf("=") != -1) {
var str = arr[i].split("=");
if (str.length == 2) {
res[str[0]] = str[1]
} else {
res[str[0]] = ""
}
} else {
res[arr[i]] = ""
}
}
return res
}
function sort_ASCII(obj) {
var arr = new Array;
var num = 0;
for (var i in obj) {
arr[num] = i;
num++
}
var sortArr = arr.sort();
var sortObj = {};
for (var i in sortArr) {
sortObj[sortArr[i]] = obj[sortArr[i]]
}
return sortObj
}
复制完成后,在本地模拟浏览器中的这段组合逻辑,把 options.data 替换成抓到的参数字符串 page=5&limit=20 进行测试。输出结果和浏览器一致,说明这两个函数扣得没有问题,如下:
接着看请求体加密这一行:
options.data = encrypt.encryptLong(data);
这里的 options 是当前 Ajax 请求配置对象,options.data 原本是页面传入的表单参数字符串;经过前面的 dataTojson → sort_ASCII → JSON.stringify 后,已经得到明文 JSON 字符串 data。这一行把 data 传入 encrypt.encryptLong(),再把返回值重新赋给 options.data,也就是说最终发出去的请求体密文就是在这里生成的。
先看 encrypt 从哪里来。它不在当前函数内部定义,往上找可以看到:
var paramPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvxXa98E1uWXnBzXkS2yHUfnBM6n3PCwLdfIox03T91joBvjtoDqiQ5x3tTOfpHs3LtiqMMEafls6b0YWtgB1dse1W5m+FpeusVkCOkQxB4SZDH6tuerIknnmB/Hsq5wgEkIvO5Pff9biig6AyoAkdWpSek/1/B7zYIepYY0lxKQIDAQAB";
var encrypt = new JSEncrypt;
encrypt.setPublicKey(paramPublicKey);
这里已经能确认请求体使用的是 RSA 公钥加密:先创建 JSEncrypt 实例,再通过 setPublicKey 设置公钥。后续本地复现时,可以用 npm 安装 jsencrypt,也可以使用站点里的离线文件;如果使用浏览器版离线文件,在 Node.js 环境里可能需要补 window。
继续跟进 encryptLong。标准 JSEncrypt 常见版本里没有这个原生方法,所以不能只安装库就结束,还要看站点自己扩展的逻辑。单步进入后,先到 JSEncrypt.prototype.encryptLong:
JSEncrypt.prototype.getKey = function(cb) {
if (!this.key) {
this.key = new JSEncryptRSAKey;
if (cb && {}.toString.call(cb) === "[object Function]") {
this.key.generateAsync(this.default_key_size, this.default_public_exponent, cb);
return
}
this.key.generate(this.default_key_size, this.default_public_exponent)
}
return this.key
}
JSEncrypt.prototype.encryptLong = function(str) {
try {
var encrypted = this.getKey().encryptLong(str) || "";
var uncrypted = this.getKey().decryptLong(encrypted) || "";
var count = 0;
var reg = /null$/g;
while (reg.test(uncrypted)) {
count++;
encrypted = this.getKey().encryptLong(str) || "";
uncrypted = this.getKey().decryptLong(encrypted) || "";
if (count > 10) {
break
}
}
return encrypted
} catch (ex) {
return false
}
}
这层主要是包装:this.getKey() 返回 JSEncryptRSAKey 实例,然后继续调用 key 对象上的 encryptLong。真正的分段加密逻辑在 RSAKey.prototype.encryptLong:
RSAKey.prototype.encryptLong = function(text) {
var _this = this;
var maxLength = (this.n.bitLength() + 7 >> 3) - 11;
try {
var ct_1 = "";
if (text.length > maxLength) {
var lt = text.match(/.{1,117}/g);
lt.forEach(function(entry) {
var t1 = _this.encrypt(entry);
ct_1 += t1
});
return hex2b64(ct_1)
}
var t = this.encrypt(text);
var y = hex2b64(t);
return y
} catch (ex) {
return false
}
}
这里的关键点有三个:
this.n.bitLength()是 RSA 模数位数,这里是 1024,所以单段密文块大小是1024 / 8 = 128字节。- PKCS#1 v1.5 填充会占 11 字节,因此单次最多加密
128 - 11 = 117字节。 - 底层
this.encrypt(entry)返回的是 hex 字符串,最后还要通过hex2b64转成 Base64,才是请求体里看到的格式。
因此本地复现时除了 JSEncrypt 和公钥,还要补上站点扩展的 encryptLong 逻辑,以及它依赖的 hex2b64:
var b64map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var b64pad = "=";
function hex2b64(h) {
var i;
var c;
var ret = "";
for (i = 0; i + 3 <= h.length; i += 3) {
c = parseInt(h.substring(i, i + 3), 16);
ret += b64map.charAt(c >> 6) + b64map.charAt(c & 63)
}
if (i + 1 == h.length) {
c = parseInt(h.substring(i, i + 1), 16);
ret += b64map.charAt(c << 2)
} else if (i + 2 == h.length) {
c = parseInt(h.substring(i, i + 2), 16);
ret += b64map.charAt(c >> 2) + b64map.charAt((c & 3) << 4)
}
while ((ret.length & 3) > 0) {
ret += b64pad
}
return ret
}
如果只是验证当前接口的分页参数,还有一个更简单的写法:直接调用 encrypt.encrypt(data)。因为这里的 data 只是类似 {"limit":"20","page":"5"} 这样的短字符串,没有超过 RSA 1024-bit + PKCS#1 v1.5 的单次加密上限 117 字节,所以它和 encryptLong 的短文本分支效果一致。
本地最小测试可以这样写:
window = globalThis;
const JSEncrypt = require('./jsencrypt.min');
var paramPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvxXa98E1uWXnBzXkS2yHUfnBM6n3PCwLdfIox03T91joBvjtoDqiQ5x3tTOfpHs3LtiqMMEafls6b0YWtgB1dse1W5m+FpeusVkCOkQxB4SZDH6tuerIknnmB/Hsq5wgEkIvO5Pff9biig6AyoAkdWpSek/1/B7zYIepYY0lxKQIDAQAB";
var encrypt = new JSEncrypt();
encrypt.setPublicKey(paramPublicKey);
var enc_data = encrypt.encrypt(data);
console.log(enc_data);
如果要在这个最小版本上补齐 encryptLong,可以直接追加下面这段。这样后面调用 encrypt.encryptLong(data) 就和浏览器里的调用方式一致:
var b64map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var b64pad = "=";
function hex2b64(h) {
var i;
var c;
var ret = "";
for (i = 0; i + 3 <= h.length; i += 3) {
c = parseInt(h.substring(i, i + 3), 16);
ret += b64map.charAt(c >> 6) + b64map.charAt(c & 63);
}
if (i + 1 == h.length) {
c = parseInt(h.substring(i, i + 1), 16);
ret += b64map.charAt(c << 2);
} else if (i + 2 == h.length) {
c = parseInt(h.substring(i, i + 2), 16);
ret += b64map.charAt(c >> 2) + b64map.charAt((c & 3) << 4);
}
while ((ret.length & 3) > 0) {
ret += b64pad;
}
return ret;
}
JSEncrypt.prototype.encryptLong = function(text) {
var key = this.getKey();
var maxLength = (key.n.bitLength() + 7 >> 3) - 11;
try {
var ct = "";
if (text.length > maxLength) {
var chunks = text.match(new RegExp(".{1," + maxLength + "}", "g"));
chunks.forEach(function(chunk) {
ct += key.encrypt(chunk);
});
return hex2b64(ct);
}
return hex2b64(key.encrypt(text));
} catch (e) {
return false;
}
};
var enc_long_data = encrypt.encryptLong(data);
console.log(enc_long_data);
这里没有照搬浏览器里 JSEncrypt.prototype.encryptLong 的重试和自检逻辑,只保留核心加密流程。对本地构造请求来说,关键是分段大小、逐段 RSA 加密、hex 拼接后转 Base64 这三步。
两种方式的区别是:
| 方法 | 适用场景 | 说明 |
|---|---|---|
encrypt.encrypt(data) | 明文长度不超过 117 字节 | 标准 JSEncrypt 单段 RSA 加密,当前分页参数可以直接用 |
encrypt.encryptLong(data) | 明文可能超过 117 字节 | 站点扩展方法,会按 117 字节分段加密后拼接,再转 Base64 |
所以做最小验证时可以直接用 encrypt.encrypt(data);但如果要完整还原站点逻辑,或者后续请求参数变长,还是应该实现 encryptLong。
请求体加密看完后,继续分析签名这一行:var sign = MD5(data + requestId + timestamp);。从代码结构看,它先把明文 JSON 字符串 data、requestId、timestamp 直接拼接成一个字符串,再把拼接结果传入 MD5。这里需要确认两件事:第一,实际参与签名的拼接字符串是什么;第二,页面里的 MD5 是否就是标准 MD5。如果输出和本地标准 MD5 一致,就不需要继续深扣这个函数,后面直接用 CryptoJS 或 Python 标准库实现即可。浏览器中验证如下:
接着在本地用同一段拼接字符串计算 MD5:
const CryptoJS = require('./CryptoJS')
// var sign = CryptoJS.MD5(data + requestId + timestamp).toString();
var sign = CryptoJS.MD5('{"limit":"20","page":"9"}8f25a62c1d0d514bec181bb1419573861782127762000').toString();
console.log(sign)
输出结果如下:
对比浏览器中的 sign 可以看到,两边结果一致,说明这里使用的就是标准 MD5,没有额外魔改。到这一步,请求体加密、动态请求头和签名逻辑都已经确认,下面先把前面扣下来的逻辑整理成一份完整 JS 代码:
function getUuid() {
var s = [];
var hexDigits = "0123456789abcdef";
for (var i = 0; i < 32; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 16), 1)
}
s[14] = "4";
s[19] = hexDigits.substr(s[19] & 3 | 8, 1);
s[8] = s[13] = s[18] = s[23];
var uuid = s.join("");
return uuid
}
function sort_ASCII(obj) {
var arr = new Array;
var num = 0;
for (var i in obj) {
arr[num] = i;
num++
}
var sortArr = arr.sort();
var sortObj = {};
for (var i in sortArr) {
sortObj[sortArr[i]] = obj[sortArr[i]]
}
return sortObj
}
function dataTojson(data) {
var arr = [];
var res = {};
arr = data.split("&");
for (var i = 0; i < arr.length; i++) {
if (arr[i].indexOf("=") != -1) {
var str = arr[i].split("=");
if (str.length == 2) {
res[str[0]] = str[1]
} else {
res[str[0]] = ""
}
} else {
res[arr[i]] = ""
}
}
return res
}
window = globalThis;
const JSEncrypt = require('./jsencrypt.min')
const CryptoJS = require('./CryptoJS')
var b64map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var b64pad = "=";
function hex2b64(h) {
var i;
var c;
var ret = "";
for (i = 0; i + 3 <= h.length; i += 3) {
c = parseInt(h.substring(i, i + 3), 16);
ret += b64map.charAt(c >> 6) + b64map.charAt(c & 63);
}
if (i + 1 == h.length) {
c = parseInt(h.substring(i, i + 1), 16);
ret += b64map.charAt(c << 2);
} else if (i + 2 == h.length) {
c = parseInt(h.substring(i, i + 2), 16);
ret += b64map.charAt(c >> 2) + b64map.charAt((c & 3) << 4);
}
while ((ret.length & 3) > 0) {
ret += b64pad;
}
return ret;
}
JSEncrypt.prototype.encryptLong = function(text) {
var key = this.getKey();
var maxLength = (key.n.bitLength() + 7 >> 3) - 11;
try {
var ct = "";
if (text.length > maxLength) {
var chunks = text.match(new RegExp(".{1," + maxLength + "}", "g"));
chunks.forEach(function(chunk) {
ct += key.encrypt(chunk);
});
return hex2b64(ct);
}
return hex2b64(key.encrypt(text));
} catch (e) {
return false;
}
};
var paramPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvxXa98E1uWXnBzXkS2yHUfnBM6n3PCwLdfIox03T91joBvjtoDqiQ5x3tTOfpHs3LtiqMMEafls6b0YWtgB1dse1W5m+FpeusVkCOkQxB4SZDH6tuerIknnmB/Hsq5wgEkIvO5Pff9biig6AyoAkdWpSek/1/B7zYIepYY0lxKQIDAQAB";
var encrypt = new JSEncrypt;
encrypt.setPublicKey(paramPublicKey);
// 封装一个函数
function getEncData(string){
var timestamp = Date.parse(new Date);
var requestId = getUuid();
// let _data = 'page=5&limit=20'
var data = JSON.stringify(sort_ASCII(dataTojson(string || "{}")))
// 其实直接这样也是可以的
// let enc_data = encrypt.encrypt(data);
let enc_data = encrypt.encryptLong(data);
var sign = CryptoJS.MD5(data + requestId + timestamp).toString();
return {
timestamp: timestamp,
requestId: requestId,
sign: sign,
body: enc_data,
}
}
接下来开始写 Python 请求代码。为了少手写请求结构和 Headers,可以回到 Network 面板,右键这条接口请求,选择 Copy → Copy as cURL (bash),再粘贴到 curlconverter.com 转成 Python 代码。转换出来的代码只作为请求模板使用,其中会随每次请求变化的字段,比如请求头里的 timestamp、requestId、sign,以及请求体里的密文 data,都先注释掉,后面统一用上面整理好的 birdreport.js 动态生成。整理后如下:
# -*- coding: utf-8 -*-
"""
@File : birdreport.py
@Author : XAMO Lab
@Date : 2026/6/22 14:58
@Blog : https://blog.csdn.net/xw1680
@Tool : PyCharm
@Desc :
"""
import subprocess
import requests
from functools import partial
subprocess.Popen = partial(subprocess.Popen, encoding="utf-8")
import execjs
ctx = execjs.compile(open('birdreport.js', encoding='utf-8').read())
result = ctx.call('getEncData', 'page=1&limit=20')
print(type(result))
print(result)
headers = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Origin': 'https://www.birdreport.cn',
'Pragma': 'no-cache',
'Referer': 'https://www.birdreport.cn/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
# 'requestId': '61bd46db1930d14fc318107158e08d25',
'requestId': result.get('requestId'),
'sec-ch-ua': '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
# 'sign': '6089bcda9defd16ed88cee2f8458470d',
'sign': result.get("sign"),
# 'timestamp': '1782112505000',
'timestamp': f'{result.get("timestamp")}',
}
# data = 'FBbr2txCLSfZUp7DFOgOD8e+z8FPxR33XOqILoKKeKYL8op+7+fixgGUDgpmknEfZXLvX9Bv/Kgf20wZDOLEguo7PBZ0W5XhMsdlydl9yqAvGeI1r8qGVJrD1y3weD/YNsiBjP2ZB6DiLBklcAj6PH23h+Wg8ojoBlI7JnUZyhA='
data = result.get('body')
response = requests.post('https://api.birdreport.cn/front/activity/search', headers=headers, data=data)
print(response.status_code)
print(response.text)
运行后接口返回 200,响应结构也和浏览器抓包一致,可以看到真正的数据仍然放在 data 字段的密文中。这说明请求侧的 RSA 加密、MD5 签名和动态请求头已经复现成功,结果如下:
请求侧跑通之后,下一步就是处理响应 data 的解密。这里可以通过 Hook JSON.parse、CryptoJS.AES.decrypt,或者直接回溯调用栈定位解密入口。Hook 的通用写法前面已经在 全国新书网 —— AES/CBC 双向加解密 里详细讲过,这里不再重复展开。命中后可以看到,表格数据在返回前会先走 BIRDREPORT_APIJS.decode(resp.data):
if (resp.count > 0) {
var decryptData = BIRDREPORT_APIJS.decode(resp.data);
var data = JSON.parse(decryptData);
return $.extend({}, rs, {"code": resp.code, "count": resp.count, "data": data, "msg": resp.msg});
}
继续跟进 BIRDREPORT_APIJS.decode,入口在 aes.util.js 中。这个文件做了 OB 混淆,但核心结构比较清楚:先处理 key 和 iv,再调用 CryptoJS.AES.decrypt 做解密:
_0x48989b[_0x1b50('0x20')]['decode'] = function(_0x291626) {
var _0x3c6fa1 = CryptoJS['enc'][_0x1b50('0xc')][_0x1b50('0x5')](this[_0x1b50('0x27')](this[_0x1b50('0x28')]))
, _0x3ec027 = CryptoJS[_0x1b50('0xe')][_0x1b50('0xc')][_0x1b50('0x5')](this[_0x1b50('0x27')](this['iv']));
return CryptoJS[_0x1b50('0x13')][_0x1b50('0x1b')](_0x291626, _0x3c6fa1, {
'iv': _0x3ec027,
'mode': CryptoJS[_0x1b50('0x3d')]['CBC'],
'padding': CryptoJS['pad'][_0x1b50('0x24')]
})['toString'](CryptoJS['enc']['Utf8']);
}
单步查看 _0x1b50(...) 和 this[...] 的实际值后,可以把关键逻辑还原出来。为了方便对比,下面保留了原始混淆表达式和还原后的写法:
_0x48989b[_0x1b50('0x20')]['decode'] = function(_0x291626) {
// CryptoJS['enc'][_0x1b50('0xc')][_0x1b50('0x5')]
// => CryptoJS.enc.Utf8.parse
// this[_0x1b50('0x27')](this[_0x1b50('0x28')])
// => 'C8EB5514AF5ADDB94B2207B08C66601C'
var key = CryptoJS.enc.Utf8.parse('C8EB5514AF5ADDB94B2207B08C66601C');
// this[_0x1b50('0x27')](this['iv'])
// => '55DD79C6F04E1A67'
var iv = CryptoJS.enc.Utf8.parse('55DD79C6F04E1A67');
return CryptoJS.AES.decrypt(_0x291626, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}).toString(CryptoJS.enc.Utf8);
}
由此可以确定响应解密算法是 AES/CBC/Pkcs7。这里的 key 是按 UTF-8 解析的 32 字节字符串,iv 是按 UTF-8 解析的 16 字节字符串。多次刷新和翻页调试后,它们都没有变化,所以本地复现时可以先写死。
接着把解密逻辑封装到 birdreport.js 中,方便 Python 侧通过 execjs 调用:
function decryptRes(encResData) {
var key = CryptoJS.enc.Utf8.parse('C8EB5514AF5ADDB94B2207B08C66601C');
var iv = CryptoJS.enc.Utf8.parse('55DD79C6F04E1A67');
return CryptoJS.AES.decrypt(encResData, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}).toString(CryptoJS.enc.Utf8);
}
在前面的 Python 测试代码里,取出响应里的 data 密文,再调用 decryptRes 验证:
# 测试解密
enc_res_data = response.json().get('data')
print(ctx.call('decryptRes', enc_res_data))
运行后可以打印出明文 JSON,说明响应解密逻辑已经复现成功:
二、完整请求流程图
原始参数 (page=9&limit=20)
│
▼
dataTojson() → {page:"9", limit:"20"}
│
▼
sort_ASCII() → 按key字母排序
│
▼
JSON.stringify() → 明文data字符串
│
├──────────────────────────────────────┐
▼ ▼
RSA公钥加密(encryptLong) MD5(data + requestId + timestamp)
│ │
▼ ▼
请求体(Base64密文) 请求头 sign
+ 请求头 timestamp
+ 请求头 requestId
响应JSON.data (Base64密文)
│
▼
AES-CBC解密 (key, iv, Pkcs7)
│
▼
明文JSON数据(观鸟记录列表)
三、Python 实现
这一节给出两种实现方式:
Python 完全纯算:用pycryptodome直接复现 RSA 请求加密、MD5 签名和 AES 响应解密。Python 调用 execjs:保留前面扣下来的核心 JS 逻辑,Python 只负责请求、并发和字段整理。
如果只是为了写爬虫,纯 Python 版更方便部署;如果还在验证 JS 逻辑,或者希望尽量贴近浏览器执行结果,execjs 版更直观。
目录说明:下面的代码不依赖固定的本机目录,建议按下面这种结构放置:
birdreport_case
├─ birdreport_activity_spider.py
├─ birdreport_activity_execjs_spider.py
└─ js/
├─ birdreport_crypto.js
├─ CryptoJS.js # execjs 版本需要,可以放这里
└─ jsencrypt.min.js # execjs 版本需要,可以放这里
如果你的项目里已经有统一的 shared/js-libs 公共库目录,也可以不把 CryptoJS.js 和 jsencrypt.min.js 复制到当前 js/ 目录。下面的 execjs 脚本会先找当前 js/,再向上查找 shared/js-libs;目录结构不一样时,也可以通过环境变量 JS_REVERSE_SHARED_JS_LIB_DIR 指定公共库目录。
依赖安装:
pip install requests pycryptodome loguru PyExecJS
其中 PyExecJS 版本还需要本地能正常调用 Node.js。
3.1 Python 完全纯算
纯 Python 版不依赖 JS 文件,适合最终落地使用。核心点是三处:
- 请求参数先按前端逻辑转成
{"limit":"20","page":"1"}这种明文 JSON 字符串。 - 明文 JSON 用 RSA/PKCS#1 v1.5 分段加密,请求头中的
sign用MD5(data + requestId + timestamp)生成。 - 响应里的
data字段用 AES/CBC/PKCS7 解密,再提取表格字段。
文件名:birdreport_activity_spider.py
# -*- coding: utf-8 -*-
"""
@File : birdreport_activity_spider.py
@Author : XAMO Lab
@Date : 2026/6/23 2:06
@Blog : https://blog.csdn.net/xw1680
@Tool : PyCharm
@Desc : 中国观鸟记录中心记录查询采集(RSA 请求加密 + MD5 签名 + AES/CBC/PKCS7 响应解密 + 多线程)
"""
import base64
import hashlib
import json
import random
import sys
import time
import warnings
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, List
warnings.filterwarnings(
"ignore",
message=r"urllib3 .* or chardet .*charset_normalizer .* doesn't match a supported version!",
)
import requests
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Util.Padding import unpad
from loguru import logger
logger.remove()
logger.add(sys.stdout, level="INFO")
class BirdreportActivitySpider:
"""中国观鸟记录中心记录查询爬虫"""
API_URL = "https://api.birdreport.cn/front/activity/search"
SITE_URL = "https://www.birdreport.cn/home/activity/page.html"
ORIGIN = "https://www.birdreport.cn"
RSA_PUBLIC_KEY_B64 = (
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvxXa98E1uWXnBzXkS2yHUfnBM6n3PCw"
"LdfIox03T91joBvjtoDqiQ5x3tTOfpHs3LtiqMMEafls6b0YWtgB1dse1W5m+FpeusVkCO"
"kQxB4SZDH6tuerIknnmB/Hsq5wgEkIvO5Pff9biig6AyoAkdWpSek/1/B7zYIepYY0lxKQIDAQAB"
)
AES_KEY_MAP = "6756696653534952657053656868665752665050485566485667545454484967"
AES_IV_MAP = "53536868555767547048526949655455"
def __init__(self, page_size: int = 20, max_workers: int = 3, retries: int = 3) -> None:
"""
:param page_size: 每页条数,页面默认 20
:param max_workers: 并发线程数
:param retries: 单页请求失败后的重试次数
"""
self.page_size = page_size
self.max_workers = max_workers
self.retries = retries
self.session = requests.Session()
self.rsa_key = RSA.import_key(
"-----BEGIN PUBLIC KEY-----\n"
+ self.RSA_PUBLIC_KEY_B64
+ "\n-----END PUBLIC KEY-----"
)
self.aes_key = self._decode_mapping(self.AES_KEY_MAP).encode("utf-8")
self.aes_iv = self._decode_mapping(self.AES_IV_MAP).encode("utf-8")
self.session.headers.update({
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": self.ORIGIN,
"Pragma": "no-cache",
"Referer": self.SITE_URL,
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/149.0.0.0 Safari/537.36"
),
"X-Requested-With": "XMLHttpRequest",
})
@staticmethod
def _decode_mapping(text: str) -> str:
"""还原 aes.util.js 中的 getMapping:每 2 位十进制数字转一个字符。"""
return "".join(chr(int(text[index:index + 2], 10)) for index in range(0, len(text), 2))
@staticmethod
def _request_id() -> str:
"""复现前端 getUuid 生成逻辑。"""
hex_digits = "0123456789abcdef"
chars = [random.choice(hex_digits) for _ in range(32)]
chars[14] = "4"
value = int(chars[19]) if chars[19].isdigit() else 0
chars[19] = hex_digits[(value & 3) | 8]
chars[8] = chars[13] = chars[18] = chars[23]
return "".join(chars)
def _plain_payload(self, page: int) -> str:
"""复现 JSON.stringify(sort_ASCII(dataTojson(options.data)))。"""
params = {
"page": str(page),
"limit": str(self.page_size),
}
return json.dumps(dict(sorted(params.items())), ensure_ascii=False, separators=(",", ":"))
def _encrypt_request_body(self, plaintext: str) -> str:
"""RSA/PKCS#1 v1.5 分段加密请求明文,输出 Base64 密文。"""
cipher = PKCS1_v1_5.new(self.rsa_key)
plain_bytes = plaintext.encode("utf-8")
max_chunk = self.rsa_key.size_in_bytes() - 11
encrypted = b"".join(
cipher.encrypt(plain_bytes[index:index + max_chunk])
for index in range(0, len(plain_bytes), max_chunk)
)
return base64.b64encode(encrypted).decode("ascii")
def _signed_headers(self, plaintext: str) -> Dict[str, str]:
"""生成 timestamp、requestId、sign 三个动态请求头。"""
timestamp = str(int(time.time() * 1000))
request_id = self._request_id()
sign = hashlib.md5((plaintext + request_id + timestamp).encode("utf-8")).hexdigest()
return {
"timestamp": timestamp,
"requestId": request_id,
"sign": sign,
}
def _decrypt_response_data(self, ciphertext: str) -> Any:
"""Base64 密文 -> AES/CBC/PKCS7 解密 -> JSON 对象。"""
raw = base64.b64decode(ciphertext)
cipher = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv)
plaintext = unpad(cipher.decrypt(raw), AES.block_size).decode("utf-8")
return json.loads(plaintext)
@staticmethod
def _records_from_decrypted(decrypted: Any) -> List[Dict[str, Any]]:
"""兼容响应解密后直接返回列表或包一层对象的情况。"""
if isinstance(decrypted, list):
return decrypted
if isinstance(decrypted, dict):
for key in ("data", "list", "records", "rows"):
value = decrypted.get(key)
if isinstance(value, list):
return value
return []
@staticmethod
def _observation_time(item: Dict[str, Any]) -> str:
"""拼接观测时间。"""
start_time = item.get("startTime") or item.get("start_time") or item.get("beginTime") or ""
end_time = item.get("endTime") or item.get("end_time") or ""
if start_time and end_time and end_time != start_time:
return f"{start_time} 至 {end_time}"
return start_time or end_time
@staticmethod
def _parse_item(item: Dict[str, Any], page: int, index: int) -> Dict[str, Any]:
"""提取记录查询表格字段,统一输出结构。"""
return {
"page": page,
"index": index,
"report_id": item.get("serialId") or item.get("reportId") or item.get("id") or "",
"observation_time": BirdreportActivitySpider._observation_time(item),
"location": item.get("address") or item.get("location") or item.get("siteName") or "",
"username": item.get("username") or item.get("userName") or item.get("nickname") or "",
"species_count": item.get("taxonCount") or item.get("speciesCount") or item.get("birdCount") or 0,
"view_count": item.get("visitsCount") or item.get("viewCount") or item.get("browseCount") or 0,
}
def fetch_page(self, page: int) -> List[Dict[str, Any]]:
"""请求单页、解密响应并返回结构化观鸟记录。"""
plaintext = self._plain_payload(page)
encrypted_body = self._encrypt_request_body(plaintext)
for attempt in range(1, self.retries + 1):
try:
logger.info("page={} 开始请求 | plain={}", page, plaintext)
response = self.session.post(
self.API_URL,
data=encrypted_body,
headers=self._signed_headers(plaintext),
timeout=20,
)
response.raise_for_status()
payload = response.json()
if payload.get("code") != 0:
raise RuntimeError(f"接口异常 code={payload.get('code')} msg={payload.get('msg')}")
decrypted = self._decrypt_response_data(payload.get("data", ""))
records = self._records_from_decrypted(decrypted)
rows = [
self._parse_item(item, page, index)
for index, item in enumerate(records, 1)
]
logger.success(
"page={} 解密成功,获取 {} 条记录 | count={}",
page,
len(rows),
payload.get("count"),
)
return rows
except Exception as exc:
if attempt >= self.retries:
raise
logger.warning("page={} 第 {} 次请求失败,准备重试: {}", page, attempt, exc)
time.sleep(0.5 * attempt)
return []
def run(self, pages: int = 3) -> List[Dict[str, Any]]:
"""并发采集前 pages 页记录查询数据。"""
logger.info(
"开始采集中国观鸟记录中心记录查询 | 共 {} 页 | 每页 {} 条 | 并发 {}",
pages,
self.page_size,
self.max_workers,
)
all_rows: List[Dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_map = {
executor.submit(self.fetch_page, page): page
for page in range(1, pages + 1)
}
for future in as_completed(future_map):
page = future_map[future]
try:
all_rows.extend(future.result())
except Exception as exc:
logger.error("page={} 采集失败: {}", page, exc)
all_rows.sort(key=lambda row: (int(row["page"]), int(row["index"])))
logger.info("采集完成,共 {} 条观鸟记录", len(all_rows))
return all_rows
if __name__ == "__main__":
spider = BirdreportActivitySpider(page_size=20, max_workers=3, retries=3)
result = spider.run(pages=3)
for row in result:
logger.info("{}", json.dumps(row, ensure_ascii=False))
3.2 Python 调用 execjs
execjs 版把请求加密、签名和响应解密继续放在 JS 中,Python 只负责加载 JS、发送请求、并发翻页和字段整理。这个方式更适合刚扣完 JS 后做对照验证。
先准备核心 JS 文件,文件名:js/birdreport_crypto.js。这个文件只放本站相关逻辑,不放 CryptoJS.js、jsencrypt.min.js 这类公共库。
/* 中国观鸟记录中心记录查询
* 本文件只保留本站核心 JS 逻辑。
* CryptoJS 和 JSEncrypt 由 Python 侧 execjs 编译前注入。
*/
const PARAM_PUBLIC_KEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCvxXa98E1uWXnBzXkS2yHUfnBM6n3PCwLdfIox03T91joBvjtoDqiQ5x3tTOfpHs3LtiqMMEafls6b0YWtgB1dse1W5m+FpeusVkCOkQxB4SZDH6tuerIknnmB/Hsq5wgEkIvO5Pff9biig6AyoAkdWpSek/1/B7zYIepYY0lxKQIDAQAB";
const AES_KEY_MAP = "6756696653534952657053656868665752665050485566485667545454484967";
const AES_IV_MAP = "53536868555767547048526949655455";
const b64map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const b64pad = "=";
function hex2b64(hex) {
let i;
let c;
let ret = "";
for (i = 0; i + 3 <= hex.length; i += 3) {
c = parseInt(hex.substring(i, i + 3), 16);
ret += b64map.charAt(c >> 6) + b64map.charAt(c & 63);
}
if (i + 1 === hex.length) {
c = parseInt(hex.substring(i, i + 1), 16);
ret += b64map.charAt(c << 2);
} else if (i + 2 === hex.length) {
c = parseInt(hex.substring(i, i + 2), 16);
ret += b64map.charAt(c >> 2) + b64map.charAt((c & 3) << 4);
}
while ((ret.length & 3) > 0) {
ret += b64pad;
}
return ret;
}
JSEncrypt.prototype.encryptLong = function(text) {
const key = this.getKey();
const maxLength = (key.n.bitLength() + 7 >> 3) - 11;
try {
let ct = "";
if (text.length > maxLength) {
const chunks = text.match(new RegExp(".{1," + maxLength + "}", "g"));
chunks.forEach(chunk => {
ct += key.encrypt(chunk);
});
return hex2b64(ct);
}
return hex2b64(key.encrypt(text));
} catch (e) {
return false;
}
};
function getUuid() {
const s = [];
const hexDigits = "0123456789abcdef";
for (let i = 0; i < 32; i += 1) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 16), 1);
}
s[14] = "4";
s[19] = hexDigits.substr(s[19] & 3 | 8, 1);
s[8] = s[13] = s[18] = s[23];
return s.join("");
}
function dataTojson(data) {
const arr = data.split("&");
const res = {};
for (let i = 0; i < arr.length; i += 1) {
if (arr[i].indexOf("=") !== -1) {
const str = arr[i].split("=");
res[str[0]] = str.length === 2 ? str[1] : "";
} else {
res[arr[i]] = "";
}
}
return res;
}
function sort_ASCII(obj) {
const sortObj = {};
Object.keys(obj).sort().forEach(key => {
sortObj[key] = obj[key];
});
return sortObj;
}
function getMapping(text) {
let out = "";
for (let i = 0; i < text.length; i += 2) {
out += String.fromCharCode(parseInt(text.substr(i, 2), 10));
}
return out;
}
function plainPayload(page, limit) {
const query = "page=" + page + "&limit=" + limit;
return JSON.stringify(sort_ASCII(dataTojson(query || "{}")));
}
function makeRequest(page, limit) {
const data = plainPayload(page || 1, limit || 20);
const timestamp = Date.parse(new Date()).toString();
const requestId = getUuid();
const encryptor = new JSEncrypt();
encryptor.setPublicKey(PARAM_PUBLIC_KEY);
return {
timestamp: timestamp,
requestId: requestId,
sign: CryptoJS.MD5(data + requestId + timestamp).toString(),
body: encryptor.encryptLong(data),
plain: data,
};
}
function decryptResponse(ciphertext) {
const key = CryptoJS.enc.Utf8.parse(getMapping(AES_KEY_MAP));
const iv = CryptoJS.enc.Utf8.parse(getMapping(AES_IV_MAP));
const plaintext = CryptoJS.AES.decrypt(ciphertext, key, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
}).toString(CryptoJS.enc.Utf8);
return JSON.parse(plaintext);
}
然后是 Python 调用 execjs 的版本,文件名:birdreport_activity_execjs_spider.py。
# -*- coding: utf-8 -*-
"""
@File : birdreport_activity_execjs_spider.py
@Author : XAMO Lab
@Date : 2026/6/23 2:36
@Blog : https://blog.csdn.net/xw1680
@Tool : PyCharm
@Desc : 中国观鸟记录中心记录查询采集(Python 调用 execjs 复现)
"""
import json
import os
import subprocess
import sys
import time
import warnings
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial
from pathlib import Path
from typing import Any, Dict, List
warnings.filterwarnings(
"ignore",
message=r"urllib3 .* or chardet .*charset_normalizer .* doesn't match a supported version!",
)
import requests
from loguru import logger
subprocess.Popen = partial(subprocess.Popen, encoding="utf-8")
import execjs
logger.remove()
logger.add(sys.stdout, level="INFO")
BASE_DIR = Path(__file__).resolve().parent
JS_DIR = BASE_DIR / "js"
CORE_JS = JS_DIR / "birdreport_crypto.js"
class BirdreportActivityExecjsSpider:
"""中国观鸟记录中心记录查询爬虫(Python 调 execjs 版本)"""
API_URL = "https://api.birdreport.cn/front/activity/search"
SITE_URL = "https://www.birdreport.cn/home/activity/page.html"
ORIGIN = "https://www.birdreport.cn"
def __init__(self, page_size: int = 20, max_workers: int = 3, retries: int = 3) -> None:
"""
:param page_size: 每页条数,页面默认 20
:param max_workers: 并发线程数
:param retries: 单页请求失败后的重试次数
"""
self.page_size = page_size
self.max_workers = max_workers
self.retries = retries
self.ctx = self._compile_js()
self.session = requests.Session()
self.session.headers.update({
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "zh-CN,zh;q=0.9",
"Cache-Control": "no-cache",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": self.ORIGIN,
"Pragma": "no-cache",
"Referer": self.SITE_URL,
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/149.0.0.0 Safari/537.36"
),
"X-Requested-With": "XMLHttpRequest",
})
@staticmethod
def _has_js_libs(path: Path) -> bool:
return (path / "CryptoJS.js").exists() and (path / "jsencrypt.min.js").exists()
@classmethod
def _resolve_js_lib_dir(cls) -> Path:
"""优先使用环境变量,其次找当前 js 目录,最后向上找 shared/js-libs。"""
env_dir = os.getenv("JS_REVERSE_SHARED_JS_LIB_DIR")
if env_dir:
path = Path(env_dir).expanduser().resolve()
if cls._has_js_libs(path):
return path
candidates = [JS_DIR]
candidates.extend(parent / "shared" / "js-libs" for parent in [BASE_DIR, *BASE_DIR.parents])
for path in candidates:
if cls._has_js_libs(path):
return path
raise FileNotFoundError("未找到 CryptoJS.js 和 jsencrypt.min.js,请放到 js/ 目录或设置 JS_REVERSE_SHARED_JS_LIB_DIR")
@classmethod
def _compile_js(cls) -> execjs.ExternalRuntime.Context:
"""读取公共库和本站核心 JS,编译为 execjs 上下文。"""
js_lib_dir = cls._resolve_js_lib_dir()
prefix = f'''
if (typeof window === "undefined") {{
global.window = global;
global.self = global;
}}
const CryptoJS = require({json.dumps(str(js_lib_dir / "CryptoJS.js"), ensure_ascii=False)});
const JSEncryptModule = require({json.dumps(str(js_lib_dir / "jsencrypt.min.js"), ensure_ascii=False)});
const JSEncrypt = JSEncryptModule.default || JSEncryptModule;
'''
source = prefix + "\n" + CORE_JS.read_text(encoding="utf-8")
return execjs.compile(source)
def _make_request_crypto(self, page: int) -> Dict[str, Any]:
"""调用 JS 生成请求体密文和动态签名头。"""
return self.ctx.call("makeRequest", page, self.page_size)
def _decrypt_response_data(self, ciphertext: str) -> Any:
"""调用 JS 解密响应 data 字段。"""
return self.ctx.call("decryptResponse", ciphertext)
@staticmethod
def _records_from_decrypted(decrypted: Any) -> List[Dict[str, Any]]:
if isinstance(decrypted, list):
return decrypted
if isinstance(decrypted, dict):
for key in ("data", "list", "records", "rows"):
value = decrypted.get(key)
if isinstance(value, list):
return value
return []
@staticmethod
def _observation_time(item: Dict[str, Any]) -> str:
start_time = item.get("startTime") or item.get("start_time") or item.get("beginTime") or ""
end_time = item.get("endTime") or item.get("end_time") or ""
if start_time and end_time and end_time != start_time:
return f"{start_time} 至 {end_time}"
return start_time or end_time
@staticmethod
def _parse_item(item: Dict[str, Any], page: int, index: int) -> Dict[str, Any]:
return {
"page": page,
"index": index,
"report_id": item.get("serialId") or item.get("reportId") or item.get("id") or "",
"observation_time": BirdreportActivityExecjsSpider._observation_time(item),
"location": item.get("address") or item.get("location") or item.get("siteName") or "",
"username": item.get("username") or item.get("userName") or item.get("nickname") or "",
"species_count": item.get("taxonCount") or item.get("speciesCount") or item.get("birdCount") or 0,
"view_count": item.get("visitsCount") or item.get("viewCount") or item.get("browseCount") or 0,
}
def fetch_page(self, page: int) -> List[Dict[str, Any]]:
for attempt in range(1, self.retries + 1):
try:
crypto = self._make_request_crypto(page)
headers = {
"timestamp": str(crypto["timestamp"]),
"requestId": crypto["requestId"],
"sign": crypto["sign"],
}
logger.info("page={} 开始请求 | plain={}", page, crypto.get("plain"))
response = self.session.post(self.API_URL, data=crypto["body"], headers=headers, timeout=20)
response.raise_for_status()
payload = response.json()
if payload.get("code") != 0:
raise RuntimeError(f"接口异常 code={payload.get('code')} msg={payload.get('msg')}")
decrypted = self._decrypt_response_data(payload.get("data", ""))
records = self._records_from_decrypted(decrypted)
rows = [self._parse_item(item, page, index) for index, item in enumerate(records, 1)]
logger.success("page={} 解密成功,获取 {} 条记录 | count={}", page, len(rows), payload.get("count"))
return rows
except Exception as exc:
if attempt >= self.retries:
raise
logger.warning("page={} 第 {} 次请求失败,准备重试: {}", page, attempt, exc)
time.sleep(0.5 * attempt)
return []
def run(self, pages: int = 3) -> List[Dict[str, Any]]:
logger.info("开始采集中国观鸟记录中心记录查询(execjs 版)| 共 {} 页 | 每页 {} 条 | 并发 {}", pages, self.page_size, self.max_workers)
all_rows: List[Dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_map = {executor.submit(self.fetch_page, page): page for page in range(1, pages + 1)}
for future in as_completed(future_map):
page = future_map[future]
try:
all_rows.extend(future.result())
except Exception as exc:
logger.error("page={} 采集失败: {}", page, exc)
all_rows.sort(key=lambda row: (int(row["page"]), int(row["index"])))
logger.info("采集完成,共 {} 条观鸟记录", len(all_rows))
return all_rows
if __name__ == "__main__":
spider = BirdreportActivityExecjsSpider(page_size=20, max_workers=3, retries=3)
result = spider.run(pages=3)
for row in result:
logger.info("{}", json.dumps(row, ensure_ascii=False))
两个版本的输出字段保持一致:
| 字段 | 含义 |
|---|---|
report_id | 报告编号 |
observation_time | 观测时间 |
location | 观测地点 |
username | 记录用户 |
species_count | 鸟种数量 |
view_count | 浏览数 |
四、总结
| 环节 | 要点 |
|---|---|
| 抓包 | POST 请求,请求体为 RSA 密文(Base64),响应 data 为 AES 密文(Base64) |
| 定位 | 搜索 requestId 在 $.ajaxSetup 的 beforeSend 中找到加密入口 |
| 请求加密 | RSA 1024-bit 公钥加密,网页通过扩展的 encryptLong 兼容长文本 |
| 签名 | MD5(排序后的 JSON 字符串 + requestId + timestamp) → 32 位小写 hex |
| 响应解密 | AES-256/CBC/PKCS7,Key 和 IV 从 OB 混淆的 aes.util.js 中还原 |
| OB 混淆 | aes.util.js 中 key/iv 是数字串,需通过 fromCharCode 两位一组转换为 ASCII |
本案例的核心收获:
- 三层处理要分开看:请求体 RSA 加密负责隐藏明文参数,
requestId + timestamp + MD5负责让服务端校验本次请求的一致性,响应 AES 加密负责隐藏返回数据。逆向时不要只盯着其中一层,否则请求可能能发出去,但会卡在签名或响应解密上。 $.ajaxSetup是 jQuery 项目的加密高频位置。它会拦截所有 AJAX 请求,在发送前统一做加密和签名。类似 Vue 项目的 axios 拦截器。- OB 混淆的 key/iv 不要直接用对象上的值。
BIRDREPORT_APIJS.key存的是数字串("6756696653534952..."),真正的 AES 密钥需要经过fromCharCode转换。如果直接拿数字串当 key 用,解密会失败。 - RSA 分段加密(encryptLong) 是 JSEncrypt 库的扩展方法。标准 RSA 单次加密有长度限制(密钥位数/8 - 填充开销),1024-bit RSA + PKCS#1 v1.5 对应的单块上限是 117 字节。网页 JS 中按
117个字符切分,本案例请求参数都是 ASCII 字符,所以和 117 字节等价;纯 Python 实现时按字节处理更稳。 - MD5 签名的明文和拼接顺序都很重要:这里的
data不是原始的page=1&limit=20,而是参数 ASCII 排序后再JSON.stringify得到的字符串。最后按data + requestId + timestamp拼接,顺序错了签名就不对。 - 落地代码可以拆成两种版本:纯 Python 版适合最终使用,
execjs版适合刚扣完 JS 后做对照验证。两者输出字段保持一致,后续排查问题会更方便。
1388

被折叠的 条评论
为什么被折叠?



