use std::time::Duration;
use super::Paper;
const CODE_CAPTCHA: i64 = 7350001;
const ACS_TOKEN_FALLBACK: &str = "parisInstance is not ready";
const PAGE_SIZE: u32 = 10;
const TIMEOUT: Duration = Duration::from_secs(30);
const USER_AGENT: &str = concat!(
"fastpaper-cli/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/zhangyee/fastpaper-cli)"
);
fn fetch_page(base_url: &str, query: &str, pn: u32) -> Result<String, String> {
let encoded = super::encode_query(query);
let url = format!(
"{}/search/api/search?wd={}&pn={}&skipStrategy=0",
base_url, encoded, pn
);
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(TIMEOUT))
.build()
.into();
let resp = agent
.get(&url)
.header("Acs-Token", ACS_TOKEN_FALLBACK)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/json, text/plain, */*")
.call()
.map_err(|e| format!("HTTP error: {e}"))?;
let status = resp.status().as_u16();
match status {
200 | 206 => resp
.into_body()
.read_to_string()
.map_err(|e| format!("read body: {e}")),
403 => Err("Baidu Xueshu blocked the request (HTTP 403 security check)".to_string()),
429 => Err("Baidu Xueshu rate limited (HTTP 429). Try again later.".to_string()),
s => Err(format!("HTTP {s}")),
}
}
pub fn search(base_url: &str, query: &str, max_results: u32) -> Result<Vec<Paper>, String> {
let mut papers: Vec<Paper> = Vec::new();
let mut pn = 0u32;
while (papers.len() as u32) < max_results {
let body = fetch_page(base_url, query, pn)?;
let page = parse_search_response(&body)?;
if page.is_empty() {
break;
}
papers.extend(page);
pn += PAGE_SIZE;
}
papers.truncate(max_results as usize);
Ok(papers)
}
fn strip_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_tag = false;
for c in s.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
c if !in_tag => out.push(c),
_ => {}
}
}
out.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
.trim()
.to_string()
}
fn pick_pdf_url(save_list: &serde_json::Value) -> Option<String> {
save_list.as_array()?.iter().find_map(|s| {
s["url"]
.as_str()
.filter(|u| u.starts_with("http") && u.to_lowercase().contains(".pdf"))
.map(|u| u.to_string())
})
}
pub fn parse_search_response(json: &str) -> Result<Vec<Paper>, String> {
let root: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("JSON parse error: {}", e))?;
let code = root["status"]["code"].as_i64().unwrap_or(-1);
if code == CODE_CAPTCHA {
return Err(
"Baidu Xueshu requires an interactive captcha (code 7350001). Not retrying; try again later or from a different network."
.to_string(),
);
}
if code != 0 {
let msg = root["status"]["msg"].as_str().unwrap_or("unknown error");
let logid = root["status"]["sLogid"].as_str().unwrap_or("-");
return Err(format!("Baidu Xueshu error {code}: {msg} (sLogid {logid})"));
}
let list = root["data"]["paper"]["paperList"]
.as_array()
.cloned()
.unwrap_or_default();
let mut papers = Vec::new();
for item in &list {
let id = item["paperId"].as_str().unwrap_or("").to_string();
let title = strip_html(item["title"].as_str().unwrap_or(""));
if id.is_empty() || title.is_empty() {
continue;
}
let authors: Vec<String> = item["authors"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|a| {
let show = a["showName"].as_str().unwrap_or("");
let name = if show.is_empty() {
a["name"].as_str().unwrap_or("")
} else {
show
};
(!name.is_empty()).then(|| name.to_string())
})
.collect()
})
.unwrap_or_default();
let abstract_text =
Some(strip_html(item["abstract"].as_str().unwrap_or(""))).filter(|s| !s.is_empty());
let doi = item["doi"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let venue = ["journalName", "publisher"]
.iter()
.find_map(|k| item["publishInfo"][*k].as_str().filter(|s| !s.is_empty()))
.map(|s| s.to_string());
let url = format!(
"https://xueshu.baidu.com/usercenter/paper/show?paperid={}&site=xueshu_se",
id
);
papers.push(Paper {
pdf_url: pick_pdf_url(&item["saveList"]),
url: Some(url),
year: item["publishYear"]
.as_u64()
.filter(|y| *y > 0)
.map(|y| y as u16),
citations: item["cited"].as_u64().map(|n| n as u32),
id,
title,
authors,
abstract_text,
doi,
venue,
fields: Vec::new(),
open_access: None,
source: "xueshu".to_string(),
});
}
Ok(papers)
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = include_str!("../../tests/fixtures/xueshu_search.json");
#[test]
fn parse_returns_ok() {
let result = parse_search_response(FIXTURE);
assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
}
#[test]
fn parse_returns_ten_papers() {
let papers = parse_search_response(FIXTURE).unwrap();
assert_eq!(papers.len(), 10);
}
#[test]
fn parse_paper_ids_not_empty() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert!(!p.id.is_empty());
}
assert_eq!(papers[0].id, "5a4afe9b6df8b07138fb6bf9b275251f");
}
#[test]
fn parse_source_is_xueshu() {
let papers = parse_search_response(FIXTURE).unwrap();
for p in &papers {
assert_eq!(p.source, "xueshu");
}
}
#[test]
fn parse_strips_em_tags_from_title() {
let papers = parse_search_response(FIXTURE).unwrap();
assert_eq!(papers[2].title, "Scale Attention Mechanism");
assert_eq!(papers[8].title, "ATTENTION MECHANISM");
for p in &papers {
assert!(!p.title.contains('<'), "title not stripped: {}", p.title);
}
}
#[test]
fn parse_authors_use_show_name() {
let papers = parse_search_response(FIXTURE).unwrap();
assert_eq!(papers[0].authors, vec!["A Bernardino", "J Santos-Victor"]);
}
#[test]
fn parse_chinese_authors_and_show_name_fallback() {
let body = r#"{"status":{"code":0,"msg":"Success"},"data":{"paper":{"paperList":[
{"paperId":"abc","title":"ChatModeler:基于<em>大语言模型</em>的方法",
"authors":[{"name":"张三","showName":"张三(教授)","affiliate":""},
{"name":"李四","showName":"","affiliate":""},
{"name":"","showName":"","affiliate":""}]}
]}}}"#;
let papers = parse_search_response(body).unwrap();
assert_eq!(papers[0].title, "ChatModeler:基于大语言模型的方法");
assert_eq!(papers[0].authors, vec!["张三(教授)", "李四"]);
}
#[test]
fn parse_empty_doi_becomes_none() {
let papers = parse_search_response(FIXTURE).unwrap();
assert_eq!(papers[0].doi, None);
assert_eq!(papers[1].doi.as_deref(), Some("10.1007/978-1-4615-0111-4"));
}
#[test]
fn parse_year_and_citations() {
let papers = parse_search_response(FIXTURE).unwrap();
assert_eq!(papers[0].year, Some(2002));
assert_eq!(papers[0].citations, Some(15));
}
#[test]
fn parse_empty_venue_becomes_none() {
let papers = parse_search_response(FIXTURE).unwrap();
assert_eq!(papers[0].venue, None);
}
#[test]
fn parse_venue_falls_back_to_publisher() {
let body = r#"{"status":{"code":0,"msg":"Success"},"data":{"paper":{"paperList":[
{"paperId":"a1","title":"T1","publishInfo":{"publisher":"P","journalName":"J","journalId":""}},
{"paperId":"a2","title":"T2","publishInfo":{"publisher":"P","journalName":"","journalId":""}}
]}}}"#;
let papers = parse_search_response(body).unwrap();
assert_eq!(papers[0].venue.as_deref(), Some("J"));
assert_eq!(papers[1].venue.as_deref(), Some("P"));
}
#[test]
fn parse_url_points_to_xueshu_paper_page() {
let papers = parse_search_response(FIXTURE).unwrap();
assert_eq!(
papers[0].url.as_deref(),
Some(
"https://xueshu.baidu.com/usercenter/paper/show?paperid=5a4afe9b6df8b07138fb6bf9b275251f&site=xueshu_se"
)
);
}
#[test]
fn parse_pdf_url_picks_first_pdf_in_save_list() {
let papers = parse_search_response(FIXTURE).unwrap();
let pdf = papers[0].pdf_url.as_deref().unwrap();
assert!(
pdf.contains("productFlyer-CN_978-0-306-47427-9.pdf"),
"got {pdf}"
);
}
#[test]
fn parse_pdf_url_none_when_save_list_has_no_pdf() {
let body = r#"{"status":{"code":0,"msg":"Success"},"data":{"paper":{"paperList":[
{"paperId":"a1","title":"T1","saveList":[{"url":"http://example.com/doc/detail?id=1","anchor":"x","type":0,"size":""}]},
{"paperId":"a2","title":"T2"}
]}}}"#;
let papers = parse_search_response(body).unwrap();
assert_eq!(papers[0].pdf_url, None);
assert_eq!(papers[1].pdf_url, None);
}
#[test]
fn parse_abstract_stripped_and_nonempty() {
let papers = parse_search_response(FIXTURE).unwrap();
let a = papers[0].abstract_text.as_deref().unwrap();
assert!(a.contains("Visual Attention"));
for p in &papers {
if let Some(ref a) = p.abstract_text {
assert!(!a.contains("<em>"), "abstract not stripped: {}", p.id);
}
}
}
#[test]
fn parse_minimal_item_with_missing_fields_ok() {
let body = r#"{"status":{"code":0,"msg":"Success"},"data":{"paper":{"paperList":[
{"paperId":"only-id","title":"Only Title","abstract":null,"publishYear":null,
"cited":null,"doi":null,"authors":null,"publishInfo":null,"saveList":null}
]}}}"#;
let papers = parse_search_response(body).unwrap();
assert_eq!(papers.len(), 1);
let p = &papers[0];
assert_eq!(p.abstract_text, None);
assert_eq!(p.year, None);
assert_eq!(p.citations, None);
assert_eq!(p.doi, None);
assert!(p.authors.is_empty());
assert_eq!(p.venue, None);
assert_eq!(p.pdf_url, None);
}
const CAPTCHA_BODY: &str = r#"{"status":{"code":7350001,"msg":"请输入验证码"}}"#;
#[test]
fn parse_captcha_code_returns_clear_err() {
let err = parse_search_response(CAPTCHA_BODY).unwrap_err();
assert!(err.contains("captcha"), "got: {err}");
assert!(err.contains("7350001"), "got: {err}");
}
#[test]
fn parse_other_error_code_returns_err_with_msg() {
let body = r#"{"status":{"code":42,"msg":"boom","sLogid":"log-1"}}"#;
let err = parse_search_response(body).unwrap_err();
assert!(err.contains("42"), "got: {err}");
assert!(err.contains("boom"), "got: {err}");
assert!(err.contains("log-1"), "got: {err}");
}
#[test]
fn parse_empty_paper_list_returns_empty_vec() {
let body = r#"{"status":{"code":0,"msg":"Success","sLogid":"x"},"data":{"paper":{"dispnum":0,"paperList":[]}}}"#;
assert!(parse_search_response(body).unwrap().is_empty());
}
#[test]
fn parse_code_zero_missing_data_returns_empty_vec() {
let body = r#"{"status":{"code":0,"msg":"Success"}}"#;
assert!(parse_search_response(body).unwrap().is_empty());
}
#[test]
fn parse_garbage_returns_parse_err() {
let err = parse_search_response("<html>not json</html>").unwrap_err();
assert!(err.contains("JSON parse error"), "got: {err}");
}
#[test]
fn strip_html_removes_tags_and_decodes_entities() {
assert_eq!(strip_html("Scale <em>Attention</em>"), "Scale Attention");
assert_eq!(strip_html("A & B <C>"), "A & B <C>");
assert_eq!(strip_html(" padded "), "padded");
assert_eq!(strip_html("no tags"), "no tags");
}
#[test]
fn search_returns_papers() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Any)
.with_status(200)
.with_body(FIXTURE)
.create();
let papers = search(&server.url(), "test", 5).unwrap();
assert_eq!(papers.len(), 5, "should truncate to max_results");
mock.assert();
}
#[test]
fn search_request_path_and_params() {
let mut server = mockito::Server::new();
let mock = server
.mock(
"GET",
mockito::Matcher::Regex(
r"/search/api/search\?wd=attention\+mechanism&pn=0&skipStrategy=0".to_string(),
),
)
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(&server.url(), "attention mechanism", 3);
mock.assert();
}
#[test]
fn search_sends_acs_token_fallback_header() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Any)
.match_header("acs-token", "parisInstance is not ready")
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(&server.url(), "test", 3);
mock.assert();
}
#[test]
fn search_sends_fastpaper_user_agent() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", mockito::Matcher::Any)
.match_header(
"user-agent",
mockito::Matcher::Regex(r"^fastpaper-cli/\d+\.\d+\.\d+ \(\+https://".to_string()),
)
.with_status(200)
.with_body(FIXTURE)
.create();
let _ = search(&server.url(), "test", 3);
mock.assert();
}
#[test]
fn search_stops_after_first_page_when_satisfied() {
let mut server = mockito::Server::new();
let m1 = server
.mock("GET", mockito::Matcher::Regex("pn=0".to_string()))
.with_status(200)
.with_body(FIXTURE)
.expect(1)
.create();
let m2 = server
.mock("GET", mockito::Matcher::Regex("pn=10".to_string()))
.expect(0)
.create();
let papers = search(&server.url(), "test", 10).unwrap();
assert_eq!(papers.len(), 10);
m1.assert();
m2.assert();
}
#[test]
fn search_paginates_with_pn_10_for_second_page() {
let mut server = mockito::Server::new();
let m1 = server
.mock("GET", mockito::Matcher::Regex("pn=0".to_string()))
.with_status(200)
.with_body(FIXTURE)
.expect(1)
.create();
let m2 = server
.mock("GET", mockito::Matcher::Regex("pn=10".to_string()))
.with_status(200)
.with_body(FIXTURE)
.expect(1)
.create();
let papers = search(&server.url(), "test", 15).unwrap();
assert_eq!(papers.len(), 15, "should truncate 20 collected to 15");
m1.assert();
m2.assert();
}
#[test]
fn search_stops_on_empty_page() {
let empty = r#"{"status":{"code":0,"msg":"Success"},"data":{"paper":{"dispnum":0,"paperList":[]}}}"#;
let mut server = mockito::Server::new();
let m1 = server
.mock("GET", mockito::Matcher::Regex("pn=0".to_string()))
.with_status(200)
.with_body(empty)
.expect(1)
.create();
let m2 = server
.mock("GET", mockito::Matcher::Regex("pn=10".to_string()))
.expect(0)
.create();
let papers = search(&server.url(), "test", 30).unwrap();
assert!(papers.is_empty());
m1.assert();
m2.assert();
}
#[test]
fn search_206_captcha_body_returns_captcha_err() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", mockito::Matcher::Any)
.with_status(206)
.with_body(CAPTCHA_BODY)
.create();
let err = search(&server.url(), "test", 3).unwrap_err();
assert!(err.contains("captcha"), "got: {err}");
}
#[test]
fn search_403_returns_blocked_err() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", mockito::Matcher::Any)
.with_status(403)
.create();
let err = search(&server.url(), "test", 3).unwrap_err();
assert!(err.contains("403"), "got: {err}");
}
#[test]
fn search_429_returns_rate_limit_err() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", mockito::Matcher::Any)
.with_status(429)
.create();
let err = search(&server.url(), "test", 3).unwrap_err();
assert!(err.contains("rate limit"), "got: {err}");
}
}