数位观察城市数据查询 —— AES/ECB/PKCS7 响应解密与数据采集

目录

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

一、分析

目标地址:

text 复制代码
https://www.swguancha.com/home/city

本案例需要抓取数位观察城市数据查询列表,循环采集前 3 页,提取每条城市数据中的城市名称、人均 GDP、公交车、户籍人口等字段。

F12 打开 DevTools,切到 Network 面板,清空请求记录。列表页数据通过 Ajax 动态加载,因此先过滤 Fetch/XHR,再点击分页按钮触发请求。可以定位到城市数据列表接口:

目标接口是一个 POST 请求:

text 复制代码
https://app.swguancha.com/client/v1/cPublic/consumer/baseInfo

多次翻页后对比 Request Payload,可以看到翻页主要改变 current,其余筛选条件保持不变:

json 复制代码
{"size":6,"current":2,"propertyCode":["DISTRICT_PROP_GJ025_RJDQSCZZ","DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS","DISTRICT_PROP_GJ001_NMHJRK"],"dimensionTime":"2019","levelType":2}
{"size":6,"current":3,"propertyCode":["DISTRICT_PROP_GJ025_RJDQSCZZ","DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS","DISTRICT_PROP_GJ001_NMHJRK"],"dimensionTime":"2019","levelType":2}
{"size":6,"current":4,"propertyCode":["DISTRICT_PROP_GJ025_RJDQSCZZ","DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS","DISTRICT_PROP_GJ001_NMHJRK"],"dimensionTime":"2019","levelType":2}

参数含义如下:

参数 说明
size 每页数量,页面默认 6 条
current 当前页码
propertyCode 要展示的指标编码列表
dimensionTime 指标年份,这里是 2019
levelType 区域层级,这里使用城市层级 2

三个指标编码分别对应:

propertyCode 含义
DISTRICT_PROP_GJ025_RJDQSCZZ 人均GDP
DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS 公交车/公共汽(电)车量
DISTRICT_PROP_GJ001_NMHJRK 户籍人口

请求头里可以看到自定义字段 deviceType: 1。它不是签名参数,也不参与请求体加密;实测只携带常见浏览器请求头也可以拿到响应。为了让脚本更接近浏览器环境,后续代码里保留 User-AgentRefererOriginAcceptContent-TypedeviceType 等字段即可。这个接口没有额外时间戳、nonce、签名字段,请求参数本身是明文 JSON。

查看 Response 可以发现,接口返回的不是常规 JSON 对象,而是一整段 Base64 形态的密文文本,例如:

text 复制代码
UiY3CaV4ZQrQR9/LFH5qq2F4H8zkRn76NFuDbcyLVvso6M5v0Bn4Gzce9QpTy34W...

如下图:

这里先记一个调试细节:Network 的 Response 面板中看到的密文可能夹杂空格、换行等空白字符,复制响应内容时也可能把这些空白一起带出来。这个细节在后面和断点处的 t.data 做对比时很容易造成误判。

接下来需要定位前端在哪里把这段密文还原成业务数据。响应解密通常有两条定位思路:

  1. Hook JSON.parse,从解密后被 parse 成对象的位置向上回溯;
  2. 在 Sources 中搜索 decryptAESCryptoJSinterceptors.response.use 等关键字。

本案例中直接搜索 AESinterceptors.response.use 更快。最终在 /static/js/app.74b0261d.js 中定位到 axios 响应拦截器,对应打包模块是 b775

关键代码如下:

javascript 复制代码
var u = n("3452"),
    l = "QV1f3nHn2qm7i3xrj3Y9K9imDdGTjTu9";

d.interceptors.response.use(function(t) {
    if ("string" === typeof t.data && t.data.length > 0)
        if ("{" === t.data[0]) {
            var e = JSON.parse(t.data);
            t.data = e;
        } else {
            var n = u.enc.Utf8.parse(l),
                r = u.AES.decrypt(t.data, n, {
                    mode: u.mode.ECB,
                    padding: u.pad.Pkcs7
                }),
                i = r.toString(u.enc.Utf8),
                s = JSON.parse(i);
            t.data = s;
        }
});

