isbn2bib
Fabrice P. Lauss𝕪's Web

isbn2bib

isbn2bib turns an ISBN into the BibTeX entry for Books.bib, as doi2bib does from a doi for sci.bib and arXiv2bib from an identifier for arXiv.bib.

Written with Claude Opus 5 on 6 August (2026), having been wanted since 19:00 on 23 August (2024) (as recorded on the ISBN page). Version 1.0.0.

Why it takes four catalogues

There is no Crossref-for-books, and that single fact shapes the whole script. A doi resolves to one authoritative record; an ISBN resolves to whatever each library happens to have catalogued, and no free source is complete or clean enough on its own. isbndb, pencilled in on the ISBN page as the answer, is a paying service. So the script asks four free ones and merges them field by field, each contributing what it alone does well:

  • Crossref — only books that own a doi, but for those it is the best record in existence: authors already split into given and family names, and a title in the publisher's own capitalisation. One catch: the ISBN matches the book and every one of its chapters, so the reply must be filtered down to a monograph or book.
  • Open Library — the widest coverage, and the only one carrying a subtitle, a place of publication and an edition name. It also supplies the url that goes into the entry.
  • K10plus — the German union catalogue, some 90 million records, reachable by SRU. It is the only free source that tells an author from an editor, which it does through the MARC roles VerfasserIn and HerausgeberIn.
  • Google Books — answers quota exceeded most days from a shared address, so it is asked last and only for what is still missing.

That third point is worth more than it sounds. Open Library and Google Books hand back whoever is printed on the cover as one undifferentiated list, so a book of collected chapters arrives looking as though its editors wrote it. The script therefore takes the author/editor split from the first cataloguing source that has one, and falls back to the flat list only when neither of them knows the book — otherwise an edited volume comes out with its editors listed twice, once as authors and once as themselves.

Both forms of the number are tried against all four, since a catalogue very often holds only the ISBN-10 or only the ISBN-13. A wrong check digit is repaired rather than carried into the entry, and the number may arrive bare, hyphenated, or still inside the URL it was copied from.

Usage

isbn2bib 9780199573127        # show the entry, write nothing
isbn2bib -w 0521773628        # ... and append it to ~/bib/Books.bib
isbn2bib -a 9780691178509     # -w, then push it to the wiki via bib2wiki
isbn2bib -t 9780199573127     # terse: drop the subtitle from the title
isbn2bib -v 9780199573127     # verbose: show what each source returned

isbn2bib quantum optics scully   # no ISBN at hand? search by title instead
isbn2bib https://openlibrary.org/isbn/9780521435956   # or paste the URL

The first of those returns:

