bms_table/fetch/
reqwest.rs

1//! 基于 `reqwest` 的网络获取模块
2//!
3//! 提供一站式从网页或头部 JSON 源拉取并解析 BMS 难度表的能力:
4//! - 获取网页并从 HTML 提取 bmstable 头部地址(如有);
5//! - 下载并解析头部 JSON;
6//! - 根据头部中的 `data_url` 下载谱面数据并解析;
7//! - 返回包含表头与谱面集合的 `BmsTable`。
8//!
9//! # 示例
10//!
11//! ```rust,no_run
12//! # #[tokio::main]
13//! # async fn main() -> anyhow::Result<()> {
14//! use bms_table::fetch::reqwest::fetch_table;
15//! let table = fetch_table("https://stellabms.xyz/sl/table.html").await?;
16//! assert!(!table.data.charts.is_empty());
17//! # Ok(())
18//! # }
19//! ```
20#![cfg(feature = "reqwest")]
21
22use anyhow::{Result, anyhow};
23use serde_json::Value;
24use std::collections::HashMap;
25use url::Url;
26
27use crate::{BmsTable, BmsTableIndexItem, BmsTableRaw};
28
29/// 从网页或头部 JSON 源拉取并解析完整的 BMS 难度表。
30///
31/// # 参数
32///
33/// - `web_url`:网页地址或直接指向头部 JSON 的地址。
34///
35/// # 返回
36///
37/// 解析后的 [`crate::BmsTable`],包含表头与谱面数据。
38///
39/// # 错误
40///
41/// - 网络请求失败(连接失败、超时等)
42/// - 响应内容无法解析为 HTML/JSON 或结构不符合预期
43/// - 头部 JSON 未包含 `data_url` 字段或其类型不正确
44pub async fn fetch_table_full(web_url: &str) -> Result<(BmsTable, BmsTableRaw)> {
45    let web_url = Url::parse(web_url)?;
46    let web_response = reqwest::Client::new()
47        .get(web_url.clone())
48        .send()
49        .await
50        .map_err(|e| anyhow!("When fetching web: {e}"))?
51        .text()
52        .await
53        .map_err(|e| anyhow!("When parsing web response: {e}"))?;
54    let (header_url, header_json, header_raw) =
55        match crate::fetch::get_web_header_json_value(&web_response)? {
56            crate::fetch::HeaderQueryContent::Url(header_url_string) => {
57                let header_url = web_url.join(&header_url_string)?;
58                let header_response = reqwest::Client::new()
59                    .get(header_url.clone())
60                    .send()
61                    .await
62                    .map_err(|e| anyhow!("When fetching header: {e}"))?;
63                let header_response_string = header_response
64                    .text()
65                    .await
66                    .map_err(|e| anyhow!("When parsing header response: {e}"))?;
67                let crate::fetch::HeaderQueryContent::Json(header_json) =
68                    crate::fetch::get_web_header_json_value(&header_response_string)?
69                else {
70                    return Err(anyhow!(
71                        "Cycled header found. web_url: {web_url}, header_url: {header_url_string}"
72                    ));
73                };
74                (header_url, header_json, header_response_string)
75            }
76            crate::fetch::HeaderQueryContent::Json(value) => {
77                let header_raw = serde_json::to_string(&value)?;
78                (web_url, value, header_raw)
79            }
80        };
81    let data_url_str = header_json
82        .get("data_url")
83        .ok_or_else(|| anyhow!("\"data_url\" not found in header json!"))?
84        .as_str()
85        .ok_or_else(|| anyhow!("\"data_url\" is not a string!"))?;
86    let data_url = header_url.join(data_url_str)?;
87    let data_response = reqwest::Client::new()
88        .get(data_url)
89        .send()
90        .await
91        .map_err(|e| anyhow!("When fetching web: {e}"))?
92        .text()
93        .await
94        .map_err(|e| anyhow!("When parsing web response: {e}"))?;
95    let data_json: Value = serde_json::from_str(&data_response)?;
96    // 直接使用库内反序列化生成 BmsTable
97    let header: crate::BmsTableHeader = serde_json::from_value(header_json)
98        .map_err(|e| anyhow!("When parsing header json: {e}"))?;
99    let data: crate::BmsTableData =
100        serde_json::from_value(data_json).map_err(|e| anyhow!("When parsing data json: {e}"))?;
101    Ok((
102        BmsTable { header, data },
103        BmsTableRaw {
104            header_raw,
105            data_raw: data_response,
106        },
107    ))
108}
109
110/// 从网页或头部 JSON 源拉取并解析完整的 BMS 难度表。
111///
112/// 参考 [`fetch_table_full`]。
113pub async fn fetch_table(web_url: &str) -> Result<BmsTable> {
114    let (table, _raw) = fetch_table_full(web_url).await?;
115    Ok(table)
116}
117
118/// 获取 BMS 表索引列表。
119///
120/// 从提供的 `web_url` 下载 JSON 数组并解析为 [`crate::BmsTableIndexItem`] 列表。
121/// 仅要求每个元素包含 `name`、`symbol` 与 `url`(字符串),其他字段将被收集到 `extra` 中。
122pub async fn fetch_table_index(web_url: &str) -> Result<Vec<BmsTableIndexItem>> {
123    let (out, _raw) = fetch_table_index_full(web_url).await?;
124    Ok(out)
125}
126
127/// 获取 BMS 表索引列表及其原始 JSON 字符串。
128///
129/// 返回解析后的索引项数组与响应的原始 JSON 文本,便于记录或调试。
130pub async fn fetch_table_index_full(web_url: &str) -> Result<(Vec<BmsTableIndexItem>, String)> {
131    let web_url = Url::parse(web_url)?;
132    let response_text = reqwest::Client::new()
133        .get(web_url)
134        .send()
135        .await
136        .map_err(|e| anyhow!("When fetching table index: {e}"))?
137        .text()
138        .await
139        .map_err(|e| anyhow!("When parsing table index response: {e}"))?;
140
141    let value: Value = serde_json::from_str(&response_text)?;
142    let arr = value
143        .as_array()
144        .ok_or_else(|| anyhow!("Table index root is not an array"))?;
145
146    let mut out = Vec::with_capacity(arr.len());
147    for (idx, item) in arr.iter().enumerate() {
148        let obj = item
149            .as_object()
150            .ok_or_else(|| anyhow!("Table index item #{idx} is not an object"))?;
151
152        let name = obj
153            .get("name")
154            .and_then(|v| v.as_str())
155            .ok_or_else(|| anyhow!("Missing required field 'name' at index {idx}"))?;
156        let symbol = obj
157            .get("symbol")
158            .and_then(|v| v.as_str())
159            .ok_or_else(|| anyhow!("Missing required field 'symbol' at index {idx}"))?;
160        let url_str = obj
161            .get("url")
162            .and_then(|v| v.as_str())
163            .ok_or_else(|| anyhow!("Missing required field 'url' at index {idx}"))?;
164        let url = Url::parse(url_str)?;
165
166        #[cfg(feature = "serde")]
167        let extra = {
168            let mut m: HashMap<String, Value> = HashMap::new();
169            for (k, v) in obj.iter() {
170                if k != "name" && k != "symbol" && k != "url" {
171                    m.insert(k.clone(), v.clone());
172                }
173            }
174            m
175        };
176
177        let entry = BmsTableIndexItem {
178            name: name.to_string(),
179            symbol: symbol.to_string(),
180            url,
181            #[cfg(feature = "serde")]
182            extra,
183        };
184        out.push(entry);
185    }
186
187    Ok((out, response_text))
188}