中国观鸟记录中心 —— RSA 请求加密 + MD5 签名 + AES 响应解密

免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、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

观察发现三个问题

  1. 请求体是密文:一段 Base64 字符串,不是明文参数


  2. 请求头有动态参数signtimestamprequestId 每次请求都不同

    requestId: 0e29693240cbd44a3949bf347ca493bd
    sign: 5c0fa2318787c31fd9ec4ca900dd6c9b
    timestamp: 1782114136000
    
    requestId: 33cfa60dd69ded410fd8f88d638714ec
    sign: ec5cd2fee8d78443d7c1605e6573acfb
    timestamp: 1782114160000
    
    requestId: f31f75a289f6f845418b0af86939de7a
    sign: 0fcb490373e6cf1b4e9b2e2afe7bca00
    timestamp: 1782114161000
    
  3. 响应体也是密文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.setRequestHeadertimestamprequestIdsign 这三个动态请求头就是在这里设置的。需要进一步确认的话,可以放开断点后回到 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 字符串:


这里真正需要还原的是两个自定义函数:dataTojsonsort_ASCIIJSON.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
    }
}

这里的关键点有三个:

  1. this.n.bitLength() 是 RSA 模数位数,这里是 1024,所以单段密文块大小是 1024 / 8 = 128 字节。
  2. PKCS#1 v1.5 填充会占 11 字节,因此单次最多加密 128 - 11 = 117 字节。
  3. 底层 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 字符串 datarequestIdtimestamp 直接拼接成一个字符串,再把拼接结果传入 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 代码。转换出来的代码只作为请求模板使用,其中会随每次请求变化的字段,比如请求头里的 timestamprequestIdsign,以及请求体里的密文 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.parseCryptoJS.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 混淆,但核心结构比较清楚:先处理 keyiv,再调用 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 实现

这一节给出两种实现方式:

  1. Python 完全纯算:用 pycryptodome 直接复现 RSA 请求加密、MD5 签名和 AES 响应解密。
  2. 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.jsjsencrypt.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 文件,适合最终落地使用。核心点是三处:

  1. 请求参数先按前端逻辑转成 {"limit":"20","page":"1"} 这种明文 JSON 字符串。
  2. 明文 JSON 用 RSA/PKCS#1 v1.5 分段加密,请求头中的 signMD5(data + requestId + timestamp) 生成。
  3. 响应里的 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.jsjsencrypt.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$.ajaxSetupbeforeSend 中找到加密入口
请求加密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

本案例的核心收获

  1. 三层处理要分开看:请求体 RSA 加密负责隐藏明文参数,requestId + timestamp + MD5 负责让服务端校验本次请求的一致性,响应 AES 加密负责隐藏返回数据。逆向时不要只盯着其中一层,否则请求可能能发出去,但会卡在签名或响应解密上。
  2. $.ajaxSetup 是 jQuery 项目的加密高频位置。它会拦截所有 AJAX 请求,在发送前统一做加密和签名。类似 Vue 项目的 axios 拦截器。
  3. OB 混淆的 key/iv 不要直接用对象上的值BIRDREPORT_APIJS.key 存的是数字串("6756696653534952..."),真正的 AES 密钥需要经过 fromCharCode 转换。如果直接拿数字串当 key 用,解密会失败。
  4. RSA 分段加密(encryptLong) 是 JSEncrypt 库的扩展方法。标准 RSA 单次加密有长度限制(密钥位数/8 - 填充开销),1024-bit RSA + PKCS#1 v1.5 对应的单块上限是 117 字节。网页 JS 中按 117 个字符切分,本案例请求参数都是 ASCII 字符,所以和 117 字节等价;纯 Python 实现时按字节处理更稳。
  5. MD5 签名的明文和拼接顺序都很重要:这里的 data 不是原始的 page=1&limit=20,而是参数 ASCII 排序后再 JSON.stringify 得到的字符串。最后按 data + requestId + timestamp 拼接,顺序错了签名就不对。
  6. 落地代码可以拆成两种版本:纯 Python 版适合最终使用,execjs 版适合刚扣完 JS 后做对照验证。两者输出字段保持一致,后续排查问题会更方便。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

XAMO Lab

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值