use std::sync::LazyLock;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use scraper::{Html, Selector};
use crate::engine::web_index::client::Client;
use crate::engine::web_index::util::positional_relevance;
use crate::engine::web_index::{Engine, EngineOptions, Error, Hit};
const SNIPPET_LIMIT: usize = 200;
static ENTRY: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("entry").expect("static selector is valid"));
static ENTRY_ID: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("entry > id").expect("static selector is valid"));
static ENTRY_TITLE: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("entry > title").expect("static selector is valid"));
static ENTRY_SUMMARY: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("entry > summary").expect("static selector is valid"));
static ENTRY_PUBLISHED: LazyLock<Selector> =
LazyLock::new(|| Selector::parse("entry > published").expect("static selector is valid"));
pub struct ArxivEngine {
client: Client,
}
impl ArxivEngine {
#[must_use]
pub fn new(client: Client) -> Self {
Self { client }
}
fn build_url(query: &str, opts: &EngineOptions) -> String {
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("search_query", &format!("all:{query}"));
serializer.append_pair("max_results", &opts.max_results.to_string());
serializer.append_pair("sortBy", "relevance");
format!("https://export.arxiv.org/api/query?{}", serializer.finish())
}
}
#[async_trait]
impl Engine for ArxivEngine {
fn name(&self) -> &'static str {
"arxiv"
}
fn should_query(&self, query: &str) -> bool {
let q = query.to_ascii_lowercase();
const ACADEMIC_HINTS: &[&str] = &[
"arxiv", "paper", "research", "study", "algorithm", "theorem", "analysis",
"benchmark", "consensus", "physics", "quantum", "neural", "deep learning",
"transformer", "model", "survey", "proof", "dataset", "optics", "math",
"astronomy", "exoplanet", "telescope", "biology", "genome",
];
ACADEMIC_HINTS.iter().any(|hint| q.contains(hint)) || q.split_whitespace().count() >= 5
}
async fn search(&self, query: &str, opts: &EngineOptions) -> Result<Vec<Hit>, Error> {
let url = Self::build_url(query, opts);
let body = self
.client
.fetch(
&url,
self.client.next_user_agent(),
opts,
"application/atom+xml",
&[],
)
.await?;
Ok(parse_results(&body, opts.max_results))
}
}
#[must_use]
pub fn parse_results(xml: &str, max_results: usize) -> Vec<Hit> {
let document = Html::parse_document(xml);
let entries: Vec<_> = document.select(&ENTRY).collect();
let total = entries.len().min(max_results);
let mut hits = Vec::with_capacity(total);
for (i, entry) in entries.into_iter().take(total).enumerate() {
let Some(url) = entry
.select(&ENTRY_ID)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
else {
continue;
};
let title = entry
.select(&ENTRY_TITLE)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
.unwrap_or_default();
if url.is_empty() || title.is_empty() {
continue;
}
let snippet = entry
.select(&ENTRY_SUMMARY)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
.unwrap_or_default();
let published_at = entry
.select(&ENTRY_PUBLISHED)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
.map(|dt| dt.with_timezone(&Utc));
hits.push(Hit {
url,
title,
snippet: truncate(&snippet, SNIPPET_LIMIT),
published_at,
relevance_score: positional_relevance(i, total),
engine: "arxiv",
});
}
hits
}
fn truncate(s: &str, limit: usize) -> String {
if s.len() <= limit {
s.to_string()
} else {
let mut end = limit;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &s[..end])
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>arXiv Query</title>
<entry>
<id>http://arxiv.org/abs/2401.00001v1</id>
<title>Async Rust in Production</title>
<summary>We study async runtimes in production systems.</summary>
<published>2024-01-01T00:00:00Z</published>
</entry>
<entry>
<id>http://arxiv.org/abs/2401.00002v1</id>
<title>Second Paper</title>
<summary>Another abstract.</summary>
<published>2024-02-01T00:00:00Z</published>
</entry>
</feed>"#;
#[test]
fn parses_atom_entries() {
let hits = parse_results(FIXTURE, 10);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].url, "http://arxiv.org/abs/2401.00001v1");
assert_eq!(hits[0].title, "Async Rust in Production");
assert_eq!(
hits[0].snippet,
"We study async runtimes in production systems."
);
assert_eq!(hits[0].engine, "arxiv");
assert!(hits[0].published_at.is_some());
}
#[test]
fn respects_max_results() {
assert_eq!(parse_results(FIXTURE, 1).len(), 1);
}
#[test]
fn empty_feed_yields_no_hits() {
assert!(parse_results("<feed></feed>", 10).is_empty());
}
#[test]
fn relevance_decays_with_rank() {
let hits = parse_results(FIXTURE, 10);
assert!(hits[0].relevance_score > hits[1].relevance_score);
}
}