CSDN 高校 IT 实力排行榜 —— HMAC-SHA256 请求头签名还原

免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、robots 协议、用户协议以及获得合法授权的前提下进行学习和实验。请勿将本文中的方法、脚本或思路用于未授权访问、批量采集、账号撞库、绕过风控、破坏验证码体系、规避平台限制、侵犯数据权益、商业化滥用或影响线上系统稳定性的行为。对于真实网站案例,读者不应直接复制代码对线上服务进行高频请求或非授权调用。若相关网站、产品方、权利方或平台认为本文内容存在不适宜公开展示之处,可通过评论区、私信或作者主页提供的联系方式联系我;核实后将及时删除、替换或调整相关内容。读者因不当使用本文内容造成的任何法律责任、业务风险或经济损失,均由使用者自行承担,与作者无关。

一、分析

目标地址:

https://bbs.csdn.net/college?utm_source=csdn_bbs_toolbar&spm=1035.2022.3001.8850&category=37

本案例需要抓取 CSDN 高校 IT 实力排行榜数据,循环采集前 3 页,并提取每条记录中的学校名称、7 日活跃人数、持续学习天数、影响力和成员数量。

打开 F12 进入 DevTools,切换到 Network 面板并清空请求记录。页面首屏数据会随 HTML 一起渲染出来,直接刷新时不一定能看到目标接口请求;因此这里先筛选 Fetch/XHR,再点击分页按钮触发异步加载,最终定位到排行榜列表接口:


点开这个请求后,可以在 Headers 面板中看到完整的请求地址:

GET https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list?deviceType=pc&page=1&pageSize=20&category=37&sort=desc&type=2
GET https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list?deviceType=pc&page=2&pageSize=20&category=37&sort=desc&type=2
GET https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list?deviceType=pc&page=3&pageSize=20&category=37&sort=desc&type=2
GET https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list?deviceType=pc&page=4&pageSize=20&category=37&sort=desc&type=2

这个接口的请求参数都是明文传输,主要参数含义如下:

参数含义
deviceType设备类型,PC 端固定为 pc
page页码,从 1 开始
pageSize每页数量,页面中固定为 20
category地区分类,本案例目标分类为 37
sort排序方向,默认 desc
type排序字段,2 对应 7 日活跃人数

响应内容是明文 JSON,不存在响应体 AES、DES、SM4 解密逻辑。真正需要还原的是请求头中的 x-ca-* 校验字段:

x-ca-key: 203899271
x-ca-nonce: cf57e6ed-f6af-4928-8231-9802adb0b777
x-ca-signature: 3lp7sCmGfck3Va78sRDeOcZIQSzqp73gwdwb3VgvnuQ=
x-ca-signature-headers: x-ca-key,x-ca-nonce

观察多次翻页请求的请求头可以发现,x-ca-key 的值始终固定为 203899271x-ca-signature-headers 的值也固定为 x-ca-key,x-ca-nonce。这两个字段本身不需要复杂生成,真正需要重点分析的是每次都会变化的 x-ca-nonce,以及依赖请求信息计算出来的 x-ca-signature

接下来采用最直接的定位方式:全局搜索请求头关键字。这里虽然 x-ca-signature-headers 不是最终要逆向的核心字段,但它的名字比较特殊,搜索结果通常会比 signaturenonce 这类泛关键词少很多,更容易定位到统一请求头设置的位置。而且这几个 x-ca-* 字段大概率是在同一个请求拦截器里一起生成的,所以先全局搜索 x-ca-signature-headers,如下:


搜索结果中可以看到有两个位置都出现了 x-ca-signature-headers 的赋值逻辑。这里不要只看静态代码判断,直接分别打上断点,然后回到页面点击分页触发请求,观察实际命中的位置。最终断点命中的是下面这个位置。严格来说,上一层 e.interceptors.request.use(...) 才是请求拦截器本身,而这里命中的 g(e) 是拦截器中实际负责补全签名请求头的处理函数:



在这个位置可以看到,x-ca-signaturex-ca-keyx-ca-noncex-ca-signature-headers 都是在同一段逻辑中写入请求头的。因此后续重点就放在这个 g 函数上。把相关代码单独拎出来,可以看到它负责读取请求配置、生成 nonce、计算签名并写回请求头:

var g = function(e) {
    var t = e.headers.common.Accept;
    null == t && (t = "*/*",
                  e.headers.Accept = t);
    var n = e.headers.date;
    null == n && (n = "");
    var o = e.method
    , i = e.headers["Content-Type"] || e.headers[o]["Content-Type"];
    null == i && (i = "");
    var s, r, a = e.method, c = e.url, d = e.params, p = e.apiSignatureOpt;
    if (p ? (s = p.appKey,
             r = p.appSecret) : (s = 203899271,
                                 r = "bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H"),
        a = a.toUpperCase(),
        "prod" !== window.__INITIAL_STATE__.CFG.ENV) {
        var v = l.a.parse(location.search.slice(1), {});
        e.headers["X-Ca-Stage"] = v.api_env || ""
    }
    return e.headers["X-Ca-Key"] = s,
        e.headers["X-Ca-Nonce"] = h(),
        "[object FormData]" === Object.prototype.toString.call(e.data) && (e.headers["X-Ca-Signed-Content-Type"] = "multipart/form-data",
                                                                           i = "multipart/form-data"),
        e.headers["X-Ca-Signature"] = f({
        method: a,
        url: c,
        accept: t,
        params: d,
        date: n,
        contentType: i,
        headers: e.headers,
        appSecret: r
    }),
        e.headers["X-Ca-Signature-Headers"] = function(e) {
        var t, n, o = m(e), i = u(Array.from(Object.keys(o)).sort());
        try {
            for (i.s(); !(n = i.n()).done; ) {
                var s = n.value;
                "x-ca-signature" !== s && (t = t ? t + "," + s : s)
            }
        } catch (e) {
            i.e(e)
        } finally {
            i.f()
        }
        return t
    }(e.headers),
        e
};

