use std::collections::HashMap;
use std::sync::LazyLock;
use crate::client::AkShareClient;
use crate::error::{Error, Result};
static RE_HTML_TAG: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"<[^>]+>").unwrap());
static RE_TABLE: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?is)<table[^>]*>(.*?)</table>").unwrap());
static RE_TR: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?is)<tr[^>]*>(.*?)</tr>").unwrap());
static RE_TH: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?is)<th[^>]*>(.*?)</th>").unwrap());
static RE_TD: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?is)<td[^>]*>(.*?)</td>").unwrap());
static RE_JSON_P: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(?s)<p\s+id="main"[^>]*>(.*?)</p>"#).unwrap());
#[allow(dead_code)]
static RE_TABLE_DATA_HL: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(
r#"(?is)<table[^>]*class="[^"]*data_table_1\s+m_table\s+m_hl[^"]*"[^>]*>(.*?)</table>"#,
)
.unwrap()
});
#[allow(dead_code)]
static RE_TABLE_M_DATA_HL: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(
r#"(?is)<table[^>]*class="[^"]*m_table\s+data_table_1\s+m_hl[^"]*"[^>]*>(.*?)</table>"#,
)
.unwrap()
});
#[allow(dead_code)]
static RE_TABLE_MAINTABLE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?is)<table[^>]*id="maintable"[^>]*>(.*?)</table>"#).unwrap()
});
#[allow(dead_code)]
static RE_TABLE_M_TABLE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?is)<table[^>]*class="[^"]*m_table[^"]*"[^>]*>(.*?)</table>"#).unwrap()
});
#[allow(dead_code)]
static RE_UL_MAIN_INTRO: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?is)<ul[^>]*class="[^"]*main_intro_list[^"]*"[^>]*>(.*?)</ul>"#).unwrap()
});
#[allow(dead_code)]
static RE_LI: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"(?is)<li[^>]*>(.*?)</li>").unwrap());
fn ths_headers() -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"
.parse()
.unwrap(),
);
headers
}
fn ths_market_code(stock_code: &str) -> i32 {
let code = stock_code.trim();
if code.starts_with("000")
|| code.starts_with("001")
|| code.starts_with("002")
|| code.starts_with("003")
|| code.starts_with("300")
{
return 33; }
if code.starts_with("600")
|| code.starts_with("601")
|| code.starts_with("603")
|| code.starts_with("605")
|| code.starts_with("688")
{
return 17; }
if code.starts_with("920") {
return 151; }
0
}
fn ths_period_code(indicator: &str) -> &str {
match indicator {
"按报告期" => "0",
"一季度" => "1",
"二季度" => "2",
"三季度" => "3",
"四季度" | "按年度" => "4",
_ => "4",
}
}
fn strip_html_tags(s: &str) -> String {
RE_HTML_TAG.replace_all(s, "").to_string()
}
fn parse_html_tables(html: &str) -> Vec<(Vec<String>, Vec<Vec<String>>)> {
let mut tables = Vec::new();
for table_cap in RE_TABLE.captures_iter(html) {
let table_content = &table_cap[1];
let mut rows = Vec::new();
for row_cap in RE_TR.captures_iter(table_content) {
let row_content = &row_cap[1];
let ths: Vec<String> = RE_TH
.captures_iter(row_content)
.map(|c| strip_html_tags(&c[1]).trim().to_string())
.collect();
if !ths.is_empty() {
rows.push(ths);
continue;
}
let tds: Vec<String> = RE_TD
.captures_iter(row_content)
.map(|c| strip_html_tags(&c[1]).trim().to_string())
.collect();
if !tds.is_empty() {
rows.push(tds);
}
}
if rows.is_empty() {
continue;
}
let headers = rows[0].clone();
let data_rows = rows[1..].to_vec();
tables.push((headers, data_rows));
}
tables
}
fn table_to_records(
headers: &[String],
rows: &[Vec<String>],
) -> Vec<HashMap<String, serde_json::Value>> {
rows.iter()
.filter(|row| !row.is_empty())
.map(|row| {
let mut map = HashMap::new();
for (i, header) in headers.iter().enumerate() {
let val = row.get(i).cloned().unwrap_or_default();
map.insert(header.clone(), serde_json::Value::String(val));
}
map
})
.collect()
}
impl AkShareClient {
pub async fn stock_financial_abstract_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = format!("https://basic.10jqka.com.cn/new/{symbol}/finance.html");
let html = self
.get(&url)
.headers(ths_headers())
.send()
.await?
.text()
.await?;
let json_str = RE_JSON_P
.captures(&html)
.map(|c| c[1].trim().to_string())
.ok_or_else(|| Error::upstream("THS finance page: main JSON not found"))?;
let data_json: serde_json::Value = serde_json::from_str(&json_str)
.map_err(|e| Error::decode(format!("THS finance JSON parse error: {e}")))?;
self.parse_ths_finance_json(&data_json, indicator, "report")
}
pub async fn stock_financial_debt_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = format!("https://basic.10jqka.com.cn/api/stock/finance/{symbol}_debt.json");
self.fetch_ths_json_table(&url, indicator, false).await
}
pub async fn stock_financial_benefit_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = format!("https://basic.10jqka.com.cn/api/stock/finance/{symbol}_benefit.json");
self.fetch_ths_json_table(&url, indicator, true).await
}
pub async fn stock_financial_cash_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = format!("https://basic.10jqka.com.cn/api/stock/finance/{symbol}_cash.json");
self.fetch_ths_json_table(&url, indicator, true).await
}
async fn fetch_ths_json_table(
&self,
url: &str,
indicator: &str,
has_simple: bool,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let resp = self
.get(url)
.headers(ths_headers())
.send()
.await?
.error_for_status()?;
let outer: serde_json::Value = resp.json().await?;
let flash_data_str = outer
.get("flashData")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::upstream("THS response missing flashData"))?;
let inner_str = if flash_data_str.starts_with('"') {
serde_json::from_str::<String>(flash_data_str)
.map_err(|e| Error::decode(format!("THS flashData decode error: {e}")))?
} else {
flash_data_str.to_string()
};
let data_json: serde_json::Value = serde_json::from_str(&inner_str)
.map_err(|e| Error::decode(format!("THS flashData JSON parse error: {e}")))?;
let default_key = if has_simple { "simple" } else { "report" };
self.parse_ths_finance_json(&data_json, indicator, default_key)
}
fn parse_ths_finance_json(
&self,
data_json: &serde_json::Value,
indicator: &str,
default_key: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let titles: Vec<String> = data_json
.get("title")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|item| {
if let Some(s) = item.as_str() {
s.to_string()
} else if let Some(arr) = item.as_array() {
arr.first()
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
} else {
String::new()
}
})
.collect()
})
.unwrap_or_default();
let data_key = match indicator {
"按报告期" => "report",
"按单季度" => {
if data_json.get("simple").is_some() {
"simple"
} else {
default_key
}
}
"按年度" => "year",
_ => default_key,
};
let table_data = data_json
.get(data_key)
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if table_data.is_empty() {
return Ok(vec![]);
}
let column_headers: Vec<String> = table_data
.first()
.and_then(|row| row.as_array())
.map(|arr| {
arr.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect()
})
.unwrap_or_default();
let mut all_records = Vec::new();
for (row_idx, row_val) in table_data.iter().enumerate().skip(1) {
let cells: Vec<String> = row_val
.as_array()
.map(|arr| {
arr.iter()
.map(|v| v.as_str().unwrap_or("").to_string())
.collect()
})
.unwrap_or_default();
let row_label = titles.get(row_idx).cloned().unwrap_or_default();
for (col_idx, col_header) in column_headers.iter().enumerate().skip(1) {
let mut record = HashMap::new();
record.insert(
"报告期".to_string(),
serde_json::Value::String(col_header.clone()),
);
record.insert(
"指标".to_string(),
serde_json::Value::String(row_label.clone()),
);
if let Some(val) = cells.get(col_idx) {
record.insert("值".to_string(), serde_json::Value::String(val.clone()));
}
all_records.push(record);
}
}
Ok(all_records)
}
pub async fn stock_financial_abstract_new_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
self.fetch_ths_new_api(symbol, "client_stock_importance", indicator)
.await
}
pub async fn stock_financial_debt_new_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let ind = if indicator == "按报告期" {
"按报告期"
} else {
"按年度"
};
self.fetch_ths_new_api(symbol, "client_stock_debt", ind)
.await
}
pub async fn stock_financial_benefit_new_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
self.fetch_ths_new_api(symbol, "client_stock_benefit", indicator)
.await
}
pub async fn stock_financial_cash_new_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
self.fetch_ths_new_api(symbol, "client_stock_cash", indicator)
.await
}
async fn fetch_ths_new_api(
&self,
symbol: &str,
api_id: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = "https://basic.10jqka.com.cn/basicapi/finance/index/v1/app_data/";
let market = ths_market_code(symbol).to_string();
let period = ths_period_code(indicator);
let resp = self
.get(url)
.headers(ths_headers())
.query(&[
("code", symbol),
("id", api_id),
("market", market.as_str()),
("type", "stock"),
("page", "1"),
("size", "50"),
("period", period),
])
.send()
.await?
.error_for_status()?;
let payload: serde_json::Value = resp.json().await?;
let financial_data = payload
.pointer("/data/data")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut all_records = Vec::new();
for report in &financial_data {
let report_date = report.get("date").and_then(|v| v.as_str()).unwrap_or("");
let report_name = report
.get("report_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let report_period = report.get("report").and_then(|v| v.as_str()).unwrap_or("");
let quarter_name = report
.get("quarter_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let index_list = match report.get("index_list") {
Some(serde_json::Value::Object(map)) => map,
_ => continue,
};
for (metric_name, metric_values) in index_list {
let mut record = HashMap::new();
record.insert(
"report_date".to_string(),
serde_json::Value::String(report_date.to_string()),
);
record.insert(
"report_name".to_string(),
serde_json::Value::String(report_name.to_string()),
);
record.insert(
"report_period".to_string(),
serde_json::Value::String(report_period.to_string()),
);
record.insert(
"quarter_name".to_string(),
serde_json::Value::String(quarter_name.to_string()),
);
record.insert(
"metric_name".to_string(),
serde_json::Value::String(metric_name.clone()),
);
if let serde_json::Value::Object(vals) = metric_values {
for (field, value) in vals {
record.insert(field.clone(), value.clone());
}
} else {
record.insert("value".to_string(), metric_values.clone());
}
all_records.push(record);
}
}
Ok(all_records)
}
pub async fn stock_management_change_ths(
&self,
symbol: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = format!("https://basic.10jqka.com.cn/new/{symbol}/event.html");
let html = self
.get(&url)
.headers(ths_headers())
.send()
.await?
.text()
.await?;
let table_content = match RE_TABLE_DATA_HL.captures(&html) {
Some(cap) => cap[1].to_string(),
None => return Ok(vec![]),
};
let tables = parse_html_tables(&format!("<table>{table_content}</table>"));
if tables.is_empty() {
return Ok(vec![]);
}
let (headers, rows) = &tables[0];
Ok(table_to_records(headers, rows))
}
pub async fn stock_shareholder_change_ths(
&self,
symbol: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = format!("https://basic.10jqka.com.cn/new/{symbol}/event.html");
let html = self
.get(&url)
.headers(ths_headers())
.send()
.await?
.text()
.await?;
let table_content = match RE_TABLE_M_DATA_HL.captures(&html) {
Some(cap) => cap[1].to_string(),
None => return Ok(vec![]),
};
let tables = parse_html_tables(&format!("<table>{table_content}</table>"));
if tables.is_empty() {
return Ok(vec![]);
}
let (headers, rows) = &tables[0];
Ok(table_to_records(headers, rows))
}
pub async fn stock_ipo_ths(
&self,
market: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let market_map: HashMap<&str, &str> = [
("全部A股", "all"),
("沪市主板", "hszb"),
("深市主板", "sszb"),
("创业板", "cyb"),
("科创板", "kcbsg"),
("京市主板", "bjzb"),
]
.into_iter()
.collect();
let path = market_map
.get(market)
.copied()
.ok_or_else(|| Error::invalid_input(format!("unsupported market: {market}")))?;
let url = format!("https://data.10jqka.com.cn/ipo/xgsgyzq/{path}/");
let html = self
.get(&url)
.headers(ths_headers())
.send()
.await?
.error_for_status()?
.text()
.await?;
let table_content = RE_TABLE_MAINTABLE
.captures(&html)
.or_else(|| RE_TABLE_M_TABLE.captures(&html))
.map(|c| c[1].to_string());
let content = match table_content {
Some(c) => c,
None => return Ok(vec![]),
};
let tables = parse_html_tables(&format!("<table>{content}</table>"));
if tables.is_empty() {
return Ok(vec![]);
}
let (headers, rows) = &tables[0];
Ok(table_to_records(headers, rows))
}
pub async fn stock_ipo_hk_ths(&self) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = "https://data.10jqka.com.cn/ipo/xgsgyzq/hkstock/";
let html = self
.get(url)
.headers(ths_headers())
.send()
.await?
.error_for_status()?
.text()
.await?;
let table_content = RE_TABLE_MAINTABLE
.captures(&html)
.or_else(|| RE_TABLE_M_TABLE.captures(&html))
.map(|c| c[1].to_string());
let content = match table_content {
Some(c) => c,
None => return Ok(vec![]),
};
let tables = parse_html_tables(&format!("<table>{content}</table>"));
if tables.is_empty() {
return Ok(vec![]);
}
let (headers, rows) = &tables[0];
Ok(table_to_records(headers, rows))
}
pub async fn stock_profit_forecast_ths(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let url = format!("https://basic.10jqka.com.cn/new/{symbol}/worth.html");
let resp = self.get(&url).headers(ths_headers()).send().await?;
let bytes = resp.bytes().await?;
let html = String::from_utf8_lossy(&bytes).to_string();
let tables = parse_html_tables(&html);
if html.contains("本年度暂无机构做出业绩预测") {
match indicator {
"预测年报每股收益" | "预测年报净利润" => return Ok(vec![]),
"业绩预测详表-机构" => {
if tables.is_empty() {
return Ok(vec![]);
}
let (headers, rows) = &tables[0];
return Ok(table_to_records(headers, rows));
}
"业绩预测详表-详细指标预测" => {
if tables.len() < 2 {
return Ok(vec![]);
}
let (headers, rows) = &tables[1];
return Ok(table_to_records(headers, rows));
}
_ => return Ok(vec![]),
}
}
let table_idx = match indicator {
"预测年报每股收益" => 0,
"预测年报净利润" => 1,
"业绩预测详表-机构" => 2,
"业绩预测详表-详细指标预测" => 3,
_ => {
return Err(Error::invalid_input(format!(
"unsupported indicator: {indicator}"
)));
}
};
if table_idx >= tables.len() {
return Ok(vec![]);
}
let (headers, rows) = &tables[table_idx];
Ok(table_to_records(headers, rows))
}
pub async fn stock_zyjs_ths(&self, symbol: &str) -> Result<HashMap<String, serde_json::Value>> {
let url = format!("https://basic.10jqka.com.cn/new/{symbol}/operate.html");
let resp = self.get(&url).headers(ths_headers()).send().await?;
let bytes = resp.bytes().await?;
let html = String::from_utf8_lossy(&bytes).to_string();
let mut result = HashMap::new();
result.insert(
"股票代码".to_string(),
serde_json::Value::String(symbol.to_string()),
);
if let Some(list_cap) = RE_UL_MAIN_INTRO.captures(&html) {
let list_content = &list_cap[1];
for li_cap in RE_LI.captures_iter(list_content) {
let text = strip_html_tags(&li_cap[1])
.replace(['\t', '\n'], "")
.replace(" ", "")
.trim()
.to_string();
if let Some((key, value)) = text.split_once(':') {
result.insert(
key.trim().to_string(),
serde_json::Value::String(value.trim().to_string()),
);
} else if let Some((key, value)) = text.split_once(':') {
result.insert(
key.trim().to_string(),
serde_json::Value::String(value.trim().to_string()),
);
}
}
}
Ok(result)
}
pub async fn stock_hk_profit_forecast_et(
&self,
symbol: &str,
indicator: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let code = symbol.trim_start_matches('0');
let code = if code.is_empty() { "0" } else { code };
let url = "https://www.etnet.com.hk/www/sc/stocks/realtime/quote_profit.php";
let html = self
.get(url)
.query(&[("code", code)])
.send()
.await?
.text()
.await?;
let tables = parse_html_tables(&html);
match indicator {
"评级总览" => {
if tables.is_empty() {
return Ok(vec![]);
}
let (headers, rows) = &tables[0];
Ok(table_to_records(headers, rows))
}
"去年度业绩表现" => {
if tables.len() < 3 {
return Ok(vec![]);
}
let (headers, rows) = &tables[2];
Ok(table_to_records(headers, rows))
}
"综合盈利预测" => {
if tables.len() < 4 {
return Ok(vec![]);
}
let (headers, rows) = &tables[3];
Ok(table_to_records(headers, rows))
}
"盈利预测概览" => {
if tables.len() < 5 {
return Ok(vec![]);
}
let (headers, rows) = &tables[4];
Ok(table_to_records(headers, rows))
}
_ => Err(Error::invalid_input(format!(
"unsupported indicator: {indicator}"
))),
}
}
}