#![allow(dead_code)]
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use reqwest::{
Client, IntoUrl,
header::{HeaderMap, HeaderName, HeaderValue},
};
use serde::de::DeserializeOwned;
use url::Url;
use bms_table::{BmsTable, BmsTableData, BmsTableHeader, BmsTableHtml, BmsTableList};
fn replace_control_chars(s: &str) -> String {
s.chars().filter(|ch: &char| !ch.is_control()).collect()
}
fn parse_json_str_with_fallback<T: DeserializeOwned>(raw: &str) -> Result<(T, String)> {
match serde_json::from_str::<T>(raw) {
Ok(v) => Ok((v, raw.to_string())),
Err(_) => {
let cleaned = replace_control_chars(raw);
let v = serde_json::from_str::<T>(&cleaned)?;
Ok((v, cleaned))
}
}
}
enum HeaderQueryContent<T> {
Url(String),
Value(T),
}
fn get_web_header_json_value<T: DeserializeOwned>(
response_str: &str,
) -> Result<HeaderQueryContent<T>> {
let cleaned = replace_control_chars(response_str);
match serde_json::from_str::<T>(&cleaned) {
Ok(header_json) => Ok(HeaderQueryContent::Value(header_json)),
Err(_) => {
let bmstable_url =
BmsTableHtml::extract_url(response_str).context("When extracting bmstable url")?;
Ok(HeaderQueryContent::Url(bmstable_url))
}
}
}
fn header_query_with_fallback<T: DeserializeOwned>(
raw: &str,
) -> Result<(HeaderQueryContent<T>, String)> {
match get_web_header_json_value::<T>(raw) {
Ok(v) => Ok((v, raw.to_string())),
Err(_) => {
let cleaned = replace_control_chars(raw);
let v = get_web_header_json_value::<T>(&cleaned)?;
Ok((v, cleaned))
}
}
}
pub struct FetchedTableList {
pub tables: Vec<bms_table::BmsTableInfo>,
pub raw_json: String,
}
#[derive(Clone)]
pub struct Fetcher {
client: Client,
}
impl Fetcher {
#[must_use]
pub const fn new(client: Client) -> Self {
Self { client }
}
pub fn lenient() -> Result<Self> {
Ok(Self::new(make_lenient_client()?))
}
pub async fn fetch_table(&self, web_url: impl IntoUrl) -> Result<BmsTable> {
let web_url = web_url.into_url().context("When parsing target url")?;
let web_page_text = self.fetch_text(web_url.clone(), "web page").await?;
let (web_header_query, _web_used_text) =
header_query_with_fallback::<BmsTableHeader>(&web_page_text)
.context("When extracting header query from web page")?;
let (header_json_url, header) = match web_header_query {
HeaderQueryContent::Url(header_url_string) => {
let header_json_url = web_url
.join(&header_url_string)
.context("When resolving header json url")?;
let header_text = self
.fetch_text(header_json_url.clone(), "header json")
.await?;
let (header_query2, _header_used_text) =
header_query_with_fallback::<BmsTableHeader>(&header_text)
.context("When parsing header json")?;
let HeaderQueryContent::Value(header) = header_query2 else {
return Err(anyhow!(
"Cycled header found. web_url: {web_url}, header_url: {header_url_string}"
));
};
(header_json_url, header)
}
HeaderQueryContent::Value(header) => (web_url, header),
};
let data_json_url = header_json_url
.join(&header.data_url)
.context("When resolving data json url")?;
let (data, _data_raw) = self
.fetch_json_with_fallback::<BmsTableData>(
data_json_url.clone(),
"data json",
"data json",
)
.await?;
Ok(BmsTable::new(header, data))
}
pub async fn fetch_table_list(&self, web_url: impl IntoUrl) -> Result<FetchedTableList> {
let list_url = web_url.into_url().context("When parsing table list url")?;
let (list, raw_used) = self
.fetch_json_with_fallback::<BmsTableList>(list_url, "table list", "table list json")
.await?;
Ok(FetchedTableList {
tables: list.listes,
raw_json: raw_used,
})
}
async fn fetch_text(&self, url: Url, fetch_ctx: &'static str) -> Result<String> {
self.client
.get(url)
.send()
.await
.with_context(|| format!("When fetching {fetch_ctx}"))?
.text()
.await
.with_context(|| format!("When reading {fetch_ctx} body"))
}
async fn fetch_json_with_fallback<T: DeserializeOwned>(
&self,
url: Url,
fetch_ctx: &'static str,
parse_ctx: &'static str,
) -> Result<(T, String)> {
let text = self.fetch_text(url, fetch_ctx).await?;
parse_json_str_with_fallback::<T>(&text)
.with_context(|| format!("When parsing {parse_ctx}"))
}
}
fn make_lenient_client() -> Result<Client> {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("accept"),
HeaderValue::from_static(
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
),
);
headers.insert(
HeaderName::from_static("accept-language"),
HeaderValue::from_static("zh-CN,zh;q=0.9,en;q=0.8"),
);
headers.insert(
HeaderName::from_static("upgrade-insecure-requests"),
HeaderValue::from_static("1"),
);
headers.insert(
HeaderName::from_static("connection"),
HeaderValue::from_static("keep-alive"),
);
let client = Client::builder()
.default_headers(headers)
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119 Safari/537.36 bms-table-rs")
.timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::limited(100))
.referer(true)
.cookie_store(true)
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build()
.context("When building client")?;
Ok(client)
}