Open Library card: a named work (openlibraryid lookup) needs only a title; cover falls back to its ISBN, description optional

This commit is contained in:
Manielle
2026-10-15 14:36:04 +02:00
parent 8bf94cb186
commit 3564f170b4
2 changed files with 160 additions and 15 deletions
+130 -13
View File
@@ -169,14 +169,33 @@ fn foreign_identity_filter(q: &GatewayQuery) -> Option<&str> {
/// what passes, permanently. A card that lacks another filter's key does not /// what passes, permanently. A card that lacks another filter's key does not
/// match it, which is the truth. /// match it, which is the truth.
pub fn to_record(doc: &SearchDoc, description: &str, query: &GatewayQuery) -> Option<DiscoveryRecord> { pub fn to_record(doc: &SearchDoc, description: &str, query: &GatewayQuery) -> Option<DiscoveryRecord> {
to_record_with(doc, Some(description), query, false)
}
/// [`to_record`] with the quality bar as a choice.
///
/// `lenient = false` is the bar every search and browse row keeps: a cover **and**
/// a description, or no card. `lenient = true` is for a lookup that names one work
/// by `openlibraryid` (a work page): the work is already known to be wanted, so a
/// title is enough. Its cover may come from the work's ISBNs when the work has none
/// of its own, and a missing description is simply left out.
pub fn to_record_with(
doc: &SearchDoc,
description: Option<&str>,
query: &GatewayQuery,
lenient: bool,
) -> Option<DiscoveryRecord> {
let id = doc.work_id()?; let id = doc.work_id()?;
let cid = card_cid(id)?; let cid = card_cid(id)?;
let title = doc.title.as_deref().map(str::trim).filter(|t| !t.is_empty())?; let title = doc.title.as_deref().map(str::trim).filter(|t| !t.is_empty())?;
let poster_url = doc.cover_url()?; let description = description.map(str::trim).filter(|d| !d.is_empty());
let description = description.trim(); let poster_url = if lenient {
if description.is_empty() { doc.cover_url().or_else(|| doc.isbn_cover_url())
return None; } else {
} // Strict: both are required.
description?;
Some(doc.cover_url()?)
};
let mut fields: BTreeMap<String, String> = BTreeMap::new(); let mut fields: BTreeMap<String, String> = BTreeMap::new();
@@ -200,7 +219,9 @@ pub fn to_record(doc: &SearchDoc, description: &str, query: &GatewayQuery) -> Op
// `description/{lang3}`, never a flat `description` — the flat key is indexed // `description/{lang3}`, never a flat `description` — the flat key is indexed
// but never written (METADATA_KEYS.md §14.6). // but never written (METADATA_KEYS.md §14.6).
fields.insert("description/eng".into(), description.to_string()); if let Some(d) = description {
fields.insert("description/eng".into(), d.to_string());
}
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let authors: Vec<&str> = doc let authors: Vec<&str> = doc
@@ -224,7 +245,9 @@ pub fn to_record(doc: &SearchDoc, description: &str, query: &GatewayQuery) -> Op
// ⚠ `poster_url`, NOT a pre-hashed `poster`: the gateway's SEEDABLE_FIELDS // ⚠ `poster_url`, NOT a pre-hashed `poster`: the gateway's SEEDABLE_FIELDS
// path fetches it, seeds the bytes and writes the content cid back. // path fetches it, seeds the bytes and writes the content cid back.
fields.insert("poster_url".into(), poster_url); if let Some(url) = poster_url {
fields.insert("poster_url".into(), url);
}
fields.insert( fields.insert(
"sourceUrl".into(), "sourceUrl".into(),
@@ -257,13 +280,14 @@ async fn project(
query: &GatewayQuery, query: &GatewayQuery,
n: usize, n: usize,
deadline: Duration, deadline: Duration,
lenient: bool,
) -> Vec<DiscoveryRecord> { ) -> Vec<DiscoveryRecord> {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let candidates: Vec<SearchDoc> = docs let candidates: Vec<SearchDoc> = docs
.into_iter() .into_iter()
.filter(|d| { .filter(|d| {
d.work_id().is_some() d.work_id().is_some()
&& d.cover_url().is_some() && (lenient || d.cover_url().is_some())
&& d.title.as_deref().is_some_and(|t| !t.trim().is_empty()) && d.title.as_deref().is_some_and(|t| !t.trim().is_empty())
}) })
.filter(|d| seen.insert(d.key.clone())) .filter(|d| seen.insert(d.key.clone()))
@@ -286,7 +310,7 @@ async fn project(
debug!(from = doc.key, to = w.id, "openlibrary: merged work; skipped"); debug!(from = doc.key, to = w.id, "openlibrary: merged work; skipped");
return None; return None;
} }
to_record(&doc, w.description.as_deref()?, query) to_record_with(&doc, w.description.as_deref(), query, lenient)
} }
Ok(None) => None, Ok(None) => None,
Err(e) => { Err(e) => {
@@ -437,7 +461,9 @@ impl FeederPlugin for OpenLibraryPlugin {
let Some(doc) = client.doc_by_work_id(id, SEARCH_DEADLINE).await? else { let Some(doc) = client.doc_by_work_id(id, SEARCH_DEADLINE).await? else {
return Ok(Vec::new()); return Ok(Vec::new());
}; };
return Ok(project(client, vec![doc], query, 1, SEARCH_DEADLINE).await); // Lenient: an explicit work lookup keeps the card even without a
// description, and finds a cover by ISBN when the work has none.
return Ok(project(client, vec![doc], query, 1, SEARCH_DEADLINE, true).await);
} }
// A curated row. // A curated row.
@@ -455,7 +481,7 @@ impl FeederPlugin for OpenLibraryPlugin {
.await? .await?
} }
}; };
return Ok(project(client, docs, query, n, BROWSE_DEADLINE).await); return Ok(project(client, docs, query, n, BROWSE_DEADLINE, false).await);
} }
// Free text. // Free text.
@@ -470,7 +496,7 @@ impl FeederPlugin for OpenLibraryPlugin {
let docs = client let docs = client
.search(text, (n * SEARCH_OVERFETCH).min(MAX_LIMIT), SEARCH_DEADLINE) .search(text, (n * SEARCH_OVERFETCH).min(MAX_LIMIT), SEARCH_DEADLINE)
.await?; .await?;
Ok(project(client, docs, query, n, SEARCH_DEADLINE).await) Ok(project(client, docs, query, n, SEARCH_DEADLINE, false).await)
} }
/// A card has no bytes: the record IS the payload. /// A card has no bytes: the record IS the payload.
@@ -489,7 +515,8 @@ impl FeederPlugin for OpenLibraryPlugin {
// different work (a merge) is not this cid's record. // different work (a merge) is not this cid's record.
let record = match self.client.as_ref() { let record = match self.client.as_ref() {
Some(c) => match c.doc_by_work_id(id, SEARCH_DEADLINE).await { Some(c) => match c.doc_by_work_id(id, SEARCH_DEADLINE).await {
Ok(Some(doc)) => project(c, vec![doc], &GatewayQuery::from_free_text(""), 1, SEARCH_DEADLINE) // Lenient, like the `openlibraryid` lookup that minted this card.
Ok(Some(doc)) => project(c, vec![doc], &GatewayQuery::from_free_text(""), 1, SEARCH_DEADLINE, true)
.await .await
.into_iter() .into_iter()
.find(|r| r.record_id == id), .find(|r| r.record_id == id),
@@ -545,6 +572,7 @@ mod tests {
cover_i: Some(14348537), cover_i: Some(14348537),
first_publish_year: Some(1813), first_publish_year: Some(1813),
id_project_gutenberg: vec![], id_project_gutenberg: vec![],
isbn: vec![],
} }
} }
@@ -651,6 +679,95 @@ mod tests {
assert!(to_record(&edition, "A novel.", &q("", &[])).is_none()); assert!(to_record(&edition, "A novel.", &q("", &[])).is_none());
} }
#[test]
fn a_lenient_card_needs_only_a_title_and_finds_a_cover_by_isbn() {
let mut d = pride();
d.cover_i = None;
d.isbn = vec!["1955252092".into(), "9781955252096".into()];
// Strict still refuses it, whatever the description.
assert!(to_record_with(&d, Some("A novel."), &q("", &[]), false).is_none());
// Lenient mints it with the ISBN-13 cover and no description at all.
let r = to_record_with(&d, None, &q("", &[]), true).unwrap();
assert_eq!(
r.fields["poster_url"],
"https://covers.openlibrary.org/b/isbn/9781955252096-L.jpg?default=false"
);
assert!(!r.fields.keys().any(|k| k.starts_with("description")));
assert_eq!(r.fields["openlibraryid"], "OL66554W");
assert_eq!(r.fields["fileType"], "card");
// Same address as the strict card for the same work.
let strict = to_record(&pride(), "A novel.", &q("", &[])).unwrap();
let key = |r: &DiscoveryRecord| r.fields.keys().find(|k| k.starts_with("cids/")).cloned();
assert_eq!(key(&r), key(&strict));
}
#[test]
fn a_lenient_card_with_no_cover_anywhere_has_no_poster_rather_than_no_card() {
let mut d = pride();
d.cover_i = None;
let r = to_record_with(&d, None, &q("", &[]), true).unwrap();
assert!(!r.fields.contains_key("poster_url"));
}
#[test]
fn isbn_cover_prefers_an_isbn13_and_ignores_junk() {
let mut d = SearchDoc::default();
d.isbn = vec!["not-an-isbn".into(), "0306406152".into(), "9780306406157".into()];
assert_eq!(
d.isbn_cover_url().as_deref(),
Some("https://covers.openlibrary.org/b/isbn/9780306406157-L.jpg?default=false")
);
d.isbn = vec!["0306406152".into()];
assert!(d.isbn_cover_url().unwrap().contains("0306406152"));
d.isbn = vec!["x".into()];
assert_eq!(d.isbn_cover_url(), None);
}
#[tokio::test]
async fn a_named_work_without_cover_or_description_still_gets_its_card_but_a_search_does_not() {
let server = MockServer::start().await;
let doc = json!({
"key": "/works/OL39284292W",
"title": "Mother of Learning",
"author_name": ["nobody103"],
"first_publish_year": 2021,
"language": ["eng"],
"isbn": ["9781955252096"]
});
// The lookup by id and the free-text search both find the same index doc.
Mock::given(method("GET"))
.and(path("/search.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"numFound": 1, "docs": [doc]})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/works/OL39284292W.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"key": "/works/OL39284292W", "title": "Mother of Learning"
})))
.mount(&server)
.await;
let (p, _dir) = plugin_against(&server).await;
let by_id = p
.handle_query(
&q("", &[("openlibraryid", "OL39284292W"), ("contentKind", "book")]),
10,
)
.await
.unwrap();
assert_eq!(by_id.len(), 1, "the work page lookup yields a card");
assert_eq!(by_id[0].fields["title"], "Mother of Learning");
assert!(by_id[0].fields["poster_url"].contains("/b/isbn/9781955252096-L.jpg"));
assert!(!by_id[0].fields.keys().any(|k| k.starts_with("description")));
let by_search = p
.handle_query(&q("mother of learning", &[("fileType", "card"), ("contentKind", "book")]), 10)
.await
.unwrap();
assert!(by_search.is_empty(), "search rows keep the strict bar");
}
#[test] #[test]
fn echoes_browse_markers_but_no_other_filter() { fn echoes_browse_markers_but_no_other_filter() {
let r = to_record( let r = to_record(
@@ -30,6 +30,9 @@ pub const DEFAULT_API_BASE: &str = "https://openlibrary.org";
/// Cover image base. `-L` is the large rendition; the gateway seeds whatever /// Cover image base. `-L` is the large rendition; the gateway seeds whatever
/// `poster_url` names, so one size is published, not three. /// `poster_url` names, so one size is published, not three.
const COVERS_BASE: &str = "https://covers.openlibrary.org/b/id"; const COVERS_BASE: &str = "https://covers.openlibrary.org/b/id";
/// Cover by ISBN. Open Library often holds an *edition's* cover for a work whose
/// own `covers` list is empty.
const COVERS_ISBN_BASE: &str = "https://covers.openlibrary.org/b/isbn";
/// Config key (and env seed) for the operator contact sent in the User-Agent. /// Config key (and env seed) for the operator contact sent in the User-Agent.
pub const CONTACT_KEY: &str = "contact"; pub const CONTACT_KEY: &str = "contact";
@@ -58,6 +61,9 @@ const RETRY_BACKOFF_MS: u64 = 250;
/// The field selection for every doc-shaped request, written once so a card /// The field selection for every doc-shaped request, written once so a card
/// resolved by search and the same card resolved by id are byte-identical. /// resolved by search and the same card resolved by id are byte-identical.
const SEARCH_FIELDS: &str = "key,title,author_name,cover_i,first_publish_year,language"; const SEARCH_FIELDS: &str = "key,title,author_name,cover_i,first_publish_year,language";
/// A lookup of one named work also asks for its ISBNs, so a cover can be found by
/// ISBN. Not on the shared list: a search row would carry hundreds of ISBNs per doc.
const DOC_BY_ID_FIELDS: &str = "key,title,author_name,cover_i,first_publish_year,language,isbn";
/// Cache lifetimes. /// Cache lifetimes.
/// ///
@@ -199,6 +205,10 @@ pub struct SearchDoc {
pub first_publish_year: Option<i32>, pub first_publish_year: Option<i32>,
#[serde(default)] #[serde(default)]
pub id_project_gutenberg: Vec<String>, pub id_project_gutenberg: Vec<String>,
/// ISBNs of the work's editions. Only requested for an `openlibraryid`
/// lookup (see [`DOC_BY_ID_FIELDS`]); empty everywhere else.
#[serde(default)]
pub isbn: Vec<String>,
} }
impl SearchDoc { impl SearchDoc {
@@ -214,6 +224,22 @@ impl SearchDoc {
.filter(|c| *c > 0) .filter(|c| *c > 0)
.map(|c| format!("{COVERS_BASE}/{c}-L.jpg")) .map(|c| format!("{COVERS_BASE}/{c}-L.jpg"))
} }
/// A cover looked up by one of the work's ISBNs (an ISBN-13 when there is one),
/// for a work with no cover of its own.
///
/// `?default=false` makes Open Library answer 404 when it has no image for the
/// ISBN, instead of a 1×1 placeholder that would be seeded as the poster.
pub fn isbn_cover_url(&self) -> Option<String> {
let digits_only = |s: &&String| s.chars().all(|c| c.is_ascii_digit());
let isbn = self
.isbn
.iter()
.filter(digits_only)
.find(|s| s.len() == 13)
.or_else(|| self.isbn.iter().filter(digits_only).find(|s| s.len() == 10))?;
Some(format!("{COVERS_ISBN_BASE}/{isbn}-L.jpg?default=false"))
}
} }
/// What a work JSON contributes to a card. /// What a work JSON contributes to a card.
@@ -440,12 +466,13 @@ impl OpenLibraryClient {
) -> Result<Option<SearchDoc>, GatewayError> { ) -> Result<Option<SearchDoc>, GatewayError> {
let body = self let body = self
.get_cached( .get_cached(
&format!("openlibrary:key:{id}"), // `key2`: entries cached before ISBNs were requested lack them.
&format!("openlibrary:key2:{id}"),
WORK_TTL_SECS, WORK_TTL_SECS,
"/search.json", "/search.json",
&[ &[
("q", format!("key:/works/{id}")), ("q", format!("key:/works/{id}")),
("fields", SEARCH_FIELDS.into()), ("fields", DOC_BY_ID_FIELDS.into()),
("limit", "1".into()), ("limit", "1".into()),
], ],
deadline, deadline,
@@ -493,6 +520,7 @@ impl OpenLibraryClient {
.and_then(|a| a.iter().filter_map(Value::as_i64).find(|c| *c > 0)), .and_then(|a| a.iter().filter_map(Value::as_i64).find(|c| *c > 0)),
first_publish_year: None, first_publish_year: None,
id_project_gutenberg: Vec::new(), id_project_gutenberg: Vec::new(),
isbn: Vec::new(),
})) }))
} }