全国建筑市场监管公共服务平台企业信息接口 —— AES-CBC 响应密文解密与 Python 还原

目录

  • 一、分析
  • [二、Python 实现](#二、Python 实现)
    • [2.1 纯 Python 复现版本](#2.1 纯 Python 复现版本)
    • [2.2 Python 调用 JS 复现版本](#2.2 Python 调用 JS 复现版本)
    • [2.3 运行结果摘要](#2.3 运行结果摘要)
  • 三、总结

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

一、分析

目标地址:

text 复制代码
https://jzsc.mohurd.gov.cn/data/company

本案例需要抓取全国建筑市场监管公共服务平台的企业列表数据,循环采集前 3 页,并提取每条记录中的统一社会信用代码、企业名称、企业法定代表人和企业注册属地。

打开 F12 进入 DevTools,切换到 Network 面板并清空请求记录。由于列表页数据是通过 Ajax 动态加载的,先筛选 Fetch/XHR 请求,再通过翻页触发接口调用,定位到目标数据包:

目标接口是一个 GET 请求:

text 复制代码
GET https://jzsc.mohurd.gov.cn/APi/webApi/dataservice/query/comp/list?pg=2&pgsz=15&total=450
GET https://jzsc.mohurd.gov.cn/APi/webApi/dataservice/query/comp/list?pg=3&pgsz=15&total=450
GET https://jzsc.mohurd.gov.cn/APi/webApi/dataservice/query/comp/list?pg=4&pgsz=15&total=450

可以很明显地看出,只有 pg 参数是动态变化的,而其他请求参数都是明文传输,所以这个案例不需要额外考虑请求参数加密。接着看一下响应内容,会发现返回结果是加密的,如下:

再看一下 Headers,如下:

http 复制代码
GET /APi/webApi/dataservice/query/comp/list?pg=3&pgsz=15&total=450 HTTP/1.1
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: no-cache
Connection: keep-alive
Cookie: Hm_lvt_b1b4b9ea61b6f1627192160766a9c55c=1780369208,1782328514; HMACCOUNT=0703805E4B458F81; Hm_lpvt_b1b4b9ea61b6f1627192160766a9c55c=1782761708
Host: jzsc.mohurd.gov.cn
Pragma: no-cache
Referer: https://jzsc.mohurd.gov.cn/data/company
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
accessToken: jkFXxgu9TcpocIyCKmJ+tfpxe/45B9dbWMUXhdY7vLWybhbMLlsuA4d7x6oBdwP7hpUUKvcMtoMqfGfwdLCb8g==
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"
timeout: 30000
v: 231012

这里有两个比较少见的请求头字段 accessTokenv,初看上去像是自定义校验字段。先说 accessToken:经过实测,把它置空、甚至整个 Cookie 都不带,接口依然能正常返回数据,所以它并不是本案例的必需校验字段,这里先不展开。v 看起来像是一个版本号,三个数据包里它的值都固定是 231012,可以先记下来------后面分析密钥时会发现,这个字段其实直接决定了服务端返回密文的加密版本,是一个不能忽略的关键字段。

当前更重要的是处理响应密文的解密逻辑,所以先采用最直接的方式,全局搜索 decrypt 等关键字,看看解密代码位于哪里,如下:

这个位置非常明显。按照前面几个案例的经验,直接点进去查看即可,定位到 /js/app.b72aa531.js,可以看到核心代码 d.a.AES.decrypt(n, f, {。我们在这里下一个断点,然后翻页触发请求,观察是否会断住。结果不出意外,断点成功命中,如下:

代码最终断在 b 函数中,所以把这个函数单独拿出来看最合适。这里的逻辑并不复杂,核心就是把接口返回的密文转成明文 JSON,分析如下:

javascript 复制代码
function b(t) {
    // d.a 是 CryptoJS 对象
    // t 是接口返回的密文
    // f 是 AES 密钥
    // m 是 IV
    // 这里使用的是 AES-CBC,填充方式为 Pkcs7
    var e = d.a.enc.Hex.parse(t)
    , n = d.a.enc.Base64.stringify(e)
    , a = d.a.AES.decrypt(n, f, {
        iv: m,
        mode: d.a.mode.CBC,
        padding: d.a.pad.Pkcs7
    })
    , r = a.toString(d.a.enc.Utf8);
    return r.toString()
}

到这里其实已经很清楚了,剩下要确认的就是密钥 fiv m。这里容易踩的一个点是,b 函数内部并没有直接定义这两个变量,它们都来自上层闭包,所以不能只盯着函数体本身看。按照逆向习惯,继续往上翻就能找到它们的定义位置,如下:

紧接着就可以在本地把这段逻辑改写出来,如下:

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

// f = d.a.enc.Utf8.parse("jo8j9wGw%6HbxfFn")
f = CryptoJS.enc.Utf8.parse("jo8j9wGw%6HbxfFn")

  // , h = void 0
// m = d.a.enc.Utf8.parse("0123456789ABCDEF");
m = CryptoJS.enc.Utf8.parse("0123456789ABCDEF");
function b(t) {
    // var e = d.a.enc.Hex.parse(t)
    var e = CryptoJS.enc.Hex.parse(t)
      // , n = d.a.enc.Base64.stringify(e)
      , n = CryptoJS.enc.Base64.stringify(e)
      // , a = d.a.AES.decrypt(n, f, {
      , a = CryptoJS.AES.decrypt(n, f, {
        iv: m,
        // mode: d.a.mode.CBC,
        mode: CryptoJS.mode.CBC,
        padding: CryptoJS.pad.Pkcs7
    })
      // , r = a.toString(d.a.enc.Utf8);
      , r = a.toString(CryptoJS.enc.Utf8);
    return r.toString()
}

但实际运行时发现,无论怎么改写都无法正常解密密文,一直会报错:

这时就要开始排查,是不是代码被动态改写过,或者是密钥、IV 取值不对。先看当前 f 的字节数组,再对比上面赋值 f 的位置 d.a.enc.Utf8.parse("jo8j9wGw%6HbxfFn"),会发现字节数组并不一致,如下:

再对比一下 iv,可以看到这一项是一致的:

其实这里直接使用字节数组去解密也是可以的。不过继续看右侧面板可以发现,f 确实位于上层闭包 Closure (27fe) 中,而且字节数组的第一个元素和浏览器里使用的是同一个,如下:

但它又不是 d.a.enc.Utf8.parse("jo8j9wGw%6HbxfFn") 的结果。看到这里基本就能判断,在这个闭包或者模块内部,f 还有其他地方被重新赋值了。继续往下翻找,果然发现 f 被再次赋值了,如下:

于是我们改用这个值再试一次,本地 js 代码调整如下:

重新执行 js 代码后,解密成功:

不过到这里可能会留下一个疑问:源码里先写了 f = ...parse("jo8j9wGw%6HbxfFn"),后面又把 f 覆盖成 Dt8j9wGw%6HbxfFn,前一个难道只是用来迷惑人的诱饵吗?多测几次会发现并不是------这两个 key 其实都是「真」的,区别在请求头里的 v

  1. 不带 v :服务端返回的密文是 95780... 开头,要用 jo8j9wGw%6HbxfFn 才能解开;
  2. v: 231012 :服务端返回的密文是 55... 开头,要用 Dt8j9wGw%6HbxfFn 才能解开。

也就是说,v 是一个接口版本号,服务端会根据它返回 不同版本(不同密钥) 加密的密文。两种情况解出来的明文 JSON 完全一致,IV、加密模式和填充方式也都没变(依旧是 AES-CBC / Pkcs7,IV 固定为 0123456789ABCDEF),唯一的差别就是加密所用的 key。

再回头看前端代码就通了:getInsideConfig 里写死了请求头 v: h,而 h = 231012,所以浏览器发出的请求 永远带 v: 231012 ,拿到的必然是 55... 格式,于是代码也顺势把 f 覆盖成配套的 Dt8j9wGw%6HbxfFn。前面那行 jo8j9wGw%6HbxfFn 只是旧版本(无 v)遗留下来的 key,在当前浏览器流程里属于永远走不到的「死代码」,但服务端出于向后兼容仍然认它------这才是它看起来像诱饵、实际却能解开另一种格式密文的真正原因。

所以后面用 Python 复现时,有两条都能走通的路:要么 带上 v: 231012、用 Dt8j9wGw%6HbxfFn (与浏览器真实行为一致,推荐这种);要么干脆不带 v、改用 jo8j9wGw%6HbxfFn 解。两者拿到的数据完全相同,本案例统一按浏览器口径走第一种。

至此,这个案例的响应解密部分就已经分析完成了。这个网站真正的难点并不在响应解密,而在点击进入详情页时的校验流程,如下:

这个点后续有机会再单独展开,这里先不作为本节重点。

二、Python 实现

前面已经把接口定位、参数规律、响应解密逻辑(含 v 头与密钥版本的关系)都分析清楚了。这里把最终可运行代码整理成两个版本:

  1. 纯 Python 复现:直接用 Python 改写前端 b 函数的 hex → AES-CBC/PKCS7 解密逻辑。
  2. Python 调用 JS:保留浏览器中扣下来的 b 函数(重命名为 decryptRes),通过 execjs 调用本地 CryptoJS 完成解密。

考虑到本案例只是循环抓取前 3 页,请求量很小,且页码之间无依赖,这里统一采用顺序抓取,不引入多线程并发。

目录结构:

text 复制代码
jzsc-company-aes-cbc
├─ README.md
├─ jzsc_company_python_spider.py
├─ jzsc_company_execjs_spider.py
├─ jzsc_company.js
└─ CryptoJS.js

字段提取说明:

字段 说明 来源
page 页码(从 1 展示) 请求参数 pg(从 0 开始)+1
credit_code 统一社会信用代码 响应字段 QY_ORG_CODE
company_name 企业名称 响应字段 QY_NAME
legal_person 企业法定代表人 响应字段 QY_FR_NAME
region_name 企业注册属地 响应字段 QY_REGION_NAME

2.1 纯 Python 复现版本

运行方式:

bash 复制代码
python jzsc_company_python_spider.py

完整代码如下:

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : jzsc_company_python_spider.py
@Author  : XAMO Lab
@Date    : 2026/6/30 5:46
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 全国建筑市场监管公共服务平台企业信息采集(AES/CBC/PKCS7 响应解密,顺序抓取)
"""
import binascii
import json
import sys
import time
import warnings
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 loguru import logger

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


class JzscCompanySpider:
    """全国建筑市场监管公共服务平台企业列表爬虫。"""

    API_URL = "https://jzsc.mohurd.gov.cn/APi/webApi/dataservice/query/comp/list"
    SITE_URL = "https://jzsc.mohurd.gov.cn/data/company"

    # 逆向自 js/app.b72aa531.js 模块 27fe:
    #   m = Utf8.parse("0123456789ABCDEF")                 -> IV
    #   f = Utf8.parse("jo8j9wGw%6HbxfFn")                 -> 旧版本(不带 v 头)遗留 key
    #   h = 231012, f = Utf8.parse("Dt8j9wGw%6HbxfFn")     -> 覆盖为新版本 key
    # 浏览器请求头固定带 v=231012,服务端返回 "55" 开头的新版本密文,
    # 因此真正生效的是 Dt8j9wGw%6HbxfFn;jo8j9wGw%6HbxfFn 仅用于不带 v 头的旧格式。
    AES_KEY = b"Dt8j9wGw%6HbxfFn"
    AES_IV = b"0123456789ABCDEF"
    V_HEADER = "231012"

    # 解析输出的目标字段:统一社会信用代码 / 企业名称 / 法定代表人 / 注册属地
    FIELD_MAP = {
        "QY_ORG_CODE": "credit_code",
        "QY_NAME": "company_name",
        "QY_FR_NAME": "legal_person",
        "QY_REGION_NAME": "region_name",
    }

    def __init__(self, page_size: int = 15, retries: int = 3, timeout: int = 30) -> None:
        """
        :param page_size: 每页条数,页面默认 15
        :param retries: 单页请求失败后的重试次数
        :param timeout: 单次请求超时时间(秒)
        """
        self.page_size = page_size
        self.retries = retries
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update({
            "Accept": "application/json, text/plain, */*",
            "Accept-Language": "zh-CN,zh;q=0.9",
            "Referer": self.SITE_URL,
            # v 是接口版本号,决定服务端返回密文的加密版本,必须带上才能用新版 key 解密。
            "v": self.V_HEADER,
            # accessToken 实测可为空字符串,接口仍正常返回,这里保留以贴近浏览器请求。
            "accessToken": "",
            "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"
            ),
        })

    def _params(self, pg: int, total: int = 0) -> Dict[str, Any]:
        """组装查询参数。pg 从 0 开始计数,total 传 0 时服务端会回填真实总数。"""
        return {"pg": pg, "pgsz": self.page_size, "total": total}

    def _decrypt(self, ciphertext: str) -> Dict[str, Any]:
        """Hex 密文文本 -> AES/CBC/PKCS7 解密 -> dict。

        前端 b(t) 里先 Hex.parse 再 Base64.stringify 喂给 CryptoJS,本质等价于
        把 hex 字符串直接还原成密文字节,因此这里 unhexlify 即可。
        """
        raw = binascii.unhexlify(ciphertext.strip())
        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)

    def _parse_item(self, item: Dict[str, Any], page: int, index: int) -> Dict[str, Any]:
        """提取企业字段,统一输出结构。"""
        row: Dict[str, Any] = {"page": page, "index": index}
        for src_key, dst_key in self.FIELD_MAP.items():
            row[dst_key] = item.get(src_key) or ""
        return row

    def fetch_page(self, pg: int, total: int = 0) -> List[Dict[str, Any]]:
        """请求单页、解密响应并返回结构化企业数据。pg 从 0 开始。"""
        params = self._params(pg, total)
        display_page = pg + 1

        for attempt in range(1, self.retries + 1):
            try:
                logger.info("page={} (pg={}) 开始请求 | params={}", display_page, pg, params)
                response = self.session.get(self.API_URL, params=params, timeout=self.timeout)
                response.raise_for_status()

                # 响应体是 hex 密文文本,不是 JSON 对象。
                data = self._decrypt(response.text)
                if data.get("code") != 200:
                    logger.warning(
                        "page={} 接口异常 code={} message={}",
                        display_page, data.get("code"), data.get("message"),
                    )
                    return []

                body = data.get("data") or {}
                rows = [
                    self._parse_item(item, display_page, idx)
                    for idx, item in enumerate(body.get("list") or [], 1)
                ]
                logger.success(
                    "page={} 解密成功,获取 {} 条企业数据 | total={}",
                    display_page, len(rows), body.get("total"),
                )
                return rows
            except Exception as exc:
                if attempt >= self.retries:
                    raise
                logger.warning("page={} 第 {} 次请求失败,准备重试: {}", display_page, attempt, exc)
                time.sleep(0.5 * attempt)

        return []

    def run(self, pages: int = 3) -> List[Dict[str, Any]]:
        """顺序采集前 pages 页企业数据(pg 从 0 开始)。"""
        logger.info("开始采集建筑市场企业数据 | 共 {} 页 | 每页 {} 条", pages, self.page_size)
        all_rows: List[Dict[str, Any]] = []

        for pg in range(0, pages):
            try:
                all_rows.extend(self.fetch_page(pg))
            except Exception as exc:
                logger.error("page={} (pg={}) 采集失败: {}", pg + 1, pg, exc)
            time.sleep(0.3)  # 顺序抓取,轻微间隔,降低请求频率

        logger.info("采集完成,共 {} 条企业数据", len(all_rows))
        return all_rows


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

2.2 Python 调用 JS 复现版本

这个版本尽量保留浏览器里扣下来的解密逻辑:jzsc_company.js 中保留前端 b 函数(这里命名为 decryptRes),只额外补上 require("./CryptoJS.js")module.exports。Python 再通过 execjsCryptoJS.js 和解密脚本编译成上下文调用。

需要注意的是,Windows 下 PyExecJS 默认按系统编码(GBK)读写 node 子进程,而我们的 JS 带中文注释、响应数据也含中文,直接运行会报 'gbk' codec can't decode。常见做法是把 subprocess.Popen 强制成 utf-8,但如果像 functools.partial 那样替换会破坏 asyncio 等库对 subprocess.Popen 的继承(loguru 会间接 import asyncio),因此这里改用一个强制 utf-8Popen 子类,并放在 import execjs 之前。

运行方式:

bash 复制代码
python jzsc_company_execjs_spider.py

JS 解密代码(jzsc_company.js)如下:

javascript 复制代码
// 全国建筑市场监管公共服务平台 ------ 企业列表响应解密
//
// 逆向自 https://jzsc.mohurd.gov.cn/js/app.b72aa531.js 模块 27fe,原函数名为 b()。
//   m = CryptoJS.enc.Utf8.parse("0123456789ABCDEF")                 -> IV
//   f = CryptoJS.enc.Utf8.parse("jo8j9wGw%6HbxfFn")                 -> 旧版本(不带 v 头)遗留 key
//   h = 231012, f = CryptoJS.enc.Utf8.parse("Dt8j9wGw%6HbxfFn")     -> 覆盖为新版本 key
// 浏览器请求头固定带 v=231012,服务端返回 "55" 开头的新版本密文,因此真正生效的是
// Dt8j9wGw%6HbxfFn;jo8j9wGw%6HbxfFn 只用于不带 v 头时的旧格式("95780" 开头),这里用不到。

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

// const f = CryptoJS.enc.Utf8.parse("jo8j9wGw%6HbxfFn"); // 旧版本 key(无 v 头)
const f = CryptoJS.enc.Utf8.parse("Dt8j9wGw%6HbxfFn");    // 新版本 key(v=231012)
const m = CryptoJS.enc.Utf8.parse("0123456789ABCDEF");    // IV

// 对应前端的 b(t):hex 密文 -> AES-CBC/PKCS7 解密 -> 明文 JSON 字符串
function decryptRes(t) {
  const e = CryptoJS.enc.Hex.parse(t);
  const n = CryptoJS.enc.Base64.stringify(e);
  const a = CryptoJS.AES.decrypt(n, f, {
    iv: m,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7,
  });
  const r = a.toString(CryptoJS.enc.Utf8);
  return r.toString();
}

if (typeof module !== "undefined") {
  module.exports = {
    decryptRes,
  };
}

CryptoJS.js 为标准 CryptoJS 打包文件,直接复用即可,这里不再贴出。

Python 代码(jzsc_company_execjs_spider.py)如下:

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : jzsc_company_execjs_spider.py
@Author  : XAMO Lab
@Date    : 2026/6/30 5:54
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 全国建筑市场监管公共服务平台企业信息采集(execjs 调用 JS 复现 AES/CBC/PKCS7 响应解密,顺序抓取)
"""
import json
import subprocess
import sys
import time
import warnings
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


# Windows 下 PyExecJS 默认用系统编码(GBK)读写 node 子进程管道,
# 而 JS 源码含中文注释、响应数据含中文,会触发 'gbk' codec 解码报错。
# 这里用一个强制 utf-8 的 Popen 子类替换(保持它仍是"类",避免像 functools.partial
# 那样破坏 asyncio 等库对 subprocess.Popen 的继承),必须在 import execjs 之前完成。
class _Utf8Popen(subprocess.Popen):
    def __init__(self, *args, **kwargs):
        kwargs.setdefault("encoding", "utf-8")
        super().__init__(*args, **kwargs)


subprocess.Popen = _Utf8Popen

import execjs

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

BASE_DIR = Path(__file__).resolve().parent


class JzscCompanyExecjsSpider:
    """全国建筑市场监管公共服务平台企业列表爬虫(Python 调用 JS 解密版本)。"""

    API_URL = "https://jzsc.mohurd.gov.cn/APi/webApi/dataservice/query/comp/list"
    SITE_URL = "https://jzsc.mohurd.gov.cn/data/company"
    V_HEADER = "231012"

    # 解析输出的目标字段:统一社会信用代码 / 企业名称 / 法定代表人 / 注册属地
    FIELD_MAP = {
        "QY_ORG_CODE": "credit_code",
        "QY_NAME": "company_name",
        "QY_FR_NAME": "legal_person",
        "QY_REGION_NAME": "region_name",
    }

    def __init__(self, page_size: int = 15, retries: int = 3, timeout: int = 30) -> None:
        """
        :param page_size: 每页条数,页面默认 15
        :param retries: 单页请求失败后的重试次数
        :param timeout: 单次请求超时时间(秒)
        """
        self.page_size = page_size
        self.retries = retries
        self.timeout = timeout
        self.js_ctx = self._load_js_context()
        self.session = requests.Session()
        self.session.headers.update({
            "Accept": "application/json, text/plain, */*",
            "Accept-Language": "zh-CN,zh;q=0.9",
            "Referer": self.SITE_URL,
            # v 是接口版本号,决定服务端返回密文的加密版本,必须带上才能用新版 key 解密。
            "v": self.V_HEADER,
            # accessToken 实测可为空字符串,接口仍正常返回,这里保留以贴近浏览器请求。
            "accessToken": "",
            "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"
            ),
        })

    @staticmethod
    def _load_js_context() -> "execjs.ExternalRuntime.Context":
        """加载本地 CryptoJS 与解密脚本,编译成 execjs 上下文。

        execjs 没有 require 机制,这里把 CryptoJS.js 直接拼接到前面,再用一个桥接语句
        把它暴露成全局 CryptoJS 变量,最后去掉 jzsc_company.js 里的 require 行。
        """
        cryptojs = (BASE_DIR / "CryptoJS.js").read_text(encoding="utf-8")
        decrypt_code = (BASE_DIR / "jzsc_company.js").read_text(encoding="utf-8")
        decrypt_code = decrypt_code.replace('const CryptoJS = require("./CryptoJS.js");', "")
        bridge = 'var CryptoJS = typeof module !== "undefined" && module.exports ? module.exports : this.CryptoJS;'
        return execjs.compile(f"{cryptojs}\n{bridge}\n{decrypt_code}")

    def _params(self, pg: int, total: int = 0) -> Dict[str, Any]:
        """组装查询参数。pg 从 0 开始计数,total 传 0 时服务端会回填真实总数。"""
        return {"pg": pg, "pgsz": self.page_size, "total": total}

    def _decrypt(self, ciphertext: str) -> Dict[str, Any]:
        """调用 JS 的 decryptRes(即前端 b 函数)解密 hex 密文,再转 dict。"""
        plain_text = self.js_ctx.call("decryptRes", ciphertext.strip())
        return json.loads(plain_text)

    def _parse_item(self, item: Dict[str, Any], page: int, index: int) -> Dict[str, Any]:
        """提取企业字段,统一输出结构。"""
        row: Dict[str, Any] = {"page": page, "index": index}
        for src_key, dst_key in self.FIELD_MAP.items():
            row[dst_key] = item.get(src_key) or ""
        return row

    def fetch_page(self, pg: int, total: int = 0) -> List[Dict[str, Any]]:
        """请求单页、解密响应并返回结构化企业数据。pg 从 0 开始。"""
        params = self._params(pg, total)
        display_page = pg + 1

        for attempt in range(1, self.retries + 1):
            try:
                logger.info("page={} (pg={}) 开始请求 | params={}", display_page, pg, params)
                response = self.session.get(self.API_URL, params=params, timeout=self.timeout)
                response.raise_for_status()

                # 响应体是 hex 密文文本,不是 JSON 对象。
                data = self._decrypt(response.text)
                if data.get("code") != 200:
                    logger.warning(
                        "page={} 接口异常 code={} message={}",
                        display_page, data.get("code"), data.get("message"),
                    )
                    return []

                body = data.get("data") or {}
                rows = [
                    self._parse_item(item, display_page, idx)
                    for idx, item in enumerate(body.get("list") or [], 1)
                ]
                logger.success(
                    "page={} 解密成功,获取 {} 条企业数据 | total={}",
                    display_page, len(rows), body.get("total"),
                )
                return rows
            except Exception as exc:
                if attempt >= self.retries:
                    raise
                logger.warning("page={} 第 {} 次请求失败,准备重试: {}", display_page, attempt, exc)
                time.sleep(0.5 * attempt)

        return []

    def run(self, pages: int = 3) -> List[Dict[str, Any]]:
        """顺序采集前 pages 页企业数据(pg 从 0 开始)。"""
        logger.info("开始采集建筑市场企业数据 | 共 {} 页 | 每页 {} 条", pages, self.page_size)
        all_rows: List[Dict[str, Any]] = []

        for pg in range(0, pages):
            try:
                all_rows.extend(self.fetch_page(pg))
            except Exception as exc:
                logger.error("page={} (pg={}) 采集失败: {}", pg + 1, pg, exc)
            time.sleep(0.3)  # 顺序抓取,轻微间隔,降低请求频率

        logger.info("采集完成,共 {} 条企业数据", len(all_rows))
        return all_rows


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

2.3 运行结果摘要

两种方案都已实际请求验证,均能顺序采集前 3 页,每页 15 条、共 45 条。日志摘要如下:

部分输出字段示例:

三、总结

这个案例的主线是先定位企业列表接口 comp/list,再处理响应密文解密。请求本身是普通 GET,分页参数明文传输,没有请求体加密;难点集中在响应解密的密钥取值上。

这里需要注意几点:

  1. 响应是 hex 密文,解密算法为 AES-128-CBC / Pkcs7,IV 固定为 0123456789ABCDEF
  2. 密钥 f 在 JS 中被赋值两次,真正生效的是后赋值的 Dt8j9wGw%6HbxfFn,前一个 jo8j9wGw%6HbxfFn 不是诱饵,而是旧版本(不带 v 头)遗留的 key。
  3. 请求头 v: 231012 决定服务端返回密文的加密版本,必须带上;浏览器始终带 v,故统一用新 key 解密。accessToken 实测可为空。
  4. 分页参数 pg 从 0 开始;total 传 0 时服务端会回填真实总数。
  5. 前端 b 函数里 Hex.parse → Base64.stringify → AES.decrypt 的来回转换,在 Python 中等价于直接 unhexlify 还原密文字节再解密。
  6. execjs 调 JS 复现时,Windows 下要把 subprocess.Popen 强制成 utf-8(用子类替换,避免破坏 asyncioPopen 的继承),否则会因中文触发 gbk 解码报错。