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