如何在ESP32 Arduino框架下解压和风天气的GZIP数据

观前提示:我只是一个 ESP32 萌新,这些代码是我和 DeepSeek 一起写出来的,所以代码烂是正常的。
另外,我用的是 PlatformIO,所以本教程对于 Arduino IDE 有些方法可能不适用。

完整源码在下方「完整demo」章节,建议先看完讲解再复制使用。

导言

何为GZIP?

GZIP 就是一种能把数据压缩变小、传输完再解压还原的通用压缩格式,就像给数据“打包”一样。
通俗来讲,服务器用 GZIP 压缩后再返回数据,可以大幅节省带宽,让你和服务器都快一点。

为什么要解压 GZIP 而不直接获取明文?

其实,和风天气官方早期是支持直接返回明文数据的。但在 2022年1月16日,官方发布公告,宣布将强制要求所有 API 请求使用 GZIP 压缩格式返回数据,用于指定明文传输的参数被废除了。

相关公告:Web API中必须使用Gzip压缩(和风天气官方博客)

这意味着,如果不自己处理 GZIP 解压,就无法正常获取天气数据了。
当时我搜了一圈,发现相关教程基本都针对 ESP-IDF 框架,在 Arduino 框架下几乎找不到完整的方案,深感头痛。
但我就是个 ESP32 小萌新,能怎么办呢?没办法,只能在 AI 的辅助下“缝缝补补”,硬着头皮把这条路蹚了出来,希望下面的能帮助到大家。

准备工作

  • ESP32开发板一块(本文使用的是 ESP32-WROOM-32D,其他型号理论上兼容)
  • ArduinoUZlib
  • 库管理器的UrlEncode(plageoj/UrlEncode@^1.0.1)
  • 还有一个ArduinoJSON库,库管理器里有,所以不给出链接

警告:千万不要去库管理器下载 ArduinoUZlib!
那个版本就是残废的,根本无法正常工作。本人已亲身踩坑,请务必通过上方的 GitHub 链接安装。

开始编写

打开项目下的platformio.ini文件,填入以下内容,等待安装完成,慢了挂代理

1
2
3
4
lib_deps = 
bblanchon/ArduinoJson@^7.2.2
https://github.com/tignioj/ArduinoUZlib.git
plageoj/UrlEncode@^1.0.1

安装好后,就该导入了,下面是导入的头文件:

1
2
3
4
5
6
7
#include <Arduino.h>             // 基本库,Arduino IDE 可以省略
#include <WiFi.h> // WiFi 连接
#include <WiFiClientSecure.h> // HTTPS 支持
#include <HTTPClient.h> // HTTP 请求
#include <ArduinoJson.h> // JSON 解析,lib_deps: bblanchon/ArduinoJson@^7.2.2
#include <ArduinoUZlib.h> // GZIP 解压,lib_deps: https://github.com/tignioj/ArduinoUZlib.git
#include <UrlEncode.h> // URL 编码,lib_deps: plageoj/UrlEncode@^1.0.1

请再次确认:ArduinoUZlib 必须是从 GitHub 仓库安装的版本!
库管理器里的版本是残缺的,如果用了后面的代码根本跑不起来!

如果编译报错,尝试把 #include <UrlEncode.h> 改成 #include “UrlEncode.h”

接下来配置 WiFi 和 API 地址。这里我习惯用 #define,简洁直观省内存 =) 当然用 const char* 也完全没问题,效果是一样的。

方式一:#define 宏定义

1
2
3
#define WIFI_SSID "你的WiFi"
#define WIFI_PASS "你的密码"
#define GEO_API_URL "https://your-api-host.com/geo/v2/city/lookup?key=your-api-key&location="

方式二:const char* 常量

1
2
3
const char* WIFI_SSID = "你的WiFi";
const char* WIFI_PASS = "你的密码";
const char* GEO_API_URL = "https://your-api-host.com/geo/v2/city/lookup?key=your-api-key&location=";

