bms_table/
fetch.rs

1//! BMS表格数据获取模块
2//!
3//! 这个模块提供了从BMS表格网站获取和解析数据的功能。
4//! 支持从HTML页面提取bmstable字段,解析JSON格式的表格头信息和分数数据。
5//!
6//! # 主要功能
7//!
8//! - 从HTML页面中提取bmstable字段指向的JSON文件URL
9//! - 解析BMS表格头信息(包含课程、奖杯等元数据)
10//! - 获取和解析分数数据(包含歌曲信息、下载链接等)
11//! - 完整的BMS表格数据获取流程
12//!
13//! # 使用示例
14//!
15//! ```rust
16//! use bms_table::fetch::BmsTableParser;
17//! use anyhow::Result;
18//!
19//! #[tokio::main]
20//! async fn main() -> Result<()> {
21//!     let parser = BmsTableParser::new();
22//!     
23//!     // 注意:这个示例需要可访问的BMS表格网站
24//!     // let (header, scores) = parser.fetch_complete_table("https://example.com/table.html").await?;
25//!     // println!("表格名称: {}", header.name);
26//!     // println!("分数数据数量: {}", scores.len());
27//!     
28//!     Ok(())
29//! }
30//! ```
31
32use anyhow::Result;
33use reqwest::Client;
34use scraper::{Html, Selector};
35use serde::{Deserialize, Serialize};
36use url::Url;
37
38/// BMS表格头信息
39///
40/// 包含表格的基本信息和课程配置。
41/// 这个结构体对应BMS表格头JSON文件的主要结构。
42#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
43pub struct BmsTableHeader {
44    /// 表格名称,如 "Satellite"
45    pub name: String,
46    /// 表格符号,如 "sl"
47    pub symbol: String,
48    /// 分数数据文件的相对URL,如 "score.json"
49    pub data_url: String,
50    /// 课程信息数组,每个元素是一个课程组的数组
51    #[serde(default)]
52    pub course: Vec<Vec<CourseInfo>>,
53}
54
55/// 课程信息
56///
57/// 定义了一个BMS课程的所有相关信息,包括约束条件、奖杯要求和MD5哈希列表。
58#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
59pub struct CourseInfo {
60    /// 课程名称,如 "Satellite Skill Analyzer 2nd sl0"
61    pub name: String,
62    /// 约束条件列表,如 ["grade_mirror", "gauge_lr2", "ln"]
63    #[serde(default)]
64    pub constraint: Vec<String>,
65    /// 奖杯信息列表,定义不同等级的奖杯要求
66    #[serde(default)]
67    pub trophy: Vec<Trophy>,
68    /// 该课程包含的BMS文件的MD5哈希列表
69    pub md5: Vec<String>,
70}
71
72/// 奖杯信息
73///
74/// 定义了获得特定奖杯需要达到的分数要求。
75#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
76pub struct Trophy {
77    /// 奖杯名称,如 "silvermedal" 或 "goldmedal"
78    pub name: String,
79    /// 最大miss率(百分比),如 5.0 表示最大5%的miss率
80    pub missrate: f64,
81    /// 最小得分率(百分比),如 70.0 表示至少70%的得分率
82    pub scorerate: f64,
83}
84
85/// 分数数据项
86///
87/// 表示一个BMS文件的分数数据,包含文件信息和下载链接。
88/// 所有字段都是可选的,因为不同的BMS表格可能有不同的字段。
89#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
90pub struct ScoreItem {
91    /// 难度等级,如 "0"
92    pub level: String,
93    /// 唯一标识符
94    pub id: Option<u64>,
95    /// 文件的MD5哈希值
96    pub md5: Option<String>,
97    /// 文件的SHA256哈希值
98    pub sha256: Option<String>,
99    /// 歌曲标题
100    pub title: Option<String>,
101    /// 艺术家名称
102    pub artist: Option<String>,
103    /// 文件下载链接
104    pub url: Option<String>,
105    /// 差分文件下载链接(可选)
106    pub url_diff: Option<String>,
107}
108
109impl<'de> serde::Deserialize<'de> for ScoreItem {
110    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
111    where
112        D: serde::Deserializer<'de>,
113    {
114        #[derive(serde::Deserialize)]
115        struct ScoreItemHelper {
116            level: String,
117            #[serde(default)]
118            id: Option<u64>,
119            #[serde(default)]
120            md5: Option<String>,
121            #[serde(default)]
122            sha256: Option<String>,
123            #[serde(default)]
124            title: Option<String>,
125            #[serde(default)]
126            artist: Option<String>,
127            #[serde(default)]
128            url: Option<String>,
129            #[serde(default)]
130            url_diff: Option<String>,
131        }
132
133        let helper = ScoreItemHelper::deserialize(deserializer)?;
134        
135        // 将空字符串转换为None
136        let id = helper.id;
137        let md5 = helper.md5.filter(|s| !s.is_empty());
138        let sha256 = helper.sha256.filter(|s| !s.is_empty());
139        let title = helper.title.filter(|s| !s.is_empty());
140        let artist = helper.artist.filter(|s| !s.is_empty());
141        let url = helper.url.filter(|s| !s.is_empty());
142        let url_diff = helper.url_diff.filter(|s| !s.is_empty());
143
144        Ok(ScoreItem {
145            level: helper.level,
146            id,
147            md5,
148            sha256,
149            title,
150            artist,
151            url,
152            url_diff,
153        })
154    }
155}
156
157/// BMS表格解析器
158///
159/// 提供从BMS表格网站获取和解析数据的功能。
160/// 使用HTTP客户端来获取HTML和JSON数据,并提供完整的解析流程。
161pub struct BmsTableParser {
162    /// HTTP客户端,用于发送请求
163    client: Client,
164}
165
166impl BmsTableParser {
167    /// 创建新的BMS表格解析器实例
168    ///
169    /// # 返回值
170    ///
171    /// 返回一个配置好的解析器实例,包含HTTP客户端。
172    ///
173    /// # 示例
174    ///
175    /// ```rust
176    /// use bms_table::fetch::BmsTableParser;
177    ///
178    /// let parser = BmsTableParser::new();
179    /// ```
180    pub fn new() -> Self {
181        Self {
182            client: Client::new(),
183        }
184    }
185
186    /// 从HTML页面中提取bmstable字段
187    ///
188    /// 解析HTML页面的head标签,查找包含bmstable字段的meta标签,
189    /// 提取指向JSON配置文件的URL。
190    ///
191    /// # 参数
192    ///
193    /// * `html_url` - HTML页面的URL
194    ///
195    /// # 返回值
196    ///
197    /// 返回提取到的bmstable URL字符串,如果未找到则返回错误。
198    ///
199    /// # 错误
200    ///
201    /// 如果无法获取HTML页面、解析失败或未找到bmstable字段,将返回错误。
202    ///
203    /// # 示例
204    ///
205    /// ```rust,no_run
206    /// use bms_table::fetch::BmsTableParser;
207    ///
208    /// #[tokio::main]
209    /// async fn main() -> anyhow::Result<()> {
210    ///     let parser = BmsTableParser::new();
211    ///     let url = parser.extract_bmstable_url("https://example.com/table.html").await?;
212    ///     println!("bmstable URL: {}", url);
213    ///     Ok(())
214    /// }
215    /// ```
216    pub async fn extract_bmstable_url(&self, html_url: &str) -> Result<String> {
217        let response = self.client.get(html_url).send().await?;
218        let html_content = response.text().await?;
219        let document = Html::parse_document(&html_content);
220
221        // 查找所有meta标签
222        let meta_selector = Selector::parse("meta").unwrap();
223
224        for element in document.select(&meta_selector) {
225            // 检查是否有name属性为"bmstable"的meta标签
226            if let Some(name_attr) = element.value().attr("name") {
227                if name_attr == "bmstable" {
228                    // 获取content属性
229                    if let Some(content_attr) = element.value().attr("content") {
230                        if !content_attr.is_empty() {
231                            return Ok(content_attr.to_string());
232                        }
233                    }
234                }
235            }
236        }
237
238        Err(anyhow::anyhow!("未找到bmstable字段"))
239    }
240
241    /// 获取并解析BMS表格头信息
242    ///
243    /// 从指定的URL获取JSON格式的BMS表格头信息,并解析为结构体。
244    ///
245    /// # 参数
246    ///
247    /// * `header_url` - 表格头信息JSON文件的URL
248    ///
249    /// # 返回值
250    ///
251    /// 返回解析后的BmsTableHeader结构体,包含表格名称、符号、课程信息等。
252    ///
253    /// # 错误
254    ///
255    /// 如果无法获取JSON文件或解析失败,将返回错误。
256    ///
257    /// # 示例
258    ///
259    /// ```rust,no_run
260    /// use bms_table::fetch::BmsTableParser;
261    ///
262    /// #[tokio::main]
263    /// async fn main() -> anyhow::Result<()> {
264    ///     let parser = BmsTableParser::new();
265    ///     let header = parser.get_table_header("https://example.com/header.json").await?;
266    ///     println!("表格名称: {}", header.name);
267    ///     Ok(())
268    /// }
269    /// ```
270    pub async fn get_table_header(&self, header_url: &str) -> Result<BmsTableHeader> {
271        let response = self.client.get(header_url).send().await?;
272        let json_content = response.text().await?;
273
274        let header: BmsTableHeader = serde_json::from_str(&json_content)?;
275        Ok(header)
276    }
277
278    /// 获取并解析分数数据
279    ///
280    /// 从指定的URL获取JSON格式的分数数据,并解析为结构体数组。
281    ///
282    /// # 参数
283    ///
284    /// * `score_url` - 分数数据JSON文件的URL
285    ///
286    /// # 返回值
287    ///
288    /// 返回解析后的ScoreItem数组,包含所有BMS文件的分数数据。
289    ///
290    /// # 错误
291    ///
292    /// 如果无法获取JSON文件或解析失败,将返回错误。
293    ///
294    /// # 示例
295    ///
296    /// ```rust,no_run
297    /// use bms_table::fetch::BmsTableParser;
298    ///
299    /// #[tokio::main]
300    /// async fn main() -> anyhow::Result<()> {
301    ///     let parser = BmsTableParser::new();
302    ///     let scores = parser.get_score_data("https://example.com/score.json").await?;
303    ///     println!("分数数据数量: {}", scores.len());
304    ///     Ok(())
305    /// }
306    /// ```
307    pub async fn get_score_data(&self, score_url: &str) -> Result<Vec<ScoreItem>> {
308        let response = self.client.get(score_url).send().await?;
309        let json_content = response.text().await?;
310
311        let scores: Vec<ScoreItem> = serde_json::from_str(&json_content)?;
312        Ok(scores)
313    }
314
315    /// 完整的BMS表格数据获取流程
316    ///
317    /// 执行完整的BMS表格数据获取流程:
318    /// 1. 从HTML页面提取bmstable字段
319    /// 2. 获取并解析表格头信息
320    /// 3. 获取并解析分数数据
321    ///
322    /// # 参数
323    ///
324    /// * `base_url` - BMS表格HTML页面的URL
325    ///
326    /// # 返回值
327    ///
328    /// 返回一个元组,包含表格头信息和分数数据数组。
329    ///
330    /// # 错误
331    ///
332    /// 如果在任何步骤中发生错误(网络错误、解析错误等),将返回错误。
333    ///
334    /// # 示例
335    ///
336    /// ```rust,no_run
337    /// use bms_table::fetch::BmsTableParser;
338    ///
339    /// #[tokio::main]
340    /// async fn main() -> anyhow::Result<()> {
341    ///     let parser = BmsTableParser::new();
342    ///     let (header, scores) = parser.fetch_complete_table("https://example.com/table.html").await?;
343    ///     
344    ///     println!("表格名称: {}", header.name);
345    ///     println!("分数数据数量: {}", scores.len());
346    ///     
347    ///     // 显示第一个分数数据
348    ///     if let Some(first_score) = scores.first() {
349    ///         if let Some(title) = &first_score.title {
350    ///             println!("第一个歌曲: {}", title);
351    ///         }
352    ///     }
353    ///     
354    ///     Ok(())
355    /// }
356    /// ```
357    pub async fn fetch_complete_table(
358        &self,
359        base_url: &str,
360    ) -> Result<(BmsTableHeader, Vec<ScoreItem>)> {
361        // 1. 从HTML页面提取bmstable URL
362        let bmstable_url = self.extract_bmstable_url(base_url).await?;
363
364        // 2. 解析bmstable URL为绝对路径
365        let base_url_obj = Url::parse(base_url)?;
366        let header_url = base_url_obj.join(&bmstable_url)?;
367
368        // 3. 获取表格头信息
369        let header = self.get_table_header(header_url.as_str()).await?;
370
371        // 4. 构建分数数据URL
372        let score_url = header_url.join(&header.data_url)?;
373
374        // 5. 获取分数数据
375        let scores = self.get_score_data(score_url.as_str()).await?;
376
377        Ok((header, scores))
378    }
379}
380
381impl Default for BmsTableParser {
382    /// 创建默认的BMS表格解析器实例
383    ///
384    /// 等同于调用 `BmsTableParser::new()`。
385    fn default() -> Self {
386        Self::new()
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    /// 测试解析器创建功能
395    #[tokio::test]
396    async fn test_parser_creation() {
397        let parser = BmsTableParser::new();
398        assert!(parser
399            .client
400            .get("https://stellabms.xyz/sl/table.html")
401            .send()
402            .await
403            .is_ok());
404    }
405}