use crate::{Result, arxiv, paper::Paper, provider};
use chrono::NaiveDate;
use serde::Deserialize;
use std::{collections::HashMap, time::Duration};
const BASE_URL: &str = "https://api.openalex.org/works";
#[derive(Debug, Clone)]
pub struct SearchQuery {
pub query: Option<String>,
pub from: Option<NaiveDate>,
pub to: Option<NaiveDate>,
pub limit: usize,
}
pub fn search_url(query: &SearchQuery, email: Option<&str>) -> String {
let mut url = format!(
"{BASE_URL}?per-page={}&sort=publication_date:desc",
query.limit
);
if let Some(search) = query
.query
.as_deref()
.filter(|query| !query.trim().is_empty())
{
url.push_str("&search=");
url.push_str(&urlencoding::encode(search));
}
let mut filters = Vec::new();
if let Some(from) = query.from {
filters.push(format!("from_publication_date:{from}"));
}
if let Some(to) = query.to {
filters.push(format!("to_publication_date:{to}"));
}
if !filters.is_empty() {
url.push_str("&filter=");
url.push_str(&urlencoding::encode(&filters.join(",")));
}
if let Some(email) = email.filter(|email| !email.trim().is_empty()) {
url.push_str("&mailto=");
url.push_str(&urlencoding::encode(email));
}
url
}
pub async fn search(query: &SearchQuery) -> Result<Vec<Paper>> {
let client = reqwest::Client::builder()
.user_agent("paper-gap/0.2")
.timeout(Duration::from_secs(20))
.build()?;
let email = std::env::var("OPENALEX_EMAIL").ok();
let body = client.get(search_url(query, email.as_deref()));
let body = provider::send_with_retry(body)
.await?
.error_for_status()?
.text()
.await?;
parse(&body)
}
pub fn parse(json: &str) -> Result<Vec<Paper>> {
let response: Response = serde_json::from_str(json)?;
Ok(response.results.into_iter().map(normalize).collect())
}
#[derive(Deserialize)]
struct Response {
results: Vec<Work>,
}
#[derive(Deserialize)]
struct Work {
id: String,
title: String,
doi: Option<String>,
publication_date: Option<String>,
updated_date: Option<String>,
abstract_inverted_index: Option<HashMap<String, Vec<usize>>>,
authorships: Vec<Authorship>,
ids: HashMap<String, String>,
primary_location: Option<Location>,
#[serde(default)]
topics: Vec<Topic>,
}
#[derive(Deserialize)]
struct Authorship {
author: Author,
}
#[derive(Deserialize)]
struct Author {
display_name: String,
}
#[derive(Deserialize)]
struct Location {
landing_page_url: Option<String>,
}
#[derive(Deserialize)]
struct Topic {
display_name: String,
}
fn normalize(work: Work) -> Paper {
let abstract_text = reconstruct_abstract(work.abstract_inverted_index.as_ref());
let arxiv_id = work
.ids
.get("arxiv")
.and_then(|url| url.rsplit('/').next())
.map(str::to_string);
let doi = work
.doi
.as_deref()
.map(|doi| doi.trim_start_matches("https://doi.org/").to_string());
let url = work
.primary_location
.and_then(|location| location.landing_page_url)
.unwrap_or_else(|| work.id.clone());
let extracted_methods = arxiv::extract_acronyms(&format!("{} {}", work.title, abstract_text));
Paper {
id: work.id,
title: work.title,
abstract_text,
authors: work
.authorships
.into_iter()
.map(|entry| entry.author.display_name)
.collect(),
published_date: work.publication_date,
updated_date: work.updated_date,
doi,
arxiv_id,
url,
categories: work
.topics
.into_iter()
.map(|topic| topic.display_name)
.collect(),
extracted_methods,
source_provider: "openalex".into(),
}
}
fn reconstruct_abstract(index: Option<&HashMap<String, Vec<usize>>>) -> String {
let Some(index) = index else {
return String::new();
};
let Some(max) = index.values().flatten().max().copied() else {
return String::new();
};
let mut words = vec![String::new(); max + 1];
for (word, positions) in index {
for &position in positions {
words[position] = word.clone();
}
}
words
.into_iter()
.filter(|word| !word.is_empty())
.collect::<Vec<_>>()
.join(" ")
}