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}