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}