内蒙古12345政务热线诉求公开 —— SM2 请求加密 + AES/CBC 响应解密

目录

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

一、分析

目标地址:https://zwfw.nmg.gov.cn/wsbsdt/website/pages/default/index

本案例目标是抓取内蒙古 12345 政务服务便民热线首页中 "诉求公开" 板块的公开工单列表数据。

从页面展示效果来看,每条公开诉求主要包含两部分信息:群众提出的问题,以及平台或承办单位给出的答复。这里先以页面可见的数据为目标,后面再根据接口实际返回内容决定最终能提取哪些字段。

进入页面后,"诉求公开" 列表会自动加载第一页数据。为了减少无关请求干扰,先打开 DevTools,切到 Network → Fetch/XHR,把已有的数据包清空,然后点击 "诉求公开" 板块下方的分页按钮。

通常情况下,清空后新出现的请求就是当前操作触发的接口;如果同时出现多个请求,就需要结合接口路径、请求参数和响应内容继续判断哪个才是列表接口。本案例比较简单,每点击一次分页按钮,Network 中只会新增一个数据包,所以定位起来很直接。我连续点击了 5 次分页,抓到的请求如下:

从上图可以确认接口为 https://zwfw.nmg.gov.cn/wsbsdt/rest/nmWebFrontPageRest/getCaseinfoList,请求方法是 POST。再看一下 Payload,发现传过去的不是明文参数,而是一段密文:

再看一下响应,发现返回的同样是一段密文,如下:

再看一下请求头,如下:

