bms_table/
fetch.rs

1//! 数据获取与 HTML 解析辅助模块
2//!
3//! 在启用 `scraper` 特性时提供 HTML 解析能力,用于从页面的
4//! `<meta name="bmstable" content="...">` 中提取头部 JSON 的地址。
5//! 同时提供一个统一的入口将响应字符串解析为头部 JSON 或其 URL。
6//!
7//! # 示例
8//!
9//! ```rust
10//! # use bms_table::fetch::{get_web_header_json_value, HeaderQueryContent};
11//! let html = r#"
12//! <!DOCTYPE html>
13//! <html>
14//!   <head>
15//!     <meta name="bmstable" content="header.json">
16//!   </head>
17//!   <body></body>
18//! </html>
19//! "#;
20//! match get_web_header_json_value(html).unwrap() {
21//!     HeaderQueryContent::Url(u) => assert_eq!(u, "header.json"),
22//!     _ => unreachable!(),
23//! }
24//! ```
25#![cfg(feature = "scraper")]
26
27pub mod reqwest;
28
29use anyhow::{Result, anyhow};
30use scraper::{Html, Selector};
31use serde_json::Value;
32
33/// [`get_web_header_json_value`] 的返回类型。
34///
35/// - 当输入是 HTML 时,返回从 `<meta name="bmstable">` 提取的 URL;
36/// - 当输入是 JSON 时,返回解析后的 JSON 值。
37pub enum HeaderQueryContent {
38    /// 提取到的头部 JSON 地址。
39    ///
40    /// 可能为相对或绝对 URL,建议使用 `url::Url::join` 进行拼接。
41    Url(String),
42    /// 原始头部 JSON 内容。
43    Json(Value),
44}
45
46/// 移除 JSON 文本中的非打印控制字符(保留 `\n`、`\r`、`\t`)。
47///
48/// 目的:某些站点返回的 JSON 前后可能夹杂非法控制字符,
49/// 在解析前进行清洗以提高兼容性,同时不影响原始 raw 文本的保存。
50pub(crate) fn replace_control_chars(s: &str) -> String {
51    s.chars().filter(|ch: &char| !ch.is_control()).collect()
52}
53
54/// 将响应字符串解析为头部 JSON 或其 URL。
55///
56/// 解析策略:优先尝试按 JSON 解析;若失败则按 HTML 解析并提取 bmstable URL。
57///
58/// # 返回
59///
60/// - `HeaderQueryContent::Json`:输入是 JSON;
61/// - `HeaderQueryContent::Url`:输入是 HTML。
62///
63/// # 错误
64///
65/// 当输入为 HTML 且未找到 bmstable 字段时返回错误。
66pub fn get_web_header_json_value(response_str: &str) -> anyhow::Result<HeaderQueryContent> {
67    // 先尝试按 JSON 解析(解析前移除非法控制字符);失败则当作 HTML 提取 bmstable URL
68    let cleaned = replace_control_chars(response_str);
69    match serde_json::from_str::<Value>(&cleaned) {
70        Ok(header_json) => Ok(HeaderQueryContent::Json(header_json)),
71        Err(_) => {
72            let bmstable_url = extract_bmstable_url(response_str)?;
73            Ok(HeaderQueryContent::Url(bmstable_url))
74        }
75    }
76}
77
78/// 从 HTML 页面内容中提取 bmstable 字段指向的 JSON 文件 URL。
79///
80/// 该函数会扫描 `<meta>` 标签,寻找 `name="bmstable"` 的元素,并读取其 `content` 属性。
81///
82/// # 错误
83///
84/// 当未找到目标标签或 `content` 为空时返回错误。
85pub fn extract_bmstable_url(html_content: &str) -> Result<String> {
86    let document = Html::parse_document(html_content);
87
88    // 查找所有meta标签
89    let Ok(meta_selector) = Selector::parse("meta") else {
90        return Err(anyhow!("未找到meta标签"));
91    };
92
93    // 1) 优先从<meta name="bmstable" content="...">或<meta property="bmstable">提取
94    for element in document.select(&meta_selector) {
95        // name 或 property 为 bmstable 的标签
96        let is_bmstable = element
97            .value()
98            .attr("name")
99            .is_some_and(|v| v.eq_ignore_ascii_case("bmstable"))
100            || element
101                .value()
102                .attr("property")
103                .is_some_and(|v| v.eq_ignore_ascii_case("bmstable"));
104        if is_bmstable
105            && let Some(content_attr) = element.value().attr("content")
106            && !content_attr.is_empty()
107        {
108            return Ok(content_attr.to_string());
109        }
110    }
111
112    // 2) 其次尝试<link rel="bmstable" href="...json">
113    if let Ok(link_selector) = Selector::parse("link") {
114        for element in document.select(&link_selector) {
115            let rel = element.value().attr("rel");
116            let href = element.value().attr("href");
117            if rel.is_some_and(|v| v.eq_ignore_ascii_case("bmstable"))
118                && let Some(href) = href
119                && !href.is_empty()
120            {
121                return Ok(href.to_string());
122            }
123        }
124    }
125
126    // 3) 再尝试在常见标签属性中寻找 *header*.json 线索
127    //    - a[href], link[href], script[src], meta[content]
128    let lower_contains_header_json = |s: &str| {
129        let ls = s.to_ascii_lowercase();
130        ls.contains("header") && ls.ends_with(".json")
131    };
132
133    // a[href]
134    if let Ok(a_selector) = Selector::parse("a") {
135        for element in document.select(&a_selector) {
136            if let Some(href) = element.value().attr("href")
137                && lower_contains_header_json(href)
138            {
139                return Ok(href.to_string());
140            }
141        }
142    }
143
144    // link[href]
145    if let Ok(link_selector) = Selector::parse("link") {
146        for element in document.select(&link_selector) {
147            if let Some(href) = element.value().attr("href")
148                && lower_contains_header_json(href)
149            {
150                return Ok(href.to_string());
151            }
152        }
153    }
154
155    // script[src]
156    if let Ok(script_selector) = Selector::parse("script") {
157        for element in document.select(&script_selector) {
158            if let Some(src) = element.value().attr("src")
159                && lower_contains_header_json(src)
160            {
161                return Ok(src.to_string());
162            }
163        }
164    }
165
166    // meta[content]
167    for element in document.select(&meta_selector) {
168        if let Some(content_attr) = element.value().attr("content")
169            && lower_contains_header_json(content_attr)
170        {
171            return Ok(content_attr.to_string());
172        }
173    }
174
175    // 4) 最后进行原始文本的极简启发式搜索:匹配包含"header"且以 .json 结尾的子串
176    if let Some((start, end)) = find_header_json_in_text(html_content) {
177        let candidate = &html_content[start..end];
178        return Ok(candidate.to_string());
179    }
180
181    Err(anyhow!("未找到bmstable字段或header JSON线索"))
182}
183
184/// 在原始文本中查找类似 "*header*.json" 的子串,返回起止下标(若找到)。
185fn find_header_json_in_text(s: &str) -> Option<(usize, usize)> {
186    let lower = s.to_ascii_lowercase();
187    let mut pos = 0;
188    while let Some(idx) = lower[pos..].find("header") {
189        let global_idx = pos + idx;
190        // 在 header 之后寻找 .json
191        if let Some(json_rel) = lower[global_idx..].find(".json") {
192            let end = global_idx + json_rel + ".json".len();
193            // 试着往前找最近的引号或空白作为起点
194            let start = lower[..global_idx]
195                .rfind(|c: char| c == '"' || c == '\'' || c.is_whitespace())
196                .map(|i| i + 1)
197                .unwrap_or(global_idx);
198            if end > start {
199                return Some((start, end));
200            }
201        }
202        pos = global_idx + 6; // 跳过 "header"
203    }
204    None
205}