Skip to main content

aspero_openlibrary/
lib.rs

1//! Open Library (Internet Archive) provider: books.
2//!
3//! Books mostly lack DOIs, so this fills the gap the article-centric
4//! providers leave. Free and keyless; Open Library asks clients to identify
5//! themselves in the User-Agent (the engine's carries the contact address).
6//!
7//! - Lookup by ISBN in batches: `/api/books?bibkeys=ISBN:a,ISBN:b&jscmd=data`
8//!   (one edition per ISBN; ISBNs it doesn't know are simply absent).
9//! - Search: `/search.json` (Solr, relevance-ordered), returning works with
10//!   every edition's ISBNs; year ranges via `first_publish_year:[A TO B]`.
11//!
12//! Data quality varies: some ISBNs carry unrelated records, so scoring (not
13//! the identifier alone) decides matches.
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use aspero_core::csl::null_default;
19use aspero_core::http::{escape_lucene, status_error};
20use aspero_core::{
21    Capabilities, Cursor, Error, FieldSet, Filter, Hit, HttpClient, HttpRequest, HttpResponse,
22    IdKind, Isbn, Lookup, Page, PartialDate, Person, Provider, ProviderId, Raw, ResiliencePolicy,
23    Response, Result, SearchMode, SearchQuery, SourceRecord, Work, WorkId, WorkType,
24};
25use async_trait::async_trait;
26use jiff::Timestamp;
27use serde::Deserialize;
28
29pub const ID: ProviderId = ProviderId::from_static("openlibrary");
30
31pub const DEFAULT_BASE_URL: &str = "https://openlibrary.org";
32
33/// ISBNs per batched lookup.
34const MAX_BATCH: usize = 50;
35
36/// Results per search page (Open Library allows more; works carry long ISBN
37/// lists, so pages stay modest).
38const MAX_PAGE: u32 = 100;
39
40/// ISBNs kept per searched work: enough to match editions, without
41/// hundreds of translations.
42const MAX_WORK_ISBNS: usize = 20;
43
44const SEARCH_FIELDS: &str = "key,title,subtitle,author_name,first_publish_year,publish_year,isbn,publisher,number_of_pages_median";
45
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub struct OpenLibraryConfig {
49    pub base_url: String,
50}
51
52impl Default for OpenLibraryConfig {
53    fn default() -> Self {
54        OpenLibraryConfig {
55            base_url: DEFAULT_BASE_URL.to_owned(),
56        }
57    }
58}
59
60pub struct OpenLibrary {
61    config: OpenLibraryConfig,
62    http: Arc<dyn HttpClient>,
63    capabilities: Capabilities,
64}
65
66impl OpenLibrary {
67    pub fn new(config: OpenLibraryConfig, http: Arc<dyn HttpClient>) -> Self {
68        let mut capabilities = Capabilities::default();
69        capabilities.lookup_kinds = vec![IdKind::Isbn];
70        capabilities.max_batch = MAX_BATCH;
71        capabilities.fields = FieldSet::of(&[
72            aspero_core::Field::Title,
73            aspero_core::Field::Authors,
74            aspero_core::Field::Issued,
75            aspero_core::Field::Type,
76            aspero_core::Field::Publication,
77            aspero_core::Field::Url,
78        ]);
79        capabilities.search = true;
80        capabilities.search_filters = vec![Filter::Years];
81        OpenLibrary {
82            config,
83            http,
84            capabilities,
85        }
86    }
87
88    /// A volunteer-run service: keep it gentle.
89    pub fn default_policy() -> ResiliencePolicy {
90        let mut policy = ResiliencePolicy::default();
91        policy.min_interval = Duration::from_millis(600);
92        policy
93    }
94
95    async fn get(&self, path: &str, params: &[(&str, String)]) -> Result<HttpResponse> {
96        let base = format!("{}{path}", self.config.base_url.trim_end_matches('/'));
97        let url = url::Url::parse_with_params(&base, params)
98            .map_err(|e| Error::BadRequest(format!("invalid Open Library base URL: {e}")))?;
99        let resp = self
100            .http
101            .send(HttpRequest::get(String::from(url)).header("accept", "application/json"))
102            .await?;
103        check(&resp)?;
104        Ok(resp)
105    }
106}
107
108fn check(resp: &HttpResponse) -> Result<()> {
109    if resp.is_success() {
110        return Ok(());
111    }
112    Err(match resp.status {
113        400 | 422 => Error::BadRequest("rejected by Open Library".into()),
114        _ => status_error(resp, None),
115    })
116}
117
118/// `/api/books?jscmd=data` record for one edition.
119#[derive(Debug, Default, Deserialize)]
120#[serde(default)]
121struct Edition {
122    title: Option<String>,
123    subtitle: Option<String>,
124    #[serde(deserialize_with = "null_default")]
125    authors: Vec<Named>,
126    publish_date: Option<String>,
127    #[serde(deserialize_with = "null_default")]
128    publishers: Vec<Named>,
129    #[serde(deserialize_with = "null_default")]
130    identifiers: Identifiers,
131    url: Option<String>,
132}
133
134#[derive(Debug, Default, Deserialize)]
135#[serde(default)]
136struct Named {
137    name: Option<String>,
138}
139
140#[derive(Debug, Default, Deserialize)]
141#[serde(default)]
142struct Identifiers {
143    #[serde(deserialize_with = "null_default")]
144    isbn_13: Vec<String>,
145    #[serde(deserialize_with = "null_default")]
146    isbn_10: Vec<String>,
147}
148
149/// `/search.json` document for one work.
150#[derive(Debug, Default, Deserialize)]
151#[serde(default)]
152struct SearchDoc {
153    key: Option<String>,
154    title: Option<String>,
155    subtitle: Option<String>,
156    #[serde(deserialize_with = "null_default")]
157    author_name: Vec<String>,
158    first_publish_year: Option<i16>,
159    /// Every edition's year.
160    #[serde(deserialize_with = "null_default")]
161    publish_year: Vec<i16>,
162    #[serde(deserialize_with = "null_default")]
163    isbn: Vec<String>,
164    #[serde(deserialize_with = "null_default")]
165    publisher: Vec<String>,
166}
167
168#[derive(Debug, Default, Deserialize)]
169#[serde(default, rename_all = "camelCase")]
170struct SearchResults {
171    num_found: Option<u64>,
172    #[serde(deserialize_with = "null_default")]
173    docs: Vec<serde_json::Value>,
174}
175
176/// Open Library keeps subtitles apart ("Reinforcement Learning" + "An
177/// Introduction"); citations give the full title.
178fn full_title(title: Option<String>, subtitle: Option<String>) -> Option<String> {
179    match (title, subtitle.filter(|s| !s.trim().is_empty())) {
180        (Some(t), Some(s)) => Some(format!("{t}: {s}")),
181        (t, _) => t,
182    }
183}
184
185fn isbns<'a>(raw: impl IntoIterator<Item = &'a String>) -> Vec<WorkId> {
186    let mut ids = Vec::new();
187    for isbn in raw.into_iter().filter_map(|s| s.parse::<Isbn>().ok()) {
188        let id = WorkId::Isbn(isbn);
189        if !ids.contains(&id) {
190            ids.push(id);
191        }
192    }
193    ids
194}
195
196fn book(
197    title: Option<String>,
198    authors: Vec<String>,
199    year: Option<i16>,
200    publisher: Option<String>,
201    url: Option<String>,
202) -> Work {
203    let mut work = Work::default();
204    work.title = title;
205    work.authors = authors.into_iter().map(Person::literal).collect();
206    work.issued = year.map(PartialDate::from_year);
207    work.publisher = publisher;
208    work.work_type = Some(WorkType::Book);
209    work.url = url;
210    work
211}
212
213/// The first four-digit year in a free-form date ("3 January 2017").
214fn year_in(date: &str) -> Option<i16> {
215    date.as_bytes()
216        .windows(4)
217        .find(|w| w.iter().all(u8::is_ascii_digit))
218        .and_then(|w| std::str::from_utf8(w).ok()?.parse().ok())
219}
220
221/// The record, or `None` with a warning when it doesn't decode: one bad
222/// record shouldn't cost the rest of the page.
223fn or_skip(record: serde_json::Result<SourceRecord>) -> Option<SourceRecord> {
224    record
225        .inspect_err(|e| {
226            tracing::warn!(
227                target: "aspero::decode",
228                provider = %ID, error = %e, "skipped a record that doesn't decode"
229            );
230        })
231        .ok()
232}
233
234fn edition_record(
235    requested: &Isbn,
236    value: &serde_json::Value,
237    at: Timestamp,
238) -> serde_json::Result<SourceRecord> {
239    let e = Edition::deserialize(value)?;
240    let mut ids = vec![WorkId::Isbn(requested.clone())];
241    for id in isbns(e.identifiers.isbn_13.iter().chain(&e.identifiers.isbn_10)) {
242        if !ids.contains(&id) {
243            ids.push(id);
244        }
245    }
246    let work = book(
247        full_title(e.title, e.subtitle),
248        e.authors.into_iter().filter_map(|a| a.name).collect(),
249        e.publish_date.as_deref().and_then(year_in),
250        e.publishers.into_iter().find_map(|p| p.name),
251        e.url,
252    );
253    Ok(SourceRecord::new(
254        ID,
255        ids,
256        work,
257        FieldSet::ALL,
258        Raw::json(value),
259        at,
260    ))
261}
262
263fn search_record(
264    base: &str,
265    value: &serde_json::Value,
266    at: Timestamp,
267) -> serde_json::Result<SourceRecord> {
268    let d = SearchDoc::deserialize(value)?;
269    let mut ids = isbns(&d.isbn);
270    ids.truncate(MAX_WORK_ISBNS);
271    let url = d.key.as_ref().map(|k| format!("{base}{k}"));
272    let mut work = book(
273        full_title(d.title, d.subtitle),
274        d.author_name,
275        d.first_publish_year,
276        d.publisher.into_iter().next(),
277        url,
278    );
279    // Later editions: a citation of the 2018 second edition is this work.
280    let mut editions: Vec<i16> = d
281        .publish_year
282        .into_iter()
283        .filter(|y| Some(*y) != d.first_publish_year)
284        .collect();
285    editions.sort_unstable();
286    editions.dedup();
287    work.other_years = editions;
288    Ok(SourceRecord::new(
289        ID,
290        ids,
291        work,
292        FieldSet::ALL,
293        Raw::json(value),
294        at,
295    ))
296}
297
298#[async_trait]
299impl Provider for OpenLibrary {
300    fn id(&self) -> ProviderId {
301        ID
302    }
303
304    fn capabilities(&self) -> &Capabilities {
305        &self.capabilities
306    }
307
308    async fn lookup(&self, ids: &[WorkId], _fields: FieldSet) -> Result<Response<Vec<Lookup>>> {
309        let isbns: Vec<&Isbn> = ids
310            .iter()
311            .map(|id| match id {
312                WorkId::Isbn(isbn) => Ok(isbn),
313                _ => Err(Error::Unsupported(
314                    "Open Library lookup of non-ISBN identifiers",
315                )),
316            })
317            .collect::<Result<_>>()?;
318        let mut lookups = Vec::with_capacity(ids.len());
319        for (chunk_ids, chunk) in ids.chunks(MAX_BATCH).zip(isbns.chunks(MAX_BATCH)) {
320            let keys: Vec<String> = chunk.iter().map(|i| format!("ISBN:{i}")).collect();
321            let params = [
322                ("bibkeys", keys.join(",")),
323                ("format", "json".to_owned()),
324                ("jscmd", "data".to_owned()),
325            ];
326            let resp = self.get("/api/books", &params).await?;
327            let body: serde_json::Map<String, serde_json::Value> =
328                serde_json::from_slice(&resp.body).map_err(Error::decode)?;
329            for ((id, isbn), key) in chunk_ids.iter().zip(chunk).zip(&keys) {
330                let record = body
331                    .get(key)
332                    .and_then(|v| or_skip(edition_record(isbn, v, resp.received_at)));
333                lookups.push(Lookup {
334                    id: id.clone(),
335                    record,
336                });
337            }
338        }
339        Ok(Response::new(lookups))
340    }
341
342    async fn search(&self, query: &SearchQuery) -> Result<Response<Page>> {
343        if query.mode != SearchMode::Keywords {
344            return Err(Error::Unsupported("Open Library has no citation matcher"));
345        }
346        if let Some((key, _)) = query.extra.iter().next() {
347            return Err(Error::BadRequest(format!(
348                "unknown Open Library parameter {key:?}"
349            )));
350        }
351        let mut clauses = Vec::new();
352        if !query.text.trim().is_empty() {
353            clauses.push(format!("({})", escape_lucene(query.text.trim())));
354        }
355        if let Some(years) = query.years {
356            let end = |y: Option<i16>| y.map_or("*".to_owned(), |y| y.to_string());
357            clauses.push(format!(
358                "first_publish_year:[{} TO {}]",
359                end(years.from),
360                end(years.to)
361            ));
362        }
363        let page_number: u32 = match &query.page.cursor {
364            None => 1,
365            Some(c) => c
366                .0
367                .parse()
368                .map_err(|_| Error::BadRequest(format!("invalid Open Library cursor {:?}", c.0)))?,
369        };
370        let limit = query.page.limit.clamp(1, MAX_PAGE);
371        let params = [
372            ("q", clauses.join(" AND ")),
373            ("fields", SEARCH_FIELDS.to_owned()),
374            ("limit", limit.to_string()),
375            ("page", page_number.to_string()),
376        ];
377        let resp = self.get("/search.json", &params).await?;
378        let results: SearchResults = serde_json::from_slice(&resp.body).map_err(Error::decode)?;
379        let base = self.config.base_url.trim_end_matches('/');
380        let hits: Vec<Hit> = results
381            .docs
382            .iter()
383            .filter_map(|v| or_skip(search_record(base, v, resp.received_at)))
384            .map(|r| Hit::new(r, None))
385            .collect();
386        let seen = u64::from(page_number) * u64::from(limit);
387        let more = !hits.is_empty() && results.num_found.is_some_and(|n| n > seen);
388        // No page follows the last one a u32 can number.
389        let next = page_number
390            .checked_add(1)
391            .filter(|_| more)
392            .map(|n| Cursor(n.to_string()));
393        Ok(Response::new(Page::new(hits, results.num_found, next)))
394    }
395}