Skip to main content

aspero_europepmc/
lib.rs

1//! Europe PMC provider: biomedical and life-science literature.
2//!
3//! Covers PubMed, PubMed Central and preprints; free and keyless. Adds
4//! what the other providers lack: PMCID lookup, broad abstract coverage
5//! for biomedical work, and "Retracted Publication" flags.
6//!
7//! - Lookups batch PMIDs, PMCIDs and DOIs into one OR query
8//!   (`(EXT_ID:… AND SRC:MED) OR PMCID:… OR DOI:"…"`); results come back
9//!   unordered and are matched by identifier.
10//! - Search is relevance-ordered and pages by `cursorMark` *without*
11//!   losing that order (checked 2026-11-10, unlike Crossref and DataCite).
12
13use std::sync::Arc;
14use std::time::Duration;
15
16use aspero_core::csl::{null_default, plain_text, strip_markup};
17use aspero_core::http::{escape_lucene, status_error};
18use aspero_core::{
19    Capabilities, Cursor, Doi, Error, FieldSet, Filter, Hit, HttpClient, HttpRequest, HttpResponse,
20    IdKind, Lookup, Nbk, OpenAccess, Page, PartialDate, Person, Pmcid, Pmid, Provider, ProviderId,
21    Raw, ResiliencePolicy, Response, Result, SearchMode, SearchQuery, SourceRecord, Update,
22    UpdateKind, Work, WorkId, WorkType, tidy_abstract,
23};
24use async_trait::async_trait;
25use jiff::Timestamp;
26use serde::Deserialize;
27
28pub const ID: ProviderId = ProviderId::from_static("europepmc");
29
30pub const DEFAULT_BASE_URL: &str = "https://www.ebi.ac.uk/europepmc/webservices/rest";
31
32/// Identifiers per batched lookup.
33const MAX_BATCH: usize = 50;
34
35/// Europe PMC's page-size cap.
36const MAX_PAGE: u32 = 1000;
37
38#[derive(Debug, Clone)]
39#[non_exhaustive]
40pub struct EuropePmcConfig {
41    pub base_url: String,
42}
43
44impl Default for EuropePmcConfig {
45    fn default() -> Self {
46        EuropePmcConfig {
47            base_url: DEFAULT_BASE_URL.to_owned(),
48        }
49    }
50}
51
52pub struct EuropePmc {
53    config: EuropePmcConfig,
54    http: Arc<dyn HttpClient>,
55    capabilities: Capabilities,
56}
57
58impl EuropePmc {
59    pub fn new(config: EuropePmcConfig, http: Arc<dyn HttpClient>) -> Self {
60        let mut capabilities = Capabilities::default();
61        capabilities.lookup_kinds = vec![IdKind::Pmid, IdKind::Pmcid, IdKind::Doi, IdKind::Nbk];
62        capabilities.max_batch = MAX_BATCH;
63        capabilities.fields = FieldSet::ALL;
64        capabilities.search = true;
65        capabilities.search_filters = vec![Filter::Years, Filter::OpenAccess];
66        EuropePmc {
67            config,
68            http,
69            capabilities,
70        }
71    }
72
73    pub fn default_policy() -> ResiliencePolicy {
74        let mut policy = ResiliencePolicy::default();
75        policy.min_interval = Duration::from_millis(200);
76        policy
77    }
78
79    async fn search_raw(&self, params: &[(&str, String)]) -> Result<(WireResults, Timestamp)> {
80        let base = format!("{}/search", self.config.base_url.trim_end_matches('/'));
81        let url = url::Url::parse_with_params(&base, params)
82            .map_err(|e| Error::BadRequest(format!("invalid Europe PMC base URL: {e}")))?;
83        let resp = self
84            .http
85            .send(HttpRequest::get(String::from(url)).header("accept", "application/json"))
86            .await?;
87        check(&resp)?;
88        let results = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
89        Ok((results, resp.received_at))
90    }
91}
92
93fn check(resp: &HttpResponse) -> Result<()> {
94    if resp.is_success() {
95        return Ok(());
96    }
97    Err(match resp.status {
98        400 => Error::BadRequest("rejected by Europe PMC".into()),
99        _ => status_error(resp, None),
100    })
101}
102
103#[derive(Debug, Default, Deserialize)]
104#[serde(default, rename_all = "camelCase")]
105struct WireResults {
106    hit_count: Option<u64>,
107    next_cursor_mark: Option<String>,
108    #[serde(deserialize_with = "null_default")]
109    result_list: ResultList,
110}
111
112#[derive(Debug, Default, Deserialize)]
113#[serde(default)]
114struct ResultList {
115    #[serde(deserialize_with = "null_default")]
116    result: Vec<serde_json::Value>,
117}
118
119#[derive(Debug, Default, Deserialize)]
120#[serde(default, rename_all = "camelCase")]
121struct WireRecord {
122    id: Option<String>,
123    source: Option<String>,
124    pmid: Option<String>,
125    pmcid: Option<String>,
126    doi: Option<String>,
127    title: Option<String>,
128    #[serde(deserialize_with = "null_default")]
129    author_list: AuthorList,
130    pub_year: Option<String>,
131    first_publication_date: Option<String>,
132    #[serde(deserialize_with = "null_default")]
133    journal_info: JournalInfo,
134    page_info: Option<String>,
135    abstract_text: Option<String>,
136    #[serde(deserialize_with = "null_default")]
137    pub_type_list: PubTypes,
138    cited_by_count: Option<u64>,
139    is_open_access: Option<String>,
140    license: Option<String>,
141    #[serde(deserialize_with = "null_default")]
142    full_text_url_list: FullTextUrls,
143    /// NCBI Bookshelf ID, for books and chapters (StatPearls).
144    bookid: Option<String>,
145    #[serde(deserialize_with = "null_default")]
146    book_or_report_details: BookDetails,
147}
148
149#[derive(Debug, Default, Deserialize)]
150#[serde(default, rename_all = "camelCase")]
151struct BookDetails {
152    publisher: Option<String>,
153    /// The book a chapter is part of, e.g. "StatPearls".
154    comprising_title: Option<String>,
155}
156
157#[derive(Debug, Default, Deserialize)]
158#[serde(default)]
159struct FullTextUrls {
160    #[serde(rename = "fullTextUrl")]
161    #[serde(deserialize_with = "null_default")]
162    full_text_url: Vec<FullTextUrl>,
163}
164
165/// A full-text link, e.g. `{"availabilityCode": "OA", "documentStyle":
166/// "pdf", "site": "Europe_PMC", "url": "…"}`. Codes: OA (open access), F
167/// (free to read), S (subscription).
168#[derive(Debug, Default, Deserialize)]
169#[serde(default, rename_all = "camelCase")]
170struct FullTextUrl {
171    availability_code: Option<String>,
172    document_style: Option<String>,
173    site: Option<String>,
174    url: Option<String>,
175}
176
177impl WireRecord {
178    fn open_access(&self) -> Option<OpenAccess> {
179        let links = &self.full_text_url_list.full_text_url;
180        if self.is_open_access.is_none() && links.is_empty() {
181            return None;
182        }
183        let free = |style: &str| {
184            let mut free: Vec<&FullTextUrl> = links
185                .iter()
186                .filter(|l| matches!(l.availability_code.as_deref(), Some("OA" | "F")))
187                .filter(|l| l.document_style.as_deref() == Some(style))
188                .collect();
189            // Europe PMC's own copies first: stable, and the PDF renders.
190            free.sort_by_key(|l| l.site.as_deref() != Some("Europe_PMC"));
191            free.first().and_then(|l| l.url.clone())
192        };
193        let mut oa = OpenAccess::default();
194        oa.pdf_url = free("pdf");
195        oa.url = free("html");
196        oa.is_oa =
197            self.is_open_access.as_deref() == Some("Y") || oa.pdf_url.is_some() || oa.url.is_some();
198        oa.license = self
199            .license
200            .as_deref()
201            .map(|l| l.trim().to_lowercase().replace(' ', "-"));
202        oa.source = Some("europepmc".into());
203        Some(oa)
204    }
205}
206
207#[derive(Debug, Default, Deserialize)]
208#[serde(default)]
209struct AuthorList {
210    #[serde(deserialize_with = "null_default")]
211    author: Vec<Author>,
212}
213
214#[derive(Debug, Default, Deserialize)]
215#[serde(default, rename_all = "camelCase")]
216struct Author {
217    full_name: Option<String>,
218    first_name: Option<String>,
219    last_name: Option<String>,
220    collective_name: Option<String>,
221    author_id: Option<AuthorId>,
222}
223
224#[derive(Debug, Default, Deserialize)]
225#[serde(default, rename_all = "camelCase")]
226struct AuthorId {
227    #[serde(rename = "type")]
228    id_type: Option<String>,
229    value: Option<String>,
230}
231
232#[derive(Debug, Default, Deserialize)]
233#[serde(default)]
234#[serde(rename_all = "camelCase")]
235struct JournalInfo {
236    year_of_publication: Option<i16>,
237    volume: Option<String>,
238    issue: Option<String>,
239    #[serde(deserialize_with = "null_default")]
240    journal: Journal,
241}
242
243#[derive(Debug, Default, Deserialize)]
244#[serde(default)]
245struct Journal {
246    title: Option<String>,
247}
248
249#[derive(Debug, Default, Deserialize)]
250#[serde(default, rename_all = "camelCase")]
251struct PubTypes {
252    #[serde(deserialize_with = "null_default")]
253    pub_type: Vec<String>,
254}
255
256impl Author {
257    fn person(self) -> Option<Person> {
258        let mut person = Person::default();
259        match (self.last_name, self.collective_name, self.full_name) {
260            (Some(last), _, _) => {
261                person.family = Some(last);
262                person.given = self.first_name;
263            }
264            (None, Some(name), _) | (None, None, Some(name)) => person.literal = Some(name),
265            (None, None, None) => return None,
266        }
267        person.orcid = self
268            .author_id
269            .filter(|id| id.id_type.as_deref() == Some("ORCID"))
270            .and_then(|id| id.value);
271        Some(person)
272    }
273}
274
275/// The record, or `None` with a warning when it doesn't decode: one bad
276/// record shouldn't cost the rest of the page.
277fn or_skip(record: serde_json::Result<SourceRecord>) -> Option<SourceRecord> {
278    record
279        .inspect_err(|e| {
280            tracing::warn!(
281                target: "aspero::decode",
282                provider = %ID, error = %e, "skipped a record that doesn't decode"
283            );
284        })
285        .ok()
286}
287
288fn to_record(value: &serde_json::Value, at: Timestamp) -> serde_json::Result<SourceRecord> {
289    let r = WireRecord::deserialize(value)?;
290    let open_access = r.open_access();
291    let mut ids: Vec<WorkId> = Vec::new();
292    ids.extend(
293        r.pmid
294            .as_deref()
295            .and_then(|p| p.parse::<Pmid>().ok())
296            .map(WorkId::Pmid),
297    );
298    ids.extend(
299        r.pmcid
300            .as_deref()
301            .and_then(|p| p.parse::<Pmcid>().ok())
302            .map(WorkId::Pmcid),
303    );
304    ids.extend(
305        r.doi
306            .as_deref()
307            .and_then(|d| d.parse::<Doi>().ok())
308            .map(WorkId::Doi),
309    );
310    let nbk = r.bookid.as_deref().and_then(|b| b.parse::<Nbk>().ok());
311    ids.extend(nbk.map(WorkId::Nbk));
312    let book = &r.book_or_report_details;
313
314    let types = &r.pub_type_list.pub_type;
315    let has = |t: &str| types.iter().any(|p| p.eq_ignore_ascii_case(t));
316    let mut work = Work::default();
317    work.title = r
318        .title
319        .map(|t| plain_text(t).trim_end_matches('.').to_owned());
320    work.authors = r
321        .author_list
322        .author
323        .into_iter()
324        .filter_map(Author::person)
325        .collect();
326    work.issued = r
327        .first_publication_date
328        .as_deref()
329        .and_then(|d| d.parse::<PartialDate>().ok())
330        .or_else(|| {
331            r.pub_year
332                .as_deref()?
333                .parse()
334                .ok()
335                .map(PartialDate::from_year)
336        });
337    work.work_type = if nbk.is_some() {
338        // Bookshelf: a chapter of a book (a StatPearls article), or a book.
339        Some(match book.comprising_title {
340            Some(_) => WorkType::Chapter,
341            None => WorkType::Book,
342        })
343    } else if r.source.as_deref() == Some("PPR") || has("Preprint") {
344        Some(WorkType::Preprint)
345    } else if has("Journal Article") || has("research-article") || has("Review") {
346        Some(WorkType::JournalArticle)
347    } else {
348        None
349    };
350    // First publication (often online) vs the journal issue's year.
351    let issued_year = work.issued.map(|d| d.year());
352    let mut other: Vec<i16> = r
353        .journal_info
354        .year_of_publication
355        .into_iter()
356        .chain(r.pub_year.as_deref().and_then(|y| y.parse().ok()))
357        .filter(|y| Some(*y) != issued_year)
358        .collect();
359    other.sort_unstable();
360    other.dedup();
361    work.other_years = other;
362    work.container_title = r
363        .journal_info
364        .journal
365        .title
366        .or_else(|| book.comprising_title.clone())
367        .map(plain_text);
368    work.publisher = book.publisher.clone();
369    work.volume = r.journal_info.volume;
370    work.issue = r.journal_info.issue;
371    work.page = r.page_info;
372    work.abstract_text = r
373        .abstract_text
374        .as_deref()
375        .map(|a| tidy_abstract(&strip_markup(a)))
376        .filter(|a| !a.is_empty());
377    work.url = match (r.source.as_deref(), r.id.as_deref()) {
378        _ if nbk.is_some() => nbk.map(|n| format!("https://www.ncbi.nlm.nih.gov/books/{n}/")),
379        (Some(source), Some(id)) => Some(format!("https://europepmc.org/article/{source}/{id}")),
380        _ => None,
381    };
382    work.cited_by_count = r.cited_by_count;
383    work.open_access = open_access;
384    if has("Retracted Publication") {
385        let mut retraction = Update::new(UpdateKind::Retraction);
386        retraction.source = Some("europepmc".into());
387        work.updates.push(retraction);
388    }
389    Ok(SourceRecord::new(
390        ID,
391        ids,
392        work,
393        FieldSet::ALL,
394        Raw::json(value),
395        at,
396    ))
397}
398
399/// The query clause that finds `id`.
400fn clause(id: &WorkId) -> Result<String> {
401    Ok(match id {
402        WorkId::Pmid(p) => format!("(EXT_ID:{p} AND SRC:MED)"),
403        WorkId::Pmcid(p) => format!("PMCID:{p}"),
404        WorkId::Nbk(n) => format!("BOOK_ID:{n}"),
405        WorkId::Doi(d) => format!("DOI:\"{}\"", d.as_str().replace('"', "\\\"")),
406        _ => {
407            return Err(Error::Unsupported(
408                "Europe PMC lookup of this identifier kind",
409            ));
410        }
411    })
412}
413
414#[async_trait]
415impl Provider for EuropePmc {
416    fn id(&self) -> ProviderId {
417        ID
418    }
419
420    fn capabilities(&self) -> &Capabilities {
421        &self.capabilities
422    }
423
424    async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
425        let clauses = ids.iter().map(clause).collect::<Result<Vec<_>>>()?;
426        let mut found = Vec::new();
427        for chunk in clauses.chunks(MAX_BATCH) {
428            // Room for clauses matching more than one record, so a requested
429            // one isn't pushed to a second page and taken as not found.
430            let page_size = (2 * chunk.len()).min(MAX_PAGE as usize);
431            let params = [
432                ("query", chunk.join(" OR ")),
433                ("resultType", "core".to_owned()),
434                ("format", "json".to_owned()),
435                ("pageSize", page_size.to_string()),
436            ];
437            let (results, at) = self.search_raw(&params).await?;
438            let records = results.result_list.result.iter();
439            found.extend(records.filter_map(|v| or_skip(to_record(v, at))));
440        }
441        let lookups = ids
442            .iter()
443            .map(|id| Lookup {
444                id: id.clone(),
445                record: found.iter().find(|r| r.ids.contains(id)).cloned(),
446            })
447            .collect();
448        Ok(Response::new(lookups))
449    }
450
451    async fn search(&self, query: &SearchQuery) -> Result<Response<Page>> {
452        if query.mode != SearchMode::Keywords {
453            return Err(Error::Unsupported("Europe PMC has no citation matcher"));
454        }
455        let mut clauses = Vec::new();
456        if !query.text.trim().is_empty() {
457            clauses.push(format!("({})", escape_lucene(query.text.trim())));
458        }
459        for (key, value) in &query.extra {
460            match key.as_str() {
461                // Raw Europe PMC query syntax, ANDed with the rest.
462                "query" => clauses.push(format!("({value})")),
463                other => {
464                    return Err(Error::BadRequest(format!(
465                        "unknown Europe PMC parameter {other:?} (supported: query)"
466                    )));
467                }
468            }
469        }
470        if let Some(years) = query.years {
471            let end = |y: Option<i16>| y.map_or("*".to_owned(), |y| y.to_string());
472            clauses.push(format!(
473                "PUB_YEAR:[{} TO {}]",
474                end(years.from),
475                end(years.to)
476            ));
477        }
478        if let Some(oa) = query.open_access {
479            clauses.push(format!("OPEN_ACCESS:{}", if oa { "y" } else { "n" }));
480        }
481        let cursor = query.page.cursor.as_ref().map_or("*", |c| c.0.as_str());
482        let params = [
483            ("query", clauses.join(" AND ")),
484            ("resultType", "core".to_owned()),
485            ("format", "json".to_owned()),
486            ("pageSize", query.page.limit.clamp(1, MAX_PAGE).to_string()),
487            ("cursorMark", cursor.to_owned()),
488        ];
489        let (results, at) = self.search_raw(&params).await?;
490        let hits: Vec<Hit> = results
491            .result_list
492            .result
493            .iter()
494            .filter_map(|v| or_skip(to_record(v, at)))
495            .map(|r| Hit::new(r, None))
496            .collect();
497        // The last page repeats its cursor; an empty page is also the end.
498        let next = results
499            .next_cursor_mark
500            .filter(|next| !hits.is_empty() && next != cursor)
501            .map(Cursor);
502        Ok(Response::new(Page::new(hits, results.hit_count, next)))
503    }
504}