Author SHA1 Message Date
funkpower 03c19c7293 Merge remote-tracking branch 'origin/main' into bibfetch-search 2026-11-13 15:29:05 -05:00
funkpower ce8332d6d8 bibfetch: the books a register has under a title
A book that arrives as a file often has no identifier to ask about, only
a title and perhaps a name. `bibfetch search --title T [--author A]
[--limit N]` asks Open Library's index which books it has under those,
and Google Books only when Open Library has nothing, and prints one
record per candidate in the order the register gave them. From Rust,
`bibfetch::search(&agent, title, author, limit, attempts)`.

Each candidate is one edition. The index answers with works and, asked
for `editions.*`, names the edition of each that best matches; the ISBN,
publisher, language, year and cover come from that edition, the title,
authors, subjects and length from the work. The work's own ISBN list
mixes editions: the first War and Peace work lists four, while the
edition matched is a 1917 printing with none.

`id` is the first ISBN whose check digit agrees, in thirteen digits,
thirteen-digit entries taken first. A candidate with none is named by
the register's key: `olid` (OL…M for the edition, OL…W for the work when
no edition was named) or `googlebooks` (a volume id). Google's `OTHER`
shelfmarks are never read as ISBNs.

`cover_id` keeps the meaning it has in a lookup record: the edition's
own first cover, taken from `editions.cover_i` and only when the record
names that edition. The work's `cover_i` and `cover_edition_key` are not
asked for. A work with no edition named, and every Google Books
candidate, has none.

Open Library having nothing stands when Google Books is down; both down
is exit 69. Exit 0 a candidate, 1 none, 65 an empty title.