记得把 your-api-host.comyour-api-key 换成你自己的实际值。
提示:用JWT也不是不行,不过这是下篇文章的事情,这篇文章暂时不提

然后,在main.cpp里加一个WiFiClientSecure secureClient;并且加一个宏定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WiFiClientSecure secureClient;

#define LOG(level, fmt, ...) \
do { \
time_t now = time(nullptr); \
if (now < 100000) { \
Serial.printf("[--:--:--] [%s] " fmt "\n", level, ##__VA_ARGS__); \
} else { \
struct tm* tm = localtime(&now); \
char t[20]; \
strftime(t, sizeof(t), "%H:%M:%S", tm); \
Serial.printf("[%s] [%s] " fmt "\n", t, level, ##__VA_ARGS__); \
} \
} while(0)

这段代码有何用处?就是串口输出的时候的日志会带上时间,如果不需要,可以不添加,但是后面的LOG("INFO","XXX");就得改成Serial.println("[INFO] XXX");或者换成自己的风格。

注意WiFiClientSecure secureClient; 后面会考

重头戏:解压Gzip

添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// ========== 调试开关(注释掉即关闭 DEBUG 输出) ==========
#define DEBUG

bool decompressGzip(WiFiClient* stream, HTTPClient& http, String* outJson) {
if (!stream) {
LOG("ERR" ,"stream 指针为空");
return false;
}

size_t totalSize = http.getSize();
if (totalSize <= 0) {
LOG("ERR", "无法获取数据大小");
return false;
}
LOG("INFO", "压缩数据大小: %d 字节", totalSize);

uint8_t* compressedData = (uint8_t*)malloc(totalSize);
if (!compressedData) {
LOG("ERR" ,"内存分配失败");
return false;
}

size_t readSoFar = 0;
unsigned long startTime = millis();
unsigned long timeout = 5000;

while (readSoFar < totalSize && stream->connected()) {
if (millis() - startTime > timeout) {
LOG("ERR" ,"读取超时");
free(compressedData);
return false;
}

if (stream->available()) {
int len = stream->read(compressedData + readSoFar, totalSize - readSoFar);
if (len > 0) {
readSoFar += len;
startTime = millis();
}
}
delay(1);
yield();
}

if (readSoFar != totalSize) {
LOG("WARN" ,"预期 %d,实际 %d", totalSize, readSoFar);
free(compressedData);
return false;
}
LOG("INFO" ,"读取 %d 字节压缩数据", readSoFar);

if (readSoFar < 2 || compressedData[0] != 0x1F || compressedData[1] != 0x8B) {
LOG("ERR" ,"不是 Gzip 格式");
free(compressedData);
return false;
}
LOG("INFO" ,"检测到 Gzip 格式");

LOG("INFO" ,"开始解压...");
uint8_t* outbuf = NULL;
uint32_t outSize = 0;

int result = ArduinoUZlib::decompress(compressedData, readSoFar, outbuf, outSize);

free(compressedData);
compressedData = NULL;

if (result < 0) {
LOG("ERR" ,"解压失败,错误码: %d", result);
return false;
}

outSize = result;
LOG("OK", "解压成功,大小: %d 字节", outSize);

#ifdef DEBUG
Serial.println();
LOG("DEBUG" ,"--- JSON 数据 ---");
Serial.write(outbuf, outSize);
Serial.println();
LOG("DEBUG" ,"--- JSON 结束 ---\n");
#endif

if (outJson != nullptr) {
outJson->clear();
for (uint32_t i = 0; i < outSize; i++) {
*outJson += (char)outbuf[i];
}
}

// 只在 outbuf 非 NULL 时释放
if (outbuf != NULL) {
free(outbuf);
outbuf = NULL;
}

return true;
}

坦白说,我对 GZIP 解压的底层原理了解有限,所以下面这部分解析由 AI 协助完成,我觉得它讲得比我自己写清楚:

decompressGzip 函数实现了完整的 Gzip 解压流程,用于处理 HTTP 响应中的压缩数据。

执行流程分为五个阶段:

  1. 参数校验:检查 stream 指针是否为空,调用 http.getSize() 获取压缩数据大小,无效则直接返回失败。

  2. 数据读取:通过 malloc 分配缓冲区,循环从 stream 读取压缩数据,直到读取完毕或连接断开。设置了 5 秒超时保护,防止网络卡死。读取完成后会校验实际读取字节数。

  3. Gzip 格式校验:检查数据头两个字节是否为 0x1F0x8B(Gzip 的标准魔数),否则释放内存并返回失败。

  4. 解压执行:调用 ArduinoUZlib::decompress 处理压缩数据,解压结果存储在 outbuf 中,实际大小由 outSize 返回。如果解压返回负数,表示解压失败。

  5. 数据输出与清理:如果调用方提供了 outJson 指针,将解压后的数据逐字节复制到 String 对象中。最后释放 outbuf 并置空,防止二次释放导致崩溃。

整个过程确保无论成功还是失败,所有动态分配的内存都能被正确释放。

函数实现

接下来是一些函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// ========== 获取 Geo API 数据 ==========
bool getGeoData(const String& location, String* outJson) {
String encodedLocation = urlEncode(location);
String fullUrl = String(GEO_API_URL) + encodedLocation;

Serial.println("[INFO] 请求 URL: " + fullUrl);

HTTPClient http;
http.begin(secureClient, fullUrl);
http.addHeader("Accept-Encoding", "gzip, deflate");
http.setTimeout(10000);

int httpCode = http.GET();
Serial.printf("[INFO] HTTP 状态码: %d\n", httpCode);

if (httpCode != HTTP_CODE_OK) {
Serial.printf("[ERR] HTTP 请求失败: %d\n", httpCode);
http.end();
return false;
}

WiFiClient* stream = http.getStreamPtr();
bool success = decompressGzip(stream, http, outJson);
http.end();

return success;
}

// ========== 解析 Geo JSON,提取经纬度 ==========
bool parseGeoJson(const String& json, String* lat, String* lon, String* cityName) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, json);

if (error) {
Serial.printf("[ERR] JSON 解析失败: %s\n", error.c_str());
return false;
}

if (doc["location"][0].isNull()) { // 这里就是代表着地名无效,和风天气数据库没有此地名的任何数据
Serial.println("[ERR] 未找到 location 数据");
return false;
}

// 一般我们取第一项,即["location"][0]
if (lat != nullptr) *lat = doc["location"][0]["lat"].as<String>();
if (lon != nullptr) *lon = doc["location"][0]["lon"].as<String>();
if (cityName != nullptr) *cityName = doc["location"][0]["name"].as<String>();

Serial.printf("[INFO] 城市: %s\n", cityName != nullptr ? cityName->c_str() : "未知");
Serial.printf("[INFO] 经纬度: %s, %s\n",
lat != nullptr ? lat->c_str() : "未知",
lon != nullptr ? lon->c_str() : "未知");

return true;
}

