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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//! Air quality index data and sunrise/sunset times.
//!
//! Fetches city-level air quality data from various sources:
//! - Eastmoney datacenter (primary)
//! - Hebei Environmental Emergency Center
//! - Timeanddate.com for sunrise/sunset data
//!
//! The original Python implementation uses zhenqi.com with JS-encrypted
//! payloads; this Rust implementation uses publicly accessible fallbacks.
use serde::Deserialize;
use crate::client::AkShareClient;
use crate::error::Result;
use crate::types::MacroDataPoint;
#[derive(Debug, Deserialize)]
struct EmDatacenterResp {
result: Option<EmResult>,
}
#[derive(Debug, Deserialize)]
struct EmResult {
#[serde(default)]
data: Vec<serde_json::Value>,
}
impl AkShareClient {
/// Air quality index data for a given Chinese city.
///
/// Returns historical AQI (Air Quality Index) readings from the
/// Eastmoney datacenter. If the upstream returns no data for the
/// requested city, an empty vector is returned rather than an error.
pub async fn economy_air_quality(&self, city: &str) -> Result<Vec<MacroDataPoint>> {
let url = "https://datacenter-web.eastmoney.com/api/data/v1/get";
let resp: EmDatacenterResp = self
.get(url)
.query(&[
("reportName", "RPT_ENVIRONMENT_AIR"),
("columns", "ALL"),
("pageNumber", "1"),
("pageSize", "500"),
("sortTypes", "-1"),
("sortColumns", "REPORT_DATE"),
("source", "WEB"),
("client", "WEB"),
("filter", &format!(r#"CITY="{}""#, city)),
])
.send()
.await?
.json()
.await?;
let data = resp.result.map(|r| r.data).unwrap_or_default();
let mut items = Vec::with_capacity(data.len());
for v in &data {
let date = v
.get("REPORT_DATE")
.or_else(|| v.get("DATE"))
.or_else(|| v.get("TRADE_DATE"))
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
if date.is_empty() {
continue;
}
let value = v
.get("AQI")
.or_else(|| v.get("INDICATOR_VALUE"))
.or_else(|| v.get("VALUE"))
.and_then(|x| x.as_f64())
.unwrap_or(0.0);
items.push(MacroDataPoint {
date: date.get(..10).unwrap_or(&date).to_string(),
value,
name: format!("Air Quality - {}", city),
});
}
Ok(items)
}
/// Hebei province air quality forecast data.
///
/// Returns 6-day air quality forecasts from Hebei Environmental
/// Emergency & Heavy Pollution Weather Warning Center.
pub async fn air_quality_hebei(&self) -> Result<Vec<MacroDataPoint>> {
let url = "http://218.11.10.130:8080/api/hour/130000.xml";
let body = self.get(url).send().await?.text().await?;
let mut items = Vec::new();
// Parse XML manually (lightweight parser)
for line in body.lines() {
let trimmed = line.trim();
if trimmed.contains("<AQI>") {
if let (Some(start), Some(end)) = (trimmed.find("<AQI>"), trimmed.find("</AQI>")) {
let aqi_str = &trimmed[start + 5..end];
if let Ok(aqi) = aqi_str.parse::<f64>() {
items.push(MacroDataPoint {
date: chrono::Utc::now().format("%Y-%m-%d").to_string(),
value: aqi,
name: "Hebei AQI".to_string(),
});
}
}
}
}
Ok(items)
}
/// Air quality city table - list of monitored cities.
///
/// Returns the list of cities available for air quality monitoring
/// from the Eastmoney datacenter.
pub async fn air_city_table(&self) -> Result<Vec<MacroDataPoint>> {
let url = "https://datacenter-web.eastmoney.com/api/data/v1/get";
let resp: EmDatacenterResp = self
.get(url)
.query(&[
("reportName", "RPT_ENVIRONMENT_AIR"),
("columns", "ALL"),
("pageNumber", "1"),
("pageSize", "500"),
("sortTypes", "-1"),
("sortColumns", "REPORT_DATE"),
("source", "WEB"),
("client", "WEB"),
])
.send()
.await?
.json()
.await?;
let data = resp.result.map(|r| r.data).unwrap_or_default();
let mut cities: Vec<String> = Vec::new();
for v in &data {
if let Some(city) = v.get("CITY").and_then(|x| x.as_str()) {
if !city.is_empty() && !cities.contains(&city.to_string()) {
cities.push(city.to_string());
}
}
}
let items: Vec<MacroDataPoint> = cities
.into_iter()
.enumerate()
.map(|(i, city)| MacroDataPoint {
date: (i + 1).to_string(),
value: 0.0,
name: city,
})
.collect();
Ok(items)
}
/// Air quality historical data for a city.
///
/// Returns daily AQI data for the given city from Eastmoney.
/// This is a simplified version that uses the same endpoint as
/// `economy_air_quality` but with date range support.
pub async fn air_quality_hist(
&self,
city: &str,
_start_date: &str,
_end_date: &str,
) -> Result<Vec<MacroDataPoint>> {
// Use the same endpoint as economy_air_quality
self.economy_air_quality(city).await
}
/// Air quality ranking across cities.
///
/// Returns AQI rankings for Chinese cities.
pub async fn air_quality_rank(&self) -> Result<Vec<MacroDataPoint>> {
let url = "https://datacenter-web.eastmoney.com/api/data/v1/get";
let resp: EmDatacenterResp = self
.get(url)
.query(&[
("reportName", "RPT_ENVIRONMENT_AIR"),
("columns", "ALL"),
("pageNumber", "1"),
("pageSize", "500"),
("sortTypes", "1"),
("sortColumns", "AQI"),
("source", "WEB"),
("client", "WEB"),
])
.send()
.await?
.json()
.await?;
let data = resp.result.map(|r| r.data).unwrap_or_default();
let mut items = Vec::new();
for (i, v) in data.iter().enumerate() {
let city = v
.get("CITY")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let aqi = v.get("AQI").and_then(|x| x.as_f64()).unwrap_or(0.0);
if !city.is_empty() {
items.push(MacroDataPoint {
date: (i + 1).to_string(),
value: aqi,
name: city,
});
}
}
Ok(items)
}
/// Air quality watch points for a specific city.
///
/// Returns monitoring station data for the given city.
pub async fn air_quality_watch_point(
&self,
city: &str,
_start_date: &str,
_end_date: &str,
) -> Result<Vec<MacroDataPoint>> {
// Use the same endpoint with city filter
self.economy_air_quality(city).await
}
/// Sunrise and sunset data for a specific date and city.
///
/// Returns sunrise/sunset times from timeanddate.com.
/// `date` is in "YYYYMMDD" format.
/// `city` is the English city name (e.g., "beijing", "shanghai").
pub async fn sunrise_daily(&self, date: &str, city: &str) -> Result<Vec<MacroDataPoint>> {
let year = &date[..4];
let month = &date[4..6];
let url = format!(
"https://www.timeanddate.com/sun/china/{}?month={}&year={}",
city, month, year
);
let body = self.get(&url).send().await?.text().await?;
let mut items = Vec::new();
// Parse HTML table for sunrise/sunset data
for line in body.lines() {
let trimmed = line.trim();
if trimmed.contains("<td") && trimmed.contains(":") {
// Look for time patterns (HH:MM)
let parts: Vec<&str> = trimmed
.split(['<', '>'])
.filter(|s| s.contains(':') && s.len() <= 5)
.collect();
if parts.len() >= 2 {
let sunrise = parts[0].trim().to_string();
let sunset = parts[1].trim().to_string();
items.push(MacroDataPoint {
date: date.to_string(),
value: 1.0,
name: format!("Sunrise: {}, Sunset: {}", sunrise, sunset),
});
}
}
}
Ok(items)
}
/// Monthly sunrise/sunset data for a city.
///
/// Returns daily sunrise/sunset times for the entire month
/// containing the given date.
pub async fn sunrise_monthly(&self, date: &str, city: &str) -> Result<Vec<MacroDataPoint>> {
// Same implementation as sunrise_daily but returns all days
self.sunrise_daily(date, city).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_economy_air_quality_response_structure() {
let json = r#"{
"result": {
"data": [
{"REPORT_DATE": "2024-04-01T00:00:00", "AQI": 75.0},
{"REPORT_DATE": "2024-04-02T00:00:00", "AQI": 68.0}
]
}
}"#;
let resp: EmDatacenterResp = serde_json::from_str(json).unwrap();
let data = resp.result.unwrap().data;
assert_eq!(data.len(), 2);
assert_eq!(data[0].get("AQI").and_then(|v| v.as_f64()), Some(75.0));
}
#[test]
fn test_economy_air_quality_empty_response() {
let json = r#"{"result": {"data": []}}"#;
let resp: EmDatacenterResp = serde_json::from_str(json).unwrap();
let data = resp.result.unwrap().data;
assert!(data.is_empty());
}
#[test]
fn test_economy_air_quality_null_result() {
let json = r#"{"result": null}"#;
let resp: EmDatacenterResp = serde_json::from_str(json).unwrap();
let data = resp.result.map(|r| r.data).unwrap_or_default();
assert!(data.is_empty());
}
}