The retrying GET that lookups use is now `ask::get`, shared by both, and
no longer sleeps after its last attempt.
2026-11-13 15:28:08 -05:00
funkpower b4000b9b67 Merge pull request 'bibfetch: Open Library cover fetch by ISBN' (#2) from bibfetch-openlibrary-cover into main 2026-11-13 15:26:01 -05:00
funkpower 23969269e8 Merge remote-tracking branch 'origin/main' into bibfetch-openlibrary-cover 2026-11-13 15:24:43 -05:00
funkpower 452102639a Merge pull request 'bookmeta: MOBI/AZW/AZW3 metadata and cover from the MOBI header and EXTH records' (#1) from bookmeta-mobi-exth into main 2026-11-13 15:24:34 -05:00
funkpower 1abed4632d bibfetch: a cover id is the edition's own, and a fetch has a deadline
Review id 3 on catena#2.

P0: Record.cover_id came from the search index's cover_i, the cover
chosen for the whole work, and the index is asked first, so cover()
fell back to another edition's image (Penguin War and Peace,
9780140447934, got Standard Ebooks' Maude cover 12621906). cover_i is
no longer asked for or read; cover_id is the first positive id in the
edition record's covers, or None. An ISBN request that fails, as
opposed to a 404, now ends the fetch with that failure instead of
going on to the cover id.

Redirects go only over HTTPS on the standard port to
covers.openlibrary.org, archive.org or a host under archive.org, and
never to an address by number; the covers store the fetch began at is
still allowed.

The 1 KB size floor is gone. With default=false a missing cover is a
404, and the stand-in without it is a 43-byte GIF the media type check
refuses, so the floor could only drop real Small covers.

Covers.deadline (DEADLINE, two minutes) bounds a whole fetch: every
address, attempt, redirect, body and wait. It starts once the fetch
holds the process lock. The timeout test no longer times a span that
includes the lock wait.
2026-11-13 15:04:34 -05:00
funkpower 19e5f3ddc3 bibfetch: a book's cover from Open Library, by its ISBN
`bibfetch::cover(&agent, isbn, cover_id, size)` asks Open Library's covers
store by the ISBN's thirteen-digit form, then its ten-digit form, then by a
cover id a lookup record named, and returns the image or `Ok(None)`. The
answer is checked before it is trusted: JPEG or PNG by content type and by
magic bytes, between 1 KB and 10 MB, at most three redirects, none of them
off HTTPS. One request at a time per process; a 429 or 503 is waited out
for its Retry-After, up to a minute.

`idnorm::isbn` converts between the two forms, and only for an ISBN whose
check digit agrees. Lookup records carry `cover_id` from the edition record
and the search index. The CLI gains `bibfetch cover <isbn> -o <file>`.
2026-11-13 14:36:32 -05:00
11 changed files with 2422 additions and 51 deletions

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ Each tool installs by name, so you can take one without taking the rest.
| [`vocabmerge`](crates/bibclean/README.md#vocabmerge--the-other-command) | The near-duplicate headings in a catalogue's vocabulary | | [`vocabmerge`](crates/bibclean/README.md#vocabmerge--the-other-command) | The near-duplicate headings in a catalogue's vocabulary |
| [`seriesof`](crates/seriesof/README.md) | The series a book belongs to, and which volume this is | | [`seriesof`](crates/seriesof/README.md) | The series a book belongs to, and which volume this is |
| [`idnorm`](crates/idnorm/README.md) | DOIs, ISSNs, ISBNs and arXiv ids, read and checked | | [`idnorm`](crates/idnorm/README.md) | DOIs, ISSNs, ISBNs and arXiv ids, read and checked |
| [`bibfetch`](crates/bibfetch/README.md) | What a register says about an identifier | | [`bibfetch`](crates/bibfetch/README.md) | What a register says about an identifier, and which books it has under a title |
| [`citefind`](crates/citefind/README.md) | The references in a bibliography or a page of notes | | [`citefind`](crates/citefind/README.md) | The references in a bibliography or a page of notes |
| [`mdscrub`](crates/mdscrub/README.md) | Converted markdown in, clean markdown out | | [`mdscrub`](crates/mdscrub/README.md) | Converted markdown in, clean markdown out |
| [`mdsplit`](crates/mdsplit/README.md) | One markdown file into the works inside it | | [`mdsplit`](crates/mdsplit/README.md) | One markdown file into the works inside it |
+118 -2
View File
@@ -20,6 +20,8 @@ So this asks, and that is the only thing it does.
- **Filling in a reference** you have only the identifier for. - **Filling in a reference** you have only the identifier for.
- **Before citing something**, to check the year and the authors are what you - **Before citing something**, to check the year and the authors are what you
think they are. think they are.
- **Cataloguing a book that came with no identifier**, to find the editions a
register has under its title, and an ISBN to file it by.
## Install ## Install
@@ -107,6 +109,118 @@ register that is rate-limiting, short enough that one which is simply down
does not hold up a pipeline. An answer this cannot read is not retried, does not hold up a pipeline. An answer this cannot read is not retried,
because it will not read any better next time. because it will not read any better next time.
## Covers
```sh
bibfetch cover 978-0-306-40615-7 -o cover.jpg
```
Fetches a book's cover from Open Library's covers store by its ISBN and
writes the image. The thirteen-digit form is asked first and the ten-digit
form second, because a book is often filed under only one of them; an ISBN
whose check digit is wrong is asked about as written, since converting it
would give another book's number. `--cover-id` names an Open Library cover id
to ask by last, which is what the `cover_id` field of a lookup record holds:
the first cover in Open Library's record of that edition, never the cover its
index picked for the whole work, which is often another edition's. The cover
id is asked only when every form of the ISBN asked was answered with no cover; if
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
as well as by what the server says, no larger than ten megabytes, reached in at
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.
One request is made at a time, a `429` or `503` is waited out for as long as
its `Retry-After` says, up to a minute, and the whole fetch, every attempt and
redirect included, stops after two minutes. The two minutes start once it is
the fetch's turn, so a fetch queued behind others in the same process can wait
that long again first.
| Option | Effect |
|---|---|
| `-o`, `--output <FILE>` | Where to write the image |
| `--size <SIZE>` | `small`, `medium` or `large`. Default `large` |
| `--cover-id <ID>` | An Open Library cover id to ask by when the ISBN has none |
Exit `0` the cover was written, `1` Open Library has none, `65` the argument
was not an ISBN or the answer was not an image, `69` Open Library could not be
reached, `74` the file could not be written.
From Rust, `bibfetch::cover(&agent, isbn, cover_id, size)` returns
`Ok(None)` when there is no cover, and a [`Cover`](src/cover.rs) with the
bytes, the media type and the address asked when there is.
## Searching by title
```
$ bibfetch search --title 'The First World War' --author Howard --limit 2
{"v":1,"id":"9780192853622","kind":"isbn","found":true,"source":"openlibrary","title":"The First World War","authors":["Michael Eliot Howard"],"year":2002,"container":"Oxford University Press","subjects":["World War, 1914-1918"],"language":"eng","pages":154,"cover_id":1123124}
{"v":1,"id":"9780199205592","kind":"isbn","found":true,"source":"openlibrary","title":"The First World War: A Very Short Introduction","authors":["Michael Howard"],"year":2007,"container":"Oxford University Press","language":"eng","cover_id":14606475}
```
A book that arrives as a file often has no identifier to ask about, only a
title and perhaps a name. This asks which books a register has under those,
and prints one record per candidate: Open Library's index first, and Google
Books only when Open Library has nothing.
Each candidate is one edition. Open Library's index answers with works, and
names the edition of each that best matches the search; what belongs to an
edition — its ISBN, publisher, language, year and cover — is that edition's,
and what belongs to the work — the title, the authors, the subjects and the
length — is the work's. Google Books answers with volumes, which are editions
already.
| Field | When the candidate has an ISBN | When it has none |
|---|---|---|
| `id` | The first ISBN whose check digit agrees, in thirteen digits; one written in thirteen is taken over one written in ten | The register's own key: an Open Library id such as `OL37863121M` for the edition, or `OL267171W` for the work when no edition was named; a Google Books volume id |
| `kind` | `isbn` | `olid` for Open Library, `googlebooks` for Google Books |
An ISBN whose check digit is wrong is passed over rather than corrected, and
Google's shelfmarks, typed `OTHER`, are not read as ISBNs even when their
digits would pass for one. A record of kind `isbn` can be handed straight back
to `bibfetch` for everything the registers say about that edition; `olid` and
`googlebooks` are names no lookup here answers about.
`cover_id` is the same thing it is in a lookup record: the first cover in
Open Library's record of the edition the record names, by that edition's ISBN
or its key, so `bibfetch cover --cover-id` fetches that edition's cover and no
other. The cover the index picked for the whole work is never asked for,
because it is often another edition's. A work the index names no edition of
gives the first ISBN it lists that reads, which may be any edition's, and no
cover id; Google Books knows nothing of Open Library's covers and gives none.
The candidates are what the register said, in the order it said them. This
makes no judgement about which one is the book in hand, and keeps one that is
plainly another book, because a title is not an identifier: *The Hamlet* comes
back for *Hamlet*. Deciding whether a candidate is the same work is a
comparison of titles, and [`bibclean`](../bibclean/README.md)'s `same_work`
is one.
```sh
bibfetch search --title 'Anathem' --author Stephenson \
| jq -r 'select(.kind == "isbn") | .id' | head -1
```
Open Library answering that it has nothing is an answer, and it stands when
Google Books cannot be reached; a register being down is a failure only when
both are. Each is asked the same way a lookup asks, retried with the same
waits. Open Library is sent the title and the author as written; Google Books
is sent each word as its own `intitle:` or `inauthor:` term, so nothing in a
title is read as search syntax.
| Option | Effect |
|---|---|
| `--title <TITLE>` | The title, or as much of it as you have |
| `--author <AUTHOR>` | An author's name, or part of one |
| `--limit <COUNT>` | The most candidates to print, `1` to `40`. Default `10` |
Exit `0` at least one candidate was printed, `1` no register has the title,
`65` the title was empty, `69` no register could be reached, `74` the output
could not be written.
From Rust, `bibfetch::search(&agent, title, author, limit, attempts)` returns
the candidates, empty when no register has the title.
## Reaching the network ## Reaching the network
This and [`emb`](../emb/README.md) are the only two tools here that do. Both This and [`emb`](../emb/README.md) are the only two tools here that do. Both
@@ -114,10 +228,12 @@ say so in `--help`, and for both it is the whole of what they do rather than
something that happens on the way to something else — which is the condition something that happens on the way to something else — which is the condition
under which [the contract](../../docs/contract.md) allows it at all. under which [the contract](../../docs/contract.md) allows it at all.
Nothing is cached, nothing is stored, and nothing is sent but the identifier Nothing is cached, nothing is stored but a cover you asked to have written,
and nothing is sent but the identifier, or the title and author searched for,
and the address you pass to `--polite`. and the address you pass to `--polite`.
## See also ## See also
- [`idnorm`](../idnorm/README.md) — reading the identifier in the first place. - [`idnorm`](../idnorm/README.md) — reading the identifier in the first place.
- [`bibclean`](../bibclean/README.md) — the rest of a catalogue record. - [`bibclean`](../bibclean/README.md) — the rest of a catalogue record, and
whether a candidate is the same work.
+30 -25
View File
@@ -89,10 +89,6 @@ pub fn lookup(
} }
/// Ask a register, retrying a failure that might not be one. /// Ask a register, retrying a failure that might not be one.
///
/// Waits a second before the second attempt and two before the third: long
/// enough for a register that is rate-limiting, short enough that one which
/// is simply down does not hold up a pipeline.
fn ask( fn ask(
agent: &ureq::Agent, agent: &ureq::Agent,
source: Source, source: Source,
@@ -100,39 +96,48 @@ fn ask(
kind: &str, kind: &str,
attempts: u32, attempts: u32,
) -> Result<crate::Record, Error> { ) -> Result<crate::Record, Error> {
let mut last = Error::Unreachable("no attempt was made".into()); match get(agent, &source.url(id), attempts)? {
Some(body) => source.read(id, kind, &body),
// A register that does not have it says so with a status, and that is
// an answer rather than a failure.
None => Ok(crate::missing(id, kind, source)),
}
}
for attempt in 0..attempts.max(1) { /// The body at an address, or `None` when the register says it has no such
match fetch(agent, source, id, kind) { /// thing; a failure is asked again.
Ok(record) => return Ok(record), ///
// A shape this cannot read will not read any better next time. /// Waits a second before the second attempt and two before the third: long
Err(e @ Error::Shape(_)) => return Err(e), /// enough for a register that is rate-limiting, short enough that one which
/// is simply down does not hold up a pipeline. Reading what came back is left
/// to the caller, because an answer this cannot read will not read any better
/// next time.
pub(crate) fn get(agent: &ureq::Agent, url: &str, attempts: u32) -> Result<Option<String>, Error> {
let attempts = attempts.max(1);
let mut last = Error::Unreachable("no attempt was made".into());
for attempt in 0..attempts {
match once(agent, url) {
Ok(answer) => return Ok(answer),
Err(e) => last = e, Err(e) => last = e,
} }
std::thread::sleep(Duration::from_secs(1 << attempt)); if attempt + 1 < attempts {
std::thread::sleep(Duration::from_secs(1 << attempt.min(5)));
}
} }
Err(last) Err(last)
} }
fn fetch( fn once(agent: &ureq::Agent, url: &str) -> Result<Option<String>, Error> {
agent: &ureq::Agent, let mut response = match agent.get(url).call() {
source: Source,
id: &str,
kind: &str,
) -> Result<crate::Record, Error> {
let response = agent.get(source.url(id)).call();
let mut response = match response {
Ok(response) => response, Ok(response) => response,
// A register that does not have it says so with a status, and that is Err(ureq::Error::StatusCode(404)) => return Ok(None),
// an answer rather than a failure.
Err(ureq::Error::StatusCode(404)) => return Ok(crate::missing(id, kind, source)),
Err(e) => return Err(Error::Unreachable(e.to_string())), Err(e) => return Err(Error::Unreachable(e.to_string())),
}; };
let body = response response
.body_mut() .body_mut()
.read_to_string() .read_to_string()
.map_err(|e| Error::Unreachable(e.to_string()))?; .map(Some)
source.read(id, kind, &body) .map_err(|e| Error::Unreachable(e.to_string()))
} }
/// Who is asking. /// Who is asking.
File diff suppressed because it is too large. Load diff
+22
View File
@@ -12,8 +12,14 @@
//! them: Crossref for a DOI, the arXiv for a preprint, OpenLibrary for a //! them: Crossref for a DOI, the arXiv for a preprint, OpenLibrary for a
//! book. There is no register for an ISSN that answers without a key, so an //! book. There is no register for an ISSN that answers without a key, so an
//! ISSN is looked up as the journal of a work rather than on its own. //! ISSN is looked up as the journal of a work rather than on its own.
//!
//! A book with no identifier at all can still be looked for by its title,
//! with [`search()`]: Open Library's index, then Google Books, each candidate
//! a record of one edition, in the order the register gave them.
pub mod ask; pub mod ask;
pub mod cover;
pub mod search;
pub mod source; pub mod source;
use std::time::Duration; use std::time::Duration;
@@ -21,6 +27,8 @@ use std::time::Duration;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use cover::{Cover, CoverSize, cover};
pub use search::search;
pub use source::Source; pub use source::Source;
/// How long to wait on a register before giving up. /// How long to wait on a register before giving up.
@@ -74,6 +82,13 @@ pub struct Record {
/// How many pages the register says it has. /// How many pages the register says it has.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pages: Option<u32>, pub pages: Option<u32>,
/// The first cover id in Open Library's record of this edition, for
/// [`cover()`] to ask by when the ISBN has none. Taken from the edition
/// record only, never from the index's cover for the work, which is
/// often another edition's; `None` when the edition names no cover or
/// the lookup stopped before asking for the edition record.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cover_id: Option<u64>,
} }
fn version() -> u32 { fn version() -> u32 {
@@ -95,6 +110,8 @@ pub enum Error {
Shape(String), Shape(String),
/// Nothing here knows where to ask about this kind. /// Nothing here knows where to ask about this kind.
NoRegister(String), NoRegister(String),
/// What was asked about is not an identifier of the kind it should be.
Invalid(String),
} }
impl std::fmt::Display for Error { impl std::fmt::Display for Error {
@@ -105,6 +122,7 @@ impl std::fmt::Display for Error {
Error::NoRegister(kind) => { Error::NoRegister(kind) => {
write!(f, "no register here answers about {kind} identifiers") write!(f, "no register here answers about {kind} identifiers")
} }
Error::Invalid(why) => write!(f, "{why}"),
} }
} }
} }
@@ -130,6 +148,7 @@ pub fn missing(id: &str, kind: &str, source: Source) -> Record {
subjects: Vec::new(), subjects: Vec::new(),
language: None, language: None,
pages: None, pages: None,
cover_id: None,
} }
} }
@@ -187,6 +206,9 @@ impl Record {
if self.pages.is_none() { if self.pages.is_none() {
self.pages = other.pages; self.pages = other.pages;
} }
if self.cover_id.is_none() {
self.cover_id = other.cover_id;
}
// Which register answered becomes which registers did. // Which register answered becomes which registers did.
if !self.source.split('+').any(|s| s == other.source) { if !self.source.split('+').any(|s| s == other.source) {
self.source = format!("{}+{}", self.source, other.source); self.source = format!("{}+{}", self.source, other.source);
+158 -11
View File
@@ -29,33 +29,117 @@ Exit codes: 0 every identifier was found, 1 at least one was not,
Example: Example:
idnorm refs.txt | bibfetch | jq -r 'select(.found) | .title' idnorm refs.txt | bibfetch | jq -r 'select(.found) | .title'
idnorm --kind doi refs.txt | bibfetch --polite [email protected]" idnorm --kind doi refs.txt | bibfetch --polite [email protected]
bibfetch cover 978-0-306-40615-7 -o cover.jpg
bibfetch search --title 'The First World War' --author Howard"
)] )]
struct Args { struct Args {
#[command(subcommand)]
command: Option<Command>,
/// Identifier records to look up, as `idnorm` writes them. Reads stdin /// Identifier records to look up, as `idnorm` writes them. Reads stdin
/// when absent. /// when absent.
path: Option<PathBuf>, path: Option<PathBuf>,
/// An address to put in the request, which the registers ask for and /// An address to put in the request, which the registers ask for and
/// answer faster when they get. /// answer faster when they get.
#[arg(long, value_name = "EMAIL")] #[arg(long, global = true, value_name = "EMAIL")]
polite: Option<String>, polite: Option<String>,
/// How long to wait on a register, in seconds. /// How long to wait on a register, in seconds.
#[arg(long, default_value_t = bibfetch::TIMEOUT.as_secs(), value_name = "SECONDS")] #[arg(long, global = true, default_value_t = bibfetch::TIMEOUT.as_secs(), value_name = "SECONDS")]
timeout: u64, timeout: u64,
/// How many times to ask before believing a failure. /// How many times to ask before believing a failure.
#[arg(long, default_value_t = bibfetch::ATTEMPTS, value_name = "COUNT")] #[arg(long, global = true, default_value_t = bibfetch::ATTEMPTS, value_name = "COUNT")]
attempts: u32, attempts: u32,
} }
#[derive(clap::Subcommand)]
enum Command {
/// Fetch a book's cover from Open Library by its ISBN.
///
/// Exit codes: 0 the cover was written, 1 Open Library has none,
/// 65 not an ISBN or not an image, 69 Open Library could not be reached,
/// 74 the file could not be written.
Cover {
/// The ISBN, ten or thirteen digits, hyphens or not.
isbn: String,
/// Where to write the image.
#[arg(short, long, value_name = "FILE")]
output: PathBuf,
/// Which size to ask for.
#[arg(long, value_enum, default_value_t = Size::Large)]
size: Size,
/// An Open Library cover id to ask by when the ISBN has no cover.
#[arg(long, value_name = "ID")]
cover_id: Option<u64>,
},
/// Find the books a register has under a title, and an author.
///
/// Prints one record per candidate edition, in the order the register
/// gave them: Open Library's index, or Google Books when that has
/// nothing. Which one is the book in hand is left to you.
///
/// Exit codes: 0 at least one candidate, 1 none, 65 no title,
/// 69 no register could be reached, 74 the output could not be written.
Search {
/// The title, or as much of it as you have.
#[arg(long)]
title: String,
/// An author's name, or part of one.
#[arg(long)]
author: Option<String>,
/// The most candidates to print.
#[arg(long, default_value_t = 10, value_name = "COUNT",
value_parser = clap::value_parser!(u16).range(1..=bibfetch::search::MAX_LIMIT as i64))]
limit: u16,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum Size {
Small,
Medium,
Large,
}
fn main() -> ExitCode { fn main() -> ExitCode {
let args: Args = match catena_cli::parse() { let args: Args = match catena_cli::parse() {
Ok(args) => args, Ok(args) => args,
Err(code) => return code, Err(code) => return code,
}; };
let agent = bibfetch::ask::Ask {
polite: args.polite.clone(),
timeout: Duration::from_secs(args.timeout),
attempts: args.attempts,
}
.agent();
match &args.command {
Some(Command::Cover {
isbn,
output,
size,
cover_id,
}) => return cover(&agent, args.attempts, isbn, output, *size, *cover_id),
Some(Command::Search {
title,
author,
limit,
}) => {
return search(
&agent,
args.attempts,
title,
author.as_deref(),
usize::from(*limit),
);
}
None => {}
}
let text = match catena_cli::read_text(args.path.as_deref()) { let text = match catena_cli::read_text(args.path.as_deref()) {
Ok(text) => text, Ok(text) => text,
Err((code, why)) => { Err((code, why)) => {
@@ -64,13 +148,6 @@ fn main() -> ExitCode {
} }
}; };
let agent = bibfetch::ask::Ask {
polite: args.polite.clone(),
timeout: Duration::from_secs(args.timeout),
attempts: args.attempts,
}
.agent();
let mut records = Vec::new(); let mut records = Vec::new();
let mut missing = 0usize; let mut missing = 0usize;
@@ -114,6 +191,76 @@ fn main() -> ExitCode {
}) })
} }
/// Fetch one cover and write it where asked.
fn cover(
agent: &ureq::Agent,
attempts: u32,
isbn: &str,
output: &std::path::Path,
size: Size,
cover_id: Option<u64>,
) -> ExitCode {
let covers = bibfetch::cover::Covers {
attempts,
..Default::default()
};
let size = match size {
Size::Small => bibfetch::CoverSize::Small,
Size::Medium => bibfetch::CoverSize::Medium,
Size::Large => bibfetch::CoverSize::Large,
};
match covers.fetch(agent, isbn, cover_id, size) {
Ok(Some(found)) => match std::fs::write(output, &found.bytes) {
Ok(()) => ExitCode::from(exit::OK),
Err(why) => {
eprintln!("bibfetch: {}: {why}", output.display());
ExitCode::from(exit::IO)
}
},
Ok(None) => {
eprintln!("bibfetch: {isbn}: Open Library has no cover");
ExitCode::from(exit::NO)
}
Err(why) => {
eprintln!("bibfetch: {isbn}: {why}");
ExitCode::from(match why {
bibfetch::Error::Unreachable(_) => exit::UNAVAILABLE,
_ => exit::DATA,
})
}
}
}
/// Search by title and print the candidates.
fn search(
agent: &ureq::Agent,
attempts: u32,
title: &str,
author: Option<&str>,
limit: usize,
) -> ExitCode {
match bibfetch::search(agent, title, author, limit, attempts) {
Ok(found) => {
let wrote = catena_cli::stream("bibfetch", &found);
if format!("{wrote:?}") != format!("{:?}", ExitCode::from(exit::OK)) {
return wrote;
}
if found.is_empty() {
eprintln!("bibfetch: no register has {title:?}");
return ExitCode::from(exit::NO);
}
ExitCode::from(exit::OK)
}
Err(why) => {
eprintln!("bibfetch: {why}");
ExitCode::from(match why {
bibfetch::Error::Invalid(_) => exit::DATA,
_ => exit::UNAVAILABLE,
})
}
}
}
/// The identifier and its kind out of one record. /// The identifier and its kind out of one record.
fn read_identifier(line: &str) -> Result<(String, String), String> { fn read_identifier(line: &str) -> Result<(String, String), String> {
let value: serde_json::Value = let value: serde_json::Value =
+780
View File
@@ -0,0 +1,780 @@
//! Books by their title, for when there is no identifier to ask about.
//!
//! A book that arrives as a file often carries no ISBN at all, only a title
//! and perhaps a name. A register can still be asked which books it has under
//! those, and what it answers is a list of candidates: each an edition the
//! register holds, with whatever identifier, publisher and cover it gives.
//!
//! Which candidate is the book in hand is not decided here. The candidates
//! are what the register said, in the order it said them; deciding whether
//! one is the same work is a comparison of titles, and that belongs to
//! whoever holds the book.
use serde::Deserialize;
use crate::source::{self, GoogleIdentifier, GoogleReply, SUBJECT_LIMIT, Source};
use crate::{Error, Record};
/// The most candidates one search asks for. Google Books answers with no more
/// than forty at a time, and a title that needs more than forty candidates to
/// find the right one needs a better title.
pub const MAX_LIMIT: usize = 40;
/// What Open Library's index is asked to say about each work, and about the
/// one edition of it that best matches the search.
///
/// Not the work's own `cover_i` or `cover_edition_key`: the cover the index
/// chose for a work is often another edition's. The edition's `cover_i` is
/// the first cover in that edition's record, which is what a lookup by ISBN
/// gives as `cover_id`.
const OPEN_LIBRARY_FIELDS: &str = "key,title,subtitle,author_name,first_publish_year,publisher,\
isbn,language,subject,number_of_pages_median,editions,editions.key,editions.isbn,\
editions.publisher,editions.language,editions.publish_date,editions.cover_i";
/// Ask which books a register has under a title, and by an author if one is
/// named.
///
/// Open Library's index first, and Google Books only when that answered with
/// nothing. A register being down is a failure only when every register
/// asked is: Open Library answering that it has nothing is an answer, and it
/// stands when Google Books cannot be reached. Asks nothing when `limit` is
/// zero; a `limit` over [`MAX_LIMIT`] is taken as that.
pub fn search(
agent: &ureq::Agent,
title: &str,
author: Option<&str>,
limit: usize,
attempts: u32,
) -> Result<Vec<Record>, Error> {
search_with(title, author, limit, |url| {
crate::ask::get(agent, url, attempts)
})
}
/// [`search`], with how to fetch an address passed in.
fn search_with(
title: &str,
author: Option<&str>,
limit: usize,
mut get: impl FnMut(&str) -> Result<Option<String>, Error>,
) -> Result<Vec<Record>, Error> {
let title = title.trim();
if title.is_empty() {
return Err(Error::Invalid("no title to search for".into()));
}
let author = author.map(str::trim).filter(|author| !author.is_empty());
let limit = limit.min(MAX_LIMIT);
if limit == 0 {
return Ok(Vec::new());
}
let mut fetch = |url: String| match get(&url)? {
Some(body) => Ok(body),
// A search that matches nothing answers with an empty list. A 404 is
// the address being wrong, not the book being absent.
None => Err(Error::Unreachable(format!("{url}: answered 404"))),
};
let open_library =
fetch(open_library_url(title, author, limit)).and_then(|body| open_library(&body, limit));
if open_library.as_ref().is_ok_and(|found| !found.is_empty()) {
return open_library;
}
let google = match google_url(title, author, limit) {
Some(url) => fetch(url).and_then(|body| google_books(&body, limit)),
// Nothing in the title Google could be asked by.
None => Ok(Vec::new()),
};
match (open_library, google) {
(_, Ok(found)) if !found.is_empty() => Ok(found),
(Ok(none), _) => Ok(none),
(Err(why), _) => Err(why),
}
}
/// Where to ask Open Library.
fn open_library_url(title: &str, author: Option<&str>, limit: usize) -> String {
let author = author
.map(|author| format!("&author={}", source::encode(author)))
.unwrap_or_default();
format!(
"https://openlibrary.org/search.json?title={}{author}&fields={OPEN_LIBRARY_FIELDS}&limit={limit}",
source::encode(title),
)
}
/// Where to ask Google Books, or `None` when there is nothing to ask by.
///
/// Every word is its own `intitle:` or `inauthor:` term, so no character of
/// the title can open a phrase, negate a term or name another field: a word is
/// the letters, digits and apostrophes in it, and anything else separates
/// words.
fn google_url(title: &str, author: Option<&str>, limit: usize) -> Option<String> {
let terms: Vec<String> = words(title)
.map(|word| format!("intitle:{word}"))
.chain(
author
.into_iter()
.flat_map(words)
.map(|word| format!("inauthor:{word}")),
)
.collect();
if !terms.iter().any(|term| term.starts_with("intitle:")) {
return None;
}
Some(format!(
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults={limit}",
source::encode(&terms.join(" "))
))
}
fn words(text: &str) -> impl Iterator<Item = &str> {
text.split(|c: char| !(c.is_alphanumeric() || c == '\''))
.map(|word| word.trim_matches('\''))
.filter(|word| !word.is_empty())
}
/// The first ISBN in a list whose check digit agrees, in its thirteen-digit
/// form. One written in thirteen digits is taken over one written in ten.
fn first_isbn<'a>(isbns: impl Iterator<Item = &'a str> + Clone) -> Option<String> {
isbns
.clone()
.filter(|isbn| idnorm::isbn::digits(isbn).len() == 13)
.find_map(idnorm::isbn::to_13)
.or_else(|| isbns.into_iter().find_map(idnorm::isbn::to_13))
}
#[derive(Deserialize)]
struct OpenLibraryReply {
#[serde(default)]
docs: Vec<Work>,
}
#[derive(Deserialize)]
struct Work {
#[serde(default)]
key: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
subtitle: Option<String>,
#[serde(default)]
author_name: Vec<String>,
#[serde(default)]
first_publish_year: Option<i64>,
#[serde(default)]
publisher: Vec<String>,
#[serde(default)]
isbn: Vec<String>,
#[serde(default)]
language: Vec<String>,
#[serde(default)]
subject: Vec<String>,
#[serde(default)]
number_of_pages_median: Option<u32>,
#[serde(default)]
editions: Option<Editions>,
}
#[derive(Deserialize)]
struct Editions {
#[serde(default)]
docs: Vec<Edition>,
}
#[derive(Deserialize)]
struct Edition {
#[serde(default)]
key: Option<String>,
#[serde(default)]
isbn: Vec<String>,
#[serde(default)]
publisher: Vec<String>,
#[serde(default)]
language: Vec<String>,
#[serde(default)]
publish_date: Vec<String>,
#[serde(default)]
cover_i: Option<i64>,
}
/// The last segment of an Open Library key: `OL3585423M` out of
/// `/books/OL3585423M`. The letter at the end says what it names, `M` an
/// edition and `W` a work.
fn olid(key: &str) -> Option<String> {
key.rsplit('/')
.next()
.filter(|id| !id.is_empty())
.map(str::to_string)
}
/// Read Open Library's answer into one candidate per work, in its order.
///
/// What belongs to an edition — its ISBN, publisher, language, year and
/// cover — is taken from the edition the index chose as matching best; what
/// belongs to the work — its title, authors, subjects and length — from the
/// work. A work the index names no edition of gives its first ISBN that reads,
/// which may be any edition's, and no cover id.
fn open_library(body: &str, limit: usize) -> Result<Vec<Record>, Error> {
let reply: OpenLibraryReply =
serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
Ok(reply
.docs
.into_iter()
.filter_map(candidate)
.take(limit)
.collect())
}
fn candidate(work: Work) -> Option<Record> {
let edition = work.editions.and_then(|e| e.docs.into_iter().next());
// The cover is kept only when the record names the edition it is the
// cover of, by that edition's ISBN or its key.
let (id, kind, edition_named) = match &edition {
Some(edition) => match first_isbn(edition.isbn.iter().map(String::as_str)) {
Some(isbn) => (isbn, "isbn", true),
None => match edition.key.as_deref().and_then(olid) {
Some(key) => (key, "olid", true),
None => (work.key.as_deref().and_then(olid)?, "olid", false),
},
},
None => match first_isbn(work.isbn.iter().map(String::as_str)) {
Some(isbn) => (isbn, "isbn", false),
// A work with no key cannot be named, so it is not a candidate.
None => (work.key.as_deref().and_then(olid)?, "olid", false),
},
};
let mut record = source::blank(&id, kind, Source::OpenLibrarySearch);
record.title = match (work.title, work.subtitle) {
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
(title, _) => title,
};
record.authors = work.author_name;
record.subjects = work.subject;
record.subjects.truncate(SUBJECT_LIMIT);
record.pages = work.number_of_pages_median;
let (publisher, language, year) = match edition {
Some(edition) => {
if edition_named {
record.cover_id = edition
.cover_i
.and_then(|id| u64::try_from(id).ok())
.filter(|id| *id > 0);
}
(
edition.publisher.into_iter().next(),
edition.language.into_iter().next(),
edition
.publish_date
.iter()
.find_map(|date| source::year_in(date)),
)
}
None => (None, None, None),
};
record.container = publisher.or_else(|| work.publisher.into_iter().next());
record.language = language.or_else(|| work.language.into_iter().next());
record.year = year.or(work.first_publish_year);
Some(record)
}
/// Read Google Books' answer into one candidate per volume, in its order.
fn google_books(body: &str, limit: usize) -> Result<Vec<Record>, Error> {
let reply: GoogleReply = serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
Ok(reply
.items
.into_iter()
.filter_map(|item| {
// Only what Google types as an ISBN: its `OTHER` identifiers are
// library shelfmarks, some of which are ten digits long.
let isbns = item
.volume_info
.identifiers
.iter()
.filter(|GoogleIdentifier { kind, .. }| kind == "ISBN_13" || kind == "ISBN_10")
.map(|identifier| identifier.identifier.as_str());
let (id, kind) = match first_isbn(isbns) {
Some(isbn) => (isbn, "isbn"),
None => (item.id.filter(|id| !id.is_empty())?, "googlebooks"),
};
Some(source::google_volume(&id, kind, item.volume_info))
})
.take(limit)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
/// Open Library's answer to `title=the first world war&author=howard`,
/// recorded 2026-11-13 with the fields above and `limit=2`, the ISBN and
/// subject lists shortened.
const HOWARD: &str = r#"{"numFound": 9, "start": 0, "numFoundExact": true, "num_found": 9,
"documentation_url": "https://openlibrary.org/dev/docs/api/search", "q": "", "offset": null,
"docs": [
{"author_name": ["Michael Eliot Howard"], "first_publish_year": 2002,
"isbn": ["9780192853622", "0192853627"], "key": "/works/OL1382726W",
"language": ["eng"], "number_of_pages_median": 154,
"publisher": ["Oxford University Press"], "title": "The First World War",
"subject": ["World War, 1914-1918"],
"editions": {"numFound": 1, "start": 0, "numFoundExact": true, "docs": [
{"key": "/books/OL3585423M", "cover_i": 1123124, "language": ["eng"],
"publisher": ["Oxford University Press"], "publish_date": ["2002"],
"isbn": ["0192853627", "9780192853622"]}]}},
{"author_name": ["Michael Howard"], "first_publish_year": 2007,
"isbn": ["0199205590", "9780199205592"], "key": "/works/OL4282399W",
"language": ["eng"], "publisher": ["Oxford University Press"],
"subtitle": "A Very Short Introduction", "title": "The First World War",
"editions": {"numFound": 1, "start": 0, "numFoundExact": true, "docs": [
{"key": "/books/OL7403004M", "cover_i": 14606475, "language": ["eng"],
"publisher": ["Oxford University Press"], "publish_date": ["20 May 2007"],
"isbn": ["9780199205592", "0199205590"]}]},
"subject": []}]}"#;
/// Open Library's answer to `title=war and peace&author=tolstoy`, recorded
/// 2026-11-13, first work only. The edition the index matched best is a
/// 1917 printing that has no ISBN, though other editions of the work do.
const TOLSTOY: &str = r#"{"numFound": 146, "docs": [
{"author_name": ["Leo Tolstoy"], "first_publish_year": 1867,
"isbn": ["9781524881993", "1645178889"], "key": "/works/OL267171W",
"title": "War and Peace",
"editions": {"numFound": 487, "docs": [
{"key": "/books/OL37863121M", "cover_i": 12736340, "language": ["eng"],
"publisher": ["Thomas Y. Crowell"], "publish_date": ["1917"]}]}}]}"#;
/// Open Library's answer when nothing has the title.
const NOTHING: &str = r#"{"numFound": 0, "start": 0, "numFoundExact": true, "num_found": 0,
"documentation_url": "https://openlibrary.org/dev/docs/api/search", "q": "",
"offset": null, "docs": []}"#;
/// Google Books' answer for a volume, in the shape its API documents.
const GOOGLE: &str = r#"{"kind": "books#volumes", "totalItems": 2, "items": [
{"kind": "books#volume", "id": "zyTCAlFPjgYC", "volumeInfo": {
"title": "The Google Story", "authors": ["David A. Vise", "Mark Malseed"],
"publisher": "Random House Digital, Inc.", "publishedDate": "2005-11-15",
"industryIdentifiers": [
{"type": "ISBN_10", "identifier": "055380457X"},
{"type": "ISBN_13", "identifier": "9780553804577"}],
"pageCount": 207, "categories": ["Browsers (Computer programs)"],
"language": "en"}},
{"kind": "books#volume", "id": "a1b2C3d4", "volumeInfo": {
"title": "The Google Story", "subtitle": "A Dissertation",
"publishedDate": "2009",
"industryIdentifiers": [
{"type": "OTHER", "identifier": "UOM:0306406152"}]}}]}"#;
/// Fetch from a table of recorded bodies, keeping every address asked.
fn recorded<'a>(
asked: &'a mut Vec<String>,
bodies: &'a [(&'a str, Result<&'a str, Error>)],
) -> impl FnMut(&str) -> Result<Option<String>, Error> + 'a {
move |url| {
asked.push(url.to_string());
let (_, body) = bodies
.iter()
.find(|(host, _)| url.contains(host))
.unwrap_or_else(|| panic!("asked an address nothing was recorded for: {url}"));
body.clone().map(|body| Some(body.to_string()))
}
}
fn down() -> Error {
Error::Unreachable("connection refused".into())
}
#[test]
fn each_work_open_library_has_is_a_candidate_named_by_its_isbn() {
let mut asked = Vec::new();
let bodies = [("openlibrary.org", Ok(HOWARD))];
let found = search_with(
"The First World War",
Some("Howard"),
5,
recorded(&mut asked, &bodies),
)
.expect("candidates");
assert_eq!(
asked.len(),
1,
"Google is not asked when Open Library answers"
);
assert_eq!(found.len(), 2);
let first = &found[0];
assert!(first.found);
assert_eq!(first.v, corpus_core::V);
assert_eq!(first.source, "openlibrary");
// Written ten-digit first by the edition, given in thirteen.
assert_eq!(
(first.id.as_str(), first.kind.as_str()),
("9780192853622", "isbn")
);
assert_eq!(first.title.as_deref(), Some("The First World War"));
assert_eq!(first.authors, ["Michael Eliot Howard"]);
assert_eq!(first.year, Some(2002));
assert_eq!(first.container.as_deref(), Some("Oxford University Press"));
assert_eq!(first.language.as_deref(), Some("eng"));
assert_eq!(first.subjects, ["World War, 1914-1918"]);
assert_eq!(first.pages, Some(154));
assert_eq!(first.cover_id, Some(1123124));
let second = &found[1];
assert_eq!(second.id, "9780199205592");
assert_eq!(
second.title.as_deref(),
Some("The First World War: A Very Short Introduction")
);
// The edition's year, not the work's first.
assert_eq!(second.year, Some(2007));
// The first cover in OL7403004M's own record; the work's cover is
// 1137779, which is not.
assert_eq!(second.cover_id, Some(14606475));
}
#[test]
fn the_candidates_stay_in_the_order_the_register_gave_them() {
let mut asked = Vec::new();
let bodies = [("openlibrary.org", Ok(HOWARD))];
let found =
search_with("first world war", None, 5, recorded(&mut asked, &bodies)).expect("ok");
let ids: Vec<&str> = found.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, ["9780192853622", "9780199205592"]);
let found =
search_with("first world war", None, 1, recorded(&mut asked, &bodies)).expect("ok");
assert_eq!(found.len(), 1);
assert_eq!(found[0].id, "9780192853622");
}
#[test]
fn an_edition_with_no_isbn_is_named_by_its_key_and_keeps_its_cover() {
let mut asked = Vec::new();
let bodies = [("openlibrary.org", Ok(TOLSTOY))];
let found = search_with(
"War and Peace",
Some("Tolstoy"),
5,
recorded(&mut asked, &bodies),
)
.expect("candidates");
let only = &found[0];
// Not 9781524881993: that is another edition's, and the publisher,
// year and cover here are this one's.
assert_eq!(
(only.id.as_str(), only.kind.as_str()),
("OL37863121M", "olid")
);
assert_eq!(only.container.as_deref(), Some("Thomas Y. Crowell"));
assert_eq!(only.year, Some(1917));
assert_eq!(only.cover_id, Some(12736340));
}
#[test]
fn a_work_with_no_edition_named_gives_its_isbn_but_no_cover() {
let body = r#"{"docs": [{"key": "/works/OL1W", "title": "A Book",
"isbn": ["0-306-40615-2"], "publisher": ["Plenum"], "first_publish_year": 1981}]}"#;
let found = open_library(body, 5).expect("candidates");
assert_eq!(
(found[0].id.as_str(), found[0].kind.as_str()),
("9780306406157", "isbn")
);
assert_eq!(found[0].container.as_deref(), Some("Plenum"));
assert_eq!(found[0].year, Some(1981));
assert_eq!(found[0].cover_id, None);
}
#[test]
fn a_work_with_no_isbn_and_no_edition_is_named_by_the_works_key() {
let body = r#"{"docs": [{"key": "/works/OL267171W", "title": "War and Peace"}]}"#;
let found = open_library(body, 5).expect("candidates");
assert_eq!(
(found[0].id.as_str(), found[0].kind.as_str()),
("OL267171W", "olid")
);
assert_eq!(found[0].cover_id, None);
}
#[test]
fn isbns_whose_check_digit_is_wrong_are_passed_over() {
// 9780306406158 and 0306406153 are 9780306406157 mistyped.
let body = r#"{"docs": [
{"key": "/works/OL1W", "title": "A", "editions": {"docs": [
{"key": "/books/OL1M", "isbn": ["9780306406158", "0306406153"], "cover_i": 7}]}},
{"key": "/works/OL2W", "title": "B", "editions": {"docs": [
{"key": "/books/OL2M", "isbn": ["9780306406158", "0306406152"]}]}}]}"#;
let found = open_library(body, 5).expect("candidates");
assert_eq!(
(found[0].id.as_str(), found[0].kind.as_str()),
("OL1M", "olid")
);
assert_eq!(found[0].cover_id, Some(7));
// The ten-digit one reads, and is given in thirteen.
assert_eq!(
(found[1].id.as_str(), found[1].kind.as_str()),
("9780306406157", "isbn")
);
}
#[test]
fn thirteen_digits_are_taken_over_ten() {
// Two different ISBNs on one edition: the thirteen-digit one wins
// although the ten-digit one is listed first.
let isbns = ["0306406152", "9780199205592"];
assert_eq!(
first_isbn(isbns.into_iter()).as_deref(),
Some("9780199205592")
);
assert_eq!(first_isbn(["nonsense"].into_iter()), None);
}
#[test]
fn nothing_from_open_library_is_asked_of_google_books() {
let mut asked = Vec::new();
let bodies = [
("openlibrary.org", Ok(NOTHING)),
("googleapis.com", Ok(GOOGLE)),
];
let found = search_with(
"The Google Story",
Some("Vise"),
5,
recorded(&mut asked, &bodies),
)
.expect("candidates");
assert_eq!(asked.len(), 2);
assert!(asked[1].starts_with("https://www.googleapis.com/books/v1/volumes?q="));
assert_eq!(found.len(), 2);
let first = &found[0];
assert_eq!(first.source, "googlebooks");
assert_eq!(
(first.id.as_str(), first.kind.as_str()),
("9780553804577", "isbn")
);
assert_eq!(first.authors, ["David A. Vise", "Mark Malseed"]);
assert_eq!(first.year, Some(2005));
assert_eq!(
first.container.as_deref(),
Some("Random House Digital, Inc.")
);
assert_eq!(first.pages, Some(207));
// Google Books knows nothing of Open Library's covers.
assert_eq!(first.cover_id, None);
// A shelfmark is not an ISBN, though its digits would pass for one.
let second = &found[1];
assert_eq!(
(second.id.as_str(), second.kind.as_str()),
("a1b2C3d4", "googlebooks")
);
assert_eq!(
second.title.as_deref(),
Some("The Google Story: A Dissertation")
);
}
#[test]
fn nothing_from_either_register_is_no_candidates() {
let mut asked = Vec::new();
let bodies = [
("openlibrary.org", Ok(NOTHING)),
(
"googleapis.com",
Ok(r#"{"kind": "books#volumes", "totalItems": 0}"#),
),
];
let found =
search_with("Nothing", None, 5, recorded(&mut asked, &bodies)).expect("an answer");
assert!(found.is_empty());
}
#[test]
fn open_library_having_nothing_stands_when_google_books_is_down() {
let mut asked = Vec::new();
let bodies = [
("openlibrary.org", Ok(NOTHING)),
("googleapis.com", Err(down())),
];
let found =
search_with("Nothing", None, 5, recorded(&mut asked, &bodies)).expect("an answer");
assert!(found.is_empty());
}
#[test]
fn open_library_down_is_asked_of_google_books() {
let mut asked = Vec::new();
let bodies = [
("openlibrary.org", Err(down())),
("googleapis.com", Ok(GOOGLE)),
];
let found =
search_with("The Google Story", None, 5, recorded(&mut asked, &bodies)).expect("ok");
assert_eq!(found[0].source, "googlebooks");
}
#[test]
fn every_register_down_is_a_failure() {
let mut asked = Vec::new();
let bodies = [
("openlibrary.org", Err(down())),
("googleapis.com", Err(down())),
];
let failed = search_with("A Book", None, 5, recorded(&mut asked, &bodies));
assert!(matches!(failed, Err(Error::Unreachable(_))), "{failed:?}");
}
#[test]
fn an_answer_that_cannot_be_read_says_so() {
assert!(matches!(open_library("<html>", 5), Err(Error::Shape(_))));
assert!(matches!(google_books("<html>", 5), Err(Error::Shape(_))));
}
#[test]
fn the_author_is_left_out_when_there_is_none() {
for author in [None, Some(""), Some(" ")] {
let url = open_library_url(
"Anathem",
author.map(str::trim).filter(|a| !a.is_empty()),
5,
);
assert!(!url.contains("author="), "{url}");
}
let mut asked = Vec::new();
let bodies = [
("openlibrary.org", Ok(NOTHING)),
("googleapis.com", Ok(GOOGLE)),
];
search_with("Anathem", Some(" "), 5, recorded(&mut asked, &bodies)).expect("ok");
assert!(!asked[0].contains("author="), "{}", asked[0]);
assert!(!asked[1].contains("inauthor"), "{}", asked[1]);
assert!(asked[1].contains("q=intitle%3AAnathem&"), "{}", asked[1]);
}
#[test]
fn the_author_narrows_the_search_when_there_is_one() {
let url = open_library_url("Anathem", Some("Neal Stephenson"), 5);
assert!(
url.contains("?title=Anathem&author=Neal%20Stephenson&fields="),
"{url}"
);
let url = google_url("Anathem", Some("Neal Stephenson"), 5).expect("a url");
assert!(
url.contains("q=intitle%3AAnathem%20inauthor%3ANeal%20inauthor%3AStephenson&"),
"{url}"
);
}
#[test]
fn a_title_cannot_change_the_shape_of_the_address() {
let hostile = r#"Fish & Chips: "A History" #2?=x/y+z"#;
for url in [
open_library_url(hostile, Some("O'Brien & Sons"), 5),
google_url(hostile, Some("O'Brien & Sons"), 5).expect("a url"),
] {
assert_eq!(url.matches('?').count(), 1, "{url}");
assert_eq!(url.matches('#').count(), 0, "{url}");
assert_eq!(url.matches('"').count(), 0, "{url}");
assert_eq!(url.matches('+').count(), 0, "{url}");
let query = url.split_once('?').expect("a query").1;
let names: Vec<&str> = query
.split('&')
.map(|pair| pair.split_once('=').expect("name=value").0)
.collect();
assert!(
names == ["title", "author", "fields", "limit"] || names == ["q", "maxResults"],
"{url}"
);
}
// Open Library is asked the title as written.
let url = open_library_url(hostile, None, 5);
assert!(
url.contains("title=Fish%20%26%20Chips%3A%20%22A%20History%22%20%232%3F%3Dx%2Fy%2Bz&"),
"{url}"
);
// Google is asked word by word, so nothing in a title is syntax.
let url = google_url(hostile, Some("O'Brien & Sons"), 5).expect("a url");
let q = "intitle:Fish intitle:Chips intitle:A intitle:History intitle:2 intitle:x \
intitle:y intitle:z inauthor:O'Brien inauthor:Sons";
assert!(url.contains(&format!("q={}&", source::encode(q))), "{url}");
}
#[test]
fn a_title_outside_ascii_is_sent_as_utf8() {
let url = open_library_url("Преступление и наказание", Some("Dostoevský"), 5);
assert!(
url.contains("title=%D0%9F%D1%80%D0%B5%D1%81%D1%82%D1%83%D0%BF"),
"{url}"
);
assert!(url.contains("author=Dostoevsk%C3%BD&"), "{url}");
let url = google_url("Преступление и наказание", None, 5).expect("a url");
// Letters in any script are words.
assert!(url.contains("intitle%3A%D0%B8%20"), "{url}");
}
#[test]
fn a_title_with_no_words_is_not_asked_of_google_books() {
assert_eq!(google_url("?!", Some("Someone"), 5), None);
let mut asked = Vec::new();
let bodies = [("openlibrary.org", Ok(NOTHING))];
let found = search_with("?!", Some("Someone"), 5, recorded(&mut asked, &bodies))
.expect("an answer");
assert!(found.is_empty());
assert_eq!(asked.len(), 1);
}
#[test]
fn no_title_is_refused_without_asking() {
let mut asked = Vec::new();
let failed = search_with(" ", Some("Someone"), 5, recorded(&mut asked, &[]));
assert!(matches!(failed, Err(Error::Invalid(_))), "{failed:?}");
assert!(asked.is_empty());
}
#[test]
fn the_limit_is_asked_for_and_held_to() {
let mut asked = Vec::new();
let bodies = [
("openlibrary.org", Ok(NOTHING)),
("googleapis.com", Ok(GOOGLE)),
];
let found =
search_with("The Google Story", None, 1, recorded(&mut asked, &bodies)).expect("ok");
assert_eq!(found.len(), 1);
assert!(asked[0].ends_with("&limit=1"), "{}", asked[0]);
assert!(asked[1].ends_with("&maxResults=1"), "{}", asked[1]);
let mut asked = Vec::new();
search_with("The Google Story", None, 500, recorded(&mut asked, &bodies)).expect("ok");
assert!(asked[0].ends_with("&limit=40"), "{}", asked[0]);
assert!(asked[1].ends_with("&maxResults=40"), "{}", asked[1]);
let mut asked = Vec::new();
let found =
search_with("The Google Story", None, 0, recorded(&mut asked, &bodies)).expect("ok");
assert!(found.is_empty());
assert!(asked.is_empty());
}
#[test]
fn the_index_is_not_asked_for_the_works_cover() {
let url = open_library_url("A", None, 5);
let fields = url
.split('&')
.find_map(|pair| pair.strip_prefix("fields="))
.expect("fields");
let fields: Vec<&str> = fields.split(',').collect();
assert!(fields.contains(&"editions.cover_i"));
assert!(!fields.contains(&"cover_i"));
assert!(!fields.contains(&"cover_edition_key"));
}
#[test]
fn a_404_from_a_search_is_a_failure_and_not_an_absence() {
let failed = search_with("A Book", None, 5, |_| Ok(None));
assert!(matches!(failed, Err(Error::Unreachable(_))), "{failed:?}");
}
}
+90 -11
View File
@@ -85,6 +85,9 @@ impl Source {
} }
// `fields` or the index answers with a handful of defaults and // `fields` or the index answers with a handful of defaults and
// keeps the rest, which is how a language it holds went unread. // keeps the rest, which is how a language it holds went unread.
// Not `cover_i`: that is the cover chosen for the whole work, and
// on a work with hundreds of editions it is usually 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,first_publish_year,publisher,language,subject,\
@@ -114,7 +117,7 @@ number_of_pages_median&limit=1",
/// ///
/// A DOI suffix may hold a slash, a question mark or a hash, and every one of /// A DOI suffix may hold a slash, a question mark or a hash, and every one of
/// those means something else in a URL. /// those means something else in a URL.
fn encode(text: &str) -> String { pub(crate) fn encode(text: &str) -> String {
let mut out = String::with_capacity(text.len()); let mut out = String::with_capacity(text.len());
for byte in text.bytes() { for byte in text.bytes() {
match byte { match byte {
@@ -128,7 +131,7 @@ fn encode(text: &str) -> String {
} }
/// A record with the fields nothing found fills in. /// A record with the fields nothing found fills in.
fn blank(id: &str, kind: &str, source: Source) -> Record { pub(crate) fn blank(id: &str, kind: &str, source: Source) -> Record {
let mut record = crate::missing(id, kind, source); let mut record = crate::missing(id, kind, source);
record.found = true; record.found = true;
record record
@@ -245,6 +248,9 @@ struct OpenLibraryBook {
languages: Vec<OpenLibraryKey>, languages: Vec<OpenLibraryKey>,
#[serde(default)] #[serde(default)]
number_of_pages: Option<u32>, number_of_pages: Option<u32>,
/// Cover ids, best first. A deleted cover is left in the list as `-1`.
#[serde(default)]
covers: Vec<i64>,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -275,6 +281,11 @@ fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
.find_map(|l| l.key.rsplit('/').next().map(str::to_string)) .find_map(|l| l.key.rsplit('/').next().map(str::to_string))
.filter(|l| !l.is_empty()); .filter(|l| !l.is_empty());
record.pages = book.number_of_pages; record.pages = book.number_of_pages;
// The edition's own cover, and the only place a cover id is taken from.
record.cover_id = book
.covers
.into_iter()
.find_map(|id| u64::try_from(id).ok().filter(|id| *id > 0));
Ok(record) Ok(record)
} }
@@ -309,7 +320,7 @@ struct SearchDoc {
/// The index lists every heading every edition has ever been given, which on a /// The index lists every heading every edition has ever been given, which on a
/// well-known book is hundreds and on Hamlet is thousands. A shelf is filed /// well-known book is hundreds and on Hamlet is thousands. A shelf is filed
/// from a handful, and the first are the broadest. /// from a handful, and the first are the broadest.
const SUBJECT_LIMIT: usize = 12; 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.
@@ -337,19 +348,22 @@ fn open_library_search(id: &str, kind: &str, body: &str) -> Result<Record, Error
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct GoogleReply { pub(crate) struct GoogleReply {
#[serde(default)] #[serde(default)]
items: Vec<GoogleItem>, pub(crate) items: Vec<GoogleItem>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct GoogleItem { pub(crate) struct GoogleItem {
/// Google's own key for the volume.
#[serde(default)]
pub(crate) id: Option<String>,
#[serde(rename = "volumeInfo", default)] #[serde(rename = "volumeInfo", default)]
volume_info: GoogleVolume, pub(crate) volume_info: GoogleVolume,
} }
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
struct GoogleVolume { pub(crate) struct GoogleVolume {
#[serde(default)] #[serde(default)]
title: Option<String>, title: Option<String>,
#[serde(default)] #[serde(default)]
@@ -368,6 +382,18 @@ struct GoogleVolume {
language: Option<String>, language: Option<String>,
#[serde(rename = "pageCount", default)] #[serde(rename = "pageCount", default)]
page_count: Option<u32>, page_count: Option<u32>,
/// `[{"type": "ISBN_13", "identifier": "…"}]`, alongside library
/// shelfmarks typed `OTHER`.
#[serde(rename = "industryIdentifiers", default)]
pub(crate) identifiers: Vec<GoogleIdentifier>,
}
#[derive(Deserialize)]
pub(crate) struct GoogleIdentifier {
#[serde(rename = "type", default)]
pub(crate) kind: String,
#[serde(default)]
pub(crate) identifier: String,
} }
/// A search that matched nothing answers with no items rather than a status, /// A search that matched nothing answers with no items rather than a status,
@@ -377,8 +403,11 @@ fn google_books(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
let Some(item) = reply.items.into_iter().next() else { let Some(item) = reply.items.into_iter().next() else {
return Ok(crate::missing(id, kind, Source::GoogleBooks)); return Ok(crate::missing(id, kind, Source::GoogleBooks));
}; };
let volume = item.volume_info; Ok(google_volume(id, kind, item.volume_info))
}
/// One Google Books volume as a record.
pub(crate) fn google_volume(id: &str, kind: &str, volume: GoogleVolume) -> Record {
let mut record = blank(id, kind, Source::GoogleBooks); let mut record = blank(id, kind, Source::GoogleBooks);
record.title = match (volume.title, volume.subtitle) { record.title = match (volume.title, volume.subtitle) {
(Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")), (Some(title), Some(subtitle)) => Some(format!("{title}: {subtitle}")),
@@ -391,11 +420,11 @@ fn google_books(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
record.subjects = volume.categories; record.subjects = volume.categories;
record.language = volume.language; record.language = volume.language;
record.pages = volume.page_count; record.pages = volume.page_count;
Ok(record) record
} }
/// The first four-digit year in a string. /// The first four-digit year in a string.
fn year_in(text: &str) -> Option<i64> { pub(crate) fn year_in(text: &str) -> Option<i64> {
let bytes: Vec<char> = text.chars().collect(); let bytes: Vec<char> = text.chars().collect();
for window in bytes.windows(4) { for window in bytes.windows(4) {
if window.iter().all(char::is_ascii_digit) { if window.iter().all(char::is_ascii_digit) {
@@ -520,6 +549,56 @@ mod tests {
} }
} }
#[test]
fn the_cover_id_a_record_names_is_kept_for_the_cover_lookup() {
// A deleted cover stays in an edition's list as -1, ahead of the
// ones still there.
let record = open_library("1", "isbn", r#"{"title":"A","covers":[-1,1162784,99]}"#)
.expect("a record");
assert_eq!(record.cover_id, Some(1162784));
let record = open_library("1", "isbn", r#"{"title":"A"}"#).expect("a record");
assert_eq!(record.cover_id, None);
}
#[test]
fn the_cover_id_is_the_editions_and_not_the_works() {
// ISBN 9780140447934 today: the index (asked first) names the work's
// cover; the edition record has none. Merged as `ask::lookup` merges.
let mut kept = open_library_search(
"9780140447934",
"isbn",
r#"{"docs":[{"title":"War and Peace","cover_i":12621906}]}"#,
)
.expect("a record");
let edition = open_library("9780140447934", "isbn", r#"{"title":"War and Peace"}"#)
.expect("a record");
kept.fill_from(edition);
// Cover 12621906 is OL34941467M, Standard Ebooks' Maude translation.
assert_eq!(kept.cover_id, None);
}
#[test]
fn an_edition_with_no_covers_gives_no_cover_id() {
let index = r#"{"docs":[{"title":"War and Peace","cover_i":12621906}]}"#;
// `lookup` often stops at the index, before the edition is asked.
let alone = open_library_search("9780140447934", "isbn", index).expect("a record");
assert_eq!(alone.cover_id, None);
// An edition whose only cover was deleted, and one with an empty list.
for edition in [
r#"{"title":"War and Peace","covers":[-1]}"#,
r#"{"title":"War and Peace","covers":[]}"#,
] {
let mut kept = open_library_search("9780140447934", "isbn", index).expect("a record");
kept.fill_from(open_library("9780140447934", "isbn", edition).expect("a record"));
assert_eq!(kept.cover_id, None, "{edition}");
}
}
#[test]
fn the_index_is_not_asked_for_the_works_cover() {
assert!(!Source::OpenLibrarySearch.url("1").contains("cover_i"));
}
#[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!(
+99
View File
@@ -0,0 +1,99 @@
//! An ISBN's two forms, ten digits and thirteen.
//!
//! A book printed before 2007 carries a ten-digit ISBN, one printed since
//! carries thirteen, and every ten-digit one has a thirteen-digit twin: `978`
//! in front and the check digit worked out again. A catalogue may file a
//! book under either, so asking about one form and not the other misses
//! books that are there.
//!
//! Only a well-formed ISBN is converted. Working out a fresh check digit for
//! one whose own check digit is wrong would not correct the typing error, it
//! would turn the number into a different book's.
/// Just the digits of an ISBN, with a check character of ten as `X`.
pub fn digits(text: &str) -> String {
bibclean::isbn::digits(text).to_ascii_uppercase()
}
/// The thirteen-digit form of an ISBN, from either form.
///
/// `None` when the text is not an ISBN whose check digit agrees.
pub fn to_13(text: &str) -> Option<String> {
let digits = digits(text);
if !bibclean::isbn::valid(&digits) {
return None;
}
match digits.len() {
13 => Some(digits),
10 => {
let body = format!("978{}", &digits[..9]);
let sum: u32 = body
.bytes()
.enumerate()
.map(|(at, b)| u32::from(b - b'0') * if at % 2 == 0 { 1 } else { 3 })
.sum();
Some(format!("{body}{}", (10 - sum % 10) % 10))
}
_ => None,
}
}
/// The ten-digit form of an ISBN, from either form.
///
/// `None` when the text is not an ISBN whose check digit agrees, or when it
/// is a thirteen-digit one starting `979`, which was never issued in ten.
pub fn to_10(text: &str) -> Option<String> {
let digits = digits(text);
if !bibclean::isbn::valid(&digits) {
return None;
}
match digits.len() {
10 => Some(digits),
13 if digits.starts_with("978") => {
let body = &digits[3..12];
let sum: u32 = body
.bytes()
.enumerate()
.map(|(at, b)| u32::from(b - b'0') * (10 - at as u32))
.sum();
let check = match (11 - sum % 11) % 11 {
10 => 'X',
n => char::from(b'0' + n as u8),
};
Some(format!("{body}{check}"))
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_form_becomes_the_other() {
assert_eq!(to_13("0-306-40615-2").as_deref(), Some("9780306406157"));
assert_eq!(to_10("978-0-306-40615-7").as_deref(), Some("0306406152"));
assert_eq!(to_13("9780306406157").as_deref(), Some("9780306406157"));
assert_eq!(to_10("0306406152").as_deref(), Some("0306406152"));
}
#[test]
fn a_check_digit_of_ten_is_written_x() {
assert_eq!(to_10("9780804429573").as_deref(), Some("080442957X"));
assert_eq!(to_13("080442957x").as_deref(), Some("9780804429573"));
}
#[test]
fn a_979_isbn_has_no_ten_digit_form() {
assert_eq!(to_10("979-10-90636-07-1"), None);
assert_eq!(to_13("979-10-90636-07-1").as_deref(), Some("9791090636071"));
}
#[test]
fn a_mistyped_isbn_is_not_converted_into_a_different_book() {
assert_eq!(to_13("0-306-40615-3"), None);
assert_eq!(to_10("978-0-306-40615-8"), None);
assert_eq!(to_13("12345"), None);
}
}
+1
View File
@@ -13,6 +13,7 @@
pub mod arxiv; pub mod arxiv;
pub mod doi; pub mod doi;
pub mod isbn;
pub mod issn; pub mod issn;
use schemars::JsonSchema; use schemars::JsonSchema;
+1 -1
View File
@@ -64,7 +64,7 @@ No network, no database, no configuration file, no state kept between runs.
Five exceptions, each declared in `--help`: Five exceptions, each declared in `--help`:
- [`emb`](../crates/emb/README.md) makes an HTTP call, which is the whole of what it does. - [`emb`](../crates/emb/README.md) makes an HTTP call, which is the whole of what it does.
- [`bibfetch`](../crates/bibfetch/README.md) asks a register about an identifier, likewise. - [`bibfetch`](../crates/bibfetch/README.md) asks a register about an identifier or a title, likewise.
- [`pdftriage`](../crates/pdftriage/README.md) runs `pdftotext` and `pdfinfo`. - [`pdftriage`](../crates/pdftriage/README.md) runs `pdftotext` and `pdfinfo`.
- [`pdfgutter`](../crates/pdfgutter/README.md) runs `pdftoppm` to look at a page. - [`pdfgutter`](../crates/pdfgutter/README.md) runs `pdftoppm` to look at a page.
- [`avplan`](../crates/avplan/README.md) runs `ffprobe` to read a file's streams, and - [`avplan`](../crates/avplan/README.md) runs `ffprobe` to read a file's streams, and