6 Commits
Author SHA1 Message Date
funkpower 2d885b5afc Merge pull request 'bibfetch: a cover whole to its end marker, and relative redirects with an address in the query (#4, #5)' (#13) from issues-3-6 into main 2026-11-13 16:33:34 -05:00
funkpower 99f4f198af Merge remote-tracking branch 'origin/main' into issues-3-6
# Conflicts:
#	crates/bibfetch/src/source.rs
2026-11-13 16:28:23 -05:00
funkpower 038b0c70ba Merge pull request 'bibfetch: language, publisher and year of an ISBN from its edition, not the work (closes #6)' (#12) from work-edition-metadata into main 2026-11-13 16:28:10 -05:00
funkpower 6b5d9c20ee bibfetch: an edition's facts from its own record, and a cover whole to its end
A lookup by ISBN took the year, publisher, language and page count from
Open Library's index, which answers for the work and gathers them from
every edition: Penguin's English War and Peace came back Spanish,
Norton's, and 1845. The index now gives only the title, the authors and
the subjects, and the edition record the rest (#6).

A cover must run to its end marker, FF D9 for a JPEG and IEND for a PNG,
so a body that only starts like an image is refused (#4). A relative
redirect whose query holds another address is resolved as relative (#5).

Closes #4, #5, #6.
2026-11-13 16:23:48 -05:00
funkpower dd41a93c9b bibfetch: take an ISBN's language, publisher, year and length from the edition, not the work
Open Library's index answers about the work. Its publishers, languages,
first year and median page count are every edition's at once, and the
first of each is usually another edition's: ISBN 9780140447934, the
Penguin Classics War and Peace, came back Spanish, from W.W. Norton, in
1845.

The index is now asked only for what is true of the work (title,
subtitle, authors, subjects). A record from it is never complete, so
lookup always goes on to the edition record for container, language,
year and pages, then Google Books for whatever the edition record does
not say. A field neither gives is left out rather than filled from the
work.

Closes #6.
2026-11-13 16:19:54 -05:00
funkpower e9f9e75c1d Merge pull request '0.5.0: review fixes, Python bindings for covers and search, version bump' (#11) from fixes-040 into main 2026-11-13 16:08:45 -05:00
4 changed files with 209 additions and 40 deletions

No files matched your search

+11 -2
View File
@@ -86,7 +86,7 @@ is a failure — nobody replied, and the identifier may be perfectly good.
|---|---|---| |---|---|---|
| `doi` | Crossref | It assigns most scholarly DOIs | | `doi` | Crossref | It assigns most scholarly DOIs |
| `arxiv` | the arXiv | Its own register of preprints | | `arxiv` | the arXiv | Its own register of preprints |
| `isbn` | OpenLibrary's edition record, then its index, then Google Books | Books, without a key | | `isbn` | OpenLibrary's index, then its edition record, then Google Books | Books, without a key |
One register is enough for a DOI or a preprint: the body that assigns the One register is enough for a DOI or a preprint: the body that assigns the
identifier is the only one that has it. A book is different. An ISBN is identifier is the only one that has it. A book is different. An ISBN is
@@ -97,6 +97,14 @@ names them. So the registers are asked in turn and the first complete answer
wins. An incomplete one is kept in case nothing better comes, and a register wins. An incomplete one is kept in case nothing better comes, and a register
being down is a failure only when every register is. being down is a failure only when every register is.
OpenLibrary's index answers about the work, not the edition: its publishers,
languages, years and page counts are every edition's at once, and the first
of each is usually another edition's. So for a book it gives only the title,
the authors and the subjects, and the `container`, `language`, `year` and
`pages` of a record come from the edition record for that ISBN, or from
Google Books when the edition record does not say. A field neither gives is
left out rather than filled from the work.
Google Books is asked last because it answers anonymous callers only until a Google Books is asked last because it answers anonymous callers only until a
daily quota runs out, after which it replies `429` to everyone sharing the daily quota runs out, after which it replies `429` to everyone sharing the
address. address.
@@ -127,7 +135,8 @@ id is asked only when every form of the ISBN asked was answered with no cover; i
asking by ISBN failed, that failure is the result. asking by ISBN failed, that failure is the result.
What comes back is checked before it is written: a JPEG or PNG by its bytes What comes back is checked before it is written: a JPEG or PNG by its bytes
as well as by what the server says, no larger than ten megabytes, reached in at as well as by what the server says, whole to its end marker (`FF D9` for a
JPEG, `IEND` for a PNG), no larger than ten megabytes, reached in at
most three redirects, each over HTTPS to `covers.openlibrary.org`, most three redirects, each over HTTPS to `covers.openlibrary.org`,
`archive.org` or a host under `archive.org`, and never to an address by number. `archive.org` or a host under `archive.org`, and never to an address by number.
One request is made at a time, a `429` or `503` is waited out for as long as One request is made at a time, a `429` or `503` is waited out for as long as
+78 -3
View File
@@ -386,6 +386,23 @@ by ISBN every five minutes from one address"
} }
} }
/// Whether an image's bytes run to its end marker: `FF D9` for a JPEG, which
/// some encoders follow with zero padding, and the `IEND` chunk for a PNG,
/// which is its last twelve bytes.
fn ends_whole(media_type: &str, bytes: &[u8]) -> bool {
match media_type {
"image/jpeg" => {
let end = bytes.iter().rposition(|&b| b != 0).map_or(0, |at| at + 1);
end >= 5 && bytes[..end].ends_with(&[0xFF, 0xD9])
}
_ => {
bytes.len() >= 8 + 12
&& bytes[bytes.len() - 12..].starts_with(&[0, 0, 0, 0])
&& &bytes[bytes.len() - 8..bytes.len() - 4] == b"IEND"
}
}
}
/// Read and check a body that is meant to be a cover. /// Read and check a body that is meant to be a cover.
fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result<Answer, Error> { fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result<Answer, Error> {
let declared = header(&response, "content-type") let declared = header(&response, "content-type")
@@ -438,6 +455,14 @@ fn read(mut response: ureq::http::Response<ureq::Body>, address: &str) -> Result
"{address}: said {media_type} but is not one" "{address}: said {media_type} but is not one"
))); )));
} }
// The start of an image is not an image. A body sent with no length,
// or a stub, can begin right and stop short; what ends it says it is
// whole.
if !ends_whole(media_type, &bytes) {
return Err(Error::Shape(format!(
"{address}: a {media_type} that stops before its end"
)));
}
// No floor on the size. Open Library's stand-in for a missing cover is a // No floor on the size. Open Library's stand-in for a missing cover is a
// 43-byte, one-pixel GIF, and it serves that only when `default=false` // 43-byte, one-pixel GIF, and it serves that only when `default=false`
// is left off: with it, as every address here has it, a missing cover is // is left off: with it, as every address here has it, a missing cover is
@@ -512,7 +537,12 @@ fn origin(url: &str) -> Option<&str> {
/// Where a `Location` points, read against the address that sent it. /// Where a `Location` points, read against the address that sent it.
fn resolve(from: &str, location: &str) -> String { fn resolve(from: &str, location: &str) -> String {
if location.contains("://") { // Absolute only when the scheme comes first: a relative address can
// carry another address in its query.
if location
.find("://")
.is_some_and(|at| !location[..at].contains(['/', '?', '#']))
{
return location.to_string(); return location.to_string();
} }
if let Some(rest) = location.strip_prefix("//") { if let Some(rest) = location.strip_prefix("//") {
@@ -670,15 +700,19 @@ mod tests {
.agent() .agent()
} }
/// A JPEG's start, filler, and its end marker, `len` bytes in all.
fn jpeg(len: usize) -> Vec<u8> { fn jpeg(len: usize) -> Vec<u8> {
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0]; let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
bytes.resize(len, 0x11); bytes.resize(len - 2, 0x11);
bytes.extend([0xFF, 0xD9]);
bytes bytes
} }
/// A PNG's signature, filler, and its `IEND` chunk, `len` bytes in all.
fn png(len: usize) -> Vec<u8> { fn png(len: usize) -> Vec<u8> {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.resize(len, 0x22); bytes.resize(len - 12, 0x22);
bytes.extend(b"\0\0\0\0IEND\xAE\x42\x60\x82");
bytes bytes
} }
@@ -1170,6 +1204,38 @@ mod tests {
assert_eq!(stub.asked(), [ISBN13, ISBN10]); assert_eq!(stub.asked(), [ISBN13, ISBN10]);
} }
#[test]
fn a_magic_number_alone_is_not_a_cover() {
let mut cut_jpeg = jpeg(4096);
cut_jpeg.truncate(4000);
let mut cut_png = png(4096);
cut_png.truncate(4000);
for (media_type, body) in [
("image/jpeg", vec![0xFF, 0xD8, 0xFF]),
("image/png", b"\x89PNG\r\n\x1a\n".to_vec()),
("image/jpeg", cut_jpeg),
("image/png", cut_png),
] {
let stub = Stub::start(move |_, _| Reply::image(media_type, body.clone()));
let found = stub
.covers()
.fetch(&agent(), "9780306406157", None, CoverSize::Large)
.map(|cover| cover.map(|cover| cover.bytes.len()));
assert!(
matches!(found, Err(Error::Shape(_))),
"{media_type}: {found:?}"
);
}
}
#[test]
fn a_jpeg_padded_after_its_end_marker_is_whole() {
let mut padded = jpeg(2048);
padded.extend([0, 0, 0]);
assert!(ends_whole("image/jpeg", &padded));
assert!(!ends_whole("image/jpeg", &[0xFF, 0xD8, 0xFF, 0, 0]));
}
#[test] #[test]
fn a_redirect_to_an_address_by_number_is_refused() { fn a_redirect_to_an_address_by_number_is_refused() {
// A listener standing in for a service on the LAN; it only counts. // A listener standing in for a service on the LAN; it only counts.
@@ -1217,6 +1283,15 @@ mod tests {
assert_eq!(resolve(from, "//c.example/x"), "https://c.example/x"); assert_eq!(resolve(from, "//c.example/x"), "https://c.example/x");
assert_eq!(resolve(from, "/x?y=1"), "https://a.example/x?y=1"); assert_eq!(resolve(from, "/x?y=1"), "https://a.example/x?y=1");
assert_eq!(resolve(from, "x.jpg"), "https://a.example/b/isbn/x.jpg"); assert_eq!(resolve(from, "x.jpg"), "https://a.example/b/isbn/x.jpg");
// An address in a relative one's query does not make it absolute.
assert_eq!(
resolve(from, "/view_archive.php?from=https://archive.org/x"),
"https://a.example/view_archive.php?from=https://archive.org/x"
);
assert_eq!(
resolve(from, "x?u=https://c.example/"),
"https://a.example/b/isbn/x?u=https://c.example/"
);
assert_eq!( assert_eq!(
origin("http://127.0.0.1:8080/a?b"), origin("http://127.0.0.1:8080/a?b"),
Some("http://127.0.0.1:8080") Some("http://127.0.0.1:8080")
+1 -2
View File
@@ -265,11 +265,10 @@ mod merge_tests {
let mut index = rec("openlibrary"); let mut index = rec("openlibrary");
index.title = Some("The First World War".into()); index.title = Some("The First World War".into());
index.authors = vec!["Michael Howard".into()]; index.authors = vec!["Michael Howard".into()];
index.year = Some(2007);
edition.fill_from(index); edition.fill_from(index);
assert_eq!(edition.authors, ["Michael Howard"]); assert_eq!(edition.authors, ["Michael Howard"]);
assert_eq!(edition.year, Some(2007)); assert_eq!(edition.language.as_deref(), Some("eng"));
// The fuller title arrived first and is not displaced by the shorter. // The fuller title arrived first and is not displaced by the shorter.
assert_eq!( assert_eq!(
edition.title.as_deref(), edition.title.as_deref(),
+119 -33
View File
@@ -17,9 +17,10 @@ pub enum Source {
Arxiv, Arxiv,
/// OpenLibrary, for books by ISBN. /// OpenLibrary, for books by ISBN.
OpenLibrary, OpenLibrary,
/// OpenLibrary's index, rather than its edition record. Asked when the /// OpenLibrary's index, rather than its edition record. Asked for what is
/// edition record has the book but not who wrote it, which is often: an /// true of the work — the title, the authors by name, the subjects —
/// edition names its authors by key, and the index names them. /// because an edition names its authors by key and the index names them.
/// Never for what differs between editions.
OpenLibrarySearch, OpenLibrarySearch,
/// Google Books, for books by ISBN. Asked last, because it answers /// Google Books, for books by ISBN. Asked last, because it answers
/// anonymous callers only until a daily quota runs out. /// anonymous callers only until a daily quota runs out.
@@ -55,14 +56,14 @@ impl Source {
match kind { match kind {
"doi" => &[Source::Crossref], "doi" => &[Source::Crossref],
"arxiv" => &[Source::Arxiv], "arxiv" => &[Source::Arxiv],
// The index first. Asked for the fields by name it answers with // The index first, for what is true of the work: the title, the
// the title, the authors, the publisher, the language, a page // authors by name, the subjects. Then the edition record, for
// count and the subjects in one request, which is the whole of // what is true of this edition only: the publisher, the language,
// what a catalogue wants; the edition record is then needed only // the year and the length. The index answers about the work, and
// for a subtitle or a publisher the index did not give. Asking it // its publishers, languages and years are every edition's at
// second cost three requests per book where one usually does, and // once, so they are not asked of it; the lookup goes on to the
// at three books in flight that is the difference between two // edition record for them. Google Books last, for whatever the
// days and two hours. // edition record does not say.
"isbn" => &[ "isbn" => &[
Source::OpenLibrarySearch, Source::OpenLibrarySearch,
Source::OpenLibrary, Source::OpenLibrary,
@@ -83,15 +84,16 @@ impl Source {
Source::OpenLibrary => { Source::OpenLibrary => {
format!("https://openlibrary.org/isbn/{}.json", encode(id)) format!("https://openlibrary.org/isbn/{}.json", encode(id))
} }
// `fields` or the index answers with a handful of defaults and // Only what is true of the work. Not `publisher`, `language`,
// keeps the rest, which is how a language it holds went unread. // `first_publish_year` or `number_of_pages_median`: those are every
// Not `cover_i`: that is the cover chosen for the whole work, and // edition's at once, and the first of each is usually another
// on a work with hundreds of editions it is usually another // edition's (the Penguin *War and Peace* came back Spanish, from
// edition's, in another translation or language. // W.W. Norton, in 1845). Not `cover_i`: that is the cover chosen
// for the whole work, often another edition's in another
// translation or language.
Source::OpenLibrarySearch => format!( Source::OpenLibrarySearch => format!(
"https://openlibrary.org/search.json?q=isbn:{}\ "https://openlibrary.org/search.json?q=isbn:{}\
&fields=title,subtitle,author_name,first_publish_year,publisher,language,subject,\ &fields=title,subtitle,author_name,subject&limit=1",
number_of_pages_median&limit=1",
encode(id) encode(id)
), ),
Source::GoogleBooks => format!( Source::GoogleBooks => format!(
@@ -295,6 +297,8 @@ struct SearchReply {
docs: Vec<SearchDoc>, docs: Vec<SearchDoc>,
} }
/// What the index says about a work. Only the fields true of every edition
/// are read, so an answer that carries the others anyway cannot leak them.
#[derive(Deserialize)] #[derive(Deserialize)]
struct SearchDoc { struct SearchDoc {
#[serde(default)] #[serde(default)]
@@ -304,15 +308,7 @@ struct SearchDoc {
#[serde(default)] #[serde(default)]
author_name: Vec<String>, author_name: Vec<String>,
#[serde(default)] #[serde(default)]
first_publish_year: Option<i64>,
#[serde(default)]
publisher: Vec<String>,
#[serde(default)]
language: Vec<String>,
#[serde(default)]
subject: Vec<String>, subject: Vec<String>,
#[serde(rename = "number_of_pages_median", default)]
pages: Option<u32>,
} }
/// How many of a work's subject headings are worth carrying. /// How many of a work's subject headings are worth carrying.
@@ -324,6 +320,11 @@ pub(crate) const SUBJECT_LIMIT: usize = 12;
/// A search that matched nothing answers with no documents rather than a /// A search that matched nothing answers with no documents rather than a
/// status, so an empty reply is the absence and not a failure. /// status, so an empty reply is the absence and not a failure.
///
/// The record says what is true of the work and nothing that belongs to one
/// edition: no publisher, language, year or page count. Those come from the
/// edition record, which [`crate::ask::lookup`] asks next because a record
/// without them is never complete.
fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error> { fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
let reply: SearchReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?; let reply: SearchReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
let Some(doc) = reply.docs.into_iter().next() else { let Some(doc) = reply.docs.into_iter().next() else {
@@ -336,14 +337,10 @@ fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error
(title, _) => title, (title, _) => title,
}; };
record.authors = doc.author_name; record.authors = doc.author_name;
record.year = doc.first_publish_year;
record.container = doc.publisher.into_iter().next();
record.language = doc.language.into_iter().next();
// The index lists every subject any edition of the work carries, which // The index lists every subject any edition of the work carries, which
// runs to hundreds on a well-known book. A shelf needs a handful. // runs to hundreds on a well-known book. A shelf needs a handful.
record.subjects = doc.subject; record.subjects = doc.subject;
record.subjects.truncate(SUBJECT_LIMIT); record.subjects.truncate(SUBJECT_LIMIT);
record.pages = doc.pages;
Ok(record) Ok(record)
} }
@@ -475,9 +472,8 @@ mod tests {
fn each_kind_goes_to_the_body_that_assigns_it() { fn each_kind_goes_to_the_body_that_assigns_it() {
assert_eq!(Source::for_kind("doi"), Some(Source::Crossref)); assert_eq!(Source::for_kind("doi"), Some(Source::Crossref));
assert_eq!(Source::for_kind("arxiv"), Some(Source::Arxiv)); assert_eq!(Source::for_kind("arxiv"), Some(Source::Arxiv));
// The index, not the edition record: it answers with the whole of // The index first, for the authors by name; the edition record names
// what a catalogue wants in one request, where the edition record // them by key, and is asked next for what only the edition knows.
// takes up to twenty-eight seconds and names its authors by key.
assert_eq!(Source::for_kind("isbn"), Some(Source::OpenLibrarySearch)); assert_eq!(Source::for_kind("isbn"), Some(Source::OpenLibrarySearch));
// No register answers about an ISSN without a key. // No register answers about an ISSN without a key.
assert_eq!(Source::for_kind("issn"), None); assert_eq!(Source::for_kind("issn"), None);
@@ -599,6 +595,96 @@ mod tests {
assert!(!Source::OpenLibrarySearch.url("1").contains("cover_i")); assert!(!Source::OpenLibrarySearch.url("1").contains("cover_i"));
} }
/// What the index said for ISBN 9780140447934, the Penguin Classics *War
/// and Peace*, on 2026-11-13, asked for the fields the lookup asked for
/// before #6: the publishers, languages and first year of every edition
/// of the work.
const WAR_AND_PEACE_INDEX: &str = r#"{"docs":[{"title":"War and Peace",
"author_name":["Leo Tolstoy"],"first_publish_year":1845,
"publisher":["W.W. Norton","IndyPublish.com","Canterbury Classics"],
"language":["spa","gre","dut","eng"],"subject":["Fiction"],
"number_of_pages_median":1296}]}"#;
/// The edition record for the same ISBN, `/isbn/9780140447934.json`, with
/// the English language its `languages` field should name.
const WAR_AND_PEACE_EDITION: &str = r#"{"title":"War and Peace (Penguin Classics)",
"publishers":["Penguin Books"],"publish_date":"September 27, 2007",
"languages":[{"key":"/languages/eng"}],"number_of_pages":1440}"#;
/// Index first, then the edition record, merged as `ask::lookup` merges.
fn looked_up(edition: &str) -> Record {
let mut kept =
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
kept.fill_from(open_library("9780140447934", "isbn", edition).expect("a record"));
kept
}
#[test]
fn the_index_says_nothing_only_an_edition_can() {
let record =
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
// What is true of the work is kept.
assert_eq!(record.title.as_deref(), Some("War and Peace"));
assert_eq!(record.authors, ["Leo Tolstoy"]);
// What belongs to one edition is not taken from all of them.
assert_eq!(record.language, None);
assert_eq!(record.container, None);
assert_eq!(record.year, None);
assert_eq!(record.pages, None);
}
#[test]
fn a_lookup_by_isbn_gives_the_editions_language_publisher_and_year() {
let record = looked_up(WAR_AND_PEACE_EDITION);
assert_eq!(record.language.as_deref(), Some("eng"));
assert_eq!(record.container.as_deref(), Some("Penguin Books"));
assert_eq!(record.year, Some(2007));
assert_eq!(record.pages, Some(1440));
assert_eq!(record.authors, ["Leo Tolstoy"]);
}
#[test]
fn what_the_edition_does_not_say_is_missing_rather_than_the_works() {
// The real edition record today names no language at all.
let record = looked_up(
r#"{"title":"War and Peace (Penguin Classics)","publishers":["Penguin Books"],
"publish_date":"September 27, 2007","number_of_pages":1440}"#,
);
assert_eq!(record.language, None);
assert_eq!(record.container.as_deref(), Some("Penguin Books"));
assert_eq!(record.year, Some(2007));
// Nor a date: the work's first year is not this edition's.
let record = looked_up(r#"{"title":"War and Peace (Penguin Classics)"}"#);
assert_eq!(record.year, None);
assert_eq!(record.container, None);
assert_eq!(record.pages, None);
}
#[test]
fn the_index_alone_never_ends_a_lookup() {
// `lookup` stops at the first complete record; the index must not be
// one, or the edition record is never asked.
let record =
open_library_search("9780140447934", "isbn", WAR_AND_PEACE_INDEX).expect("a record");
assert!(!record.complete());
}
#[test]
fn the_index_is_not_asked_for_what_its_editions_differ_in() {
let url = Source::OpenLibrarySearch.url("9780140447934");
for field in [
"publisher",
"language",
"first_publish_year",
"number_of_pages_median",
] {
assert!(
!url.split(['=', ',', '&']).any(|f| f == field),
"{field} in {url}"
);
}
}
#[test] #[test]
fn an_answer_that_is_not_what_it_should_be_says_so() { fn an_answer_that_is_not_what_it_should_be_says_so() {
assert!(matches!( assert!(matches!(