接着在浏览器里对 g 函数做单步调试。可以先在函数入口处打断点,前面读取 AcceptdateContent-Type 的部分先快速略过,重点看下面这段 apiSignatureOpt 的判断逻辑:


这里的 p 对应 e.apiSignatureOpt。如果调用方额外传入了签名配置,就从 p.appKeyp.appSecret 中取值;如果没有传入,就走默认分支,直接把 s 赋值为 203899271,把 r 赋值为 bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H

继续往下看就能对应上请求头写入逻辑:e.headers["X-Ca-Key"] = s。也就是说,当前页面没有单独传 apiSignatureOpt,所以 X-Ca-Key 使用的就是默认固定值 203899271。它不是每次动态算出来的字段,但 Python 复现请求时仍然需要带上。

接下来关注 e.headers["X-Ca-Nonce"] = h()。这里可以看出 X-Ca-Nonce 来自 h 函数的返回值,所以继续单步进入 h 函数,代码如下:

h = function(e) {
    var t = e || null;
    return null == t && (t = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (function(e) {
        var t = 16 * Math.random() | 0;
        return ("x" === e ? t : 3 & t | 8).toString(16)
    }
                                                                                     ))),
        t
}

这个函数逻辑很简单:如果外部没有传入参数,就用 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 这个模板配合 Math.random() 生成一个 UUID 风格的随机字符串。其中 4xxx 固定了版本位,y 位置通过 3 & t | 8 控制在 UUID 常见的变体范围内。

由于这里用到的都是 Math.random、正则替换等浏览器和 Node.js 都支持的基础 API,可以把这段函数直接拷贝到本地 bbs_csdn.js 中验证。实际执行后可以正常生成和浏览器请求头格式一致的 nonce,如下:


接下来继续往下看 X-Ca-Signature 的生成位置,关键代码如下:

e.headers["X-Ca-Signature"] = f({
    method: a,
    url: c,
    accept: t,
    params: d,
    date: n,
    contentType: i,
    headers: e.headers,
    appSecret: r
})

可以看到,X-Ca-Signature 不是直接写死的,而是由 f 函数计算得到。这里传入的是一个配置对象,里面包含请求方法、接口地址、请求参数、已有请求头以及用于签名的固定 appSecret。在 g 函数里写的是 appSecret: r,而当前页面没有额外传 apiSignatureOpt,所以这里的 r 实际就是默认密钥 bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H。单步进入 f 函数后,先看一下这次真实传入的对象结构:

{
    "method": "GET",
    "url": "https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list",
    "accept": "application/json, text/plain, */*",
    "params": {
        "deviceType": "pc",
        "page": 2,
        "pageSize": 20,
        "category": 37,
        "sort": "desc",
        "type": 2
    },
    "date": "",
    "contentType": "",
    "headers": {
        "common": {
            "Accept": "application/json, text/plain, */*"
        },
        "delete": {},
        "get": {},
        "head": {},
        "post": {
            "Content-Type": "application/json;charset=UTF-8"
        },
        "put": {
            "Content-Type": "application/x-www-form-urlencoded"
        },
        "patch": {
            "Content-Type": "application/x-www-form-urlencoded"
        },
        "X-Ca-Key": 203899271,
        "X-Ca-Nonce": "a5383058-206a-45bc-a776-792888819009"
    },
    "appSecret": "bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H"
}

这个对象里需要重点关注几类值:

  1. f 函数的拼接逻辑看,methodacceptcontentTypedate 都会进入待签名字符串;其中本次 GET 请求的 contentTypedate 为空,所以实际表现为两个空行占位。url 则会在去掉域名后,和排序后的 params 一起拼成最后一行资源路径。
  2. params 就是接口请求参数,其中 page 需要和真实请求页码保持一致。
  3. headers 里已经提前写入了 X-Ca-KeyX-Ca-Nonce,后面会从这里筛选出参与签名的 x-ca-* 头。
  4. appSecret 是前面默认分支中取到的固定密钥,也就是 bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H

到这里,X-Ca-KeyappSecret 都已经明确是固定值,X-Ca-Nonce 前面也确认可以由 h 函数生成。剩下真正需要扣出来的就是 f 函数,也就是签名字符串拼接和 HMAC 计算这一段。

关于 f 函数,我这里不再把每一行单独拆开写成笔记,而是把完整代码和相关依赖都整理到下面的本地 JS 中。代码块里的 TODO 注释就是扣代码和补依赖的过程说明,阅读时建议按 TODO 编号看,不要完全按代码从上到下看:

