Skip to main content

paper_gap/
arxiv.rs

1use crate::{Result, err};
2use chrono::{Duration, NaiveDate, Utc};
3use quick_xml::Reader;
4use quick_xml::events::Event;
5
6pub use crate::paper::Paper;
7
8#[derive(Debug, Clone, Default)]
9pub struct PaperQuery {
10    pub query: Option<String>,
11    pub category: Option<String>,
12    pub since: Option<String>,
13    pub from: Option<NaiveDate>,
14    pub to: Option<NaiveDate>,
15}
16
17pub fn query_string(args: &PaperQuery) -> Result<String> {
18    let mut parts = Vec::new();
19    if let Some(query) = &args.query {
20        parts.push(format!("all:{}", query));
21    }
22    if let Some(category) = &args.category {
23        parts.push(format!("cat:{}", category));
24    }
25    if parts.is_empty() {
26        return Err(err("scan needs --query, --category, or both"));
27    }
28    let (from, to) = date_range(args)?;
29    if let (Some(from), Some(to)) = (from, to) {
30        parts.push(format!(
31            "submittedDate:[{}0000 TO {}2359]",
32            from.format("%Y%m%d"),
33            to.format("%Y%m%d")
34        ));
35    }
36    Ok(parts.join(" AND "))
37}
38
39pub fn date_range(args: &PaperQuery) -> Result<(Option<NaiveDate>, Option<NaiveDate>)> {
40    if let Some(since) = &args.since {
41        let days = since
42            .strip_suffix('d')
43            .ok_or_else(|| err("--since only supports Nd, e.g. 30d"))?
44            .parse::<i64>()?;
45        let today = Utc::now().date_naive();
46        return Ok((Some(today - Duration::days(days)), Some(today)));
47    }
48    Ok((args.from, args.to))
49}
50
51pub fn search_url(query: &str, limit: usize) -> String {
52    format!(
53        "https://export.arxiv.org/api/query?search_query={}&start=0&max_results={}&sortBy=submittedDate&sortOrder=descending",
54        urlencoding::encode(query),
55        limit
56    )
57}
58
59pub async fn search(query: &str, limit: usize) -> Result<Vec<Paper>> {
60    let client = reqwest::Client::builder()
61        .user_agent("paper-gap/0.1 (local on-demand CLI; contact: unknown)")
62        .timeout(std::time::Duration::from_secs(20))
63        .build()?;
64    let response = client.get(search_url(query, limit)).send().await?;
65    if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
66        return Err(err(
67            "arXiv rate limit reached; wait a few minutes and retry with a smaller --limit",
68        ));
69    }
70    let body = response.error_for_status()?.text().await?;
71    parse(&body)
72}
73
74pub fn parse(xml: &str) -> Result<Vec<Paper>> {
75    let mut reader = Reader::from_str(xml);
76    reader.config_mut().trim_text(true);
77    let mut papers = Vec::new();
78    let mut current: Option<Paper> = None;
79    let mut field = String::new();
80    let mut in_entry = false;
81    let mut author_name = false;
82    loop {
83        match reader.read_event()? {
84            Event::Start(e) => {
85                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
86                if name == "entry" {
87                    in_entry = true;
88                    current = Some(Paper {
89                        id: String::new(),
90                        title: String::new(),
91                        abstract_text: String::new(),
92                        authors: Vec::new(),
93                        published_date: None,
94                        updated_date: None,
95                        doi: None,
96                        arxiv_id: None,
97                        url: String::new(),
98                        categories: Vec::new(),
99                        extracted_methods: Vec::new(),
100                        source_provider: "arxiv".into(),
101                    });
102                } else if in_entry && name == "category" {
103                    if let Some(term) = e.attributes().flatten().find(|a| a.key.as_ref() == b"term")
104                    {
105                        if let Some(p) = &mut current {
106                            p.categories
107                                .push(String::from_utf8_lossy(&term.value).to_string());
108                        }
109                    }
110                } else {
111                    author_name = in_entry && name == "name";
112                    field = name;
113                }
114            }
115            Event::Empty(e) => {
116                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
117                if in_entry && name == "category" {
118                    if let Some(term) = e.attributes().flatten().find(|a| a.key.as_ref() == b"term")
119                    {
120                        if let Some(p) = &mut current {
121                            p.categories
122                                .push(String::from_utf8_lossy(&term.value).to_string());
123                        }
124                    }
125                }
126            }
127            Event::Text(e) => {
128                if let Some(p) = &mut current {
129                    let text = e.unescape()?.to_string();
130                    match field.as_str() {
131                        "id" => {
132                            p.id = text.clone();
133                            p.url = text.clone();
134                            p.arxiv_id = text.rsplit('/').next().map(|s| s.to_string());
135                        }
136                        "title" => p.title.push_str(clean(&text).as_str()),
137                        "summary" => p.abstract_text.push_str(clean(&text).as_str()),
138                        "published" => p.published_date = Some(text),
139                        "updated" => p.updated_date = Some(text),
140                        "doi" | "arxiv:doi" => p.doi = Some(text),
141                        "name" if author_name => p.authors.push(text),
142                        _ => {}
143                    }
144                }
145            }
146            Event::End(e) => {
147                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
148                if name == "entry" {
149                    if let Some(mut p) = current.take() {
150                        p.extracted_methods =
151                            extract_acronyms(&format!("{} {}", p.title, p.abstract_text));
152                        papers.push(p);
153                    }
154                    in_entry = false;
155                }
156                field.clear();
157                author_name = false;
158            }
159            Event::Eof => break,
160            _ => {}
161        }
162    }
163    Ok(papers)
164}
165
166pub fn clean(s: &str) -> String {
167    s.split_whitespace().collect::<Vec<_>>().join(" ")
168}
169
170pub fn extract_acronyms(text: &str) -> Vec<String> {
171    let mut out = Vec::new();
172    for word in text.split(|c: char| !c.is_alphanumeric() && c != '-') {
173        if word.len() > 1
174            && word.len() <= 12
175            && word.chars().any(|c| c.is_ascii_uppercase())
176            && word
177                .chars()
178                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
179            && !out.iter().any(|w| w == word)
180        {
181            out.push(word.to_string());
182        }
183    }
184    out
185}