这段逻辑很直接:如果响应字符串以 { 开头,就按明文 JSON 解析;否则把响应字符串当作密文,使用 CryptoJS 做 AES 解密。这里的 u 是打包后的 CryptoJS 对象,可以展开看到 AESencmodepad 等模块。l 是写死在同一模块里的密钥:

javascript 复制代码
l = "QV1f3nHn2qm7i3xrj3Y9K9imDdGTjTu9"

u.AES.decrypt(...)s = JSON.parse(i) 附近下断点,翻页后会命中当前接口的统一响应拦截器:

在 Console 中查看 t.data,可以确认它就是当前接口返回的 Base64 密文。不过这里要注意:断点处的 t.data 已经是没有空格、没有换行的规整字符串,而 Network 面板复制出来的响应密文可能带有空白。如果直接拿复制出来的服务端响应去 Console 里搜索 t.data,可能会因为这些空白字符查找不到,从而误以为断点里的密文来自其他地方。实际不是密文变了,只是展示/复制出来的字符串多了空白。

继续执行到 i = r.toString(u.enc.Utf8) 后,i 就是解密后的 JSON 字符串:

由此可以确定关键参数:

项目
算法 AES
模式 ECB
填充 Pkcs7
Key QV1f3nHn2qm7i3xrj3Y9K9imDdGTjTu9
IV 无,ECB 模式不使用 IV

需要注意三点:

  1. CryptoJS.AES.decrypt(t.data, key, ...) 中的 t.data 是 Base64 密文字符串,CryptoJS 会自动按 Base64 CipherParams 处理;Python 复现时需要先 base64.b64decode(response.text)
  2. 本接口响应体不是 {code,msg,data} 明文 JSON,也不是带外层双引号的 JSON 字符串;它直接返回 Base64 密文文本。因此 Python 中不要使用 response.json() 读取密文,直接使用 response.text 即可。
  3. Network 的 Response 展示或复制出来的密文可能夹杂空格、换行等空白字符,但断点处的 t.data 已经没有这些空白。Python 中 base64.b64decode(response.text) 默认会忽略空白字符;只有在使用 validate=True 或其他严格 Base64 解码器时,才需要先清理空白。

复现流程可以概括为:

text 复制代码
响应 Base64 密文文本
→ Base64 解码
→ AES/ECB/PKCS7 解密
→ UTF-8 解码
→ json.loads 得到 {code,msg,data,ok}
→ data.data 中是城市列表

先用一个最小 Python 片段验证算法:

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

import requests
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

headers = {
    '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',
}

json_data = {
    'size': 6,
    'current': 2,
    'propertyCode': [
        'DISTRICT_PROP_GJ025_RJDQSCZZ',
        'DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS',
        'DISTRICT_PROP_GJ001_NMHJRK',
    ],
    'dimensionTime': '2019',
    'levelType': 2,
}

response = requests.post('https://app.swguancha.com/client/v1/cPublic/consumer/baseInfo', headers=headers,
                         json=json_data)
response.raise_for_status()

# response.text 是 Base64 密文文本,里面即使夹杂空白字符,默认 b64decode 也能容忍。
aes_obj = AES.new(b'QV1f3nHn2qm7i3xrj3Y9K9imDdGTjTu9', mode=AES.MODE_ECB)
plain_text = unpad(aes_obj.decrypt(base64.b64decode(response.text)), AES.block_size).decode('utf-8')
print(json.loads(plain_text))
# print(response.text)

执行结果如下:

二、Python 实现

前面的最小验证代码已经能证明算法可行,但直接用于采集还不够完整:缺少封装、并发、异常隔离、日志输出和字段结构化处理。这里把代码整理成面向对象版本,使用 ThreadPoolExecutor 并发请求前 3 页,并用 loguru 输出每页请求、解密状态和每条结构化城市数据。

这里保留抓包时常见的浏览器请求头,例如 User-AgentRefererOriginAcceptContent-Type 等。deviceType: 1 不是请求成功的必要条件,也不参与签名或加密;脚本里保留它只是为了让请求更贴近页面抓包结果。

需要解析的字段包括:

字段 来源字段 说明
页码 请求页码 current 对应的页码
页内序号 页内顺序 每页返回列表中的顺序
城市名称 cityName 城市中文名称
城市编码 cityCode 城市行政编码
指标数 cityKpiNum 页面展示的指标收录数量
查看次数 viewCount 页面展示的查看次数
排序号 sortNum 接口返回的排序字段
人均GDP simpleVOListDISTRICT_PROP_GJ025_RJDQSCZZ 拼接 propertyValuevalueUnit
公交车 simpleVOListDISTRICT_PROP_GJ117_NMSYGGQDCYYCLS 页面文案为公交车,接口 simpleName 可能是公共汽(电)车量
户籍人口 simpleVOListDISTRICT_PROP_GJ001_NMHJRK 拼接 propertyValuevalueUnit

完整代码如下:

python 复制代码
# -*- coding: utf-8 -*-
"""
@File    : swguancha_city_baseinfo_spider.py
@Author  : XAMO Lab
@Date    : 2026/6/15 4:24
@Blog    : https://blog.csdn.net/xw1680
@Tool    : PyCharm
@Desc    : 数位观察城市数据查询采集(AES/ECB/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 loguru import logger

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


class SwguanchaCityBaseInfoSpider:
    """数位观察城市数据查询爬虫"""

    API_URL = "https://app.swguancha.com/client/v1/cPublic/consumer/baseInfo"
    SITE_URL = "https://www.swguancha.com/home/city"
    AES_KEY = b"QV1f3nHn2qm7i3xrj3Y9K9imDdGTjTu9"

    PROPERTY_CODES = [
        "DISTRICT_PROP_GJ025_RJDQSCZZ",
        "DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS",
        "DISTRICT_PROP_GJ001_NMHJRK",
    ]
    PROPERTY_NAME_MAP = {
        "DISTRICT_PROP_GJ025_RJDQSCZZ": "per_capita_gdp",
        "DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS": "bus_count",
        "DISTRICT_PROP_GJ001_NMHJRK": "registered_population",
    }
    PROPERTY_CN_NAME_MAP = {
        "DISTRICT_PROP_GJ025_RJDQSCZZ": "人均GDP",
        "DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS": "公交车",
        "DISTRICT_PROP_GJ001_NMHJRK": "户籍人口",
    }

    def __init__(self, page_size: int = 6, max_workers: int = 3, retries: int = 3) -> None:
        """
        :param page_size: 每页条数,页面默认 6
        :param max_workers: 并发线程数
        :param retries: 单页请求失败后的重试次数
        """
        self.page_size = page_size
        self.max_workers = max_workers
        self.retries = retries
        self.session = requests.Session()
        self.session.headers.update({
            "Accept": "application/json, text/plain, */*",
            "Content-Type": "application/json;charset=UTF-8",
            "Origin": "https://www.swguancha.com",
            "Referer": self.SITE_URL,
            # deviceType 不是签名参数,保留它只是让请求更贴近页面抓包结果。
            "deviceType": "1",
            "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 _payload(self, page: int) -> Dict[str, Any]:
        """组装 Request Payload。"""
        return {
            "size": self.page_size,
            "current": page,
            "propertyCode": self.PROPERTY_CODES,
            "dimensionTime": "2019",
            "levelType": 2,
        }

    def _decrypt(self, ciphertext: str) -> Dict[str, Any]:
        """Base64 密文文本 -> AES/ECB/PKCS7 解密 -> dict"""
        raw = base64.b64decode(ciphertext)
        cipher = AES.new(self.AES_KEY, AES.MODE_ECB)
        plaintext = unpad(cipher.decrypt(raw), AES.block_size).decode("utf-8")
        return json.loads(plaintext)

    @staticmethod
    def _format_metric_value(metric: Dict[str, Any]) -> str:
        """拼接指标值和单位。"""
        value = metric.get("propertyValue")
        unit = metric.get("valueUnit") or ""
        return f"{value}{unit}" if value is not None else ""

    def _parse_item(self, item: Dict[str, Any], page: int, index: int) -> Dict[str, Any]:
        """提取城市字段,统一输出结构。"""
        row: Dict[str, Any] = {
            "page": page,
            "index": index,
            "city_name": item.get("cityName", ""),
            "city_code": item.get("cityCode", ""),
            "city_kpi_num": item.get("cityKpiNum", ""),
            "view_count": item.get("viewCount", ""),
            "sort_num": item.get("sortNum", ""),
        }

        for metric in item.get("simpleVOList") or []:
            property_code = metric.get("propertyCode")
            field_name = self.PROPERTY_NAME_MAP.get(property_code)
            if not field_name:
                continue
            row[field_name] = self._format_metric_value(metric)
            row[f"{field_name}_name"] = self.PROPERTY_CN_NAME_MAP.get(property_code, metric.get("simpleName", ""))
            row[f"{field_name}_area"] = metric.get("dimensionAreaName", "")
            row[f"{field_name}_time"] = metric.get("dimensionTime", "")

        for field_name in self.PROPERTY_NAME_MAP.values():
            row.setdefault(field_name, "")
            row.setdefault(f"{field_name}_name", "")
            row.setdefault(f"{field_name}_area", "")
            row.setdefault(f"{field_name}_time", "")

        return row

    def fetch_page(self, page: int) -> List[Dict[str, Any]]:
        """请求单页、解密响应并返回结构化城市数据。"""
        payload = self._payload(page)

        for attempt in range(1, self.retries + 1):
            try:
                logger.info("page={} 开始请求 | payload={}", page, payload)
                response = self.session.post(self.API_URL, json=payload, timeout=20)
                response.raise_for_status()

                # 响应体是 Base64 密文文本,不是 JSON 对象;密文中即使夹杂空白字符,默认 b64decode 也能容忍。
                data = self._decrypt(response.text)
                if data.get("code") != 0:
                    logger.warning("page={} 接口异常 code={} msg={}", page, data.get("code"), data.get("msg"))
                    return []

                page_data = data.get("data") or {}
                rows = [
                    self._parse_item(item, page, index)
                    for index, item in enumerate(page_data.get("data") or [], 1)
                ]
                logger.success(
                    "page={} 解密成功,获取 {} 条城市数据 | total={}",
                    page,
                    len(rows),
                    page_data.get("total"),
                )
                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 = SwguanchaCityBaseInfoSpider(page_size=6, max_workers=3, retries=3)
    result = spider.run(pages=3)
    for row in result:
        logger.info("{}", json.dumps(row, ensure_ascii=False))

运行脚本后,日志会先输出每一页的请求和解密状态,再按页码、页内序号输出 JSON 格式的结构化城市数据。当前测试前 3 页共获取 18 条数据,其中东莞市这一条接口没有返回公交车指标,脚本会保留为空字符串:

三、总结

这个案例的核心是定位数位观察前端的统一响应解密逻辑。目标业务接口是:

text 复制代码
POST https://app.swguancha.com/client/v1/cPublic/consumer/baseInfo

翻页参数和筛选参数都在 Request Payload 中明文传递,不涉及请求参数加密或签名。这里主要关注这些字段:size 控制每页数量,current 控制页码,dimensionTime 控制统计年份,levelType 控制城市层级,propertyCode 控制需要返回的指标列表。

本案例容易误判的地方在响应体。Network 面板中看到的响应不是普通 JSON 对象,而是一整段 Base64 字符串密文。前端在 static/js/app.74b0261d.js 的 axios 响应拦截器中统一处理响应:如果响应字符串以 { 开头,就直接 JSON.parse;否则调用 CryptoJS 的 AES.decrypt 解密,再把解密后的 UTF-8 字符串转成 JSON。

关键代码位于 app.74b0261d.jsb775 模块,逻辑可以简化为:

javascript 复制代码
var u = n("3452");
var l = "QV1f3nHn2qm7i3xrj3Y9K9imDdGTjTu9";

var key = u.enc.Utf8.parse(l);
var decrypted = u.AES.decrypt(responseText, key, {
  mode: u.mode.ECB,
  padding: u.pad.Pkcs7
});
var data = JSON.parse(decrypted.toString(u.enc.Utf8));

因此 Python 复现时不需要 IV,流程可以概括为:

text 复制代码
POST /client/v1/cPublic/consumer/baseInfo
→ 返回 Base64 字符串密文
→ Base64 解码
→ AES/ECB/PKCS7 解密,Key = QV1f3nHn2qm7i3xrj3Y9K9imDdGTjTu9
→ UTF-8 解码后 json.loads
→ 得到 {code,msg,data,ok},其中 data.data 是城市列表

解密后的每条城市数据包含 cityNamecityCodecityKpiNumviewCount 等基础字段,具体指标放在 simpleVOList 中。三个目标指标分别对应:

text 复制代码
DISTRICT_PROP_GJ025_RJDQSCZZ        人均GDP
DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS  公交车/公共汽(电)车量
DISTRICT_PROP_GJ001_NMHJRK          户籍人口

所以采集脚本的重点不是构造复杂请求,而是复现前端的统一响应解密,并按 propertyCodesimpleVOList 中提取指标。循环请求 current=1、2、3 即可得到前 3 页,每页 size=6,合计最多 18 条城市数据。

相关推荐
冰履踏青云4 小时前
东方航空 refer__1036逆向复盘
js逆向·东航·refer__1036
如烟花的信页4 小时前
外贸*登录逆向分析
javascript·爬虫·python·js逆向
冰履踏青云6 小时前
从 202 到 200:一次 aws-waf-token 纯 Python 还原实录
js逆向·aws-waf-token
Amo Xiang2 天前
SpiderDemo 第5题:OB混淆实战 —— 反调试绕过与 signature 签名还原
python·js逆向·爬虫逆向·反调试·spiderdemo·ob混淆
冰履踏青云3 天前
宏翼平台登录参数逆向
js逆向·宏翼平台登录
Amo Xiang3 天前
申万宏源证券新闻中心 —— AES/ECB 响应解密(摩斯电码派生密钥)
js逆向·python爬虫·逆向工程·aes加密·响应解密
冰履踏青云3 天前
某音x-tt-session-dtrait 算法逆向复盘
js逆向·session-dtrait
如烟花的信页4 天前
*花顺cookie逆向分析
javascript·爬虫·python·js逆向
Amo Xiang4 天前
SpiderDemo 第1题:请求头检测挑战 —— Disable cache 缓存头与请求特征差异
js逆向·爬虫逆向·spiderdemo·tls指纹·请求头检测·disable cache