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:
@@ -32,7 +32,17 @@ use meta_feeder_sdk::types::{DiscoveryRecord, GatewayError, PluginHealth};
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::openlibrary_client::{self, OpenLibraryClient};
|
||||
use crate::openlibrary::first_filter;
|
||||
use crate::openlibrary_client::{self, Etext, OpenLibraryClient};
|
||||
|
||||
/// An edition's Open Library work, and how it was found.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Binding {
|
||||
work: String,
|
||||
/// Bound by title and author ([`OpenLibraryClient::title_work`]) rather than
|
||||
/// by an identifier Open Library records: graded Matched, not Anchored.
|
||||
by_title: bool,
|
||||
}
|
||||
|
||||
/// Canonical Gutendex base URL. Overridable via [`GutenbergPlugin::with_base_url`]
|
||||
/// for tests + private mirrors.
|
||||
@@ -390,31 +400,72 @@ impl GutenbergPlugin {
|
||||
.await
|
||||
}
|
||||
|
||||
/// The Gutendex search a work page's query stands for.
|
||||
///
|
||||
/// meta-read's book page asks for `openlibraryid:OL…W` with no free text.
|
||||
/// That used to fall through to the catalogue browse — the 32 most
|
||||
/// downloaded books — and the gateway kept those bound to the work, so a
|
||||
/// book outside that list never showed an edition on its own page. Searching
|
||||
/// for the work's main title and its first author's surname finds it
|
||||
/// wherever it ranks; the gateway still keeps only the records bound to the
|
||||
/// work. `None` (no work named, Open Library off or failing) keeps the browse.
|
||||
async fn work_search_text(&self, query: &GatewayQuery) -> Option<String> {
|
||||
let work = first_filter(query, "openlibraryid")?.to_ascii_uppercase();
|
||||
if !openlibrary_client::is_work_id(&work) {
|
||||
return None;
|
||||
}
|
||||
let client = self.openlibrary.as_ref()?;
|
||||
let lookup = client.doc_by_work_id(&work, openlibrary_client::BINDING_DEADLINE);
|
||||
let doc = tokio::time::timeout(BINDING_TIMEOUT, lookup).await.ok()?.ok()??;
|
||||
let title = openlibrary_client::main_title(doc.title.as_deref()?);
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let author = doc.author_name.first().map(|a| openlibrary_client::surname(a)).unwrap_or_default();
|
||||
Some(format!("{title} {author}").trim().to_string())
|
||||
}
|
||||
|
||||
/// Each result's Open Library work, through the Project Gutenberg id Open
|
||||
/// Library records (reading-model §2.4). Aligned with the input slice;
|
||||
/// `None` means no binding — none recorded, ambiguous, not configured, over
|
||||
/// budget, or the lookup failed.
|
||||
/// Library records (reading-model §2.4), or, for an etext Open Library lists
|
||||
/// under no work, by title and author (`by_title`, graded Matched). Aligned
|
||||
/// with the input slice; `None` means no binding — ambiguous, no title match,
|
||||
/// not configured, over budget, or the lookup failed.
|
||||
///
|
||||
/// ⚠ **A failure never blocks or drops a record.** The binding upgrades an
|
||||
/// edition that is already readable. Every failure is left uncached, so the
|
||||
/// next query that surfaces the book tries again; the budget deadline is
|
||||
/// short on purpose (`BINDING_DEADLINE`), so a cold browse row binds the
|
||||
/// first handful of books and the cache fills the rest over later queries.
|
||||
async fn fetch_openlibrary_works(&self, books: &[GutendexBook]) -> Vec<Option<String>> {
|
||||
async fn fetch_openlibrary_works(&self, books: &[GutendexBook]) -> Vec<Option<Binding>> {
|
||||
use futures::stream::{self, StreamExt};
|
||||
const BINDING_CONCURRENCY: usize = 4;
|
||||
let Some(client) = self.openlibrary.as_ref() else {
|
||||
return vec![None; books.len()];
|
||||
};
|
||||
// Owned ids, not `books.iter().map(..)`: a borrowing closure inside the
|
||||
// Owned values, not `books.iter().map(..)`: a borrowing closure inside the
|
||||
// stream makes `handle_query`'s future fail the `async_trait` lifetime
|
||||
// check ("FnOnce is not general enough").
|
||||
let ids: Vec<u64> = books.iter().map(|b| b.id).collect();
|
||||
stream::iter(ids)
|
||||
.map(|id| async move {
|
||||
let lookup = client.gutenberg_work(id, openlibrary_client::BINDING_DEADLINE);
|
||||
let wanted: Vec<(u64, String, String)> = books
|
||||
.iter()
|
||||
.map(|b| (b.id, b.title.clone(), b.authors.first().map(|a| a.name.clone()).unwrap_or_default()))
|
||||
.collect();
|
||||
stream::iter(wanted)
|
||||
.map(|(id, title, author)| async move {
|
||||
// The identifier first; a title match only for an etext Open
|
||||
// Library lists under no work at all.
|
||||
let lookup = async {
|
||||
let deadline = openlibrary_client::BINDING_DEADLINE;
|
||||
Ok::<_, GatewayError>(match client.gutenberg_etext(id, deadline).await? {
|
||||
Etext::One(work) => Some(Binding { work, by_title: false }),
|
||||
Etext::Several => None,
|
||||
Etext::Unlisted => client
|
||||
.title_work(&title, &author, deadline)
|
||||
.await?
|
||||
.map(|work| Binding { work, by_title: true }),
|
||||
})
|
||||
};
|
||||
match tokio::time::timeout(BINDING_TIMEOUT, lookup).await {
|
||||
Ok(Ok(work)) => work,
|
||||
Ok(Ok(binding)) => binding,
|
||||
Ok(Err(e)) => {
|
||||
debug!(
|
||||
target: "meta-share::gateway",
|
||||
@@ -507,7 +558,16 @@ impl FeederPlugin for GutenbergPlugin {
|
||||
// everything, already ordered by download count (identical to
|
||||
// `?sort=popular`), so the row leads with Pride and Prejudice rather than
|
||||
// with whatever happens to contain punctuation.
|
||||
let q = query.free_text.trim();
|
||||
// A work page's query names its Open Library work and no text: search
|
||||
// for that work's title and author instead of browsing. See
|
||||
// `work_search_text`.
|
||||
let free = query.free_text.trim();
|
||||
let work_text = if free.is_empty() {
|
||||
self.work_search_text(query).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let q = work_text.as_deref().unwrap_or(free);
|
||||
let params: Vec<(&str, &str)> = if q.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -650,7 +710,7 @@ fn into_discovery_record(
|
||||
book: GutendexBook,
|
||||
cover_cid: Option<String>,
|
||||
bibrec: BTreeMap<String, String>,
|
||||
openlibrary_work: Option<String>,
|
||||
openlibrary_work: Option<Binding>,
|
||||
) -> DiscoveryRecord {
|
||||
let GutendexBook {
|
||||
id,
|
||||
@@ -803,16 +863,25 @@ fn into_discovery_record(
|
||||
let _ = download_count;
|
||||
fields.insert("fileName".into(), format!("gutenberg-{id}.epub"));
|
||||
|
||||
// The work binding and its trust claim (reading-model §2.4). Written only on
|
||||
// an identifier link Open Library itself records, so the claim is the plain
|
||||
// Anchored form: `anchored` + `anchorSource`, and deliberately NO
|
||||
// `anchorMethod` — that marks a title match, which this never is. The
|
||||
// The work binding and its trust claim (reading-model §2.4). On an
|
||||
// identifier link Open Library itself records, the claim is the plain
|
||||
// Anchored form: `anchored` + `anchorSource`, and NO `anchorMethod` — that
|
||||
// marks a title match. The
|
||||
// gateway transcribes the claim into `anchoredBy/openlibrary:OL…W` and
|
||||
// strips the transient keys before storing.
|
||||
if let Some(work) = openlibrary_work.filter(|w| openlibrary_client::is_work_id(w)) {
|
||||
fields.insert("openlibraryid".into(), work);
|
||||
//
|
||||
// A title binding (an etext Open Library lists under no work) carries the
|
||||
// same claim plus `anchorMethod=title`: the gateway then records
|
||||
// `matchedBy/openlibrary:OL…W` instead of `anchoredBy/`, and meta-read grades
|
||||
// the edition Matched — listed on its work's page, ranked after any
|
||||
// identifier-bound edition.
|
||||
if let Some(binding) = openlibrary_work.filter(|b| openlibrary_client::is_work_id(&b.work)) {
|
||||
fields.insert("openlibraryid".into(), binding.work);
|
||||
fields.insert("anchored".into(), "true".into());
|
||||
fields.insert("anchorSource".into(), "openlibrary".into());
|
||||
if binding.by_title {
|
||||
fields.insert("anchorMethod".into(), "title".into());
|
||||
}
|
||||
}
|
||||
|
||||
for (k, v) in bibrec {
|
||||
@@ -1678,6 +1747,98 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open Library's answer to `title=…&author=…` for Frankenstein: several
|
||||
/// works by Mary Shelley share the title; the most published is the novel.
|
||||
fn frankenstein_by_title() -> Mock {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search.json"))
|
||||
.and(query_param("title", "frankenstein"))
|
||||
.and(query_param("author", "shelley"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "numFound": 3, "docs": [
|
||||
{ "key": "/works/OL26860869W", "title": "Frankenstein", "author_name": ["Mary Shelley"], "edition_count": 84 },
|
||||
{ "key": "/works/OL450063W", "title": "Frankenstein; or, The Modern Prometheus", "author_name": ["Mary Shelley"], "edition_count": 2187 },
|
||||
{ "key": "/works/OL25595002W", "title": "Mary Shelley's Frankenstein", "author_name": ["Mary Shelley"], "edition_count": 90 }
|
||||
]})))
|
||||
}
|
||||
|
||||
/// ⚠ Open Library lists etext 84 under no work, so the identifier rule alone
|
||||
/// left the most read novel on Gutenberg unbound and its book page empty.
|
||||
#[tokio::test]
|
||||
async fn an_etext_no_work_lists_is_bound_by_title_and_graded_matched() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/books/"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(books_json()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
binding(
|
||||
1342,
|
||||
serde_json::json!({ "numFound": 1, "docs": [
|
||||
{ "key": "/works/OL66554W", "id_project_gutenberg": ["1342"] }
|
||||
]}),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
binding(84, serde_json::json!({ "numFound": 0, "docs": [] }))
|
||||
.mount(&server)
|
||||
.await;
|
||||
frankenstein_by_title().expect(1).mount(&server).await;
|
||||
|
||||
let (plugin, _dir) = configured_plugin_against(&server);
|
||||
let records = plugin
|
||||
.handle_query(&GatewayQuery::from_free_text("alice"), 50)
|
||||
.await
|
||||
.expect("handle_query");
|
||||
|
||||
let pride = &records[0].fields;
|
||||
assert_eq!(pride.get("openlibraryid").map(String::as_str), Some("OL66554W"));
|
||||
assert!(!pride.contains_key("anchorMethod"), "an identifier binding stays Anchored");
|
||||
|
||||
let frankenstein = &records[1].fields;
|
||||
assert_eq!(frankenstein.get("openlibraryid").map(String::as_str), Some("OL450063W"));
|
||||
assert_eq!(frankenstein.get("anchored").map(String::as_str), Some("true"));
|
||||
assert_eq!(frankenstein.get("anchorSource").map(String::as_str), Some("openlibrary"));
|
||||
assert_eq!(frankenstein.get("anchorMethod").map(String::as_str), Some("title"));
|
||||
}
|
||||
|
||||
/// ⚠ A work page asks for its work and no text. That used to browse the 32
|
||||
/// most downloaded books, so a book outside them never reached its own page.
|
||||
#[tokio::test]
|
||||
async fn a_work_page_query_searches_for_the_works_title_and_author() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/search.json"))
|
||||
.and(query_param("q", "key:/works/OL450063W"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "numFound": 1, "docs": [
|
||||
{ "key": "/works/OL450063W", "title": "Frankenstein; or, The Modern Prometheus", "author_name": ["Mary Shelley"] }
|
||||
]})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/books/"))
|
||||
.and(query_param("search", "frankenstein shelley"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(books_json()))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
binding(1342, serde_json::json!({ "numFound": 0, "docs": [] }))
|
||||
.mount(&server)
|
||||
.await;
|
||||
binding(84, serde_json::json!({ "numFound": 0, "docs": [] }))
|
||||
.mount(&server)
|
||||
.await;
|
||||
frankenstein_by_title().mount(&server).await;
|
||||
|
||||
let (plugin, _dir) = configured_plugin_against(&server);
|
||||
let mut query = GatewayQuery::from_free_text("");
|
||||
query.filters.insert("openlibraryid".into(), vec!["OL450063W".into()]);
|
||||
query.filters.insert("contentKind".into(), vec!["book".into()]);
|
||||
let records = plugin.handle_query(&query, 50).await.expect("handle_query");
|
||||
let frankenstein = records.iter().find(|r| r.record_id == "84").expect("etext 84 is found");
|
||||
assert_eq!(frankenstein.fields.get("openlibraryid").map(String::as_str), Some("OL450063W"));
|
||||
assert_eq!(frankenstein.fields.get("anchorMethod").map(String::as_str), Some("title"));
|
||||
}
|
||||
|
||||
/// ⚠ A binding failure never costs the record.
|
||||
#[tokio::test]
|
||||
async fn no_work_or_a_failed_lookup_leaves_the_record_unbound_but_present() {
|
||||
|
||||
Reference in New Issue
Block a user