http 复制代码
POST /wsbsdt/rest/nmWebFrontPageRest/getCaseinfoList HTTP/1.1
Accept: application/json, text/javascript, */*; q=0.01
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: no-cache
Connection: keep-alive
Content-Length: 322
Content-Type: application/json;charset=UTF-8
Cookie: .....cookie太长我省略了
Host: zwfw.nmg.gov.cn
Origin: https://zwfw.nmg.gov.cn
Pragma: no-cache
Referer: https://zwfw.nmg.gov.cn/wsbsdt/website/pages/defaultIndex/index.html
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
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
encrypt: 1
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"

右键请求 → Copy as cURL(bash) → 粘贴到 curlconverter.com 转成 Python 代码执行,先看能不能正常请求。这样也顺手整理出一个后面测试用的请求模板。因为前面已经看到请求体是密文,如果后面要抓多页,就得先搞清楚请求体密文是怎么生成的。实际测试下来,完整请求头可以正常拿到响应密文;把多余请求头删掉后,只保留 Content-TypeUser-Agentencrypt 这三个请求头,也还是能够正常返回密文,代码如下:

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : zwfw_nmg.py
@Author  : XAMO Lab
@Date    : 2026/6/23 15:14
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 
"""

import requests

headers = {
    'Content-Type': 'application/json;charset=UTF-8',
    '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',
    'encrypt': '1',
}

data = '04ddde9e45deb3b8d66841f2c37f6941a42424d6ed05c570b10fb238345f3573126c69f49b893a0942723c3eefd93f59629e15fc43d623316d27daed786d0460f6b9825c135ae3f2af3d30be008869aece40b13ac01ecdf9242102a6b7fd5cc1f59187d320da73537968b40c3dcc8955beef7d838aebddf6363281169d270dd768f314f219fe6613611b6e9eb63635eecb45bd545a9dae970742f3e39ca4de5cff'

response = requests.post(
    'https://zwfw.nmg.gov.cn/wsbsdt/rest/nmWebFrontPageRest/getCaseinfoList',
    # cookies=cookies,
    headers=headers,
    data=data,
)
print(response.status_code)
print(response.text)

接下来就可以开始分析了。这个案例我先解决的是响应密文的问题,验证方式也很直接:把浏览器返回的密文拿到本地扣好的 JS 代码里执行,能还原出页面上的明文就说明方向对了;当然,也可以直接用 Python 复写网页端逻辑。

分析阶段先扣 JS 代码,先定位解密处。常见办法有 Hook JSON.parse、搜索关键字、堆栈回溯几种,我这里还是先用最简单的方式,直接搜索 decrypt,结果如下:

这些结果都还比较可疑,可以点进去逐个下断点,再翻页看看具体命中的是哪一个。我这里先测试第一个,进入 /js/_dist/core_jq3.js?_=1.0.6_20170822 后,看到 var decResult = window.CnsAESUtil.decryptNew(data); 这一行,直接在这里下断点,然后点击分页按钮验证。结果果然断住了,如下:

传入的参数 data 看着像响应的密文,这时可以直接在 Console 里执行 window.CnsAESUtil.decryptNew(data),进一步确认这里是不是我们要找的解密位置。执行后能看到返回的是页面明文数据,如下:

这一步基本可以确认,响应解密逻辑就在 decryptNew 里。接下来单步进入这个方法,看一下它内部到底做了什么:

javascript 复制代码
u.decryptNew = function (data) {
    var key = CryptoJS.enc.Utf8.parse('qnbpwgttcfv96fgw');
    var iv = CryptoJS.enc.Utf8.parse('5141928399038306');
    var decCode = CryptoJS.AES.decrypt(data, key, {iv: iv, mode: CryptoJS.mode.CBC});
    var result = CryptoJS.enc.Utf8.stringify(decCode).toString();
    return result;
};

这段逻辑很清楚:data 是响应密文,keyiv 都是硬编码字符串,最后调用 CryptoJS.AES.decrypt 做 AES-CBC 解密。这里先不急着写 Python,先在本地新建一个 zwfw_nmg.js,把浏览器里的解密函数稍微改成 Node.js 能运行的形式:

javascript 复制代码
CryptoJS = require('./CryptoJS')

function aesDecrypt(data) {
    var key = CryptoJS.enc.Utf8.parse('qnbpwgttcfv96fgw');
    var iv = CryptoJS.enc.Utf8.parse('5141928399038306');
    var decCode = CryptoJS.AES.decrypt(data, key, {iv: iv, mode: CryptoJS.mode.CBC});
    var result = CryptoJS.enc.Utf8.stringify(decCode).toString();
    return result;
}

// 测试
console.log(aesDecrypt('dIT8ypziNG27TZlq9Zx38Go/1W8zpIzoy/Qx40hlAd8OWOb9yR9s.....Sas8kmF9rq/Y='));

把浏览器响应里的密文复制进去测试,能够正常解出明文,说明响应解密这条链路已经扣对了,如下:

本地 JS 验证没问题后,就可以接回前面整理好的 Python 请求模板。这里先用 execjs 调用 zwfw_nmg.js,把接口返回的 response.text 传给 aesDecrypt

python 复制代码
import subprocess
from functools import partial

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

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

# .... 省略已经写好的代码
print(ctx.call('aesDecrypt', response.text))

运行后可以看到,Python 请求拿到的响应密文同样能被解开:

响应解密跑通之后,再来看请求体密文是怎么生成的。思路还是先从简单方法开始,直接全局搜索 encrypt,结果如下:

搜索结果里有一处出现了 paramStr,这个变量名很像 "参数字符串",和请求体加密高度相关,先点进去看上下文:

进入后可以看到 var encryptText = CnsAESUtil.encryptSM2(paramStr); 这一行,基本符合 "明文参数 → 加密请求体" 的特征。为了确认当前分页接口是否真的走这里,直接在这一行下断点,然后回到页面点击分页按钮。断点命中,说明请求体加密入口找对了:

和前面验证响应解密一样,这里也可以在 Console 中直接执行 CnsAESUtil.encryptSM2(paramStr),看返回值是否和 Network 里看到的 Request Payload 形态一致:

从结果来看,返回值和请求体里的密文形态一致。断点处还能看到 paramStr 的明文内容,例如:

python 复制代码
{"token":"","params":{"first":3,"pagesize":5}}
{"token":"","params":{"first":4,"pagesize":5}}

多次翻页对比可以发现,first 会随着分页变化,pagesize 固定为每页条数,所以 first 就是当前页索引。参数结构确认后,下一步只需要继续看 encryptSM2 内部到底调用了什么。单步进入后可以看到:

javascript 复制代码
u.encryptSM2 = function (str) {
    var result = sm2Util.encrypt(str);
    return result;
};

这里的 str 就是前面的 paramStr,也就是请求参数的 JSON 字符串。encryptSM2 本身只是转调了一层,真正的加密逻辑在 sm2Util.encrypt(str) 里。继续单步进入,会跳到 /js/widgets/sm2Util.js?_=1.0.6_20170822,最终停在混淆后的 x 函数中,入参 z 就是需要加密的明文参数:

sm2Util.js 是混淆后的国密算法文件,当前阶段不需要把整份代码完全还原。更直接的做法是先把这个文件保存到本地,然后把命中的 x 函数导出到全局,后面直接调用它生成请求体密文:

测试:

javascript 复制代码
console.log(globalThis.x('{"token":"","params":{"first":5,"pagesize":5}}'));

结果:

单独调用 globalThis.x(...) 能生成密文后,再封装成一个更清晰的函数,统一写到 zwfw_nmg.js 里管理:

javascript 复制代码
require('./sm2Util')
function sm2Encrypt(paramStr){
    return globalThis.x(paramStr)

}

console.log(sm2Encrypt('{"token":"","params":{"first":5,"pagesize":5}}'));

封装后同样可以正常生成密文。接着把 Python 请求测试代码里的 data 替换成 sm2Encrypt(paramStr) 的返回值:

这里要注意一点:页面里的第一页 first 是从 0 开始的,不是从 1 开始。到这一步,请求体参数加密和响应体解密都已经跑通,整个接口复现流程就闭合了。

二、完整请求流程图

把前面断点里看到的逻辑串起来,这个接口的完整流程可以拆成两条线:请求方向负责把分页参数加密成 Request Payload,响应方向负责把服务端返回的密文解成 JSON。

text 复制代码
页面点击分页按钮
        │
        ▼
defaultIndex/js/index.js
getOpen(pageIndex, pageSize)
        │
        ▼
原始业务参数
{ first: pageIndex, pagesize: pageSize }
        │
        ▼
core_jq3.js -> Util.ajax(options)
统一包装参数
{
  token: "",
  params: {
    first: pageIndex,
    pagesize: pageSize
  }
}
        │
        ▼
JSON.stringify(options.data)
        │
        ▼
paramStr 明文字符串
{"token":"","params":{"first":0,"pagesize":5}}
        │
        ▼
CnsAESUtil.encryptSM2(paramStr)
        │
        ▼
sm2Util.encrypt(paramStr)
        │
        ├─ 先对 paramStr 做 Base64 编码
        │
        └─ 再用 SM2 公钥加密,模式参数为 0
              公钥:
              04A2C5ABFE372540F0CFAB644776B1CEC911F21739042D9FDF8326324357
              790DBA3E3900338DE4FFDBA48204A176D444687904422180E0B1E3AF316C
              4CA09AA704
        │
        ▼
Request Payload
04 开头的十六进制 SM2 密文字符串
        │
        ▼
POST /wsbsdt/rest/nmWebFrontPageRest/getCaseinfoList
Content-Type: application/json;charset=UTF-8
encrypt: 1

服务端返回后,前端会在同一个 Ajax 封装里继续处理响应:

text 复制代码
Response Body
Base64 形式的 AES 密文字符串
        │
        ▼
core_jq3.js -> dataFilter(data, type)
判断 options.headers.encrypt == 1
        │
        ▼
CnsAESUtil.decryptNew(data)
        │
        ├─ AES/CBC/PKCS7
        ├─ Key: qnbpwgttcfv96fgw
        └─ IV : 5141928399038306
        │
        ▼
解密后的 JSON 字符串
        │
        ▼
jQuery 按 dataType=json 解析成对象
        │
        ▼
success 回调拿到 res
res.custom.ccount       总条数
res.custom.list         当前页列表
        │
        ▼
列表字段
rqstcontent     诉求内容
answercontent   答复内容
finishtime      办结时间(接口返回字段,页面列表中不直接展示)

这个流程里没有发现额外的动态 signtimestamp 或登录态校验。真正需要复现的核心只有两块:请求体的 SM2 加密,以及响应体的 AES-CBC 解密。

三、Python 实现

上面已经把请求体加密和响应体解密逻辑都确认清楚了,接下来就可以写 Python 脚本了。这里我准备了两个版本:

  1. nmg_12345_caseinfo_spider.py:纯 Python 版本,用 gmssl 生成 SM2 请求密文,用 pycryptodome 解 AES 响应。
  2. nmg_12345_caseinfo_execjs_spider.py:Python 调用 JS 版本,直接复用前面扣下来的 sm2Util.jsCryptoJS.js

这个案例是政府网站,代码里默认只请求前 2 页,每页 5 条,并发数也只给到 2,学习验证够用了,不需要把请求量开大。

目录结构如下:

text 复制代码
nmg-12345-sm2-aes
├─ README.md
├─ nmg_12345_caseinfo_spider.py
├─ nmg_12345_caseinfo_execjs_spider.py
└─ js/
   └─ nmg_12345_crypto.js

依赖安装:

bash 复制代码
pip install requests pycryptodome gmssl loguru PyExecJS

其中 PyExecJS 版本需要本地能正常调用 Node.js。这个版本还需要准备两个前端公共库文件:CryptoJS.jssm2Util.js

最简单的目录放法是把这两个文件放到当前案例的 js/ 目录下:

text 复制代码
nmg-12345-sm2-aes
└─ js/
   ├─ nmg_12345_crypto.js
   ├─ CryptoJS.js
   └─ sm2Util.js

如果你自己的项目里有统一的公共库目录,也可以不复制文件,直接通过环境变量 JS_REVERSE_SHARED_JS_LIB_DIR 指向那个目录,脚本会从里面读取 CryptoJS.jssm2Util.js

3.1 Python 完全纯算

纯 Python 版本不依赖前端 JS 文件,适合最后落地使用。这里要注意三点:

  1. 第一页的 first0,不是 1
  2. 请求明文格式是 {"token":"","params":{"first":0,"pagesize":5}}
  3. 请求体是 JSON -> Base64 -> SM2 -> 前面补 04,响应体是 AES/CBC/PKCS7 解密。

文件名:nmg_12345_caseinfo_spider.py

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : nmg_12345_caseinfo_spider.py
@Author  : XAMO Lab
@Date    : 2026/6/23 17:25
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 内蒙古12345政务热线诉求公开采集(SM2 请求加密 + AES/CBC/PKCS7 响应解密 + 小并发测试)
"""
import base64
import json
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
from Crypto.Util.Padding import unpad
from gmssl import sm2
from loguru import logger

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


class Nmg12345Crypto:
    """内蒙古12345接口加解密"""

    # sm2Util.js 中硬编码的 SM2 公钥,原始值带 04 前缀。
    SM2_PUBLIC_KEY = (
        "04A2C5ABFE372540F0CFAB644776B1CEC911F21739042D9FDF8326324357"
        "790DBA3E3900338DE4FFDBA48204A176D444687904422180E0B1E3AF316C"
        "4CA09AA704"
    )
    AES_KEY = b"qnbpwgttcfv96fgw"
    AES_IV = b"5141928399038306"

    def __init__(self) -> None:
        public_key = (
            self.SM2_PUBLIC_KEY[2:]
            if self.SM2_PUBLIC_KEY.startswith("04")
            else self.SM2_PUBLIC_KEY
        )
        self.sm2_crypt = sm2.CryptSM2(private_key="", public_key=public_key, mode=0)

    def encrypt_request(self, payload: Dict[str, Any]) -> str:
        """
        请求体加密:
        JSON 字符串 -> Base64 -> SM2 加密 -> 前面补 04。
        """
        plaintext = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
        b64_plaintext = base64.b64encode(plaintext.encode("utf-8"))
        return "04" + self.sm2_crypt.encrypt(b64_plaintext).hex()

    def decrypt_response(self, cipher_text: str) -> Dict[str, Any]:
        """响应体解密: Base64 密文 -> AES/CBC/PKCS7 -> JSON。"""
        cipher = AES.new(self.AES_KEY, AES.MODE_CBC, self.AES_IV)
        plain = unpad(cipher.decrypt(base64.b64decode(cipher_text.strip())), AES.block_size)
        return json.loads(plain.decode("utf-8"))


class Nmg12345CaseinfoSpider:
    """内蒙古12345政务热线诉求公开采集"""

    API_URL = "https://zwfw.nmg.gov.cn/wsbsdt/rest/nmWebFrontPageRest/getCaseinfoList"
    REFERER = "https://zwfw.nmg.gov.cn/wsbsdt/website/pages/defaultIndex/index.html"
    ORIGIN = "https://zwfw.nmg.gov.cn"

    def __init__(
        self,
        page_size: int = 5,
        max_workers: int = 2,
        retries: int = 2,
        request_interval: float = 0.3,
    ) -> None:
        """
        :param page_size: 每页条数,页面默认 5
        :param max_workers: 并发线程数,政府网站测试保持小并发
        :param retries: 单页失败后的重试次数
        :param request_interval: 重试间隔,避免过快请求
        """
        self.page_size = page_size
        self.max_workers = max_workers
        self.retries = retries
        self.request_interval = request_interval
        self.crypto = Nmg12345Crypto()
        self.session = requests.Session()
        self.session.headers.update({
            "Accept": "application/json, text/javascript, */*; q=0.01",
            "Accept-Language": "zh-CN,zh;q=0.9",
            "Content-Type": "application/json;charset=UTF-8",
            "Origin": self.ORIGIN,
            "Referer": self.REFERER,
            "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",
            "encrypt": "1",
        })

    @staticmethod
    def build_payload(page_index: int, page_size: int) -> Dict[str, Any]:
        """构造加密前的请求体。页面第一页 page_index 从 0 开始。"""
        return {
            "token": "",
            "params": {
                "first": page_index,
                "pagesize": page_size,
            },
        }

    @staticmethod
    def parse_cases(data: Dict[str, Any]) -> List[Dict[str, str]]:
        """解析诉求公开列表字段。"""
        rows = []
        for item in data.get("custom", {}).get("list", []):
            rows.append({
                "诉求内容": item.get("rqstcontent") or item.get("rqsttitle", ""),
                "答复内容": item.get("answercontent", ""),
                "办结时间": item.get("finishtime", ""),
            })
        return rows

    def fetch_page(self, page_index: int) -> List[Dict[str, str]]:
        """请求并解析单页数据。"""
        payload = self.build_payload(page_index, self.page_size)
        encrypted_body = self.crypto.encrypt_request(payload)

        for attempt in range(1, self.retries + 1):
            try:
                resp = self.session.post(self.API_URL, data=encrypted_body, timeout=20)
                resp.raise_for_status()
                decrypted = self.crypto.decrypt_response(resp.text)
                rows = self.parse_cases(decrypted)
                logger.info("page_index={} 获取 {} 条记录", page_index, len(rows))
                return rows
            except Exception as exc:
                logger.warning(
                    "page_index={} 第 {}/{} 次请求失败: {}",
                    page_index,
                    attempt,
                    self.retries,
                    exc,
                )
                if attempt < self.retries:
                    time.sleep(self.request_interval * attempt)

        logger.error("page_index={} 请求失败,已跳过", page_index)
        return []

    def run(self, pages: int = 2) -> List[Dict[str, str]]:
        """
        小规模测试采集。
        政府网站案例仅用于学习验证,默认只请求前 2 页。
        """
        all_rows: List[Dict[str, str]] = []
        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            futures = {
                executor.submit(self.fetch_page, page_index): page_index
                for page_index in range(pages)
            }
            for future in as_completed(futures):
                page_index = futures[future]
                try:
                    all_rows.extend(future.result())
                except Exception as exc:
                    logger.error("page_index={} 处理异常: {}", page_index, exc)

        all_rows.sort(key=lambda row: row.get("办结时间", ""), reverse=True)
        return all_rows