你看,由于和风天气的API强制使用HTTPS,前面创建的WiFiClientSecure secureClient;就有用了,http.begin(secureClient, fullUrl);就是以HTTPS协议跟服务器通讯。

setup和loop

最后,我们来写setup和loop:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=========================================");
Serial.println("Geo API 获取 + Gzip 解压 测试");
Serial.println("=========================================\n");

// 1. WiFi
Serial.printf("[INFO] 连接 WiFi: %s\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASS);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("\n[ERR] WiFi 连接失败");
return;
}
Serial.println("\n[INFO] WiFi 连接成功");
Serial.print("[INFO] IP: ");
Serial.println(WiFi.localIP());

// 2. SSL 客户端
secureClient.setInsecure();
secureClient.setTimeout(10000);

// 3. 获取 Geo 数据
String location = "北京";
String rawJson;

Serial.printf("\n[INFO] 查询位置: %s\n", location.c_str());

if (getGeoData(location, &rawJson)) {
Serial.println("[INFO] Geo 数据获取成功");
Serial.println("\n[DEBUG] 原始 JSON:");
Serial.println(rawJson);
Serial.println("\n[DEBUG] --- 结束 ---\n");

// 4. 解析
String lat, lon, cityName;
if (parseGeoJson(rawJson, &lat, &lon, &cityName)) {
Serial.println("[OK] 解析成功");
}
} else {
Serial.println("[ERR] Geo 数据获取失败");
}