// TODO 4.引入本地离线 CryptoJS 文件
const CryptoJS = require('./CryptoJS')

// TODO 2.1 将m函数扣到本地 浏览器中调试一下看有没有需要新扣的函数
// t[o] = e[n] 这些几乎都是局部变量 所以不用扣了 直接执行
// 简单看了一下逻辑 就是为了过滤 x-ca-key 与 x-ca-nonce
// 最后的返回值为 {
//     "x-ca-key": 203899271,
//     "x-ca-nonce": "6f7899e0-60fb-4089-92e1-2041e6f204cf"
// }
// 赋值给了 h
var m = function (e) {
    var t = {};
    for (var n in e) {
        var o = n.toLowerCase();
        o.startsWith("x-ca-") && ("x-ca-signature" !== o && "x-ca-signature-headers" !== o && "x-ca-key" !== o && "x-ca-nonce" !== o || (t[o] = e[n]))
    }
    return t
}

// TODO 2.2 扣u函数
function u(e, t) {
    var n = "undefined" != typeof Symbol && e[Symbol.iterator] || e["@@iterator"];
    if (!n) {
        if (Array.isArray(e) || (n = function (e, t) {
            if (e) {
                if ("string" == typeof e)
                    return p(e, t);
                var n = {}.toString.call(e).slice(8, -1);
                return "Object" === n && e.constructor && (n = e.constructor.name),
                    "Map" === n || "Set" === n ? Array.from(e) : "Arguments" === n || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n) ? p(e, t) : void 0
            }
        }(e)) || t && e && "number" == typeof e.length) {
            n && (e = n);
            var o = 0
                , i = function () {
            };
            return {
                s: i,
                n: function () {
                    return o >= e.length ? {
                        done: !0
                    } : {
                        done: !1,
                        value: e[o++]
                    }
                },
                e: function (e) {
                    throw e
                },
                f: i
            }
        }
        throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")
    }
    var s, r = !0, a = !1;
    return {
        s: function () {
            n = n.call(e)
        },
        n: function () {
            var e = n.next();
            return r = e.done,
                e
        },
        e: function (e) {
            a = !0,
                s = e
        },
        f: function () {
            try {
                r || null == n.return || n.return()
            } finally {
                if (a)
                    throw s
            }
        }
    }
}


// TODO 1.首先把浏览器中生成 headers["X-Ca-Signature"] 的函数f拿到本地