def main() -> None:
    spider = Nmg12345CaseinfoSpider(page_size=5, max_workers=2, retries=2)
    rows = spider.run(pages=2)
    logger.info("本次共获取 {} 条记录", len(rows))
    for row in rows:
        logger.info("{}", json.dumps(row, ensure_ascii=False))


if __name__ == "__main__":
    main()

3.2 Python 调用 execjs

execjs 版本更适合刚扣完 JS 后做对照验证。公共库不写进案例目录,只保留本站自己的核心包装逻辑。

核心 JS 文件:js/nmg_12345_crypto.js

javascript 复制代码
/* 内蒙古12345政务热线诉求公开
 * 本文件只保留本站核心 JS 包装逻辑。
 * CryptoJS 和 sm2Util.js 由 Python 侧 execjs 编译前注入。
 */

const AES_KEY = "qnbpwgttcfv96fgw";
const AES_IV = "5141928399038306";

function buildPayload(pageIndex, pageSize) {
    return JSON.stringify({
        token: "",
        params: {
            first: pageIndex,
            pagesize: pageSize,
        },
    });
}

function sm2Encrypt(paramStr) {
    if (typeof globalThis.x !== "function") {
        throw new Error("sm2Util.js 未暴露 globalThis.x");
    }
    return globalThis.x(paramStr);
}

