gutenberg: bind by title when Open Library lists no work, and answer work pages by searching
Test / cargo test (push) Failing after 2m35s
Test / cargo test (push) Failing after 2m35s
Two reasons a book's page in meta-read showed no Gutenberg edition, both found on the live stack with Frankenstein (OL450063W, etext 84): - An edition was bound to its work only when Open Library's search lists the etext under exactly one work (id_project_gutenberg). For many classics it lists it under none: no edition of OL450063W carries etext 84. Such an etext is now bound by title and author: same main title (main_title), an author with the same surname, and the most published of those works, since Open Library holds many small "Frankenstein" works by Mary Shelley. The record carries the same claim plus anchorMethod=title, so the gateway records matchedBy/openlibrary:OL...W and meta-read grades it Matched, below an identifier binding. An etext several works list (an unmerged duplicate) still gets no binding: gutenberg_etext keeps "several" apart from "none". - A work page asks for openlibraryid:OL...W with no text, which fell through to the catalogue browse (the 32 most downloaded books), so a book outside them never reached its own page. Such a query now searches Gutendex for the work's main title and its first author's surname (work_search_text); the gateway still keeps only the records bound to the work. Tests: title binding graded Matched while an identifier binding stays Anchored; a work query searches "frankenstein shelley"; main_title, surname, the most-published choice, ties and non-matches; Etext one/several/unlisted and its cache form.
This commit is contained in:
@@ -614,6 +614,248 @@ impl OpenLibraryClient {
|
||||
self.cache_put(&key, &serde_json::json!({ "work": work }));
|
||||
Ok(work)
|
||||
}
|
||||
|
||||
/// What Open Library records about an etext: the one work that lists it,
|
||||
/// several works, or none.
|
||||
///
|
||||
/// Same lookup as [`Self::gutenberg_work`], but it keeps the difference
|
||||
/// between "several" and "none", which that one folds into `None`: only an
|
||||
/// etext no work lists may be bound by title ([`Self::title_work`]). Several
|
||||
/// works listing one etext is an unmerged duplicate, and guessing between
|
||||
/// them is exactly what the identifier rule refuses.
|
||||
pub async fn gutenberg_etext(
|
||||
&self,
|
||||
gutenberg_id: u64,
|
||||
deadline: Duration,
|
||||
) -> Result<Etext, GatewayError> {
|
||||
let key = format!("openlibrary:etext:{gutenberg_id}");
|
||||
if let Some(c) = self.cache_get(&key) {
|
||||
if let Some(etext) = Etext::from_cached(&c.body) {
|
||||
let ttl = if matches!(etext, Etext::One(_)) {
|
||||
BINDING_HIT_TTL_SECS
|
||||
} else {
|
||||
BINDING_MISS_TTL_SECS
|
||||
};
|
||||
if now_secs().saturating_sub(c.at) <= ttl {
|
||||
return Ok(etext);
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = self
|
||||
.get(
|
||||
"/search.json",
|
||||
&[
|
||||
("q", format!("id_project_gutenberg:{gutenberg_id}")),
|
||||
("fields", "key,id_project_gutenberg".into()),
|
||||
("limit", "2".into()),
|
||||
],
|
||||
deadline,
|
||||
)
|
||||
.await?;
|
||||
let etext = etext_of(&body, gutenberg_id);
|
||||
self.cache_put(&key, &etext.to_cached());
|
||||
Ok(etext)
|
||||
}
|
||||
|
||||
/// The work a book is bound to by **title and author**, for an etext Open
|
||||
/// Library lists under no work.
|
||||
///
|
||||
/// Open Library's `id_project_gutenberg` links are patchy even for the most
|
||||
/// read books — none of the editions of *Frankenstein; or, The Modern
|
||||
/// Prometheus* (OL450063W) lists etext 84 — so the identifier rule alone left
|
||||
/// them unbound, and their book pages, which ask for `openlibraryid:OL…W`,
|
||||
/// empty. This is the weaker proof the trust model already has a grade for:
|
||||
/// the caller stamps `anchorMethod=title`, the gateway records
|
||||
/// `matchedBy/openlibrary:OL…W`, and meta-read ranks such an edition after an
|
||||
/// identifier-bound one.
|
||||
///
|
||||
/// Same main title ([`main_title`]), an author with the same surname, and,
|
||||
/// among those, the work with the most editions — Open Library holds many
|
||||
/// small works titled *Frankenstein* by Mary Shelley (adaptations, annotated
|
||||
/// and school editions), and the canonical one is the one most published.
|
||||
/// `None` when nothing matches or two works tie.
|
||||
pub async fn title_work(
|
||||
&self,
|
||||
title: &str,
|
||||
author: &str,
|
||||
deadline: Duration,
|
||||
) -> Result<Option<String>, GatewayError> {
|
||||
let (want, surname) = (main_title(title), surname(author));
|
||||
if want.is_empty() || surname.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let key = format!("openlibrary:titlework:{want}:{surname}");
|
||||
if let Some(c) = self.cache_get(&key) {
|
||||
let work = c.body.get("work").and_then(Value::as_str).map(str::to_string);
|
||||
let ttl = if work.is_some() {
|
||||
BINDING_HIT_TTL_SECS
|
||||
} else {
|
||||
BINDING_MISS_TTL_SECS
|
||||
};
|
||||
if now_secs().saturating_sub(c.at) <= ttl {
|
||||
return Ok(work);
|
||||
}
|
||||
}
|
||||
let body = self
|
||||
.get(
|
||||
"/search.json",
|
||||
&[
|
||||
("title", want.clone()),
|
||||
("author", surname.clone()),
|
||||
("fields", "key,title,author_name,edition_count".into()),
|
||||
("limit", "20".into()),
|
||||
],
|
||||
deadline,
|
||||
)
|
||||
.await?;
|
||||
let work = best_title_work(&body, &want, &surname);
|
||||
self.cache_put(&key, &serde_json::json!({ "work": work }));
|
||||
Ok(work)
|
||||
}
|
||||
}
|
||||
|
||||
/// What Open Library records about one Gutenberg etext. See
|
||||
/// [`OpenLibraryClient::gutenberg_etext`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Etext {
|
||||
/// Exactly one work lists it: an identifier binding, graded Anchored.
|
||||
One(String),
|
||||
/// Several works list it: an unmerged duplicate, so no binding at all.
|
||||
Several,
|
||||
/// No work lists it: the one case a title binding may fill.
|
||||
Unlisted,
|
||||
}
|
||||
|
||||
impl Etext {
|
||||
fn to_cached(&self) -> Value {
|
||||
match self {
|
||||
Etext::One(work) => serde_json::json!({ "etext": "one", "work": work }),
|
||||
Etext::Several => serde_json::json!({ "etext": "several" }),
|
||||
Etext::Unlisted => serde_json::json!({ "etext": "unlisted" }),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_cached(v: &Value) -> Option<Self> {
|
||||
match v.get("etext")?.as_str()? {
|
||||
"one" => Some(Etext::One(v.get("work")?.as_str()?.to_string())),
|
||||
"several" => Some(Etext::Several),
|
||||
"unlisted" => Some(Etext::Unlisted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn etext_of(body: &Value, gutenberg_id: u64) -> Etext {
|
||||
if let Some(work) = single_gutenberg_work(body, gutenberg_id) {
|
||||
return Etext::One(work);
|
||||
}
|
||||
let docs = parse_docs(body.get("docs"));
|
||||
let total = body
|
||||
.get("numFound")
|
||||
.or_else(|| body.get("num_found"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(docs.len() as u64);
|
||||
if total == 0 && docs.is_empty() {
|
||||
Etext::Unlisted
|
||||
} else {
|
||||
Etext::Several
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a title two catalogues agree on: before any subtitle (`;`, `:`,
|
||||
/// a bracket, or `, or`), lower-cased, letters and digits only. Gutenberg's
|
||||
/// *Frankenstein; Or, The Modern Prometheus* and Open Library's *Frankenstein;
|
||||
/// or, The Modern Prometheus* are both `frankenstein`.
|
||||
pub fn main_title(raw: &str) -> String {
|
||||
let lower = raw.to_lowercase();
|
||||
let head = lower
|
||||
.split(|c: char| matches!(c, ';' | ':' | '(' | '['))
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let head = head.find(", or").map_or(head, |i| &head[..i]);
|
||||
head.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// The surname a name ends on, lower-cased, letters and digits only: Gutendex
|
||||
/// writes `Shelley, Mary Wollstonecraft`, Open Library `Mary Shelley`; both give
|
||||
/// `shelley`.
|
||||
pub fn surname(raw: &str) -> String {
|
||||
let raw = raw.trim();
|
||||
let last = match raw.split_once(',') {
|
||||
Some((last, _)) => last,
|
||||
None => raw.rsplit(char::is_whitespace).next().unwrap_or_default(),
|
||||
};
|
||||
last.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// Does `name` carry `surname`? Compared with the separators removed, so a
|
||||
/// compound surname (`Saint-Exupéry`) matches however it is spaced.
|
||||
fn author_has_surname(name: &str, surname: &str) -> bool {
|
||||
let flat = name
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
!surname.is_empty() && flat.contains(surname)
|
||||
}
|
||||
|
||||
/// One document of the title search, with the edition count the choice rests on.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TitleDoc {
|
||||
#[serde(default)]
|
||||
key: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
author_name: Vec<String>,
|
||||
#[serde(default)]
|
||||
edition_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// Decide a title binding from a `title=…&author=…` response. See
|
||||
/// [`OpenLibraryClient::title_work`].
|
||||
fn best_title_work(body: &Value, want: &str, surname: &str) -> Option<String> {
|
||||
let docs: Vec<TitleDoc> = body
|
||||
.get("docs")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|d| serde_json::from_value::<TitleDoc>(d.clone()).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut best: Option<(u64, String)> = None;
|
||||
let mut tie = false;
|
||||
for d in docs {
|
||||
let Some(id) = d.key.strip_prefix("/works/").filter(|id| is_work_id(id)) else {
|
||||
continue;
|
||||
};
|
||||
if main_title(d.title.as_deref().unwrap_or_default()) != want {
|
||||
continue;
|
||||
}
|
||||
if !d.author_name.iter().any(|a| author_has_surname(a, surname)) {
|
||||
continue;
|
||||
}
|
||||
let n = d.edition_count.unwrap_or(0);
|
||||
let top = best.as_ref().map(|(m, _)| *m);
|
||||
if top.is_none_or(|m| n > m) {
|
||||
best = Some((n, id.to_string()));
|
||||
tie = false;
|
||||
} else if top == Some(n) {
|
||||
tie = true;
|
||||
}
|
||||
}
|
||||
if tie {
|
||||
None
|
||||
} else {
|
||||
best.map(|(_, id)| id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide a binding from a `q=id_project_gutenberg:<id>` response.
|
||||
@@ -670,6 +912,70 @@ fn clean_description(raw: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_main_title_drops_the_subtitle_case_and_punctuation() {
|
||||
assert_eq!(main_title("Frankenstein; Or, The Modern Prometheus"), "frankenstein");
|
||||
assert_eq!(main_title("Frankenstein; or, The Modern Prometheus"), "frankenstein");
|
||||
assert_eq!(main_title("Frankenstein, or the Modern Prometheus"), "frankenstein");
|
||||
assert_eq!(main_title("Frankenstein (Oxford Playscripts)"), "frankenstein");
|
||||
assert_eq!(main_title("Alice's Adventures in Wonderland"), "alice s adventures in wonderland");
|
||||
assert_eq!(main_title(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_surname_reads_both_name_orders() {
|
||||
assert_eq!(surname("Shelley, Mary Wollstonecraft"), "shelley");
|
||||
assert_eq!(surname("Mary Shelley"), "shelley");
|
||||
assert_eq!(surname(" Austen, Jane "), "austen");
|
||||
assert_eq!(surname("Saint-Exupéry, Antoine de"), "saintexupéry");
|
||||
assert!(author_has_surname("Antoine de Saint-Exupéry", "saintexupéry"));
|
||||
assert!(!author_has_surname("Jason Cobley", "shelley"));
|
||||
}
|
||||
|
||||
fn title_doc(key: &str, title: &str, author: &str, editions: u64) -> Value {
|
||||
serde_json::json!({ "key": key, "title": title, "author_name": [author], "edition_count": editions })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_title_binding_takes_the_most_published_work_of_that_title_and_author() {
|
||||
let body = serde_json::json!({ "docs": [
|
||||
title_doc("/works/OL26860869W", "Frankenstein", "Mary Shelley", 84),
|
||||
title_doc("/works/OL450063W", "Frankenstein; or, The Modern Prometheus", "Mary Shelley", 2187),
|
||||
title_doc("/works/OL25595002W", "Mary Shelley's Frankenstein", "Mary Shelley", 9000),
|
||||
title_doc("/works/OL15443537W", "Frankenstein", "Dean Koontz", 5000),
|
||||
]});
|
||||
// The 9000-edition work has another title, the 5000 one another author.
|
||||
assert_eq!(best_title_work(&body, "frankenstein", "shelley").as_deref(), Some("OL450063W"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tie_or_no_match_binds_nothing() {
|
||||
let tie = serde_json::json!({ "docs": [
|
||||
title_doc("/works/OL1W", "Frankenstein", "Mary Shelley", 10),
|
||||
title_doc("/works/OL2W", "Frankenstein", "Mary Shelley", 10),
|
||||
]});
|
||||
assert_eq!(best_title_work(&tie, "frankenstein", "shelley"), None);
|
||||
let other = serde_json::json!({ "docs": [title_doc("/works/OL3W", "Dracula", "Bram Stoker", 99)] });
|
||||
assert_eq!(best_title_work(&other, "frankenstein", "shelley"), None);
|
||||
let not_a_work = serde_json::json!({ "docs": [title_doc("/books/OL4M", "Frankenstein", "Mary Shelley", 99)] });
|
||||
assert_eq!(best_title_work(¬_a_work, "frankenstein", "shelley"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_etext_is_one_several_or_unlisted() {
|
||||
let one = serde_json::json!({ "numFound": 1, "docs": [{ "key": "/works/OL66554W", "id_project_gutenberg": ["1342"] }] });
|
||||
assert_eq!(etext_of(&one, 1342), Etext::One("OL66554W".into()));
|
||||
let several = serde_json::json!({ "numFound": 2, "docs": [
|
||||
{ "key": "/works/OL450063W", "id_project_gutenberg": ["84"] },
|
||||
{ "key": "/works/OL18388W", "id_project_gutenberg": ["84"] }
|
||||
]});
|
||||
assert_eq!(etext_of(&several, 84), Etext::Several);
|
||||
assert_eq!(etext_of(&serde_json::json!({ "numFound": 0, "docs": [] }), 84), Etext::Unlisted);
|
||||
for e in [Etext::One("OL1W".into()), Etext::Several, Etext::Unlisted] {
|
||||
assert_eq!(Etext::from_cached(&e.to_cached()), Some(e));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_ids_are_works_only() {
|
||||
assert!(is_work_id("OL66554W"));
|
||||
|
||||
Reference in New Issue
Block a user