Serial.println("\n[INFO] 测试完成");
}

void loop(){
delay(10000); // 什么也不做
}

完整demo

现在就好了,然后我们看看main.cpp的最终完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <ArduinoUZlib.h>
#include <UrlEncode.h>

// ========== 调试开关,不用即关(注释掉就是关) ==========
#define DEBUG

// ========== WiFi 配置 ==========
const char* WIFI_SSID = "你的WiFi";
const char* WIFI_PASS = "你的密码";

// ========== API 配置 ==========
const char* GEO_API_URL = "https://your-api-host.com/geo/v2/city/lookup?key=your-api-key&location=";
// 当然,你也可以写JWT

WiFiClientSecure secureClient;

// ========== 日志宏 ==========
#define LOG(level, fmt, ...) \
do { \
time_t now = time(nullptr); \
if (now < 100000) { \
Serial.printf("[--:--:--] [%s] " fmt "\n", level, ##__VA_ARGS__); \
} else { \
struct tm* tm = localtime(&now); \
char t[20]; \
strftime(t, sizeof(t), "%H:%M:%S", tm); \
Serial.printf("[%s] [%s] " fmt "\n", t, level, ##__VA_ARGS__); \
} \
} while(0)

// ========== Gzip 解压函数 ==========
bool decompressGzip(WiFiClient* stream, HTTPClient& http, String* outJson) {
if (!stream) {
LOG("ERR" ,"stream 指针为空");
return false;
}

size_t totalSize = http.getSize();
if (totalSize <= 0) {
LOG("ERR", "无法获取数据大小");
return false;
}
LOG("INFO", "压缩数据大小: %d 字节", totalSize);

uint8_t* compressedData = (uint8_t*)malloc(totalSize);
if (!compressedData) {
LOG("ERR" ,"内存分配失败");
return false;
}

size_t readSoFar = 0;
unsigned long startTime = millis();
unsigned long timeout = 5000;

while (readSoFar < totalSize && stream->connected()) {
if (millis() - startTime > timeout) {
LOG("ERR" ,"读取超时");
free(compressedData);
return false;
}

if (stream->available()) {
int len = stream->read(compressedData + readSoFar, totalSize - readSoFar);
if (len > 0) {
readSoFar += len;
startTime = millis();
}
}
delay(1);
yield();
}

if (readSoFar != totalSize) {
LOG("WARN" ,"预期 %d,实际 %d", totalSize, readSoFar);
free(compressedData);
return false;
}
LOG("INFO" ,"读取 %d 字节压缩数据", readSoFar);

if (readSoFar < 2 || compressedData[0] != 0x1F || compressedData[1] != 0x8B) {
LOG("ERR" ,"不是 Gzip 格式");
free(compressedData);
return false;
}
LOG("INFO" ,"检测到 Gzip 格式");

LOG("INFO" ,"开始解压...");
uint8_t* outbuf = NULL;
uint32_t outSize = 0;

int result = ArduinoUZlib::decompress(compressedData, readSoFar, outbuf, outSize);

free(compressedData);
compressedData = NULL;

if (result < 0) {
LOG("ERR" ,"解压失败,错误码: %d", result);
return false;
}

outSize = result;
LOG("OK", "解压成功,大小: %d 字节", outSize);

#ifdef DEBUG
Serial.println();
LOG("DEBUG" ,"--- JSON 数据 ---");
Serial.write(outbuf, outSize);
Serial.println();
LOG("DEBUG" ,"--- JSON 结束 ---\n");
#endif

if (outJson != nullptr) {
outJson->clear();
for (uint32_t i = 0; i < outSize; i++) {
*outJson += (char)outbuf[i];
}
}

// 只在 outbuf 非 NULL 时释放
if (outbuf != NULL) {
free(outbuf);
outbuf = NULL;
}

return true;
}

// ========== 获取 Geo API 数据 ==========
bool getGeoData(const String& location, String* outJson) {
String encodedLocation = urlEncode(location);
String fullUrl = String(GEO_API_URL) + encodedLocation;

Serial.println("[INFO] 请求 URL: " + fullUrl);

HTTPClient http;
http.begin(secureClient, fullUrl);
http.addHeader("Accept-Encoding", "gzip, deflate");
http.setTimeout(10000);

int httpCode = http.GET();
Serial.printf("[INFO] HTTP 状态码: %d\n", httpCode);

if (httpCode != HTTP_CODE_OK) {
Serial.printf("[ERR] HTTP 请求失败: %d\n", httpCode);
http.end();
return false;
}

WiFiClient* stream = http.getStreamPtr();
bool success = decompressGzip(stream, http, outJson);
http.end();

return success;
}

// ========== 解析 Geo JSON,提取经纬度 ==========
bool parseGeoJson(const String& json, String* lat, String* lon, String* cityName) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, json);

if (error) {
Serial.printf("[ERR] JSON 解析失败: %s\n", error.c_str());
return false;
}

if (doc["location"][0].isNull()) {
Serial.println("[ERR] 未找到 location 数据");
return false;
}

if (lat != nullptr) *lat = doc["location"][0]["lat"].as<String>();
if (lon != nullptr) *lon = doc["location"][0]["lon"].as<String>();
if (cityName != nullptr) *cityName = doc["location"][0]["name"].as<String>();

Serial.printf("[INFO] 城市: %s\n", cityName != nullptr ? cityName->c_str() : "未知");
Serial.printf("[INFO] 经纬度: %s, %s\n",
lat != nullptr ? lat->c_str() : "未知",
lon != nullptr ? lon->c_str() : "未知");

return true;
}