function aesDecrypt(ciphertext) {
    const key = CryptoJS.enc.Utf8.parse(AES_KEY);
    const iv = CryptoJS.enc.Utf8.parse(AES_IV);
    const decCode = CryptoJS.AES.decrypt(ciphertext, key, {
        iv: iv,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7,
    });
    return CryptoJS.enc.Utf8.stringify(decCode).toString();
}

function makeRequestBody(pageIndex, pageSize) {
    const plain = buildPayload(pageIndex, pageSize);
    return {
        plain: plain,
        body: sm2Encrypt(plain),
    };
}

function decryptResponse(ciphertext) {
    return JSON.parse(aesDecrypt(ciphertext));
}

Python 文件:nmg_12345_caseinfo_execjs_spider.py

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : nmg_12345_caseinfo_execjs_spider.py
@Author  : XAMO Lab
@Date    : 2026/6/23 17:47
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 内蒙古12345政务热线诉求公开采集(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 / "nmg_12345_crypto.js"


class Nmg12345CaseinfoExecjsSpider:
    """内蒙古12345政务热线诉求公开采集(Python 调 execjs 版本)"""

    API_URL = "https://zwfw.nmg.gov.cn/wsbsdt/rest/nmWebFrontPageRest/getCaseinfoList"
    REFERER = "https://zwfw.nmg.gov.cn/wsbsdt/website/pages/defaultIndex/index.html"
    ORIGIN = "https://zwfw.nmg.gov.cn"

    def __init__(
        self,
        page_size: int = 5,
        max_workers: int = 2,
        retries: int = 2,
        request_interval: float = 0.3,
    ) -> None:
        """
        :param page_size: 每页条数,页面默认 5
        :param max_workers: 并发线程数,政府网站测试保持小并发
        :param retries: 单页失败后的重试次数
        :param request_interval: 重试间隔,避免过快请求
        """
        self.page_size = page_size
        self.max_workers = max_workers
        self.retries = retries
        self.request_interval = request_interval
        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",
            "Content-Type": "application/json;charset=UTF-8",
            "Origin": self.ORIGIN,
            "Referer": self.REFERER,
            "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",
            "encrypt": "1",
        })

    @staticmethod
    def _has_js_libs(path: Path) -> bool:
        return (path / "CryptoJS.js").exists() and (path / "sm2Util.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 和 sm2Util.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;
    global.globalThis = global;
}}
const CryptoJS = require({json.dumps(str(js_lib_dir / "CryptoJS.js"), ensure_ascii=False)});
require({json.dumps(str(js_lib_dir / "sm2Util.js"), ensure_ascii=False)});
'''
        source = prefix + "\n" + CORE_JS.read_text(encoding="utf-8")
        return execjs.compile(source)

    def _make_request_body(self, page_index: int) -> Dict[str, str]:
        """调用 JS 生成请求体密文。"""
        return self.ctx.call("makeRequestBody", page_index, self.page_size)

    def _decrypt_response(self, ciphertext: str) -> Dict[str, Any]:
        """调用 JS 解密响应密文。"""
        return self.ctx.call("decryptResponse", ciphertext.strip())

    @staticmethod
    def parse_cases(data: Dict[str, Any]) -> List[Dict[str, str]]:
        """解析诉求公开列表字段。"""
        rows = []
        for item in data.get("custom", {}).get("list", []):
            rows.append({
                "诉求内容": item.get("rqstcontent") or item.get("rqsttitle", ""),
                "答复内容": item.get("answercontent", ""),
                "办结时间": item.get("finishtime", ""),
            })
        return rows

    def fetch_page(self, page_index: int) -> List[Dict[str, str]]:
        """请求并解析单页数据。"""
        for attempt in range(1, self.retries + 1):
            try:
                crypto = self._make_request_body(page_index)
                logger.info("page_index={} 开始请求 | plain={}", page_index, crypto.get("plain"))
                resp = self.session.post(self.API_URL, data=crypto["body"], timeout=20)
                resp.raise_for_status()
                decrypted = self._decrypt_response(resp.text)
                rows = self.parse_cases(decrypted)
                logger.success("page_index={} 解密成功,获取 {} 条记录", page_index, len(rows))
                return rows
            except Exception as exc:
                logger.warning(
                    "page_index={} 第 {}/{} 次请求失败: {}",
                    page_index,
                    attempt,
                    self.retries,
                    exc,
                )
                if attempt < self.retries:
                    time.sleep(self.request_interval * attempt)

        logger.error("page_index={} 请求失败,已跳过", page_index)
        return []

    def run(self, pages: int = 2) -> List[Dict[str, str]]:
        """
        小规模测试采集。
        政府网站案例仅用于学习验证,默认只请求前 2 页。
        """
        logger.info(
            "开始采集内蒙古12345诉求公开(execjs 版)| 共 {} 页 | 每页 {} 条 | 并发 {}",
            pages,
            self.page_size,
            self.max_workers,
        )
        all_rows: List[Dict[str, str]] = []
        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            futures = {
                executor.submit(self.fetch_page, page_index): page_index
                for page_index in range(pages)
            }
            for future in as_completed(futures):
                page_index = futures[future]
                try:
                    all_rows.extend(future.result())
                except Exception as exc:
                    logger.error("page_index={} 处理异常: {}", page_index, exc)

        all_rows.sort(key=lambda row: row.get("办结时间", ""), reverse=True)
        logger.info("采集完成,共 {} 条记录", len(all_rows))
        return all_rows


def main() -> None:
    spider = Nmg12345CaseinfoExecjsSpider(page_size=5, max_workers=2, retries=2)
    rows = spider.run(pages=2)
    for row in rows:
        logger.info("{}", json.dumps(row, ensure_ascii=False))


if __name__ == "__main__":
    main()

3.3 运行结果

运行命令:

bash 复制代码
python nmg_12345_caseinfo_spider.py
python nmg_12345_caseinfo_execjs_spider.py

两份脚本都使用默认的小规模配置进行测试:

text 复制代码
pages=2
page_size=5
max_workers=2

纯 Python 版本运行结果:

text 复制代码
2026-06-23 18:39:41.527 | INFO     | __main__:fetch_page:144 - page_index=0 获取 5 条记录
2026-06-23 18:39:41.541 | INFO     | __main__:fetch_page:144 - page_index=1 获取 5 条记录
2026-06-23 18:39:41.542 | INFO     | __main__:main:185 - 本次共获取 10 条记录

execjs 版本运行结果:

text 复制代码
2026-06-23 18:39:58.353 | INFO     | __main__:run:168 - 开始采集内蒙古12345诉求公开(execjs 版)| 共 2 页 | 每页 5 条 | 并发 2
2026-06-23 18:39:58.515 | INFO     | __main__:fetch_page:142 - page_index=0 开始请求 | plain={"token":"","params":{"first":0,"pagesize":5}}
2026-06-23 18:39:58.516 | INFO     | __main__:fetch_page:142 - page_index=1 开始请求 | plain={"token":"","params":{"first":1,"pagesize":5}}
2026-06-23 18:39:59.249 | SUCCESS  | __main__:fetch_page:147 - page_index=0 解密成功,获取 5 条记录
2026-06-23 18:39:59.273 | SUCCESS  | __main__:fetch_page:147 - page_index=1 解密成功,获取 5 条记录

最终整理出来的字段如下:

字段 来源字段 说明
诉求内容 rqstcontent 群众提交的问题内容
答复内容 answercontent 平台或承办单位答复内容
办结时间 finishtime 接口返回字段,页面列表不展示

四、总结

这个案例整体不算复杂,真正需要拆开的就两件事:请求体为什么不是明文,以及响应体为什么直接看不到 JSON。

请求方向还是按前面的思路来定位:先搜索 encrypt,找到 paramStrCnsAESUtil.encryptSM2(paramStr) 这一处后下断点。断住以后可以看到,真正送去加密的分页参数是下面这种结构:

json 复制代码
{"token":"","params":{"first":0,"pagesize":5}}

然后调用 CnsAESUtil.encryptSM2(paramStr)。继续跟进去可以看到,它最后走的是 sm2Util.encrypt,也就是前端混淆文件 sm2Util.js 里的 SM2 加密逻辑。这里要特别注意两点:第一页的 first0 开始;生成的 SM2 密文前面带 04,Python 复现时也要保持这个格式。

响应方向更直接,列表接口返回的是一整段密文,真正解密的位置在 core_jq3.js 里的 window.CnsAESUtil.decryptNew(data)。这个函数没有动态参数,AES 的 Key 和 IV 都是写死的:

text 复制代码
Key = qnbpwgttcfv96fgw
IV  = 5141928399038306
Mode = CBC
Padding = PKCS7

所以这个接口最终可以拆成下面这个流程:

text 复制代码
分页参数 -> JSON.stringify -> Base64 -> SM2 加密 -> POST 请求
响应密文 -> AES/CBC/PKCS7 解密 -> JSON.parse -> 提取字段

落地代码我给了两个版本。纯 Python 版本适合最终使用,不依赖浏览器端 JS;execjs 版本适合刚分析完 JS 后做对照验证,能更直观地确认自己扣下来的逻辑是不是和浏览器一致。实际写案例时,我一般会先用 execjs 跑通,再根据算法和参数改成纯 Python,这样排查问题会轻松很多。

最后还是要强调一下,这类政务网站案例只适合学习逆向流程和加解密还原,不适合大量采集。脚本里默认只请求前 2 页,每页 5 条,并发数也控制为 2,这样既能验证请求加密、响应解密和字段解析都没问题,也不会对目标网站造成额外压力。