目录
免责声明:本文内容仅用于合法授权范围内的技术学习、安全研究、逆向分析方法交流与风控防护理解,不针对任何网站、产品或服务提供绕过、攻击、滥用或破坏性使用建议。文中涉及的接口分析、参数加解密、调试定位、代码复现、数据请求等内容,仅用于说明相关技术原理和分析流程。读者应在遵守相关法律法规、平台规则、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-Agent、Referer、Origin、Accept、Content-Type、deviceType 等字段即可。这个接口没有额外时间戳、nonce、签名字段,请求参数本身是明文 JSON。
查看 Response 可以发现,接口返回的不是常规 JSON 对象,而是一整段 Base64 形态的密文文本,例如:
text
UiY3CaV4ZQrQR9/LFH5qq2F4H8zkRn76NFuDbcyLVvso6M5v0Bn4Gzce9QpTy34W...
如下图:

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

接下来需要定位前端在哪里把这段密文还原成业务数据。响应解密通常有两条定位思路:
- Hook
JSON.parse,从解密后被 parse 成对象的位置向上回溯; - 在 Sources 中搜索
decrypt、AES、CryptoJS、interceptors.response.use等关键字。
本案例中直接搜索 AES 或 interceptors.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 对象,可以展开看到 AES、enc、mode、pad 等模块。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 |
需要注意三点:
CryptoJS.AES.decrypt(t.data, key, ...)中的t.data是 Base64 密文字符串,CryptoJS 会自动按 Base64 CipherParams 处理;Python 复现时需要先base64.b64decode(response.text)。- 本接口响应体不是
{code,msg,data}明文 JSON,也不是带外层双引号的 JSON 字符串;它直接返回 Base64 密文文本。因此 Python 中不要使用response.json()读取密文,直接使用response.text即可。 - 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-Agent、Referer、Origin、Accept、Content-Type 等。deviceType: 1 不是请求成功的必要条件,也不参与签名或加密;脚本里保留它只是为了让请求更贴近页面抓包结果。
需要解析的字段包括:
| 字段 | 来源字段 | 说明 |
|---|---|---|
| 页码 | 请求页码 | current 对应的页码 |
| 页内序号 | 页内顺序 | 每页返回列表中的顺序 |
| 城市名称 | cityName |
城市中文名称 |
| 城市编码 | cityCode |
城市行政编码 |
| 指标数 | cityKpiNum |
页面展示的指标收录数量 |
| 查看次数 | viewCount |
页面展示的查看次数 |
| 排序号 | sortNum |
接口返回的排序字段 |
| 人均GDP | simpleVOList 中 DISTRICT_PROP_GJ025_RJDQSCZZ |
拼接 propertyValue 和 valueUnit |
| 公交车 | simpleVOList 中 DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS |
页面文案为公交车,接口 simpleName 可能是公共汽(电)车量 |
| 户籍人口 | simpleVOList 中 DISTRICT_PROP_GJ001_NMHJRK |
拼接 propertyValue 和 valueUnit |
完整代码如下:
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.js 的 b775 模块,逻辑可以简化为:
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 是城市列表
解密后的每条城市数据包含 cityName、cityCode、cityKpiNum、viewCount 等基础字段,具体指标放在 simpleVOList 中。三个目标指标分别对应:
text
DISTRICT_PROP_GJ025_RJDQSCZZ 人均GDP
DISTRICT_PROP_GJ117_NMSYGGQDCYYCLS 公交车/公共汽(电)车量
DISTRICT_PROP_GJ001_NMHJRK 户籍人口
所以采集脚本的重点不是构造复杂请求,而是复现前端的统一响应解密,并按 propertyCode 从 simpleVOList 中提取指标。循环请求 current=1、2、3 即可得到前 3 页,每页 size=6,合计最多 18 条城市数据。