f = function (e) {
    var t = e.method
        , n = e.url
        , o = e.appSecret
        , i = e.accept
        , s = e.date
        , r = e.contentType
        , a = e.params
        , l = e.headers
        , c = "";
    a || -1 === n.indexOf("?") ? a || (a = {}) : (a = function (e) {
        var t = {}
            , n = e.match(/[?&]([^=&#]+)=([^&#]*)/g);
        if (n)
            for (var o in n) {
                var i = n[o].split("=")
                    , s = i[0].substr(1)
                    , r = i[1];
                t[s] ? t[s] = [].concat(t[s], r) : t[s] = r
            }
        return t
    }(n),
        n = n.split("?")[0]);
    c += "".concat(t, "\n"),
        c += "".concat(i, "\n"),
        c += "".concat("", "\n"),
        c += "".concat(r, "\n"),
        c += "".concat(s, "\n");
    // TODO 2.执行报错 缺 m 函数 这里在旁边很明显看到应该还缺 u 函数 所以一起扣
    // 扣了m函数后,处理 执行报错: ReferenceError: u is not defined
    // 紧接着扣u函数 u函数逻辑一大堆 我反正直接复制过来了 没咋看 执行不报错
    var p, h = m(l), f = u(Array.from(Object.keys(h)).sort());
    try {
        for (f.s(); !(p = f.n()).done;) {
            var v = p.value;
            c += v + ":" + h[v] + "\n"
        }
    } catch (e) {
        f.e(e)
    } finally {
        f.f()
    }
    return c += function (e, t) {
        var n, o = null, i = u(Array.from(Object.keys(t)).sort());
        try {
            for (i.s(); !(n = i.n()).done;) {
                var s = n.value
                    , r = void 0;
                null !== t[s] && void 0 !== t[s] && (r = "" !== t[s] ? s + "=" + t[s] : s + t[s],
                    o = o ? o + "&" + r : r)
            }
        } catch (e) {
            i.e(e)
        } finally {
            i.f()
        }
        return o ? e + "?" + o : e
    }(n.replace(/^(?=^.{3,255}$)(http(s)?:\/\/)?(www\.)?[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.csdn\.net)/, ""), a),
        // TODO 3.紧接着这里报错 ReferenceError: d is not defined
        // d.a 浏览器中查看太熟悉了 就是 CryptoJS 这里直接替换就好了
        // d.a.HmacSHA256(c, o).toString(d.a.enc.Base64)
        // TODO 5.替换 d.a
        // 由于是摘要算法 那同样的输入肯定是相同的输出 我们用浏览器中传入的参数e测试的
        // 那么最后的结果肯定也和浏览器一致 简单看了一下确实是一样的 生成的结果都是:
        // 'h8rxgKcSM0+FZFaxR90KvEv4+A+1cwRLL15tkgLcuF4='
        CryptoJS.HmacSHA256(c, o).toString(CryptoJS.enc.Base64)
};

// let e = {
//     "method": "GET",
//     "url": "https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list",
//     "accept": "application/json, text/plain, */*",
//     "params": {
//         "deviceType": "pc",
//         "page": 1,
//         "pageSize": 20,
//         "category": 37,
//         "sort": "desc",
//         "type": 2
//     },
//     "date": "",
//     "contentType": "",
//     "headers": {
//         "common": {
//             "Accept": "application/json, text/plain, */*"
//         },
//         "delete": {},
//         "get": {},
//         "head": {},
//         "post": {
//             "Content-Type": "application/json;charset=UTF-8"
//         },
//         "put": {
//             "Content-Type": "application/x-www-form-urlencoded"
//         },
//         "patch": {
//             "Content-Type": "application/x-www-form-urlencoded"
//         },
//         "X-Ca-Key": 203899271,
//         "X-Ca-Nonce": "6f7899e0-60fb-4089-92e1-2041e6f204cf"
//     },
//     "appSecret": "bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H"
// }

// 调用函数 并用浏览器中传入的e做测试 执行
// console.log(f(e));

// 简单封装
function getCaSignature(page, xCaNonce) {
    let e = {
        "method": "GET",
        "url": "https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list",
        "accept": "application/json, text/plain, */*",
        "params": {
            "deviceType": "pc",
            "page": page,
            "pageSize": 20,
            "category": 37,
            "sort": "desc",
            "type": 2
        },
        "date": "",
        "contentType": "",
        "headers": {
            "common": {
                "Accept": "application/json, text/plain, */*"
            },
            "delete": {},
            "get": {},
            "head": {},
            "post": {
                "Content-Type": "application/json;charset=UTF-8"
            },
            "put": {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            "patch": {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            "X-Ca-Key": 203899271,
            "X-Ca-Nonce": xCaNonce
        },
        "appSecret": "bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H"
    }

    return f(e)
}

这样一来,浏览器里负责生成请求头的逻辑就基本还原完成了:h() 负责生成 X-Ca-NoncegetCaSignature(page, xCaNonce) 负责根据页码和 nonce 计算 X-Ca-Signature,另外 X-Ca-KeyX-Ca-Signature-Headers 直接使用固定值即可。

为了先验证签名逻辑是否可用,这里先用 Python 调用本地 bbs_csdn.js 发起一次真实请求。这个阶段只验证接口能不能成功返回,数据解析、日志封装等内容放到下一小节再整理。测试代码如下:

# -*- coding: utf-8 -*-
"""
@File    : bbs_csdn.py
@Author  : XAMO Lab
@Date    : 2026/6/30 16:17
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 
"""

import requests
import subprocess

from functools import partial

subprocess.Popen = partial(subprocess.Popen, encoding='utf-8')

import execjs


ctx = execjs.compile(open('./bbs_csdn.js', 'r', encoding='utf-8').read())

cookies = {
    'uuid_tt_dd': '10_17839729830-1782805542288-263127',
    'dc_session_id': '10_1782805542288.194824',
    'c_pref': 'default',
    'c_ref': 'default',
    'fid': '20_07621909277-1782805542247-491515',
    'c_first_ref': 'default',
    'c_segment': '7',
    'c_utm_source': 'csdn_bbs_toolbar',
    'utm_source': 'csdn_bbs_toolbar',
    'Hm_lvt_6bcd52f51e9b3dce32bec4a3997715ac': '1782805545',
    'HMACCOUNT': '92E1E1CC969C4C50',
    'is_advert': '1',
    'hide_login': '1',
    'dc_sid': 'f1e265f3910b9fe7e06141e8ef364157',
    'c_first_page': 'https%3A//bbs.csdn.net/forums/school_11',
    'c_dsid': '11_1782805591706.714272',
    'c_page_id': 'default',
    'log_Id_pv': '2',
    'Hm_lpvt_6bcd52f51e9b3dce32bec4a3997715ac': '1782805592',
    'creative_btn_mp': '2',
    'log_Id_view': '12',
    'dc_tos': 'thfpls',
}

for page in range(1, 3):
    x_ca_nonce = ctx.call('h')
    x_ca_signature = ctx.call('getCaSignature', page, x_ca_nonce)
    headers = {
        'accept': 'application/json, text/plain, */*',
        'accept-language': 'zh-CN,zh;q=0.9',
        'cache-control': 'no-cache',
        'origin': 'https://bbs.csdn.net',
        'pragma': 'no-cache',
        'priority': 'u=1, i',
        'referer': 'https://bbs.csdn.net/college?utm_source=csdn_bbs_toolbar&spm=1035.2022.3001.8850&category=37',
        '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"',
        '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',
        # x-ca-key 可以写死
        'x-ca-key': '203899271',
        # 'x-ca-nonce': '270e5141-ddcb-44b5-9439-54ad5687fba0',
        'x-ca-nonce': x_ca_nonce,
        # 'x-ca-signature': 'iJZamjQNFJcA3s8nWeju2YycMy4QYbVYt/+7sgOZUsg=',
        'x-ca-signature': x_ca_signature,
        # x-ca-signature-headers 这个也是可以写死的
        'x-ca-signature-headers': 'x-ca-key,x-ca-nonce',
        # 'cookie': 'uuid_tt_dd=10_17839729830-1782805542288-263127; dc_session_id=10_1782805542288.194824; c_pref=default; c_ref=default; fid=20_07621909277-1782805542247-491515; c_first_ref=default; c_segment=7; c_utm_source=csdn_bbs_toolbar; utm_source=csdn_bbs_toolbar; Hm_lvt_6bcd52f51e9b3dce32bec4a3997715ac=1782805545; HMACCOUNT=92E1E1CC969C4C50; is_advert=1; hide_login=1; dc_sid=f1e265f3910b9fe7e06141e8ef364157; c_first_page=https%3A//bbs.csdn.net/forums/school_11; c_dsid=11_1782805591706.714272; c_page_id=default; log_Id_pv=2; Hm_lpvt_6bcd52f51e9b3dce32bec4a3997715ac=1782805592; creative_btn_mp=2; log_Id_view=12; dc_tos=thfpls',
    }

    params = {
        'deviceType': 'pc',
        'page': f'{page}',
        'pageSize': '20',
        'category': '37',
        'sort': 'desc',
        'type': '2',
    }

    response = requests.get(
        'https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list',
        params=params,
        # cookies=cookies,
        headers=headers,
    )
    print(response.status_code)
    print(response.text)

这里先不处理数据解析和日志输出,只验证基础请求链路是否已经跑通。执行后接口能够正常返回 JSON 数据,说明 X-Ca-NonceX-Ca-Signature 的还原逻辑没有问题,结果如下:


二、Python 实现

前面已经把 x-ca-* 请求头的生成逻辑分析清楚了。这个接口的响应体本身就是明文 JSON,不需要响应解密;核心工作就是在请求前补齐 X-Ca-KeyX-Ca-NonceX-Ca-SignatureX-Ca-Signature-Headers

这里把最终代码整理成两个版本:

  1. 纯 Python 复现版本:直接用标准库 hmachashlibbase64 改写前端 CryptoJS.HmacSHA256(...).toString(CryptoJS.enc.Base64) 逻辑。
  2. Python 调用 JS 复现版本:保留一份本地 bbs_csdn.js,用 execjs 调 JS 生成 noncesignature

本案例接口不需要并发验证,两个版本都按顺序抓取前 3 页,每页 20 条,共 60 条数据。输出字段如下:

字段含义响应字段
school_name学校名称communityName
active_user_num_7d7 日活跃人数activeUserNum
continue_learning_days持续学习天数continueLearningDays
influence影响力influence
member_count成员数量communityNum

案例目录结构如下:

csdn-college-ranking-hmac-sha256
├─ csdn_college_ranking_python_spider.py
├─ csdn_college_ranking_js_spider.py
├─ bbs_csdn.js
└─ CryptoJS.js

2.1 纯 Python 复现版本

运行方式:

python csdn_college_ranking_python_spider.py

完整代码如下:

# -*- coding: utf-8 -*-
"""
@File    : csdn_college_ranking_python_spider.py
@Author  : XAMO Lab
@Date    : 2026/6/30 17:52
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : CSDN 高校 IT 实力排行榜采集(纯 Python 复现 HMAC-SHA256 请求头签名,顺序抓取)
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import sys
import time
import uuid
import warnings
from dataclasses import asdict, dataclass
from typing import Any
from urllib.parse import urlencode

warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version.*")

import requests
from loguru import logger

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")

logger.remove()
logger.add(sys.stdout, level="INFO")

BASE_URL = "https://bizapi.csdn.net"
API_PATH = "/community-cloud/v1/colleges/main_page/list"
API_URL = f"{BASE_URL}{API_PATH}"
REFERER_URL = (
    "https://bbs.csdn.net/college?"
    "utm_source=csdn_bbs_toolbar&spm=1035.2022.3001.8850&category=37"
)

APP_KEY = "203899271"
APP_SECRET = "bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H"
ACCEPT = "application/json, text/plain, */*"


@dataclass(frozen=True)
class CollegeRankItem:
    page: int
    index: int
    school_name: str
    active_user_num_7d: int
    continue_learning_days: int
    influence: int
    member_count: int


class CsdnCollegeSigner:
    """纯 Python 复现前端 http-InterceptorX 中的 x-ca-* 签名逻辑。"""

    def __init__(self, app_key: str = APP_KEY, app_secret: str = APP_SECRET) -> None:
        self.app_key = app_key
        self.app_secret = app_secret

    @staticmethod
    def make_nonce() -> str:
        return str(uuid.uuid4())

    @staticmethod
    def canonical_query(params: dict[str, Any]) -> str:
        items: list[str] = []
        for key in sorted(params):
            value = params[key]
            if value is None:
                continue
            items.append(f"{key}={value}" if value != "" else key)
        return "&".join(items)

    def build_string_to_sign(self, method: str, path: str, params: dict[str, Any], nonce: str) -> str:
        return (
            f"{method.upper()}\n"
            f"{ACCEPT}\n"
            "\n"
            "\n"
            "\n"
            f"x-ca-key:{self.app_key}\n"
            f"x-ca-nonce:{nonce}\n"
            f"{path}?{self.canonical_query(params)}"
        )

    def sign(self, method: str, path: str, params: dict[str, Any], nonce: str) -> str:
        string_to_sign = self.build_string_to_sign(method, path, params, nonce)
        digest = hmac.new(
            self.app_secret.encode("utf-8"),
            string_to_sign.encode("utf-8"),
            hashlib.sha256,
        ).digest()
        return base64.b64encode(digest).decode("utf-8")


class CsdnCollegeRankingPythonSpider:
    def __init__(self, page_size: int = 20, retries: int = 3, timeout: int = 20) -> None:
        self.page_size = page_size
        self.retries = retries
        self.timeout = timeout
        self.signer = CsdnCollegeSigner()
        self.session = self.build_session()

    @staticmethod
    def build_session() -> requests.Session:
        session = requests.Session()
        session.trust_env = False
        return session

    def build_params(self, page: int) -> dict[str, Any]:
        return {
            "deviceType": "pc",
            "page": page,
            "pageSize": self.page_size,
            "category": 37,
            "sort": "desc",
            "type": 2,
        }

    @staticmethod
    def browser_query(params: dict[str, Any]) -> str:
        return urlencode(
            [
                ("deviceType", params["deviceType"]),
                ("page", params["page"]),
                ("pageSize", params["pageSize"]),
                ("category", params["category"]),
                ("sort", params["sort"]),
                ("type", params["type"]),
            ]
        )

    def build_headers(self, params: dict[str, Any]) -> dict[str, str]:
        nonce = self.signer.make_nonce()
        signature = self.signer.sign("GET", API_PATH, params, nonce)
        return {
            "Accept": ACCEPT,
            "Accept-Language": "zh-CN,zh;q=0.9",
            "Origin": "https://bbs.csdn.net",
            "Referer": REFERER_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-Ca-Key": APP_KEY,
            "X-Ca-Nonce": nonce,
            "X-Ca-Signature": signature,
            "X-Ca-Signature-Headers": "x-ca-key,x-ca-nonce",
        }

    @staticmethod
    def parse_item(item: dict[str, Any], page: int, index: int) -> CollegeRankItem:
        return CollegeRankItem(
            page=page,
            index=index,
            school_name=item.get("communityName") or "",
            active_user_num_7d=item.get("activeUserNum") or 0,
            continue_learning_days=item.get("continueLearningDays") or 0,
            influence=item.get("influence") or 0,
            member_count=item.get("communityNum") or 0,
        )

    def fetch_page(self, page: int) -> list[CollegeRankItem]:
        params = self.build_params(page)
        url = f"{API_URL}?{self.browser_query(params)}"

        for attempt in range(1, self.retries + 1):
            try:
                logger.info("page={} 开始请求", page)
                response = self.session.get(url, headers=self.build_headers(params), timeout=self.timeout)
                response.raise_for_status()
                data = response.json()
                if data.get("code") != 200:
                    raise RuntimeError(f"接口返回异常: {data}")

                rows = data.get("data", {}).get("list") or []
                result = [self.parse_item(item, page, idx) for idx, item in enumerate(rows, 1)]
                logger.success("page={} 采集成功,返回 {} 条", page, len(result))
                return result
            except Exception as exc:
                if attempt >= self.retries:
                    logger.error("page={} 第 {} 次请求失败,停止重试: {}", page, attempt, exc)
                    raise
                logger.warning("page={} 第 {} 次请求失败,准备重试: {}", page, attempt, exc)
                time.sleep(0.5 * attempt)

        return []

    def run(self, pages: int = 3) -> list[CollegeRankItem]:
        logger.info("开始顺序采集 CSDN 高校 IT 实力排行榜 | pages={} | page_size={}", pages, self.page_size)
        all_rows: list[CollegeRankItem] = []
        for page in range(1, pages + 1):
            all_rows.extend(self.fetch_page(page))
            time.sleep(0.2)
        logger.info("采集完成,共 {} 条学校数据", len(all_rows))
        return all_rows


if __name__ == "__main__":
    spider = CsdnCollegeRankingPythonSpider(page_size=20, retries=3)
    result = spider.run(pages=3)
    for row in result:
        logger.info("{}", json.dumps(asdict(row), ensure_ascii=False))

2.2 Python 调用 JS 复现版本

这个版本把签名逻辑保留在 bbs_csdn.js 中,Python 只负责调用 JS 生成 X-Ca-NonceX-Ca-Signature,再组装请求头请求接口。由于 JS 里使用了 CryptoJS.HmacSHA256,同目录下需要放一份离线 CryptoJS.js

JS 签名代码(bbs_csdn.js)如下:

const CryptoJS = require("./CryptoJS.js");

function pickCaHeaders(headers) {
    var result = {};
    for (var name in headers) {
        var lower = name.toLowerCase();
        lower.startsWith("x-ca-") && (
            "x-ca-signature" !== lower &&
            "x-ca-signature-headers" !== lower &&
            "x-ca-key" !== lower &&
            "x-ca-nonce" !== lower ||
            (result[lower] = headers[name])
        );
    }
    return result;
}

function makeNonce(value) {
    var nonce = value || null;
    return null == nonce && (nonce = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (char) {
        var random = 16 * Math.random() | 0;
        return ("x" === char ? random : 3 & random | 8).toString(16);
    })), nonce;
}

function buildResource(path, params) {
    var query = null;
    Object.keys(params).sort().forEach(function (key) {
        var value = params[key];
        if (value !== null && value !== undefined) {
            var item = value !== "" ? key + "=" + value : key + value;
            query = query ? query + "&" + item : item;
        }
    });
    return query ? path + "?" + query : path;
}

function signConfig(config) {
    var method = config.method;
    var url = config.url;
    var appSecret = config.appSecret;
    var accept = config.accept;
    var date = config.date;
    var contentType = config.contentType;
    var params = config.params || {};
    var headers = config.headers;

    var stringToSign = "";
    stringToSign += method + "\n";
    stringToSign += accept + "\n";
    stringToSign += "\n";
    stringToSign += contentType + "\n";
    stringToSign += date + "\n";

    var caHeaders = pickCaHeaders(headers);
    Object.keys(caHeaders).sort().forEach(function (key) {
        stringToSign += key + ":" + caHeaders[key] + "\n";
    });

    stringToSign += buildResource(
        url.replace(/^(?=^.{3,255}$)(http(s)?:\/\/)?(www\.)?[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.csdn\.net)/, ""),
        params
    );

    return CryptoJS.HmacSHA256(stringToSign, appSecret).toString(CryptoJS.enc.Base64);
}

function getCaSignature(page, xCaNonce) {
    var config = {
        method: "GET",
        url: "https://bizapi.csdn.net/community-cloud/v1/colleges/main_page/list",
        accept: "application/json, text/plain, */*",
        params: {
            deviceType: "pc",
            page: page,
            pageSize: 20,
            category: 37,
            sort: "desc",
            type: 2
        },
        date: "",
        contentType: "",
        headers: {
            common: {
                Accept: "application/json, text/plain, */*"
            },
            delete: {},
            get: {},
            head: {},
            post: {
                "Content-Type": "application/json;charset=UTF-8"
            },
            put: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            patch: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            "X-Ca-Key": 203899271,
            "X-Ca-Nonce": xCaNonce
        },
        appSecret: "bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H"
    };
    return signConfig(config);
}

module.exports = {
    makeNonce: makeNonce,
    getCaSignature: getCaSignature
};

运行方式:python csdn_college_ranking_js_spider.py

Python 代码(csdn_college_ranking_js_spider.py)如下:

# -*- coding: utf-8 -*-
"""
@File    : csdn_college_ranking_js_spider.py
@Author  : XAMO Lab
@Date    : 2026/6/30 17:52
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : CSDN 高校 IT 实力排行榜采集(Python 调用 JS 复现 HMAC-SHA256 请求头签名,顺序抓取)
"""

from __future__ import annotations

import json
import subprocess
import sys
import time
import warnings
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlencode

warnings.filterwarnings("ignore", message=r"urllib3 .* doesn't match a supported version.*")


class _Utf8Popen(subprocess.Popen):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        kwargs.setdefault("encoding", "utf-8")
        super().__init__(*args, **kwargs)


subprocess.Popen = _Utf8Popen

import execjs
import requests
from loguru import logger

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")

logger.remove()
logger.add(sys.stdout, level="INFO")

BASE_DIR = Path(__file__).resolve().parent
BASE_URL = "https://bizapi.csdn.net"
API_URL = f"{BASE_URL}/community-cloud/v1/colleges/main_page/list"
REFERER_URL = (
    "https://bbs.csdn.net/college?"
    "utm_source=csdn_bbs_toolbar&spm=1035.2022.3001.8850&category=37"
)

APP_KEY = "203899271"
ACCEPT = "application/json, text/plain, */*"


@dataclass(frozen=True)
class CollegeRankItem:
    page: int
    index: int
    school_name: str
    active_user_num_7d: int
    continue_learning_days: int
    influence: int
    member_count: int


class CsdnCollegeJsSigner:
    """通过 Node/execjs 调用本地 bbs_csdn.js 生成 nonce 和签名。"""

    def __init__(self) -> None:
        self.ctx = self.compile_js()

    @staticmethod
    def compile_js() -> execjs.ExternalRuntime.Context:
        js_code = (BASE_DIR / "bbs_csdn.js").read_text(encoding="utf-8")
        return execjs.compile(js_code, cwd=str(BASE_DIR))

    def make_nonce(self) -> str:
        return self.ctx.call("makeNonce")

    def sign(self, page: int, nonce: str) -> str:
        return self.ctx.call("getCaSignature", page, nonce)


class CsdnCollegeRankingJsSpider:
    def __init__(self, page_size: int = 20, retries: int = 3, timeout: int = 20) -> None:
        self.page_size = page_size
        self.retries = retries
        self.timeout = timeout
        self.signer = CsdnCollegeJsSigner()
        self.session = self.build_session()

    @staticmethod
    def build_session() -> requests.Session:
        session = requests.Session()
        session.trust_env = False
        return session

    def build_params(self, page: int) -> dict[str, Any]:
        return {
            "deviceType": "pc",
            "page": page,
            "pageSize": self.page_size,
            "category": 37,
            "sort": "desc",
            "type": 2,
        }

    @staticmethod
    def browser_query(params: dict[str, Any]) -> str:
        return urlencode(
            [
                ("deviceType", params["deviceType"]),
                ("page", params["page"]),
                ("pageSize", params["pageSize"]),
                ("category", params["category"]),
                ("sort", params["sort"]),
                ("type", params["type"]),
            ]
        )

    def build_headers(self, page: int) -> dict[str, str]:
        nonce = self.signer.make_nonce()
        signature = self.signer.sign(page, nonce)
        return {
            "Accept": ACCEPT,
            "Accept-Language": "zh-CN,zh;q=0.9",
            "Origin": "https://bbs.csdn.net",
            "Referer": REFERER_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-Ca-Key": APP_KEY,
            "X-Ca-Nonce": nonce,
            "X-Ca-Signature": signature,
            "X-Ca-Signature-Headers": "x-ca-key,x-ca-nonce",
        }

    @staticmethod
    def parse_item(item: dict[str, Any], page: int, index: int) -> CollegeRankItem:
        return CollegeRankItem(
            page=page,
            index=index,
            school_name=item.get("communityName") or "",
            active_user_num_7d=item.get("activeUserNum") or 0,
            continue_learning_days=item.get("continueLearningDays") or 0,
            influence=item.get("influence") or 0,
            member_count=item.get("communityNum") or 0,
        )

    def fetch_page(self, page: int) -> list[CollegeRankItem]:
        params = self.build_params(page)
        url = f"{API_URL}?{self.browser_query(params)}"

        for attempt in range(1, self.retries + 1):
            try:
                logger.info("page={} 开始请求", page)
                response = self.session.get(url, headers=self.build_headers(page), timeout=self.timeout)
                response.raise_for_status()
                data = response.json()
                if data.get("code") != 200:
                    raise RuntimeError(f"接口返回异常: {data}")

                rows = data.get("data", {}).get("list") or []
                result = [self.parse_item(item, page, idx) for idx, item in enumerate(rows, 1)]
                logger.success("page={} 采集成功,返回 {} 条", page, len(result))
                return result
            except Exception as exc:
                if attempt >= self.retries:
                    logger.error("page={} 第 {} 次请求失败,停止重试: {}", page, attempt, exc)
                    raise
                logger.warning("page={} 第 {} 次请求失败,准备重试: {}", page, attempt, exc)
                time.sleep(0.5 * attempt)

        return []

    def run(self, pages: int = 3) -> list[CollegeRankItem]:
        logger.info("开始顺序采集 CSDN 高校 IT 实力排行榜 | pages={} | page_size={}", pages, self.page_size)
        all_rows: list[CollegeRankItem] = []
        for page in range(1, pages + 1):
            all_rows.extend(self.fetch_page(page))
            time.sleep(0.2)
        logger.info("采集完成,共 {} 条学校数据", len(all_rows))
        return all_rows


if __name__ == "__main__":
    spider = CsdnCollegeRankingJsSpider(page_size=20, retries=3)
    result = spider.run(pages=3)
    for row in result:
        logger.info("{}", json.dumps(asdict(row), ensure_ascii=False))

2.3 运行结果摘要

两个版本都已经实际请求验证,均能顺序采集前 3 页,每页 20 条,共 60 条。验证摘要如下:

其中纯 Python 版本和 Python 调 JS 版本返回的前 3 条学校名称一致,说明请求头签名逻辑和字段解析逻辑都已经跑通。

三、总结

这个案例的主线是先通过分页操作定位高校排行榜接口,再回到前端统一请求封装中还原 x-ca-* 请求头签名。接口参数本身是明文 GET 参数,响应内容也是明文 JSON,所以难点不在请求体加密或响应解密,而在 X-Ca-Signature 的生成规则。

这里需要注意几点:

  1. X-Ca-Key 固定为 203899271X-Ca-Signature-Headers 固定为 x-ca-key,x-ca-nonce,这两个字段不需要动态计算,但请求时必须带上。
  2. X-Ca-Nonce 是 UUID 风格随机字符串,前端通过 xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 模板和 Math.random() 生成。Python 里可以直接用 uuid.uuid4() 等价替代。
  3. X-Ca-Signature 的核心算法是 HMAC-SHA256 + Base64,密钥为 bK9jk5dBEtjauy6gXL7vZCPJ1fOy076H,不是 AES、DES、SM4 这类加解密算法。
  4. 待签名字符串中,methodacceptcontentTypedate 都会占位拼接;本案例 GET 请求的 contentTypedate 为空,所以会表现为连续空行。
  5. 参与签名的资源路径需要去掉域名,并且 query 参数要按 key 升序排序;但实际请求 URL 可以保持浏览器原始参数顺序,只要签名时的 canonical query 与前端一致即可。
  6. 前端中的 g(e) 不是请求拦截器本身,而是请求拦截器 e.interceptors.request.use(...) 中调用的签名处理函数。真正负责拼接签名字符串并调用 CryptoJS.HmacSHA256 的是里面的 f 函数。
  7. 本案例最终整理了两个可运行版本:纯 Python 复现版本适合真正落地使用;Python 调 JS 版本适合保留前端扣下来的逻辑,便于和浏览器代码逐步对照验证。

整体来看,这类接口没有明显的请求参数密文或响应密文,容易一开始误判为不需要逆向。实际抓包时只要看到 x-ca-signaturex-ca-nonce 这类自定义签名头,就要重点检查统一请求拦截器。只要把签名字符串的换行、header 排序和 query 排序还原准确,Python 复现就比较直接。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

XAMO Lab

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

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

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

打赏作者

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

抵扣说明:

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

余额充值