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