@Book{lewenstein_book12a,
  author =    {M. Lewenstein and A. Sanpera and V. Ahufinger},
  title =     {Ultracold Atoms in Optical Lattices: Simulating quantum many-body systems},
  publisher = oup,
  year =      2012,
  isbn =      {9780199573127},
  url =       {https://openlibrary.org/isbn/9780199573127}
}

The house conventions it keeps

None of these were invented for the occasion; they were read off Books.bib as it stands.

  • Key surname_bookYYa, with the full year before 1900 (as in bortkewitsch_book1898a), and the next free letter found by scanning the file.
  • Particles behave as they already do in the file: a Germanic one is dropped from the key and a Romance one kept, so von Neumann gives neumann while de Gennes gives degennes. In the entry itself a multi-word surname is braced, since BibTeX would otherwise keep only its last word.
  • Layout copied from pearl_book00a, the entry that already had this shape: the field name and its = padded out to column 14.
  • Publishers go through the @string abbreviations the file already declares, so Cambridge University Press comes out as publisher = cup. The match is made word by word rather than on bare letters, because on letters alone Wiley is a prefix of vch = Wiley-VCH, and every Wiley book was being published by Wiley-VCH.
  • Accents pass through the same bibnames substitutions doi2bib uses, so a name is spelled the same here as everywhere else. What that file does not yet cover is spelled by decomposing the character — Laloë becomes Lalo\"e — and the script says which ones it had to work out for itself, in case they are worth adding.
  • Duplicates are caught on the ISBN in either form, and, since most of Books.bib is older than the ISBN, also on same author and same year: it then prints the neighbouring entry's title so one can judge. Feeding it Breuer and Petruccione answers breuer_book02a is already there: The Theory of Open Quantum Systems ← same title!

The entry is appended at the end of the file rather than sorted into place, which is where doi2bib and this one part company. sci.bib is machine-kept and can be re-sorted on every write; Books.bib is hand-kept, in no particular order, and a good third of its keys (agranovich66_booka, volovik03_book03a) do not fit the name-year-letter pattern a sort would have to assume.

bib2wiki

-a pushes the new book to the wiki the way doi2bib -a pushes an article, which took bib2wiki to v2.4: it now reads Books.bib as a third file, and knows what a book looks like. The publisher — or, for a thesis, the school — stands where the journal would, there is no volume:pages for the link to hang on so it goes on the publisher instead, and an entry with editors and no author at all is no longer skipped:

<u>[[gianelli_book17a|The Tests of Time: Readings in the Development of Physical Theory]]</u>. [[A. F. Gianelli]], [[G. N. Statile]] and [[L. M. Dolling]] (eds.), [https://openlibrary.org/isbn/9781400889167 ''Princeton University Press''] ([[2017]]).

Three traps, for the next script

  • open($p, '-|:encoding(UTF-8)', @cmd) silently ignores the layer and hands back raw bytes. The accented characters then arrive one byte at a time and no amount of Unicode handling downstream can put them together again. binmode($p, ':encoding(UTF-8)') after the open is the fix.
  • A my %table = (...) written in a block below the main flow is still empty when a subroutine called from that flow reads it: the assignment is a run-time statement like any other, and the program printed its answer and exited long before reaching it. Declare above, fill inside the subroutine on first use.
  • The shebang has no -s, unlike its two elder siblings, precisely because perl -s eats a flag written before the argument — the bug that made doi2bib -a 10.x a silent no-op until v0.9.2.

The script

#!/usr/bin/perl
#  _     _         ____  _     _ _
# (_)___| |__  _ _|___ \| |__ (_) |__
# | / __| '_ \| '_ \ __) | '_ \| | '_ \
# | \__ \ |_) | | | / __/| |_) | | |_) |
# |_|___/_.__/|_| |_|_____|_.__/|_|_.__/
# F.P. Laussy - [email protected]
# v1.0.0 Thu Aug 06 2026 - first version. Companion to doi2bib / arXiv2bib,
#                          but writing into ~/bib/Books.bib.
#
# Books have no Crossref-for-everything, so this queries FOUR sources and
# merges them field by field, best source first:
#
#   Crossref     api.crossref.org/works?filter=isbn:...   authors split into
#                given/family, properly-cased titles.  Only academic books
#                that own a DOI, but when it is there it is the best record.
#   Open Library openlibrary.org/api/books?jscmd=details  widest coverage,
#                the only one with subtitle / publish_places / edition_name,
#                and the source of the url= we put in the entry.
#   K10plus      sru.k10plus.de (German union catalogue, ~90M records) --
#                library cataloguing, so it knows author from editor
#                (VerfasserIn / HerausgeberIn) even for old books.
#   Google Books rate-limited from a shared IP (429 most days), so it is
#                asked last and only for whatever is still missing.
#
# The shebang deliberately has no -s (contrary to doi2bib/arXiv2bib): perl -s
# eats a flag written *before* the argument and sets $w instead of pushing it
# to @ARGV, which made "doi2bib -a 10.x" a silent no-op until v0.9.2.  Plain
# @ARGV parsing means -w works on either side of the ISBN.
#
# The entry goes at the END of Books.bib, not sorted into place the way
# doi2bib sorts sci.bib: Books.bib is hand-kept, in no particular order, and
# a third of its keys (agranovich66_booka, volovik03_book03a...) do not fit
# the name+year+letter pattern a sort would need.
#
# -a needs bib2wiki v2.4 or later, which is the version that reads Books.bib
# and knows how to render a book (publisher instead of journal, no
# volume:pages, editor-only entries).
#
# Usage:
#   isbn2bib 9780199573127        # show the entry, write nothing
#   isbn2bib -w 0521773628        # ... and append it to ~/bib/Books.bib
#   isbn2bib -a 9780691178509     # -w, then push it to the wiki via bib2wiki
#   isbn2bib -t 9780199573127     # terse: drop the subtitle from the title
#   isbn2bib -v 9780199573127     # verbose: show what each source returned
#   isbn2bib quantum optics scully   # no ISBN? search Open Library by title
#                                    # and list candidate ISBNs to feed back
#
# The ISBN may be bare, hyphenated, ISBN-10 or ISBN-13, or still inside the
# URL it was copied from (openlibrary, amazon, worldcat...).  Both forms are
# tried against every source, since a catalogue often has only one of them,
# and a wrong check digit is repaired rather than carried into the entry.

use strict;
use warnings;
use utf8;              # the accent table below is written with real characters

use LWP::UserAgent;
use JSON::PP;
use Encode qw(decode_utf8 encode_utf8);
use Unicode::Normalize qw(NFKD NFC);
use File::Temp qw(tempfile);

binmode(STDOUT, ':encoding(UTF-8)');
binmode(STDERR, ':encoding(UTF-8)');
STDOUT->autoflush(1);   # keep the entry and the diagnostics in step

my $bibfile  = "/home/laussy/bib/Books.bib";
my $bibnames = "/home/laussy/bib/doi2bib/bibnames";

# ------------------------------------------------------------------ arguments
my ($write_flag, $wiki_flag, $terse, $verbose) = (0, 0, 0, 0);
my $isbn_raw = '';
my @words;

foreach my $arg (@ARGV) {
    if    ($arg eq '-w') { $write_flag = 1 }
    elsif ($arg eq '-a') { $write_flag = 1; $wiki_flag = 1 }
    elsif ($arg eq '-t') { $terse = 1 }
    elsif ($arg eq '-v') { $verbose = 1 }
    elsif ($arg =~ /^-/) { warn "Unknown flag $arg, ignored\n" }
    else {
        my $try = isbn_in($arg);
        if (defined $try) { $isbn_raw = $try } else { push @words, $arg }
    }
}

my $ua = LWP::UserAgent->new(
    timeout => 25,
    agent   => 'isbn2bib/1.0 ([email protected])',
);

if ($isbn_raw eq '' && @words) { search_by_title(@words); exit 0 }

if ($isbn_raw eq '') {
    print STDERR "Please provide me with an ISBN, e.g.,\n";
    print STDERR "  isbn2bib 9780199573127\n";
    print STDERR "or a few words of the title to look one up:\n";
    print STDERR "  isbn2bib quantum optics scully\n";
    exit(-1);
}

# ------------------------------------------------------- ISBN-10 <-> ISBN-13
my $isbn = uc $isbn_raw;
unless (isbn_valid($isbn)) {
    # a wrong check digit is nearly always a mistyped last character; repair it
    # rather than carry the bad number into isbn= and the url
    my $fixed = fix_check_digit($isbn);
    if (defined $fixed && $fixed ne $isbn) {
        print STDERR "Note: $isbn fails its check digit; reading it as $fixed.\n";
        $isbn = $fixed;
    } else {
        print STDERR "Warning: $isbn fails its check digit -- a typo?  Trying anyway.\n";
    }
}
my ($isbn13, $isbn10);
if (length($isbn) == 13) { $isbn13 = $isbn; $isbn10 = to_isbn10($isbn) }
else                     { $isbn10 = $isbn; $isbn13 = to_isbn13($isbn) }
# every form worth querying or matching against the file
my @forms = grep { defined && length } ($isbn13, $isbn10);
my %seen_form; @forms = grep { !$seen_form{$_}++ } @forms;

# ------------------------------------------- read Books.bib, look for a dupe
my $content = '';
if (-f $bibfile) {
    open(my $fh, '<:encoding(UTF-8)', $bibfile) or die "Cannot open $bibfile: $!";
    { local $/; $content = <$fh>; }
    close $fh;
}

foreach my $entry_txt ($content =~ /(@(?!string\b)\w+\{.*?\n\s*\}?)(?=\s*@|\s*\z)/gis) {
    my ($key) = $entry_txt =~ /^@\w+\{\s*([^,]+),/;
    next unless $key;
    (my $flat = $entry_txt) =~ s/[^0-9A-Za-z]//g;   # ISBN with any hyphenation
    foreach my $f (@forms) {
        if ($flat =~ /\Q$f\E/) {
            print STDERR "This ISBN is already in Books.bib, under $key:\n\n";
            print "$entry_txt\n";
            print STDERR "\n{{$key}}\n";
            exit 0;
        }
    }
}

# --------------------------------------------------------------- the sources
my %OL = fetch_openlibrary(@forms);
my %CR = fetch_crossref(@forms);
my %KP = fetch_k10plus(@forms);
my %GB = (%OL && %CR) ? () : fetch_google(@forms);   # only if still thin

# who actually answered, recorded now: the merge below reads these hashes and
# a later "did it have anything?" test is one refactor away from lying
my @hits = ((%CR ? 'Crossref' : ()), (%OL ? 'OpenLibrary' : ()),
            (%KP ? 'K10plus'  : ()), (%GB ? 'GoogleBooks' : ()));

if ($verbose) {
    for ( ['Crossref',\%CR], ['OpenLibrary',\%OL], ['K10plus',\%KP], ['GoogleBooks',\%GB] ) {
        my ($nm, $h) = @$_;
        if (%$h) {
            print STDERR "--- $nm\n";
            for my $k (sort keys %$h) {
                my $v = $h->{$k};
                $v = join(' | ', map { ref $_ ? join('/', map { $_ // '' } @$_) : $_ } @$v) if ref $v eq 'ARRAY';
                print STDERR sprintf("    %-12s %s\n", $k, $v) if defined $v && length $v;
            }
        } else {
            print STDERR "--- $nm: nothing\n";
        }
    }
}

unless (@hits) {
    print STDERR "Nothing found for ISBN $isbn";
    print STDERR " (also tried " . join(', ', grep { $_ ne $isbn } @forms) . ")" if @forms > 1;
    print STDERR ".\n";
    print STDERR "Open Library, Crossref, K10plus and Google Books all draw a blank.\n";
    print STDERR "Try the other ISBN of the same book (paperback/hardback differ), or\n";
    print STDERR "  isbn2bib <a few words of the title>\n";
    exit(-1);
}

# ------------------------------------------------------------- merge, field by field
# people: an arrayref of [given, family] pairs, plus the role we found them in
my ($authors, $editors) = merge_people(\%CR, \%KP, \%OL, \%GB);

my $title    = first_of($CR{title}, $OL{title}, $GB{title}, $KP{title});
# Open Library and Crossref often drop the leading article that the library
# catalogue kept ("Tests of Time" vs "The Tests of Time"): put it back
foreach my $alt ($KP{title}, $OL{title}, $GB{title}) {
    next unless defined $alt && defined $title;
    (my $a = $alt) =~ s/\s+/ /g;
    if ($a =~ /^((?:The|A|An)\s+)\Q$title\E\b/i) { $title = $1 . $title; last }
}
my $subtitle = first_of($OL{subtitle}, $GB{subtitle});
# a source that keeps the subtitle inside the title field still has it: take it
# from there rather than lose it ("Quantum Mechanics" when the library
# catalogue says "Quantum mechanics: concepts and applications")
unless ($subtitle) {
    foreach my $alt ($KP{title}, $OL{title}, $GB{title}, $CR{title}) {
        next unless defined $alt && defined $title;
        (my $a = $alt) =~ s/\s+/ /g;
        if ($a =~ /^\Q$title\E\s*[:.]\s*(\S.*)$/i) { $subtitle = clean($1); last }
    }
}
if (!$terse && $subtitle) {
    my $probe = lc $subtitle; $probe =~ s/\W//g;
    my $have  = lc($title // ''); $have =~ s/\W//g;
    # library records give the subtitle in sentence case and lowercase its
    # first word ("simulating quantum many-body systems")
    $title .= ": " . ucfirst($subtitle) unless $have =~ /\Q$probe\E/;
}
# K10plus pads the non-filing article ("The   theory of...")
$title =~ s/\s+/ /g if $title;
$title =~ s/^\s+|\s+$//g if $title;

my $publisher = first_of($CR{publisher}, $OL{publisher}, $KP{publisher}, $GB{publisher});
my $year      = first_of($OL{year}, $CR{year}, $GB{year}, $KP{year});
my $address   = first_of($OL{place});
my $edition   = normalise_edition(first_of($OL{edition}, $CR{edition}, $KP{edition}));
my $series    = first_of($OL{series});

unless ($title) { print STDERR "Error: no title anywhere for ISBN $isbn\n"; exit(-1) }
unless ($year)  { print STDERR "Warning: no year found, using 0000\n"; $year = '0000' }

# address is a city in this file, not a country
if ($address) {
    $address =~ s/\s*,\s*(U\.?\s?K\.?|U\.?\s?S\.?\s?A?\.?|England|Great Britain|Germany|France)\.?$//i;
    $address = clean($address);
}
# publisher: drop the address if it is already spelled out, then try @string
if ($publisher && $address) {
    my $a = quotemeta $address;
    $address = '' if $publisher =~ /$a/i;
}
my $pub_field = publisher_field($publisher, $content);
# the @string abbreviations already carry their publisher's home city
$address = '' if $pub_field && $pub_field !~ /^\{/;

# ------------------------------------------------------------------- the key
my @key_people = @$authors ? @$authors : @$editors;
unless (@key_people) {
    print STDERR "Warning: no author and no editor found; the key will say 'unknown'\n";
}
my $surname = @key_people ? $key_people[0][1] : 'unknown';
my $keyname = key_from_surname($surname);
my $yy      = $year >= 1900 ? sprintf("%02d", $year % 100) : $year;
my $basekey = "${keyname}_book${yy}";

my %letters;
while ($content =~ /@(?!string\b)\w+\{\s*\Q$basekey\E([a-z])\s*,/gi) { $letters{lc $1} = 1 }
my $letter = 'a';
$letter = chr(ord($letter) + 1) while $letters{$letter};
my $bibkey = $basekey . $letter;

# ---------------------------------------------------------------- the entry
my $people_field = @$authors ? 'author' : (@$editors ? 'editor' : 'author');
my @people       = @$authors ? @$authors : @$editors;
my $people_str   = format_people(@people);

# field layout copied from pearl_book00a, the entry in Books.bib that already
# has this shape (isbn + openlibrary url): "name =" padded to column 14
sub fld { sprintf("  %-12s%s,\n", "$_[0] =", $_[1]) }

my $showisbn = $isbn13 // $isbn;
my $entry  = "\@Book{$bibkey,\n";
$entry .= fld($people_field, "{$people_str}")                 if $people_str;
$entry .= fld('title',     '{' . latexify_title($title) . '}');
$entry .= fld('editor',    '{' . format_people(@$editors) . '}')
                                                              if @$authors && @$editors;
$entry .= fld('publisher', $pub_field)                        if $pub_field;
$entry .= fld('address',   '{' . latexify($address) . '}')    if $address;
$entry .= fld('series',    '{' . latexify($series) . '}')     if $series;
$entry .= fld('edition',   "{$edition}")                      if $edition;
$entry .= fld('year',      $year);
$entry .= fld('isbn',      "{$showisbn}");
$entry .= sprintf("  %-12s%s\n", "url =", "{https://openlibrary.org/isbn/$showisbn}");
$entry .= "}";

print "$entry\n";

print STDERR "\n====\n\n";
print STDERR "ISBN $isbn13" . ($isbn10 ? " (= $isbn10)" : "") . "\n";
print STDERR "sources: " . join(', ', @hits) . "\n";
print STDERR "Warning: no publisher found -- fill it in by hand.\n" unless $pub_field;

# Books.bib is old and most of it predates ISBNs, so the exact-ISBN check above
# cannot catch a book that is already in under a bare author/title.  Same author
# and same year is the tell: show those entries' titles and let Fabrice judge.
foreach my $l (sort keys %letters) {
    my ($t) = $content =~ /@\w+\{\s*\Q$basekey$l\E\s*,.*?\btitle\s*=\s*[\{"]?\s*(.*?)\s*[\}"]?\s*,?\s*\n/is;
    my $same = '';
    if (defined $t) {
        my ($x, $y) = (lc $t, lc $title);
        s/[^a-z0-9]//g for $x, $y;
        $same = ($x eq $y || index($y, $x) == 0 || index($x, $y) == 0)
                ? "  <-- same title!" : '';
    }
    print STDERR "note: $basekey$l is already there"
               . (defined $t ? ": $t" : '') . "$same\n";
}

# ----------------------------------------------------------------- write out
if ($write_flag) {
    open(my $out, '>>:encoding(UTF-8)', $bibfile) or die "Cannot write to $bibfile: $!";
    print $out "\n" unless $content =~ /\n\z/;   # Books.bib ends on a bare "}"
    print $out "\n$entry\n";
    close $out;
    print STDERR "$bibkey appended at the end of $bibfile\n";
    print STDERR "{{$bibkey}}\n";
}

if ($wiki_flag) { system("bib2wiki", $bibkey) }

exit 0;

# ==================================================================== sources

# a fetcher must answer "did you know this book?", not "did I set some keys?":
# drop everything empty, and an all-empty record then reports as no record
sub prune {
    my (%h) = @_;
    foreach my $k (keys %h) {
        my $v = $h{$k};
        delete $h{$k} if !defined $v
                      || (ref $v eq 'ARRAY' ? !@$v : $v !~ /\S/);
    }
    return %h;
}

sub get_json {
    my ($url) = @_;
    my $r = $ua->get($url, 'Accept' => 'application/json');
    return undef unless $r->is_success;
    my $data = eval { JSON::PP->new->utf8->decode($r->content) };
    return $data;
}

# --- Open Library -----------------------------------------------------------
sub fetch_openlibrary {
    my (@ids) = @_;
    foreach my $id (@ids) {
        my $d = get_json("https://openlibrary.org/api/books?bibkeys=ISBN:$id"
                        . "&format=json&jscmd=details");
        next unless $d && ref $d eq 'HASH' && %$d;
        my ($rec) = values %$d;
        my $det = $rec->{details} or next;
        my %h;
        $h{title}    = clean($det->{title});
        $h{subtitle} = clean($det->{subtitle});
        $h{publisher}= clean($det->{publishers}[0]) if $det->{publishers};
        $h{place}    = clean($det->{publish_places}[0]) if $det->{publish_places};
        $h{series}   = clean($det->{series}[0]) if ref $det->{series} eq 'ARRAY';
        $h{edition}  = clean($det->{edition_name});
        $h{year}     = year_of($det->{publish_date});
        # by_statement ("Marlan O. Scully and M. Suhail Zubairy.") keeps every
        # author in printed order; the authors[] array often lists only one.
        $h{people}   = people_from_statement($det->{by_statement})
                       if $det->{by_statement};
        $h{people} ||= [ map { split_name($_->{name}) } @{ $det->{authors} } ]
                       if $det->{authors};
        return prune(%h);
    }
    return ();
}

# --- Crossref ---------------------------------------------------------------
sub fetch_crossref {
    my (@ids) = @_;
    foreach my $id (@ids) {
        my $d = get_json("https://api.crossref.org/works?filter=isbn:$id&rows=20");
        next unless $d && $d->{message} && $d->{message}{items};
        # the ISBN matches the whole book and every one of its chapters: keep
        # the book itself, never a book-chapter
        my ($best) = grep { ($_->{type} // '') =~ /^(monograph|book|reference-book|edited-book|book-set)$/ }
                     @{ $d->{message}{items} };
        next unless $best;
        my %h;
        $h{title}     = clean($best->{title}[0]) if $best->{title};
        $h{subtitle}  = clean($best->{subtitle}[0]) if $best->{subtitle};
        $h{publisher} = clean($best->{publisher});
        $h{year}      = $best->{'published-print'}{'date-parts'}[0][0]
                     // $best->{issued}{'date-parts'}[0][0];
        $h{edition}   = clean($best->{edition_number});
        $h{doi}       = $best->{DOI};
        $h{people}    = [ map { [ clean($_->{given}), clean($_->{family}) ] }
                          grep { $_->{family} } @{ $best->{author} // [] } ];
        $h{editors}   = [ map { [ clean($_->{given}), clean($_->{family}) ] }
                          grep { $_->{family} } @{ $best->{editor} // [] } ];
        delete $h{people}  unless @{ $h{people} };
        delete $h{editors} unless @{ $h{editors} };
        return prune(%h);
    }
    return ();
}

# --- K10plus (SRU, Dublin Core) ---------------------------------------------
sub fetch_k10plus {
    my (@ids) = @_;
    foreach my $id (@ids) {
        my $r = $ua->get("https://sru.k10plus.de/opac-de-627?version=1.1"
                       . "&operation=searchRetrieve&query=pica.isb%3D$id"
                       . "&maximumRecords=1&recordSchema=dc");
        next unless $r->is_success;
        my $x = decode_utf8($r->content);
        next if $x =~ m{<zs:numberOfRecords>0</zs:numberOfRecords>};
        my %h;
        my @t = $x =~ m{<dc:title[^>]*>(.*?)</dc:title>}gs;
        $h{title} = clean(unxml($t[0])) if @t;
        my ($p) = $x =~ m{<dc:publisher[^>]*>(.*?)</dc:publisher>}s;
        $h{publisher} = clean(unxml($p)) if $p;
        my ($dt) = $x =~ m{<dc:date[^>]*>(.*?)</dc:date>}s;
        $h{year} = year_of(unxml($dt)) if $dt;
        my (@auth, @edit, %dup);
        foreach my $c ($x =~ m{<dc:(?:creator|contributor)[^>]*>(.*?)</dc:(?:creator|contributor)>}gs) {
            $c = unxml($c);
            my $role = $c =~ /Herausgeber/i ? 'ed' : 'au';
            $c =~ s/\s*\([^)]*\)\s*$//;      # (VerfasserIn)
            # life dates, with or without the comma that usually precedes them
            # ("Zettili, Nouredine 1949-" without it gave an initial of "1."),
            # and MARC writes an unknown one as "19XX-"
            $c =~ s/\s*,?\s*\d{2}[\dXxUu?]{0,2}\s*-\s*[\dXxUu?]{0,4}\s*$//;
            $c =~ s/\s+$//;
            next unless $c =~ /,/;
            next if $dup{lc "$role$c"}++;
            my ($fam, $giv) = split /\s*,\s*/, $c, 2;
            $role eq 'ed' ? push(@edit, [clean($giv), clean($fam)])
                          : push(@auth, [clean($giv), clean($fam)]);
        }
        $h{people}  = \@auth if @auth;
        $h{editors} = \@edit if @edit;
        return prune(%h);
    }
    return ();
}

# --- Google Books (429 most days from a shared IP, hence last) ---------------
sub fetch_google {
    my (@ids) = @_;
    foreach my $id (@ids) {
        my $d = get_json("https://www.googleapis.com/books/v1/volumes?q=isbn:$id");
        next unless $d && $d->{items} && @{ $d->{items} };
        my $v = $d->{items}[0]{volumeInfo} or next;
        my %h;
        $h{title}     = clean($v->{title});
        $h{subtitle}  = clean($v->{subtitle});
        $h{publisher} = clean($v->{publisher});
        $h{year}      = year_of($v->{publishedDate});
        $h{people}    = [ map { split_name($_) } @{ $v->{authors} // [] } ];
        delete $h{people} unless @{ $h{people} };
        return prune(%h);
    }
    return ();
}

# --- title search, when the user has no ISBN to hand ------------------------
sub search_by_title {
    my (@w) = @_;
    my $q = join('+', map { my $s = $_; $s =~ s/[^\w]//g; $s } @w);
    my $d = get_json("https://openlibrary.org/search.json?q=$q"
                   . "&fields=title,author_name,first_publish_year,publisher,isbn&limit=8");
    unless ($d && $d->{docs} && @{ $d->{docs} }) {
        print "No book matches '@w' in Open Library.\n";
        return;
    }
    print "No ISBN given, so here is what Open Library has for '@w':\n\n";
    foreach my $doc (@{ $d->{docs} }) {
        my $i = (grep { length($_) == 13 } @{ $doc->{isbn} // [] })[0]
             // ($doc->{isbn} // [])->[0] // '(no isbn)';
        printf("  %-15s %s -- %s (%s)\n", $i,
               substr($doc->{title} // '?', 0, 55),
               join(', ', @{ $doc->{author_name} // ['?'] }),
               $doc->{first_publish_year} // '?');
    }
    print "\nThen: isbn2bib -w <the ISBN of the edition you own>\n";
}

# ==================================================================== merging

sub first_of {
    foreach my $v (@_) { return $v if defined $v && $v =~ /\S/ }
    return undef;
}

# Only Crossref and K10plus tell an author from an editor (DOI metadata and
# MARC roles); Open Library and Google Books put whoever is on the cover into
# one undifferentiated list.  So take the author/editor *split* from the first
# cataloguing source that has one, and fall back to the flat list only when
# neither knows the book -- otherwise an edited volume comes out with its
# editors listed twice, once as authors.
sub merge_people {
    my ($cr, $kp, $ol, $gb) = @_;
    foreach my $src ($cr, $kp) {
        my $au = first_of_list($src->{people});
        my $ed = first_of_list($src->{editors});
        return ($au, $ed) if @$au || @$ed;
    }
    my $flat = first_of_list($ol->{people}, $gb->{people});
    return ($flat, []);
}

sub first_of_list {
    foreach my $v (@_) { return $v if ref $v eq 'ARRAY' && @$v }
    return [];
}

# ============================================================ name formatting

# "Marlan O. Scully" -> ["Marlan O.", "Scully"];  "van der Waals" kept whole
sub split_name {
    my ($n) = @_;
    return () unless defined $n && $n =~ /\S/;
    $n = clean($n);
    return () unless $n;
    if ($n =~ /^([^,]+),\s*(.+)$/) { return [ clean($2), clean($1) ] }  # "Last, First"
    my @tok = split /\s+/, $n;
    return [ '', $n ] if @tok == 1;
    # pull the surname off the end, dragging any particle with it
    my @fam = (pop @tok);
    while (@tok && $tok[-1] =~ /^(von|van|der|den|del|della|di|da|dos|de|du|le|la|ter|zu|of)$/i) {
        unshift @fam, pop @tok;
    }
    return [ join(' ', @tok), join(' ', @fam) ];
}

# "Marlan O. Scully and M. Suhail Zubairy." / "A, B and C" / "A; B"
sub people_from_statement {
    my ($s) = @_;
    return undef unless defined $s && $s =~ /\S/;
    $s = clean($s);
    $s =~ s/\.\s*$//;
    return undef if $s =~ /\b(edited|translated|with|illustrat)/i && $s !~ /\band\b/;
    $s =~ s/\s*\([^)]*\)//g;              # (Texas A&M University)
    $s =~ s/^\s*(?:by|edited by)\s+//i;
    my @parts = split /\s*(?:;|,(?!\s*(?:Jr|Sr|II|III)\b)|\band\b|&)\s*/, $s;
    my @out;
    foreach my $p (@parts) {
        next unless $p =~ /\S/;
        next if $p =~ /^\s*(et al|and others)\s*$/i;
        my $r = split_name($p);
        push @out, $r if $r && $r->[1];
    }
    return @out ? \@out : undef;
}

# ["Marlan O.","Scully"] -> "M. O. Scully";  hyphens survive: "H.-P. Breuer"
sub initials {
    my ($given) = @_;
    return '' unless defined $given && $given =~ /\S/;
    my @out;
    foreach my $tok (split /\s+/, $given) {
        next unless $tok =~ /[[:alpha:]]/;   # never initialise a stray "1949-"
        my @bits = map { my $b = $_;
                         $b =~ s/^(\W*)(\w).*/$1\u$2./s ? $b : $b }
                   split /-/, $tok;
        push @out, join('-', @bits);
    }
    return join(' ', @out);
}

sub format_people {
    my (@p) = @_;
    return '' unless @p;
    @p = dedupe_people(@p);
    my @s;
    foreach my $x (@p) {
        my ($given, $family) = @$x;
        my $ini = initials($given);
        # a multi-word surname must be braced or BibTeX takes the last word
        # only -- except for a leading lowercase particle, which it handles
        my $fam = $family;
        if ($fam =~ /\s/ && $fam !~ /^(von|van|der|den|del|della|di|da|dos|de|du|le|la|ter|zu)\s/i) {
            $fam = "{$fam}";
        }
        push @s, $ini ? "$ini $fam" : $fam;
    }
    return latexify(join(' and ', @s));
}

# Open Library's by_statement is transcribed from the title page and sometimes
# lists the same person twice, once per spelling ("Rudiger Frey ... Rudiger D.
# Frey").  Same surname and same first initial is the same author: keep the
# fuller given name, and keep the position of the first mention.
sub dedupe_people {
    my (@p) = @_;
    my (@out, %at);
    foreach my $x (@p) {
        my ($given, $family) = @$x;
        my $sig = lc(($family // '') . '|' . substr(($given // '') . ' ', 0, 1));
        $sig =~ s/\s//g;
        if (defined(my $i = $at{$sig})) {
            $out[$i][0] = $given if length($given // '') > length($out[$i][0] // '');
        } else {
            $at{$sig} = scalar @out;
            push @out, [ $given, $family ];
        }
    }
    return @out;
}

# key surname: ASCII, lowercase, no punctuation.  A Germanic particle goes
# (von Neumann -> neumann, as in the file), a Romance one stays (de Gennes ->
# degennes, likewise as in the file).
sub key_from_surname {
    my ($fam) = @_;
    $fam //= 'unknown';
    $fam =~ s/^(von|van|zu|ter|of)\s+//i;
    $fam = NFKD($fam);
    $fam =~ s/\p{NonspacingMark}//g;
    $fam =~ s/[^A-Za-z]//g;
    $fam = lc $fam;
    return $fam || 'unknown';
}

# ============================================================ LaTeX / cleanup

sub clean {
    my ($s) = @_;
    return undef unless defined $s;
    $s = "$s";
    $s =~ s/\s+/ /g;
    $s =~ s/^\s+|\s+$//g;
    $s =~ s/[.,;:]+$// if $s =~ /\w[.,;:]+$/ && $s !~ /\b\w\.$/;
    return length($s) ? $s : undef;
}

sub unxml {
    my ($s) = @_;
    return '' unless defined $s;
    $s =~ s/&lt;/</g; $s =~ s/&gt;/>/g; $s =~ s/&quot;/"/g;
    $s =~ s/&#(\d+);/chr($1)/ge; $s =~ s/&amp;/&/g;
    return $s;
}

sub year_of {
    my ($d) = @_;
    return undef unless defined $d;
    return $1 if $d =~ /\b((?:1[0-9]|20)\d{2})\b/;
    return undef;
}

sub normalise_edition {
    my ($e) = @_;
    return undef unless defined $e && $e =~ /\S/;
    return undef if $e =~ /^\s*(1|1st|first)(\s*ed\.?(ition)?)?\s*$/i;  # 1st is implicit
    if ($e =~ /(\d+)/) {
        my $n = $1;
        return undef if $n == 1;
        my $suf = ($n % 100 >= 11 && $n % 100 <= 13) ? 'th'
                : $n % 10 == 1 ? 'st' : $n % 10 == 2 ? 'nd' : $n % 10 == 3 ? 'rd' : 'th';
        return "$n$suf";
    }
    $e =~ s/\s*ed(ition)?\.?\s*$//i;
    return clean($e);
}

# use one of the @string abbreviations already declared in Books.bib when the
# publisher matches: "publisher = cup," rather than a fourth spelling of it
sub publisher_field {
    my ($pub, $file) = @_;
    return undef unless defined $pub && $pub =~ /\S/;
    my %strings;
    while ($file =~ /\@string\s*\{\s*(\w+)\s*=\s*"([^"]*)"/gi) { $strings{$1} = $2 }
    # compare word by word, and accept a partial match only when the shorter
    # name is at least three words long: on bare letters, "Wiley" is a prefix
    # of vch = "Wiley-VCH" and every Wiley book came out as Wiley-VCH.  Three
    # words still lets "Cambridge University Press" find cup, which spells
    # itself "Cambridge University Press, Cambridge".
    my @probe = grep { length } split /[^a-z0-9]+/, lc $pub;
    foreach my $abbr (sort keys %strings) {
        my @full = grep { length } split /[^a-z0-9]+/, lc $strings{$abbr};
        next unless @probe && @full;
        my ($short, $long) = @probe <= @full ? (\@probe, \@full) : (\@full, \@probe);
        next unless @$short == @$long || @$short >= 3;
        my $same = 1;
        for my $k (0 .. $#$short) { $same = 0, last if $short->[$k] ne $long->[$k] }
        return $abbr if $same;
    }
    return '{' . latexify($pub) . '}';
}

# apply Fabrice's own bibnames substitutions (the same file doi2bib uses), so
# accents and the special names stay spelled exactly as everywhere else
sub latexify {
    my ($s) = @_;
    return '' unless defined $s && length $s;
    # escape first, on the raw text: afterwards the string is full of the
    # backslashes and $...$ that bibnames and tex_accent put there
    $s = bibtex_escape($s);
    return $s if $s =~ /^[\x00-\x7F]*$/ || ! -f $bibnames;
    my ($fh, $tmp) = tempfile();
    binmode($fh, ':encoding(UTF-8)');
    print $fh $s;
    close $fh;
    my $out = '';
    if (open(my $p, '-|', 'sed', '-f', $bibnames, $tmp)) {
        # the layer has to be set afterwards: perl silently ignores it when it
        # is written into the mode of a piped open, and hands back raw bytes
        binmode($p, ':encoding(UTF-8)');
        { local $/; $out = <$p>; }
        close $p;
    }
    unlink $tmp;
    $out = $s unless defined $out && $out =~ /\S/;
    chomp $out;
    # bibnames only covers the accents Fabrice has actually met so far; spell
    # the rest by decomposing them, and say which ones so they can be added
    if ($out =~ /[^\x00-\x7F]/) {
        my @filled;
        $out = join '', map { my $c = $_;
            if ($c =~ /[^\x00-\x7F]/) {
                my $tex = tex_accent($c);
                if (defined $tex) { push @filled, "$c -> $tex"; $tex } else { $c }
            } else { $c }
        } split //, $out;
        print STDERR "Note: not in bibnames, spelled here as: "
                   . join(', ', do { my %u; grep { !$u{$_}++ } @filled }) . "\n" if @filled;
        if ($out =~ /[^\x00-\x7F]/) {
            my @bad = ($out =~ /([^\x00-\x7F])/g);
            my %u; @bad = grep { !$u{$_}++ } @bad;
            print STDERR "Warning: no LaTeX spelling at all for: @bad\n";
        }
    }
    return $out;
}

# one accented character -> its LaTeX spelling, via Unicode decomposition, so
# the table is the combining marks rather than a list of every letter
# NB the two tables are filled inside the sub, not by a "my %mark = (...)" in
# an enclosing block: that assignment only runs when control reaches it, which
# is after the main flow has already printed the entry and exited.
my (%mark, %whole);   # declared here, filled on first use inside the sub
sub tex_accent {
    my ($c) = @_;
    unless (%mark) {
    %mark = ("\x{0300}" => '`', "\x{0301}" => "'", "\x{0302}" => '^',
                "\x{0303}" => '~', "\x{0304}" => '=', "\x{0306}" => 'u',
                "\x{0307}" => '.', "\x{0308}" => '"', "\x{030A}" => 'r',
                "\x{030B}" => 'H', "\x{030C}" => 'v', "\x{0327}" => 'c',
                "\x{0328}" => 'k', "\x{0331}" => 'b', "\x{0323}" => 'd');
    %whole = ('ß' => '{\ss}', 'æ' => '{\ae}', 'Æ' => '{\AE}',
              'œ' => '{\oe}', 'Œ' => '{\OE}', 'ø' => '{\o}',
              'Ø' => '{\O}',  'ł' => '{\l}',  'Ł' => '{\L}',
              'đ' => '{\dj}', 'Đ' => '{\DJ}', 'ı' => '{\i}',
              'å' => '{\aa}', 'Å' => '{\AA}',
              '–' => '--', '—' => '---', '‐' => '-', '−' => '-',
              '’' => "'", '‘' => "`", '“' => '``', '”' => "''",
              '…' => '\ldots{}', '×' => '$\times$', '°' => '$^\circ$');
    }
    return $whole{$c} if exists $whole{$c};
    my $d = NFKD($c);
    return undef unless length($d) >= 2;
    my ($base, @marks) = split //, $d;
    return undef if $base =~ /[^\x00-\x7F]/ || @marks != 1;
    my $m = $mark{$marks[0]} or return undef;
    return $m =~ /^\w$/ ? "\\$m\{$base\}" : "\\$m$base";
}

# BibTeX would read these as markup; every one of them turns up in real titles
# ("Volume 1 & 2", "C_60", "50% efficiency")
sub bibtex_escape {
    my ($s) = @_;
    return $s unless defined $s;
    $s =~ s/(?<!\\)([&%\$#_])/\\$1/g;
    return $s;
}

# brace what BibTeX's title-casing would otherwise flatten: acronyms and roman
# numerals ("Statistical Physics {II}", "{QED}")
sub latexify_title {
    my ($t) = @_;
    $t = latexify($t);
    $t =~ s/(?<![\{\\\w])([A-Z]{2,}|[IVXLC]{2,})(?![\}\w])/{$1}/g;
    return $t;
}

# ======================================================== ISBN arithmetic

# pull an ISBN out of whatever got pasted: bare, hyphenated, "ISBN 0-521-...",
# or a whole openlibrary / amazon / worldcat URL with an ISBN somewhere in it
sub isbn_in {
    my ($s) = @_;
    return undef unless defined $s;
    (my $t = $s) =~ s/^\s*(?:isbn(?:-1[03])?)\s*[-: ]*//i;
    # a bare number is the common case, but only if it IS bare: stripping the
    # non-digits out of an amazon URL welds the ISBN to the tracking junk
    # after it ("/dp/0521773628/ref=sr_1_1" -> 0521773628112)
    unless ($t =~ /[a-wyzA-WYZ]/) {
        (my $bare = $t) =~ s/[^0-9Xx]//g;
        return uc $bare if length($bare) == 10 || length($bare) == 13;
    }
    foreach my $re (qr/97[89][-\s]?(?:\d[-\s]?){9}\d/, qr/(?<!\d)\d(?:[-\s]?\d){8}[-\s]?[\dXx](?!\d)/) {
        if ($s =~ /($re)/) { (my $h = $1) =~ s/[^0-9Xx]//g; return uc $h }
    }
    return undef;
}

sub isbn_valid {
    my ($i) = @_;
    if (length($i) == 10) {
        my $s = 0;
        for my $k (0 .. 9) {
            my $c = substr($i, $k, 1);
            my $v = $c eq 'X' ? 10 : $c =~ /\d/ ? $c : return 0;
            $s += (10 - $k) * $v;
        }
        return $s % 11 == 0;
    }
    if (length($i) == 13) {
        return 0 if $i =~ /\D/;
        my $s = 0;
        for my $k (0 .. 12) { $s += substr($i, $k, 1) * ($k % 2 ? 3 : 1) }
        return $s % 10 == 0;
    }
    return 0;
}

sub fix_check_digit {
    my ($i) = @_;
    if (length($i) == 13 && $i !~ /\D/) {
        my $s = 0;
        for my $k (0 .. 11) { $s += substr($i, $k, 1) * ($k % 2 ? 3 : 1) }
        return substr($i, 0, 12) . ((10 - $s % 10) % 10);
    }
    if (length($i) == 10 && substr($i, 0, 9) !~ /\D/) {
        my $s = 0;
        for my $k (0 .. 8) { $s += (10 - $k) * substr($i, $k, 1) }
        my $c = (11 - $s % 11) % 11;
        return substr($i, 0, 9) . ($c == 10 ? 'X' : $c);
    }
    return undef;
}

sub to_isbn13 {
    my ($i) = @_;
    return undef unless length($i) == 10;
    my $body = '978' . substr($i, 0, 9);
    my $s = 0;
    for my $k (0 .. 11) { $s += substr($body, $k, 1) * ($k % 2 ? 3 : 1) }
    return $body . ((10 - $s % 10) % 10);
}

sub to_isbn10 {
    my ($i) = @_;
    return undef unless length($i) == 13 && $i =~ /^978/;
    my $body = substr($i, 3, 9);
    my $s = 0;
    for my $k (0 .. 8) { $s += (10 - $k) * substr($body, $k, 1) }
    my $c = (11 - $s % 11) % 11;
    return $body . ($c == 10 ? 'X' : $c);
}