// ========== setup ==========
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=========================================");
Serial.println("Geo API 获取 + Gzip 解压 测试");
Serial.println("=========================================\n");

// 1. WiFi
Serial.printf("[INFO] 连接 WiFi: %s\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASS);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("\n[ERR] WiFi 连接失败");
return;
}
Serial.println("\n[INFO] WiFi 连接成功");
Serial.print("[INFO] IP: ");
Serial.println(WiFi.localIP());

// 2. SSL 客户端
secureClient.setInsecure();
secureClient.setTimeout(10000);

// 3. 获取 Geo 数据
String location = "北京";
String rawJson;

Serial.printf("\n[INFO] 查询位置: %s\n", location.c_str());

if (getGeoData(location, &rawJson)) {
Serial.println("[INFO] Geo 数据获取成功");
Serial.println("\n[DEBUG] 原始 JSON:");
Serial.println(rawJson);
Serial.println("\n[DEBUG] --- 结束 ---\n");

// 4. 解析
String lat, lon, cityName;
if (parseGeoJson(rawJson, &lat, &lon, &cityName)) {
Serial.println("[OK] 解析成功");
}
} else {
Serial.println("[ERR] Geo 数据获取失败");
}

Serial.println("\n[INFO] 测试完成");
}

void loop() {
delay(10000); // 什么也不做
}

效果展示

(因为我没有同步NTP时间,所以日志显示的就是[–:–:–])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
=========================================
Geo API 获取 + Gzip 解压 测试
=========================================

[INFO] 连接 WiFi: TP-LINK_16F3
....
[INFO] WiFi 连接成功
[INFO] IP: 192.168.0.104

[INFO] 查询位置: 北京
[INFO] 请求 URL: (码)
[INFO] HTTP 状态码: 200
[--:--:--] [INFO] 压缩数据大小: 629 字节
[--:--:--] [INFO] 读取 629 字节压缩数据
[--:--:--] [INFO] 检测到 Gzip 格式
[--:--:--] [INFO] 开始解压...
decompressed 2822 bytes
[--:--:--] [OK] 解压成功,大小: 2822 字节

[--:--:--] [DEBUG] --- JSON 数据 ---
{"code":"200","location":[{"name":"北京","id":"101010100","lat":"39.90499","lon":"116.40529","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"10","fxLink":"https://www.qweather.com/weather/beijing-101010100.html"},{"name":"海淀","id":"101010200","lat":"39.95607","lon":"116.31032","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"15","fxLink":"https://www.qweather.com/weather/haidian-101010200.html"},{"name":"朝阳","id":"101010300","lat":"39.92149","lon":"116.48641","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"15","fxLink":"https://www.qweather.com/weather/chaoyang-101010300.html"},{"name":"顺义","id":"101010400","lat":"40.12894","lon":"116.65353","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/shunyi-101010400.html"},{"name":"怀柔","id":"101010500","lat":"40.32427","lon":"116.63712","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/huairou-101010500.html"},{"name":"通州","id":"101010600","lat":"39.90249","lon":"116.65860","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"23","fxLink":"https://www.qweather.com/weather/tongzhou-101010600.html"},{"name":"昌平","id":"101010700","lat":"40.21809","lon":"116.23591","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"23","fxLink":"https://www.qweather.com/weather/changping-101010700.html"},{"name":"延庆","id":"101010800","lat":"40.46532","lon":"115.98501","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/yanqing-101010800.html"},{"name":"丰台","id":"101010900","lat":"39.86364","lon":"116.28696","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"25","fxLink":"https://www.qweather.com/weather/fengtai-101010900.html"},{"name":"石景山","id":"101011000","lat":"39.91460","lon":"116.19544","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"35","fxLink":"https://www.qweather.com/weather/shijingshan-101011000.html"}],"refer":{"sources":["QWeather"],"license":["QWeather Developers License"]}}
[--:--:--] [DEBUG] --- JSON 结束 ---

[INFO] Geo 数据获取成功

[DEBUG] 原始 JSON:
{"code":"200","location":[{"name":"北京","id":"101010100","lat":"39.90499","lon":"116.40529","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"10","fxLink":"https://www.qweather.com/weather/beijing-101010100.html"},{"name":"海淀","id":"101010200","lat":"39.95607","lon":"116.31032","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"15","fxLink":"https://www.qweather.com/weather/haidian-101010200.html"},{"name":"朝阳","id":"101010300","lat":"39.92149","lon":"116.48641","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"15","fxLink":"https://www.qweather.com/weather/chaoyang-101010300.html"},{"name":"顺义","id":"101010400","lat":"40.12894","lon":"116.65353","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/shunyi-101010400.html"},{"name":"怀柔","id":"101010500","lat":"40.32427","lon":"116.63712","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/huairou-101010500.html"},{"name":"通州","id":"101010600","lat":"39.90249","lon":"116.65860","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"23","fxLink":"https://www.qweather.com/weather/tongzhou-101010600.html"},{"name":"昌平","id":"101010700","lat":"40.21809","lon":"116.23591","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"23","fxLink":"https://www.qweather.com/weather/changping-101010700.html"},{"name":"延庆","id":"101010800","lat":"40.46532","lon":"115.98501","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"33","fxLink":"https://www.qweather.com/weather/yanqing-101010800.html"},{"name":"丰台","id":"101010900","lat":"39.86364","lon":"116.28696","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"25","fxLink":"https://www.qweather.com/weather/fengtai-101010900.html"},{"name":"石景山","id":"101011000","lat":"39.91460","lon":"116.19544","adm2":"北京","adm1":"北京市","country":"中国","tz":"Asia/Shanghai","utcOffset":"+08:00","isDst":"0","type":"city","rank":"35","fxLink":"https://www.qweather.com/weather/shijingshan-101011000.html"}],"refer":{"sources":["QWeather"],"license":["QWeather Developers License"]}}

[DEBUG] --- 结束 ---

[INFO] 城市: 北京
[INFO] 经纬度: 39.90499, 116.40529
[OK] 解析成功

[INFO] 测试完成