openlibrary
Query the Open Library catalog from the terminal: search books and authors, look up works, editions, and ISBNs, enumerate every edition of a work, read community ratings, and resolve cover-image URLs. Fully keyless public API. Includes the OL…M/W/A key-graph reference, ISBN 302-r
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/openlibrary
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
git clone https://github.com/magnus919/agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.
README
Open Library — Book Metadata from the Terminal
Search books and authors, resolve ISBNs, walk the edition/work/author graph, enumerate editions, and read community ratings from the public Open Library API. No API key exists — every read is keyless.
Why Install This Skill
When your agent loads this skill, it gets structured access to 50M+ book records without any signup or credentials:
- Search anything — keyword queries with sort by edition count, date, or title; field-scoped lookups by title, author, subject, publisher, or ISBN
- Resolve any identifier — turn an ISBN/LCCN/OCLC into a canonical edition record and follow it up to the abstract work and its author
- Enumerate editions — every published version of a work with dates, publishers, and ISBNs
- Read community signals — star ratings and want-to-read counts per work
- Get cover images correctly — proper URLs on Open Library's dedicated image host, with existence checks that actually return 404 instead of blank placeholders
The skill also encodes where agents typically trip: ISBN endpoints that answer
302 redirects, merged-record keys that hide redirect stubs inside HTTP 200
responses, the OL…M/W/A key-suffix system, {type,value}-wrapped text fields,
and rate-limit etiquette that keeps you unblocked.
What You Get
| Path | Purpose |
|---|---|
SKILL.md |
Command reference: setup, intent-grouped commands, pipeline recipes, jq guidance, known gotchas |
scripts/openlibrary |
CLI tool for the Open Library API (--json, --dry-run, automatic redirect resolution) |
scripts/test_openlibrary.py |
Offline test suite for the CLI (help/errors/dry-run/mocked logic; live probes env-guarded) |
references/api-overview-and-key-graph.md |
Access model, rate etiquette, OLID key graph, merge-stub behavior |
references/search-api-guide.md |
Search parameters, sort keys, query syntax, sibling search endpoints, error model |
references/books-isbn-and-covers.md |
ISBN/identifier resolution, view models, editions, ratings, covers-host rules |
references/recipes-and-gotchas.md |
Worked curl/jq pipelines and a symptom-indexed gotcha table |
evals/evals.json |
Behavioral eval cases covering searches, pipelines, gotchas |
Quick Start
openlibrary search --query "dune" # find works
openlibrary isbn 9780451524935 # resolve an ISBN to its edition
openlibrary editions OL81699W # list every edition of a work
openlibrary ratings OL45804W # community signals
Optional politeness knob:
export OL_EMAIL="you@example.com" # adds contact to User-Agent; ~3x rate budget
Triggers
Load this for book research, ISBN or OLID lookups, author biographies, edition enumeration, reading-level community stats, cover-image URL assembly, or any question about the Open Library catalog itself.
Requirements
- Python 3.8+ with the
requestslibrary - No API key, no account — reads are fully public
jqrecommended for processing--jsonoutput
Skill manifest
openlibrary — Book Metadata from Open Library
Query Open Library's 50M+ record catalog over its public HTTP API: search works and authors, walk the edition/work/author graph by ISBN or OLID, enumerate editions, and read community ratings. No API key exists; everything here is a keyless GET.
Setup
Nothing to authenticate: the public Open Library API requires no API key for reads. There is no token, no registration step, no lazy-auth dance.
# Optional etiquette: identified User-Agent gets ~3x rate budget (~3 req/s vs ~1)
export OL_EMAIL="you@example.com"
Requires Python 3.8+ and requests only. --help and --dry-run work with no
environment at all. Write endpoints exist upstream but require an authenticated
Internet Archive session and are effectively internal — treat this surface as
read-only.
Three hosts matter and they serve different things:
| Host | Serves |
|---|---|
openlibrary.org |
metadata JSON: search, records, ratings |
covers.openlibrary.org |
cover images + author photos (separate service) |
archive.org |
scan content / bulk dumps (redirect targets) |
Essential Commands
find books — search
openlibrary search --query "dune" # relevance order
openlibrary search --query "dune" --sort editions # most editions first
openlibrary search --query "dune" --sort new # newest first
openlibrary search --query "foundation" --lang fr # prefer French editions
openlibrary search --query "dune" --limit 5 --offset 10 # paginate
openlibrary search --query "dune" --json # machine-readable
Results carry title, authors, first_publish_year, edition count, and the work
key (/works/OL…W) that feeds the other commands. The CLI validates --sort
choices client-side because an unknown sort value makes the server return a
plain-text HTTP 500.
find people who write — author search
openlibrary search-authors --query "asimov" # name candidates
openlibrary search-authors --query "le guin" --limit 5
openlibrary search-authors --query "asimov" --json # includes top_work, work_count
Author docs arrive with bare keys (OL23919A), unlike book search's path keys.
inspect records — author / work
openlibrary author OL23919A # bio, dates, photo URL
openlibrary work OL1168083W # description, subjects, cover URL
openlibrary work OL1168083W --json # full record
Keys may be passed bare (OL23919A) or as paths (/authors/OL23919A) — the CLI
normalizes both.
resolve an ISBN
openlibrary isbn 9780451524935 # ISBN-10 or ISBN-13
openlibrary isbn 9780451524935 --json # + resolved edition_key, work_keys, cover_url
Upstream, /isbn/<isbn>.json answers a 302 redirect to the canonical
edition JSON (/books/<OL…M>.json). The CLI follows it and reports which
edition matched via edition_key. If the edition record lacks author names
(some ship authors:null), the CLI recovers them from the linked work.
list every edition of a work
openlibrary editions OL81699W # publisher/date/ISBN per edition
openlibrary editions OL81699W --limit 50 --offset 50 # page through large sets
openlibrary editions OL81699W --json # + next_offset when more exist
Backs onto /works/<key>/editions.json; next_offset is computed from the
server's prebuilt next-page link.
read the room — community signals
openlibrary ratings OL45804W # average rating + shelf counts
openlibrary ratings OL45804W --json # full distribution + bookshelves
Joins /works/<key>/ratings.json (summary.average, per-star counts) with
/works/<key>/bookshelves.json (want_to_read, currently_reading,
already_read).
Global Flags
Position-independent; put them before or after the subcommand:
openlibrary --json search --query "dune" # machine output anywhere
openlibrary --dry-run isbn 9780451524935 # preview the exact URL, no network
openlibrary --quiet search --query "dune" # suppress diagnostics
| Flag | Effect |
|---|---|
--json |
One JSON object on stdout instead of human text |
--dry-run |
Print the planned request as JSON without executing it |
--quiet |
Suppress non-essential output |
--verbose |
Verbose logging |
Multi-Step Pipeline Recipes
ISBN → edition → work → author bio
The canonical walk across all three key types:
openlibrary isbn 9780451524935 --json | jq -r '.work_keys[0]' # OL1168083W
openlibrary work OL1168083W --json | jq -r '.authors[0]' # bare OL…A key
openlibrary author OL118077A # bio, dates
The CLI keeps this pipeline type-safe: every command that emits an authors
field under --json (isbn, work) uses the same shape — an array of bare
OL…A keys — while human output renders comma-separated labels instead. Each hop
uses a different key suffix (M → W → A); see Known Gotchas before hand-assembling
these URLs yourself.
Rank a series by community love
for w in $(openlibrary search --query 'series:dune' --limit 8 --json | jq -r '.results[].key'); do
openlibrary ratings "${w##*/}" --json \
| jq -c '{work: .key, avg: .average, rated: .ratings_count,
want_to_read: .bookshelves.want_to_read}'
done
Find readable ebooks, then their print editions
openlibrary search --query 'title:"moby dick"' --sort editions --json \
| jq '.results[] | select(.has_fulltext) | {title, key}'
openlibrary editions OL81699W --json | jq '.editions[] | {key, isbn_13}'
Using --json with jq
openlibrary search --query "voracious" --json | jq '.results[] | {title, first_publish_year}'
openlibrary search-authors --query "butler" --json | jq '.results[0] | {name, key, top_work}'
openlibrary isbn 9780451524935 --json | jq -r '.cover_url' # covers host URL
openlibrary editions OL81699W --json | jq '[.editions[].publish_date]'
openlibrary ratings OL45804W --json | jq '.rating_distribution'
Known Gotchas
- ISBN endpoints answer 302, not JSON —
/isbn/<isbn>.json,/lccn/*.json,/oclc/*.jsonredirect to/books/<OL…M>.json. Clients must follow redirects (curl -L;requestsdoes by default) or they parse an HTML redirect page. Direct/books/<OLID>.jsoncalls return 200 immediately. - Merged keys return redirect stubs inside HTTP 200 — Open Library is a wiki;
when duplicate works merge, the old key keeps answering 200 with
{"type":{"key":"/type/redirect"},"location":"/works/<master>"}instead of a 3xx. Detect the stub type in success responses and re-fetch. The bundled CLI does this automatically (and appends.jsonto stub locations, since extension-less URLs redirect to HTML pages). - Key types are encoded in the suffix —
OL…M= edition (/books/),OL…W= work (/works/),OL…A= author (/authors/). Works link to authors double-nested (authors[].author.key); editions nest flat (authors[].key) — or shipauthors:nullentirely, recovering names from the linked work. Requesting a key under the wrong collection yields a 301 reroute. - Search empties are not errors — malformed queries parse loosely and come
back HTTP 200 with
numFound:0; conversely a badsort=enum is a plain-text HTTP 500 and non-integerlimitis a FastAPI 422. Branch on bodies, not just status codes. availabilityneedsia— in raw/search.jsoncalls, requestingfields=availabilitysilently returns nothing unlessiais also requested.- Covers live on another host — image URLs are always
covers.openlibrary.org/b/id/<cover_id>-{S,M,L}.jpg(or/b/isbn/...,/a/id/...for author photos). Missing covers return a blank placeholder with HTTP 200 unless you add?default=false; non-ID/non-OLID lookups cap at 100 req/IP per 5 min then 403; cover URLs often 302 into archive.org zip shards. - Rate limits are policy, not headers — no Retry-After/X-Rate-Limit headers
exist. Anonymous ≈1 req/s; a
User-Agent: App (email)(setOL_EMAIL) raises it to ≈3 req/s. Batch with one search rather than hundreds of lookups; bulk belongs in monthly dumps. - Text fields nest
{type,value}objects — older records wrapbio,description,notesas{"type":"/type/text","value":"..."}while newer ones use plain strings. Handle both; the CLI unwraps centrally. - OLID ≠ long-term identity — deleted keys can be reassigned to unrelated books, and merged-away keys become redirect stubs. Pair OLIDs with title/ISBN in any cache.
.jsonplacement matters for slugged URLs — append.jsonto the bare key (/authors/OL23919A.json), never after a slug path.
When to use
- Any question about books, authors, or works Open Library's public catalog can answer
- Resolving ISBNs/OLID keys to canonical records and walking edition/work/author graphs
- Finding readable or borrowable scans, cover images, community ratings/shelf counts
When not to use
Do not use this skill for local library-catalog administration — Koha/Evergreen ILS configuration, MARC batch processing, patron management — or for licensed commercial data feeds (ISBNdb, Google Books), citation formatting, or managing your own Open Library reading account/lists (that requires site login this skill deliberately does not handle). For movie/TV metadata use tmdb instead.
Reference Files
| File | Topic | Read when |
|---|---|---|
| references/api-overview-and-key-graph.md | Access model, rate-limit etiquette, OLID M/W/A key graph, cross-collection 301s, merge-stub handling, {type,value} text wrapping |
Assembling record URLs by hand, handling merges/wrong-key errors, or planning request pacing |
| references/search-api-guide.md | /search.json parameters, sort keys, field scopes (title:, isbn:, …), fields= projection incl. the availability/ia interaction, /search/authors.json, /subjects/<name>.json, inside-book search, error model |
Building precise queries, paginating deep result sets, or debugging empty results |
| references/books-isbn-and-covers.md | Identifier endpoints and their 302 resolution, raw records vs legacy /api/books view models, editions listing, ratings/bookshelves shapes, covers-host URL rules and limits |
Working with ISBNs/LCCNs/OCLCs, enumerating editions, or fetching images |
| references/recipes-and-gotchas.md | End-to-end curl/jq pipelines (ISBN→work→author chain, ebook discovery, cover assembly, disambiguation) plus a symptom-indexed gotcha table | Wiring multi-step workflows or diagnosing an unexpected response |
Available Scripts
| Script | Purpose | Invocation |
|---|---|---|
scripts/openlibrary |
The CLI this skill drives: search, search-authors, author, work, isbn, editions, ratings — all with --json/--dry-run, automatic 302 + merge-stub resolution, {type,value} unwrapping, covers-host URL assembly, and client-side sort validation. Run it for every book-metadata question above. |
scripts/openlibrary search --query "dune" --json |
scripts/test_openlibrary.py |
Offline pytest/unittest suite covering help, argument errors, dry-run plans, mocked-client logic (redirect stubs, author fallback, editions paging), plus two env-guarded live probes (OPENLIBRARY_LIVE_TESTS=1). Zero egress otherwise. |
.venv/bin/python3 -m pytest -p no:cacheprovider --strict-markers scripts/test_openlibrary.py |
Prerequisites
- Python 3.8+ with
requests(stdlib otherwise); invoke aspython3 scripts/openlibrary ...if not executable directly - No credentials of any kind; optional
OL_EMAILfor rate-limit etiquette jqrecommended for--jsonpost-processing
Files (agent-skills)
-
evals
-
evals.json 6.7 KB
{ "schema_version": 1, "skill_name": "openlibrary", "evals": [ { "id": "isbn-to-work-author-chain", "prompt": "Look up the book with ISBN 9780451524935 and tell me about the underlying work and its author.", "expected_output": "Scenario: read-only key graph walk. The agent runs `openlibrary isbn 9780451524935 --json`, which resolves through Open Library's 302 redirect to a canonical OL…M edition record and reports the resolved edition_key plus work_keys. It then fetches the linked work (OL…W) for description/subjects and follows the work's double-nested authors[].author.key to an OL…A author record for the bio, unwrapping any {type,value}-shaped text fields. All output comes from real CLI output; no writes are attempted.", "assertions": [ "The ISBN lookup runs via openlibrary isbn with --json before any follow-up calls", "The resolved edition key (OL…M) and work keys (OL…W) are read from the CLI output rather than invented", "Author links on works are accessed as authors[].author.key (double-nested), not authors[].key", "Bio/description fields wrapped as {type:/type/text,value} dicts are unwrapped to plain text" ] }, { "id": "find-readable-ebooks-by-subject", "prompt": "Find me some classic science fiction books I can read online right now.", "expected_output": "Scenario: multi-step search pipeline. The agent searches with `openlibrary search --query 'subject:\"science fiction\"' --sort editions` (or a title/author query), then checks full-text availability via has_fulltext/ebook_access signals in the JSON output, optionally fetching each candidate's edition or cover data. It explains that /search.json's `availability` field is silently omitted unless `ia` is also requested in fields= — a gotcha it avoids by relying on the CLI's surfaced has_fulltext flag or by fetching the edition record directly. Results are presented from actual command output with titles and first-publish years.", "assertions": [ "A search runs with openlibrary search rather than guessing at book records", "Readability is judged from real fields (has_fulltext, ebook_access, or edition-level data) instead of assumed", "No claim is made that availability data appears in search results without also requesting ia alongside it", "Final recommendations cite titles/authors traceable to command output" ] }, { "id": "isbn-302-redirect-gotcha", "prompt": "I curl'd https://openlibrary.org/isbn/9780451524935.json and my script choked parsing HTML instead of the book data. What went wrong?", "expected_output": "Scenario: gotcha diagnosis. The agent explains that identifier endpoints (/isbn/<isbn>.json, /lccn/<lccn>.json, /oclc/<num>.json) answer HTTP 302 with a Location header pointing at the canonical /books/<OL…M>.json URL — they never serve the record directly. A client that does not follow redirects sees only an HTML redirect page. Fix: follow redirects (curl -L; Python requests does so by default). The agent may demonstrate with `openlibrary isbn 9780451524935 --json`, which handles resolution automatically, and notes the final record's key reveals which edition matched.", "assertions": [ "The 302-redirect behavior of /isbn/<isbn>.json is named as the root cause", "The fix is to follow redirects (curl -L or requests' default behavior)", "The canonical target format /books/<edition-OLID>.json is stated", "The bundled CLI is offered as a path that already handles resolution" ] }, { "id": "author-disambiguation-and-top-works", "prompt": "There are several authors named John Herbert. Which one wrote the Foundation series, and what else did they write?", "expected_output": "Scenario: author disambiguation. The agent corrects the premise gently if needed (Foundation is by Isaac Asimov) but demonstrates the workflow: run `openlibrary search-authors --query '...' --json` to list candidates with bare OL…A keys, birth/death dates, top_work, and work_count, pick the right author, then fetch details with `openlibrary author <key>`. If enumerating their books it can use the work listing endpoint described in references. Claims come from command output only.", "assertions": [ "search-authors is used to enumerate name candidates before picking one", "Candidate identity is judged from top_work/work_count/date fields in output", "The chosen bare OL…A key is passed to openlibrary author for details", "Any correction of the premise (Foundation's actual author) is grounded in verified lookup results" ] }, { "id": "koha-catalog-migration-not-openlibrary", "prompt": "Help me migrate our public library's MARC records into our Koha ILS and set up patron accounts.", "expected_output": "Scenario: should-not-trigger. This request targets local library-catalog administration (Koha ILS migration, MARC batch processing, patron account management), which this skill explicitly does not cover — Open Library is a public metadata API, not an integrated library system. The agent does not load openlibrary or invoke its CLI; it routes toward Koha's own tooling/documentation instead, noting the skill covers reading Open Library's catalog data only.", "assertions": [ "The openlibrary skill is not loaded or executed for Koha/MARC administration work", "Koha-native tooling is suggested as the appropriate route", "No Open Library API calls are made as part of planning the ILS migration" ] }, { "id": "keyless-setup-and-rate-etiquette", "prompt": "Set up whatever credentials you need to start researching books for me, and tell me what the limits are.", "expected_output": "Scenario: setup expectations. The agent explains no API key or registration exists at all — reads on openlibrary.org are fully keyless, so there is nothing to configure beyond optional etiquette: setting OL_EMAIL to add a mailto contact to the User-Agent, which raises the polite rate budget from ~1 request/second to 3. Covers live on a separate host (covers.openlibrary.org) where ISBN-keyed lookups cap at 100 requests/IP per 5 minutes while cover-ID lookups are exempt. Bulk jobs belong in monthly dumps, not API loops. No secrets are requested because none exist.", "assertions": [ "States explicitly that the public API requires no key or registration for reads", "OL_EMAIL is described as optional User-Agent identification raising the rate budget (~1/s anonymous vs ~3/s identified)", "Covers-host separation and its distinct 100 req/IP-per-5-min limit on non-ID lookups is mentioned", "No credential, token, or secret is requested or fabricated" ] } ] }
-
-
references
-
api-overview-and-key-graph.md 8.5 KB
# Open Library API Overview and the OLID Key Graph Open Library (an Internet Archive project) exposes its catalog of 50M+ records through public, keyless HTTP APIs. This file covers the access model, etiquette, and the record-key graph that everything else builds on. Companion files: the Search API ([search-api-guide.md](search-api-guide.md)), ISBN/Books/Covers endpoints ([books-isbn-and-covers.md](books-isbn-and-covers.md)), and worked recipes ([recipes-and-gotchas.md](recipes-and-gotchas.md)). ## Access model: no key, three hosts, open CORS Reads require **no API key and no registration**. Three hostnames matter: | Host | Serves | |------|--------| | `openlibrary.org` | Metadata JSON: search, works, editions, authors, ratings | | `covers.openlibrary.org` | Cover images and author photos (separate service) | | `archive.org` | Ebook/scan content and bulk dumps (redirect targets) | Metadata endpoints answer `application/json`, support `GET` + `OPTIONS`, and send `access-control-allow-origin: *`, so browser-side fetches work directly. Optional identification: put your app name and contact email in the User-Agent, e.g. `User-Agent: MyLibraryApp (contact@example.org)`. Identified traffic gets a 3x rate allowance (see below) and gives staff someone to contact before blocking you. There is no token, secret, or account step anywhere in the read surface. ## Rate limits and etiquette (official guidance) From the official APIs index page ([developers/api](https://openlibrary.org/developers/api)): - Anonymous clients: **1 request/second**. Identified clients (User-Agent carrying app name + contact email/phone): **3 requests/second** ("identified requests will enjoy a 3x request limit"). - No `X-Rate-Limit-*` or `Retry-After` headers are sent today; the limit is advisory policy, not header-signaled. Exceeding it politely means sleeping, because there is nothing to read out of the response. - Explicitly discouraged: HTML scraping (use the API endpoints), spreading traffic across 5+ IPs, bulk harvesting, hundreds of single-book GETs where one `/search.json` batch would do, or using Open Library as a backend for a high-traffic service. Violations bring "aggressive rate limiting or blocking". - For bulk data use the monthly dumps instead ([developers/dumps](https://openlibrary.org/developers/dumps)): editions ~9.2G, works ~2.9G, authors ~0.5G, ratings/reading-log much smaller. Dump lines are `type, key, revision, last_modified, JSON`. The covers service has its own harder limit: non-ID/non-OLID cover lookups are capped at **100 requests/IP per 5 minutes**, then **403 Forbidden** ([dev/docs/api/covers](https://openlibrary.org/dev/docs/api/covers)). ## The key graph: OLIDs and the letter-suffix type system Every catalog entity has a stable-shaped identifier called an OLID whose **final letter encodes the type**: | Suffix | Type | Canonical JSON path | Example | |--------|------|--------------------|---------| | `M` | Edition (a physical/digital publication) | `/books/OL34854896M.json` | `OL34854896M` | | `W` | Work (the abstract creative work) | `/works/OL45804W.json` | `OL45804W` | | `A` | Author | `/authors/OL23919A.json` | `OL23919A` | Two surface forms appear in payloads and docs alike: bare OLIDs (`OL45804W`) and path keys (`/works/OL45804W`). Search results return path keys for works (`/works/OL…W`) but bare keys in author search (`OL…A`). Parse defensively: strip or add the collection prefix by inspecting the suffix letter rather than assuming one form. Graph wiring, verified against live records: - **Edition → work**: `edition.works` is a list of key refs: `"works": [{"key": "<RECORD_KEY>"}]`. - **Work → authors**: double-nested, with a role node: `"authors": [{"author": {"key": "<RECORD_KEY>"}, "type": {"key": "<RECORD_KEY>"}}]`. Read `work.authors[].author.key`, never `work.authors[].key`. - **Edition → authors**: flat single nesting instead: `"authors": [{"key": "<RECORD_KEY>"}]`. The two collections disagree — handle both. - **Work → editions**: not embedded; enumerate via `/works/OL…W/editions.json` (see [books-isbn-and-covers.md](books-isbn-and-covers.md)). - Common record furniture: `type.key` (`/type/edition`, `/type/work`, `/type/author`), integer-array `covers` / `photos`, `created`/`last_modified` timestamps, `revision`/`latest_revision` integers. ## Wrong key type: cross-collection 301 reroutes Requesting a key under the wrong collection is forgiven with a redirect to the right one (live-verified): ``` GET https://openlibrary.org/books/OL23919A.json # author OLID under /books HTTP/2 301 location: https://openlibrary.org/authors/OL23919A.json GET https://openlibrary.org/works/OL123M.json # edition OLID under /works HTTP/2 301 location: https://openlibrary.org/books/OL123M.json ``` So a client that follows redirects survives suffix/collection mismatches automatically (`requests` follows by default; `curl` needs `-L`). The practical symptom of *not* following: your parser sees an HTML 301 page instead of JSON. ## Missing keys and the merge problem: redirect stubs inside HTTP 200 Truly nonexistent keys 404 with a JSON body: ``` GET https://openlibrary.org/books/OL999999999M.json HTTP/2 404 {"error": "notfound", "key": "<RECORD_KEY>"} ``` But Open Library is a wiki: duplicates are merged and spam is deleted, and **merged keys do not 3xx**. A merged-away key keeps serving `HTTP 200` with a stub record (live-verified on a real merge found via `/recentchanges/merge-works.json`): ```json {"location": "<RECORD_KEY>", "type": {"key": "<RECORD_KEY>"}, "latest_revision": 4, "revision": 4, ...} ``` The old key `/works/OL24776360W` had just been merged into `/works/OL14868272W`, yet the JSON endpoint returned **200**, not 302. Clients must detect `payload["type"]["key"] == "/type/redirect"` in successful responses and re-fetch `payload["location"]` themselves. (The HTML page for the same key does 302; only JSON gives you the stub.) The bundled CLI performs this follow-up automatically. Related identity hazards: - **Deleted-and-reassigned keys**: an OLID freed by deletion can be reissued for an unrelated book (observed live). Never treat an OLID as long-term identity for caching; pair it with title or ISBN. - Recent merges are observable at `/recentchanges/merge-works.json?limit=N` (`data.master`, `data.duplicates[]`) if you need to audit drift. ## Text-valued fields nest `{type, value}` objects Free-text fields (`bio` on authors; `description`, `notes`, `first_sentence` on editions/works) arrive in **two shapes** depending on record age: ```json "bio": {"type": "/type/text", "value": "Joanne \"Jo\" Murray, OBE ..."} ``` Older records carry a plain string instead. Always branch: if dict, take `["value"]`; if str, use as-is. The bundled CLI unwraps these automatically. Photo/cover ID arrays mix in `-1` placeholders meaning "no image" (e.g. `"photos": [5543033, -1]`): filter out negatives before building image URLs. ## Author URL quirk: `.json` placement matters Bare author URLs HTML-redirect to slugged pages (`/authors/OL23919A` → `/authors/OL23919A/J._K._Rowling`). Appending `.json` after the slug (`/authors/OL23919A/J._K._Rowling.json`) does **not** serve JSON — append it to the bare key: `/authors/<AUTHOR_KEY>.json` ([dev/docs/api/authors](https://openlibrary.org/dev/docs/api/authors)). ## Writes exist but are outside the keyless surface Authenticated writes exist (`POST /account/login.json` with Internet Archive S3 keys returns a session cookie; `PUT` resource JSON updates records), but the RESTful doc states this is effectively internal: PUT/POST without permission returns **403**, and the docs warn the API "works only from the localhost" ([dev/docs/restful_api](https://openlibrary.org/dev/docs/restful_api)). Plan around reads only; expect every write path to require credentials this skill deliberately does not handle. ## Sources - https://openlibrary.org/developers/api — API index; rate limits (1/s anonymous, 3/s identified), User-Agent identification format, bulk-access policy - https://openlibrary.org/dev/docs/api/authors — Authors API; slug/`.json`-placement rule - https://openlibrary.org/dev/docs/api/books — Books/Editions/Works API; record shapes - https://openlibrary.org/dev/docs/restful_api — write/login mechanics, status codes, localhost-only caveat - https://openlibrary.org/developers/dumps — monthly dump catalog and line format - Live read-only probes against openlibrary.org (2026-08-26): 301 cross-collection reroutes, 404 body shape, merge-stub 200 responses, `{type,value}` text nesting -
books-isbn-and-covers.md 9.1 KB
# ISBN Lookup, Editions, Ratings, and the Covers Host This file covers everything keyed to a specific book: identifier-style endpoints (ISBN/LCCN/OCLC/OLID), their 302-redirect resolution, the legacy Books API view models, edition enumeration under a work, community aggregates, and image URLs on the separate covers host. Key-graph fundamentals are in [api-overview-and-key-graph.md](api-overview-and-key-graph.md). ## Identifier endpoints answer 302, not JSON `/isbn/<isbn>.json`, `/lccn/<lccn>.json`, and `/oclc/<num>.json` do not serve the record directly. They **redirect (HTTP 302) to the canonical edition JSON** at `https://openlibrary.org/books/<EDITION_OLID>.json`. Live transcripts: ``` $ curl -is 'https://openlibrary.org/isbn/9780451524935.json' HTTP/2 302 location: https://openlibrary.org/books/OL34854896M.json $ curl -is 'https://openlibrary.org/lccn/93005405.json' HTTP/2 302 location: https://openlibrary.org/books/OL1397864M.json $ curl -is 'https://openlibrary.org/oclc/28419896.json' HTTP/2 302 location: https://openlibrary.org/books/OL1397864M.json ``` Practical consequences: - **Follow redirects or get nothing useful.** Python `requests.get()` follows by default; `curl` needs `-L`; a raw HTTP client that ignores Location sees only an HTML 302 page. - After following, you land on `200 application/json`, the raw edition record — including its `key` (`/books/OL34854896M`), which is how you discover which edition an ISBN resolved to. - The official Books API doc documents the HTML flavor of this redirect (`/isbn/9780140328721` → `/books/OL7353617M`) and notes `.json` may be appended to such page URLs; the 302-on-JSON behavior itself is established by live probes. - An ISBN matching no record eventually 404s after redirects with body `{"error": "notfound", ...}`. Direct edition keys skip the redirect entirely: `/books/OL7440033M.json` → `200`. ## Raw edition records vs the legacy Books API view models **Two different representations exist for the same book.** Know which one you have: 1. **Raw record** (what identifier endpoints redirect to): flat bibliographic fields, string arrays for `publishers`, integer-array `covers`, path-shaped `key`, `type: {"key": "<RECORD_KEY>"}`, key-ref lists `works[]`/`authors[]`. Observed fields include: `title`, `subtitle`, `isbn_10`, `isbn_13`, `lccn`, `oclc_numbers`, `publishers`, `publish_date`, `publish_places`, `number_of_pages`, `pagination`, `languages` (`[{"key": "<RECORD_KEY>"}]`), `covers`, `works`, `authors`, `description`, `notes`, `first_sentence`, `table_of_contents`, `identifiers`, `lc_classifications`, `dewey_decimal_class`, `weight`, `physical_format`, `edition_name`, `copyright_date`, `ocaid` (the archive.org scan id), `source_records`. 2. **Legacy view models** via `GET /api/books?bibkeys=ISBN:<isbn>&format=json&jscmd=<mode>`: | jscmd | Shape | |-------|-------| | *(absent)* / `viewapi` | Tiny object: `bib_key`, `info_url`, `preview` (`noview`/`full`/`restricted`), `preview_url`, `thumbnail_url`. Use `preview` to test readability; `preview_url` is always present even when unreadable. | | `data` | Friendly model: `url`, `title`, `authors[{name,url}]`, `publishers[{name}]` (objects!), grouped `identifiers{isbn_10,isbn_13,lccn,oclc,goodreads,...}`, `classifications{lc_classifications,dewey_decimal_class}`, `subjects[]`, ready-made `cover{small,medium,large}` URLs, `ebooks[]`, `excerpts[]`, `links[]`. Docs recommend this as the stable format. | | `details` | viewapi fields plus a nested raw record under `details`; docs advise using `jscmd=data` instead. | `bibkeys` is comma-separated with prefixes `ISBN:`, `OCLC:`, `LCCN:`, `OLID:`; both ISBN-10 and ISBN-13 accepted. `format=json` required for machine use (default is JSONP-style JavaScript). The whole `/api/books` endpoint is flagged legacy ("may be phased out"); prefer search + direct record fetches for new work. Live contrast for ISBN 9780451524935: ```json // jscmd=data {"ISBN:9780451524935": { "title": "Nineteen Eighty-Four", "url": "http://openlibrary.org/books/OL34854896M/Nineteen_Eighty-Four", "publishers": [{"name": "Signet Classics"}], "cover": {"small": "https://covers.openlibrary.org/b/id/12054527-S.jpg", "medium": "https://covers.openlibrary.org/b/id/12054527-M.jpg", "large": "https://covers.openlibrary.org/b/id/12054527-L.jpg"}, ...}} // default viewapi {"ISBN:9780451524935": {"bib_key": "ISBN:9780451524935", "info_url": "http://openlibrary.org/books/OL34854896M/Nineteen_Eighty-Four", "preview": "restricted", "preview_url": "https://archive.org/details/nineteeneightyfo0000orwe_g7l1", "thumbnail_url": "https://covers.openlibrary.org/b/id/12054527-S.jpg"}} ``` ## Listing every edition of a work ``` GET https://openlibrary.org/works/<WORK_OLID>/editions.json[?limit=N&offset=M] ``` Response envelope: ```json {"size": 6, "links": {"self": "/works/OL81699W/editions.json?limit=2", "work": "/works/OL81699W", "next": "/works/OL81699W/editions.json?limit=2&offset=2"}, "entries": [ {full edition records}, ... ]} ``` - `entries[]` holds complete edition records (same shape as single-edition JSON). - Pagination via `limit`/`offset` verified live; while more pages remain, `links.next` carries the prebuilt next URL — follow it rather than recomputing. - The same `size`/`links.self`/`entries` structure serves author works at `/authors/OL…A/works.json` (default page size 50 there, `limit` up to 1000 per the Authors API doc). ## Community aggregates on works Public, keyless GETs on any work key: **Ratings** — `GET /works/<OLID>/ratings.json` ```json {"summary": {"average": 3.966386554621849, "count": 119, "sortable": 3.7388955319679584}, "counts": {"1": 10, "2": 6, "3": 18, "4": 29, "5": 56}} ``` Note the string keys `"1"`–`"5"` in `counts`, and that `summary.average` is absent when nobody has rated. **Bookshelves** — `GET /works/<OLID>/bookshelves.json` ```json {"counts": {"want_to_read": 1191, "currently_reading": 97, "already_read": 189, "stopped_reading": 0}} ``` Shelf names are literal: `want_to_read`, `currently_reading`, `already_read`, `stopped_reading`. A per-work `/readinglog.json` route is **not part of the documented public API** (404 in testing); reading-log data flows through the My Books API ([dev/docs/api/mybooks](https://openlibrary.org/dev/docs/api/mybooks)) — e.g. `/people/<username>/books/want-to-read.json` — or monthly dumps. ## Covers and author photos: a separate host with its own rules All images live on `covers.openlibrary.org`, never on `openlibrary.org`: ``` Book covers: https://covers.openlibrary.org/b/{id|olid|isbn|lccn|oclc}/<value>-{S|M|L}.jpg Author photos: https://covers.openlibrary.org/a/{id|olid}/<value>-{S|M|L}.jpg Cover metadata: append .json → https://covers.openlibrary.org/b/id/12547191.json ``` Sizes: S = thumbnail, M = details-page size, L = large. The same cover is reachable by any of its keys (`/b/id/240727-S.jpg`, `/b/olid/OL7440033M-S.jpg`, `/b/isbn/0385472579-S.jpg` all hit one image). Behaviors verified live: - Cover URLs commonly **302 into archive.org zip shards** (`location: https://archive.org/download/s_covers_0012/s_covers_0012_05.zip/0012054527-S.jpg`); some lookups answer 200 directly. Follow redirects regardless. - A missing cover returns a **blank placeholder image with HTTP 200** unless you add `?default=false`, which yields a proper **404**: ``` $ curl -sI '.../b/id/999999999999-M.jpg' → HTTP 200 (blank) $ curl -sI '.../b/id/999999999999-M.jpg?default=false' → HTTP 404 ``` Always pass `default=false` when you need existence semantics. - Rate limit: non-ID/non-OLID lookups (ISBN/LCCN/OCLC forms) are capped at **100 requests/IP per 5 minutes**, then **403 Forbidden**; ID- and OLID-based lookups are exempt ([dev/docs/api/covers](https://openlibrary.org/dev/docs/api/covers)). Resolve to cover IDs first if you will fetch many images. - Cover metadata JSON includes `width`, `height`, `olid`, shard filenames — handy for checking existence before downloading. - Author photo IDs come from the author record's `photos` array (filter out `-1` placeholders) and map to `/a/id/<photo_id>-<S|M|L>.jpg`. - Etiquette: don't crawl covers; bulk archives live on archive.org items (`s_covers_*`, `m_covers_*`, `l_covers_*`). A courtesy link back to Open Library is appreciated when displaying covers. ## Sources - https://openlibrary.org/dev/docs/api/books — identifier endpoints, redirect behavior, legacy /api/books and jscmd modes - https://openlibrary.org/dev/docs/api/covers — URL patterns, sizes, default=false, rate limits - https://openlibrary.org/dev/docs/api/authors — /authors/…/works.json pagination - https://openlibrary.org/dev/docs/api/mybooks — documented reading-log surface - https://openlibrary.org/developers/api — etiquette governing image/metadata fetching - Live read-only probes against openlibrary.org and covers.openlibrary.org (2026-08-26): 302 Locations, editions.json envelope, ratings/bookshelves shapes, blank-vs-404 cover behavior, archive.org shard redirects -
recipes-and-gotchas.md 8.2 KB
# Worked Recipes and Gotcha Compendium Multi-step pipelines against the Open Library API using `curl`/`jq` (or the bundled CLI), followed by a symptom-indexed gotcha table. Fundamentals: [api-overview-and-key-graph.md](api-overview-and-key-graph.md) (key graph, merges), [search-api-guide.md](search-api-guide.md) (search params/errors), [books-isbn-and-covers.md](books-isbn-and-covers.md) (ISBN redirects, covers). ## Recipe 1: ISBN → edition → work → full author bio The canonical resolution chain. Each hop uses a different key type — this is where OL…M/W/A confusion bites: ```bash ISBN=9780451524935 # 1. ISBN resolves via 302 to an OL…M edition record (requests follows by default) curl -sL "https://openlibrary.org/isbn/$ISBN.json" > edition.json # 2. Pull the work key out of the edition (path form /works/OL…W) WORK=$(jq -r '.works[0].key' edition.json) # e.g. /works/OL166894W # 3. Fetch the work through the CLI. Its JSON handoff exposes bare OL…A keys. openlibrary work "${WORK##*/}" --json > work.json AUTHOR=$(jq -r '.authors[0]' work.json) # OL23919A # 4. Author record; the CLI accepts the bare key and unwraps bio text. openlibrary author "$AUTHOR" --json | jq -r ' if (.bio | type) == "object" then .bio.value else .bio end' ``` Failure modes at each hop: step 1 needs `-L` in curl or you parse an HTML 302 page; the edition record behind step 1 frequently ships `"authors": null` outright — the CLI absorbs that plus the nested-vs-flat split (works double-nest `.authors[].author.key`, editions stay flat `.authors[].key`) by falling back to the linked work, so build pipelines on its `--json` handoff instead of hand-probing record fields; step 4 crashes naive parsers when `bio` is an object. ## Recipe 2: search → filter to readable ebooks → fetch editions of the top hit ```bash # availability requires ia in fields= (silently absent otherwise!) curl -s 'https://openlibrary.org/search.json' \ --data-urlencode 'q=title:"moby dick"' \ --data-urlencode 'fields=key,title,author_name,first_publish_year,ia,ebook_access,availability' \ --data-urlencode 'sort=editions' --data-urlencode 'limit=5' > hits.json jq -r '.docs[] | select(.ebook_access == "public") | .key' hits.json | head -1 > workkey WORK=$(cat workkey) # enumerate all editions with pagination links NEXT="/works/${WORK##*/}/editions.json?limit=50" while [ "$NEXT" != "null" ] && [ -n "$NEXT" ]; do curl -s "https://openlibrary.org$NEXT" | jq '.entries[] | {key, isbn_13, publish_date}' NEXT=$(curl -s "https://openlibrary.org$NEXT" | jq -r '.links.next // empty') done ``` ## Recipe 3: cover-image URL assembly without blank-image surprises Cover IDs come from records (`covers:[12054527]`) or search results (`cover_edition_key`, `cover_i`). Build URLs on the covers host and demand real 404s: ```bash COVER_ID=$(jq -r '.covers[0] | select(. >= 0)' edition.json | head -1) for size in S M L; do url="https://covers.openlibrary.org/b/id/${COVER_ID}-${size}.jpg?default=false" code=$(curl -s -o /dev/null -w '%{http_code}' -L "$url") echo "$size $code" # 200 = exists; 404 = no cover at this size done ``` Without `?default=false` every probe returns 200 (blank placeholder), so existence checks silently lie. For batch image pulls, resolve to numeric cover IDs first — ISBN-based lookups are rate-limited at 100 req/IP per 5 min, ID-based are exempt. ## Recipe 4: author disambiguation via search-authors, then their top works ```bash curl -s 'https://openlibrary.org/search/authors.json?q=herbert&limit=5' \ | jq '.docs[] | {name, key, birth_date, death_date, top_work, work_count}' # pick the right bare OL…A key, then: curl -s 'https://openlibrary.org/authors/OL3874685A/works.json?limit=10' \ | jq '{size, works: [.entries[].title]}' ``` Author-search keys arrive **bare** (`OL…A`); book-search keys arrive as paths (`/works/OL…W`). When assembling URLs from either, strip everything up to the final slash first. ## Recipe 5: community-signal ranking of a series' entries ```bash for W in $(curl -s 'https://openlibrary.org/search.json?q=series:dune&limit=8' \ | jq -r '.docs[].key'); do WID=${W##*/} ratings=$(curl -s "https://openlibrary.org/works/$WID/ratings.json") shelves=$(curl -s "https://openlibrary.org/works/$WID/bookshelves.json") jq -n --arg w "$WID" --argjson r "$ratings" --argjson s "$shelves" \ '{work: $w, avg: $r.summary.average, rated: $r.summary.count, want_to_read: $s.counts.want_to_read}' sleep 1 # anonymous budget is ~1 req/s; be polite done ``` ## Gotchas indexed by symptom | Symptom | Cause | Fix | |---------|-------|-----| | JSON parse error / HTML instead of data after an ISBN lookup | `/isbn/<isbn>.json` answers **302** to `/books/OL…M.json`; client didn't follow | Follow redirects (`requests` default, `curl -L`) | | `KeyError: 'author'` reading work authors | Work records double-nest: `authors[].author.key`; editions nest flat | Branch on collection or use a tolerant accessor | | Bio/description arrives as dict, not string | Legacy `{type: "/type/text", value}` wrapper on older records | `v["value"] if isinstance(v, dict) else v` | | Requested `availability` missing from search docs | `availability` requires `ia` in the same `fields=` list | `fields=key,title,ia,availability` | | Search returned 0 results but no error | Malformed q parses loosely and returns HTTP 200 empty; empty/missing q too | Treat empties as results-not-errors; validate input client-side | | `Internal Server Error` plain text from search | Invalid `sort=` enum (e.g. `bogus`) → HTTP 500 non-JSON | Validate sort choices before sending | | 422 validation JSON from search | Non-integer `limit`, negative `offset` (FastAPI validation) | Clamp inputs client-side | | Merged work key returns odd body with HTTP 200 | Wiki merges leave `{type:{key:"/type/redirect"}, location}` stubs, not 3xx | Detect redirect-type bodies and re-fetch `location` | | Author OLID under `/books/` "fails" | Wrong collection for suffix letter → 301 reroute to correct one | Follow redirects, or normalize keys by suffix before fetching | | Slug-URL `.json` gives HTML not JSON | `.json` must attach to the bare key (e.g. `/authors/<AUTHOR_KEY>.json`), never after a slug path (`/authors/<AUTHOR_KEY>/Slug.json`) | Append `.json` directly to the key | | Cover check says exists but image is blank | Missing covers return blank placeholder **with HTTP 200** | Add `?default=false` to get true 404s | | Covers start returning 403 | Non-ID/non-OLID cover lookups cap at 100 req/IP per 5 min | Resolve to cover IDs (`/b/id/...`), which are exempt | | Cover/image download stalls mid-pipeline | Cover URLs often 302 into archive.org zip shards | Follow redirects there too | | Old cached OLID now serves a different book | Deleted keys get reassigned; OLIDs aren't long-term identity | Pair OLID with title/ISBN in caches | | Rate-limited or blocked entirely | Anonymous budget ~1 req/s (3 identified); no Retry-After header exists | Send `User-Agent: AppName (email)`, cache, sleep ≥1s, batch via search.json | ## Design rules for robust clients 1. Always follow redirects everywhere; both metadata and images redirect routinely. 2. Normalize every key by its suffix letter (M/W/A) and rebuild canonical URLs; accept both bare and path forms on input. 3. Handle `{type,value}` text wrapping centrally, once. 4. Never branch on HTTP status alone: merged-stub 200s, silent-empty 200s, and non-JSON 500s all exist. Inspect bodies. 5. Identify your client via User-Agent email; sleep between bursts; prefer one `/search.json` over hundreds of single-record GETs. 6. Cache by (OLID + title), not OLID alone. ## Sources - https://openlibrary.org/dev/docs/api/books — identifier endpoints, view models, redirect semantics - https://openlibrary.org/dev/docs/api/covers — cover URL patterns, default=false, rate limits - https://openlibrary.org/dev/docs/api/search — fields=/sort/error semantics exercised in recipes - https://openlibrary.org/dev/docs/api/authors — slug rule, works.json paging, batch-by-key trick - https://openlibrary.org/developers/api — rate-limit etiquette encoded in recipe sleeps - https://openlibrary.org/search/howto — field scopes and filters used in queries - Live read-only probes (2026-08-26) validating each recipe's chain end-to-end -
search-api-guide.md 10.1 KB
# Open Library Search API Guide `/search.json` is the primary read surface: a Solr-backed work index with offset pagination, field-scoped queries, and server-side projection. This file documents the full parameter surface, result schema, sibling search endpoints, and the error model observed live. Key-graph basics (OL…M/W/A) live in [api-overview-and-key-graph.md](api-overview-and-key-graph.md); ISBN/edition/covers endpoints in [books-isbn-and-covers.md](books-isbn-and-covers.md). ## Endpoint and parameters ``` GET https://openlibrary.org/search.json?q=<query>&<params...> ``` | Parameter | Behavior | |-----------|----------| | `q` | Solr query string; supports field scopes and Lucene syntax (below). | | `fields` | Comma-separated projection. `*` returns ~120 fields (docs warn it's "expensive, please use sparingly"). Special value `availability` adds an availability subdocument — **only if `ia` is also requested** (gotcha below). | | `sort` | One of the sort keys below; default is relevance. Invalid values cause an HTTP 500 (error model below). | | `limit` / `offset` | Offset pagination. `page`/`limit` also works (page starts at 1); if both `page` and `offset` are sent, **offset wins**. | | `lang` | Two-letter ISO 639-1 preference — "influences but doesn't exclude" results. To *exclude*, use `language:<code>` inside `q`. | | `title`, `author` | Top-level scoped params equivalent to prefixing inside q (e.g. `/search.json?title=the+lord+of+the+rings`). | ### Sort keys All of these returned HTTP 200 in live probes (`q=harry potter&limit=1`); the authoritative enumeration lives in Open Library source (`openlibrary/plugins/worksearch/schemes/works.py`): `new`, `old`, `rating asc`, `rating desc` (bare `rating` = desc), `editions`, `title`, `scans`, `key` (sorts as a *string*, not numerically), `random`, `readinglog`, `already_read`, `want_to_read`, `currently_reading`, `ebook_access`. The existing CLI exposes `--sort {editions,new,old,rating,title}` plus empty for relevance — a safe subset. ## Pagination model: offsets only, no tokens There is no cursor or token concept anywhere on this API — clients compute the next offset from `numFound` and `start`: ```json {"numFound": 4045, "start": 0, "numFoundExact": true, "num_found": 4045, "documentation_url": "...", "q": "harry potter", "offset": null, "docs": [...]} ``` - `start` mirrors the effective zero-based offset of the first doc (`page=3&limit=10` → `start: 20`). - `numFoundExact: false` signals the count is approximate. - No documented cap on `limit` or `offset`: live probes honored `limit=2000` and `offset=11000`. The response simply clamps to the matched set. Stay modest anyway — the etiquette policy forbids using OL as a bulk backend. ## Result document schema Common `docs[]` fields: `key` (path form `/works/OL…W`), `title`, `author_name[]`, `author_key[]`, `first_publish_year`, `edition_count`, `cover_edition_key`, `cover_i`, `ia[]` (Internet Archive scan ids), `has_fulltext`, `public_scan_b`, `language[]`, `subject[]`, `publisher[]`, `publish_year[]`, `isbn[]`, `number_of_pages_median`, `ebook_access`, `ratings_average`, `ratings_count`, `readinglog_count`, `seed[]`. The docs state the schema "is not guaranteed to be stable, but most common fields … should be safe to depend on". Treat exotic fields as best-effort. ## Query syntax: field scopes and filters Verified live prefixes: | Prefix | Example | Notes | |--------|---------|-------| | `title:` | `q=title:flammable` | 551 hits | | `author:` | `q=author:solnit` | 129 hits | | `subject:` | `q=subject:"tennis rules"` | fuzzy containment (AND), not exact phrase | | `publisher:` | `q=publisher:harper` | 77,889 hits | | `isbn:` | `q=isbn:9780451524935` | ISBN-10 and ISBN-13 both resolve to the same single work | | `language:` | `q=language:fre` | excludes works without matching-language editions | Lucene extras from the official how-to ([search/howto](https://openlibrary.org/search/howto)): ranges (`first_publish_year:[1200 TO 1400]`, `publish_year:[* TO 1800]`), booleans `AND`/`OR`/`NOT`, negation `-subject_key:"apache_solr"`, prefix wildcards `ddc:200*`, normalized exact keys (`subject_key:`, `person_key:`, `place_key:`, `time_key:` — lowercase, spaces/slashes → underscores), availability filter `ebook_access:` with values `no_ebook`, `printdisabled`, `borrowable`, `public`, plus `has_fulltext:true`, `edition_count:N`, `readinglog_count:[25 TO *]`. ## The `fields=` projection and its availability gotcha Requesting fewer fields shrinks payloads dramatically. Live behavior: - `fields=key,title` returns exactly those keys per doc. - **`availability` is silently omitted unless `ia` is also requested** — verified live: - `fields=key,title,availability` → doc keys exactly `['key','title']` - `fields=key,title,ia,availability` → full availability subdocument present With `ia` included, each doc gains: ```json "availability": { "status": "borrow_available", "is_readable": false, "is_lendable": true, "is_printdisabled": true, "openlibrary_work": "OL82563W", "openlibrary_edition": "OL61057835M", ... } ``` `status` values include `borrow_available`, `borrow_unavailable`, `printdisabled`, `open` (readable), and absent/`error` when no scan exists. Bonus expansion: `fields=key,title,editions` nests a mini-result-set under each work (`numFound`/`start`/`docs[]` with edition fields); individual edition fields are requested as `editions.key`, `editions.ebook_access`, `editions.language`; `&editions.sort` overrides default boosting. ## Author search: `/search/authors.json` Same envelope (`numFound`/`start`/`docs[]`); author docs carry bare-form keys (`OL9937375A`) unlike book-search path keys: ```json {"name": "Mark Twain", "key": "OL9937375A", "birth_date": "30 November 1835", "death_date": "21 April 1910", "top_work": "Roughing It", "work_count": 2157, "top_subjects": ["Twain, mark, 1835-1910", ...]} ``` Supports Solr syntax in `q` too (e.g. `birth_date:1973`) plus `limit`/`offset`. Note `birth_date`/`death_date` may be null or free-text strings ("7 February 1812") — they are display strings, not typed dates. Batch-fetch trick documented on the Authors API page: partial author records via book search with `q=key:(/authors/OL11111A OR /authors/OL22222A)`; there is no batch endpoint for full author records. ## Subject browsing: `/subjects/<name>.json` (plural!) The Subjects API ([dev/docs/api/subjects](https://openlibrary.org/dev/docs/api/subjects), marked experimental) browses works grouped by normalized subject: ``` GET https://openlibrary.org/subjects/pizza.json?limit=1 → {"key": "<RECORD_KEY>", "name": "pizza", "work_count": 519, "works": [{"key": "<RECORD_KEY>", "title": "Pete's a Pizza", "edition_count": 19, "authors": [{"name": "William Steig"}], "first_publish_year": 1998, "availability": {...}}, ...]} ``` - Path is **plural** `/subjects/<name>.json`; singular `/subject/pizza.json` 404s (live-verified). - Names use underscores: `science_fiction`. - Params: `details=true` (adds related `subjects[]`/`authors[]`/`publishers[]` with counts plus `publishing_history`), `ebooks=true`, `published_in=1500-1600`, `limit`, `offset`. - Works here include `availability` by default, unlike `/search.json`. - Sibling collections exist for persons/places/times (`/persons/<name>.json` etc.). ## Full-text inside-book search: `/search/inside.json` Searches OCR text across millions of scanned books; Elasticsearch-shaped response: ``` GET https://openlibrary.org/search/inside.json?q=%22library science%22 → hits.total, hits.hits[] with _id (ia identifier), _score, highlight.text[] ("{{{Library Science}}}" marks matches), fields.identifier (ia id), edition.key/title, availability ``` Default page size 20; `limit`/`offset` supported (live-verified). A separate, documented-but-experimental *per-book* inside search actually runs on archive.org data nodes (`https://ia800204.us.archive.org/fulltext/inside.php?item_id=...`) — see [dev/docs/api/search_inside](https://openlibrary.org/dev/docs/api/search_inside) if you need per-page match geometry; that host is outside this skill's CLI. ## Error model: silent empties vs hard failures Live-probed status codes — counterintuitive but consistent: | Request | Status | Body | |---------|--------|------| | missing or empty `q` | **200** | normal envelope, `numFound: 0`, `docs: []` | | malformed query `q=title:"unclosed` | **200** | `numFound: 0` — no error surfaced | | loosely-parseable garbage `q=(OR` | **200** | 568k loose matches | | invalid enum `sort=bogus` | **500** | plain text `Internal Server Error` (not JSON!) | | non-integer `limit=abc` | **422** | FastAPI validation JSON `{"detail":[{"type":"int_parsing",...}]}` | | negative `offset=-5` | **422** | FastAPI validation JSON `greater_than_equal` | | singular `/subject/pizza.json` | **404** | HTML error page | Design consequence: user-facing "no results" is usually **not** an error — treat empty `docs` as success. Conversely a bad `--sort` choice fails loudly as non-JSON 500, so clients should validate sort choices before sending (as the bundled CLI does). One environment caveat observed during research: responses can arrive with key fields masked to asterisks by anti-bot middleware depending on client reputation. Production responses carry real keys (the docs' own examples show them), but parsers should tolerate both `OL…W` and `/works/OL…W` shapes and not assume key presence. ## Sources - https://openlibrary.org/dev/docs/api/search — Search API parameters, fields= semantics, editions sub-query - https://openlibrary.org/search/howto — query syntax, field scopes, filter examples - https://openlibrary.org/developers/api — rate-limit etiquette applying to search traffic - https://openlibrary.org/dev/docs/api/authors — author batch-fetch via key:(…) search - https://openlibrary.org/dev/docs/api/subjects — Subjects API params (details/ebooks/published_in) - https://openlibrary.org/dev/docs/api/search_inside — experimental per-book inside search (archive.org hosted) - Live read-only probes against openlibrary.org (2026-08-26): sort keys, limit/offset ranges, fields=availability interaction, error status codes
-
-
scripts
-
openlibrary 22.5 KB · in bundle
-
test_openlibrary.py 23.7 KB
"""Offline tests for the bundled openlibrary CLI (scripts/openlibrary). Four test classes per skill-builder contract: 1. --help output 2. argument-error paths 3. --dry-run behavior 4. mocked-client logic (requests mocked at the client-call site) Plus one env-guarded live class: Open Library is a keyless public API, so a small bounded set of live GETs runs ONLY when OPENLIBRARY_LIVE_TESTS=1; they skip cleanly otherwise (proxy-trap reruns pass with them skipped). """ import contextlib import importlib.machinery import importlib.util import io import json import os import pathlib import unittest from unittest import mock import requests SCRIPT = pathlib.Path(__file__).resolve().parent / "openlibrary" LOADER = importlib.machinery.SourceFileLoader("openlibrary_cli", str(SCRIPT)) SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) ol_cli = importlib.util.module_from_spec(SPEC) LOADER.exec_module(ol_cli) EDITION_KEY = "/books/" + "OL34854896M" WORK_KEY = "/works/" + "OL1168083W" AUTHOR_KEY = "/authors/" + "OL118077A" def run_cli(*argv): """Invoke main() with argv[0] prepended; returns (exit_code, stdout, stderr).""" out, err = io.StringIO(), io.StringIO() code = 0 with mock.patch.object(ol_cli.sys, "argv", ["openlibrary", *argv]): with mock.patch.object(ol_cli.sys, "stdout", out), \ mock.patch.object(ol_cli.sys, "stderr", err), \ contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): try: ol_cli.main() except SystemExit as exc: code = exc.code if isinstance(exc.code, int) else 0 return code, out.getvalue(), err.getvalue() class FakeResponse: def __init__(self, status_code=200, payload=None, text="", headers=None, url="https://openlibrary.org/x"): self.status_code = status_code self._payload = payload self.text = text or (json.dumps(payload) if payload is not None else "") self.headers = headers or {} self.url = url def json(self): if self._payload is None: raise ValueError("no json") return self._payload # === Class 1: help output === class HelpOutputTests(unittest.TestCase): def test_help_lists_all_subcommands(self): code, out, _ = run_cli("--help") self.assertEqual(code, 0) for noun in ("search", "search-authors", "author", "work", "isbn", "editions", "ratings"): self.assertIn(noun, out) def test_help_mentions_keyless_setup(self): _, out, _ = run_cli("--help") self.assertIn("No API key", out) def test_subcommand_help_mentions_flags(self): _, out, _ = run_cli("search", "--help") for flag in ("--query", "--limit", "--offset", "--sort", "--lang"): self.assertIn(flag, out) def test_editions_help_documents_pagination(self): _, out, _ = run_cli("editions", "--help") self.assertIn("--limit", out) self.assertIn("--offset", out) # === Class 2: argument errors === class ArgumentErrorTests(unittest.TestCase): def test_search_requires_query(self): code, _, err = run_cli("search") self.assertEqual(code, 2) self.assertIn("--query", err) def test_no_command_prints_help_and_exits(self): code, out, _ = run_cli() self.assertEqual(code, 1) self.assertIn("usage:", out) def test_unknown_subcommand_fails(self): code, _, err = run_cli("frobnicate") self.assertEqual(code, 2) self.assertIn("invalid choice", err) def test_sort_rejects_unknown_values_client_side(self): # A bogus sort value makes Open Library return a plain-text HTTP 500, # so the CLI validates sort choices before ever sending. code, _, err = run_cli("search", "--query", "dune", "--sort", "bogus") self.assertEqual(code, 2) self.assertIn("--sort", err) # === Class 3: dry-run behavior === class DryRunTests(unittest.TestCase): def test_dry_run_isbn_reports_302_resolution_plan(self): code, out, _ = run_cli("--dry-run", "--json", "isbn", "9780451524935") self.assertEqual(code, 0) plan = json.loads(out) self.assertTrue(plan["dry_run"]) self.assertEqual(plan["command"], "isbn") self.assertIn("/isbn/9780451524935.json", plan["url"]) self.assertIn("302", plan["note"]) def test_dry_run_editions_emits_query_params(self): code, out, _ = run_cli("--json", "--dry-run", "editions", WORK_KEY, "--limit", "5") self.assertEqual(code, 0) plan = json.loads(out) self.assertEqual(plan["key"], "OL1168083W") self.assertEqual(plan["params"]["limit"], 5) def test_dry_run_ratings_plans_both_endpoints(self): code, out, _ = run_cli("--dry-run", "--json", "ratings", "OL45804W") self.assertEqual(code, 0) plan = json.loads(out) joined = " ".join(plan["urls"]) self.assertIn("/ratings.json", joined) self.assertIn("/bookshelves.json", joined) def test_dry_run_never_touches_network(self): with mock.patch.object(requests, "get") as req: code, _, _ = run_cli("--dry-run", "work", WORK_KEY) self.assertEqual(code, 0) req.assert_not_called() # === Class 4: mocked client logic === EDITION_RECORD = { "type": {"key": "/type/edition"}, "key": EDITION_KEY, "title": "Nineteen Eighty-Four", "authors": None, # real records ship authors:null sometimes "works": [{"key": WORK_KEY}], "covers": [12054527, -1], "number_of_pages": 328, "publish_date": "1993?", "publishers": ["Signet Classics"], "description": {"type": "/type/text", "value": "A dystopian classic."}, } WORK_RECORD = { "type": {"key": "/type/work"}, "key": WORK_KEY, "title": "Nineteen Eighty-Four", "authors": [{"author": {"key": AUTHOR_KEY}, "type": {"key": "/type/author_role"}}], "subjects": ["Totalitarianism"], "description": "A dystopian classic.", "covers": [-1], } class MockedClientTests(unittest.TestCase): """Mock requests.get at the client-call site; zero network in this class.""" def setUp(self): ol_cli.QUIET = False ol_cli.GLOBAL_FLAGS.update(json=True, dry_run=False, quiet=False) def test_isbn_surfaces_resolved_edition_and_work_keys(self): # The edition record carries works[] but authors:null, so the CLI # follows the work link once to recover author keys. edition = FakeResponse( 200, EDITION_RECORD, url="https://openlibrary.org/books/" + "OL34854896M.json") work = FakeResponse(200, WORK_RECORD) with mock.patch.object(requests, "get", side_effect=[edition, work]) as req: code, out, _ = run_cli("--json", "isbn", "9780451524935") self.assertEqual(code, 0) self.assertEqual(req.call_count, 2) data = json.loads(out) self.assertEqual(data["edition_key"], "OL34854896M") self.assertEqual(data["work_keys"], ["OL1168083W"]) # Cover URLs point at the SEPARATE covers host, skipping -1 placeholders. self.assertEqual(data["cover_url"], ( "https://covers.openlibrary.org/b/id/" + "12054527-M.jpg")) def test_isbn_falls_back_to_work_authors_when_edition_has_none(self): edition = FakeResponse(200, EDITION_RECORD) work = FakeResponse(200, WORK_RECORD) with mock.patch.object(requests, "get", side_effect=[edition, work]) as req: code, out, _ = run_cli("--json", "isbn", "9780451524935") self.assertEqual(code, 0) self.assertEqual(req.call_count, 2) self.assertTrue(req.call_args_list[1].args[0].endswith(WORK_KEY + ".json")) self.assertEqual(json.loads(out)["authors"], ["OL118077A"]) def test_work_with_explicit_null_authors_returns_empty_array(self): # Real-world work records sometimes carry an explicit "authors": null; # the CLI must render '?' and hand off [] instead of raising TypeError. record = dict(WORK_RECORD, authors=None) with mock.patch.object(requests, "get", return_value=FakeResponse(200, record)): code, out, _ = run_cli("--json", "work", WORK_KEY) self.assertEqual(code, 0) data = json.loads(out) self.assertIsInstance(data["authors"], list) self.assertEqual(data["authors"], []) ol_cli.GLOBAL_FLAGS.update(json=False) try: with mock.patch.object(requests, "get", return_value=FakeResponse(200, record)): human_code, human_out, _ = run_cli("work", WORK_KEY) finally: ol_cli.GLOBAL_FLAGS.update(json=True) self.assertEqual(human_code, 0) self.assertIn("?", human_out) def test_isbn_and_work_emit_same_json_type_under_authors_key(self): # Symmetry contract: any "authors"-keyed field across CLI commands is a # JSON array of bare OL…A key strings. Downstream jq pipelines can # treat .authors identically regardless of the entry command. edition_authored = { "type": {"key": "/type/edition"}, "key": EDITION_KEY, "title": "Nineteen Eighty-Four", "authors": [{"key": AUTHOR_KEY}], "works": [{"key": WORK_KEY}], } edition = FakeResponse(200, edition_authored) with mock.patch.object(requests, "get", return_value=FakeResponse(200, WORK_RECORD)): _, work_out, _ = run_cli("--json", "work", WORK_KEY) _, isbn_out, _ = run_cli("--json", "isbn", "9780451524935") work_data = json.loads(work_out) isbn_data = json.loads(isbn_out) for data in (work_data, isbn_data): self.assertIsInstance(data["authors"], list) self.assertNotIsInstance(data["authors"], str) self.assertTrue(all( isinstance(k, str) and k.endswith("A") for k in data["authors"])) self.assertEqual(isbn_data["authors"], ["OL118077A"]) self.assertEqual(work_data["authors"], ["OL118077A"]) def test_edition_key_only_author_refs_become_bare_keys_in_json(self): edition_authored = { "type": {"key": "/type/edition"}, "key": EDITION_KEY, "title": "Nineteen Eighty-Four", "authors": [{"key": "/authors/" + "OL118077A"}, {"key": "/authors/" + "OL7862984A"}], "works": [{"key": WORK_KEY}], } edition = FakeResponse(200, edition_authored) with mock.patch.object(requests, "get", return_value=edition) as req: code, out, _ = run_cli("--json", "isbn", "9780451524935") self.assertEqual(code, 0) self.assertEqual(req.call_count, 1) # no fallback read needed data = json.loads(out) self.assertEqual(data["authors"], sorted(["OL118077A", "OL7862984A"])) def test_isbn_human_output_still_shows_comma_joined_labels(self): # The display surface is unchanged: comma-joined names on stdout while # --json carries the array shape. edition = FakeResponse(200, { "type": {"key": "/type/edition"}, "key": EDITION_KEY, "title": "Nineteen Eighty-Four", "authors": [{"name": "George Orwell"}, {"name": "Thomas Pynchon"}], "works": [{"key": WORK_KEY}]}) ol_cli.GLOBAL_FLAGS.update(json=False) try: with mock.patch.object(requests, "get", return_value=edition): code, out, _ = run_cli("isbn", "9780451524935") finally: ol_cli.GLOBAL_FLAGS.update(json=True) self.assertEqual(code, 0) self.assertIn("George Orwell, Thomas Pynchon", out) def test_work_json_authors_are_bare_keys_array(self): with mock.patch.object(requests, "get", return_value=FakeResponse(200, WORK_RECORD)): code, out, _ = run_cli("--json", "work", WORK_KEY) self.assertEqual(code, 0) data = json.loads(out) self.assertIsInstance(data["authors"], list) self.assertEqual(data["authors"], ["OL118077A"]) self.assertRegex(data["authors"][0], r"^OL\d+A$") def test_isbn_work_author_pipeline_handoff_is_executable(self): """Mock the documented ISBN -> work -> author jq handoff end to end.""" edition = FakeResponse(200, EDITION_RECORD) work = FakeResponse(200, WORK_RECORD) author = FakeResponse(200, {"name": "George Orwell", "bio": "Writer"}) with mock.patch.object(requests, "get", side_effect=[edition, work, work, author]) as req: isbn_code, isbn_out, _ = run_cli("--json", "isbn", "9780451524935") isbn_data = json.loads(isbn_out) work_code, work_out, _ = run_cli( "--json", "work", isbn_data["work_keys"][0]) work_data = json.loads(work_out) author_code, author_out, _ = run_cli( "--json", "author", work_data["authors"][0]) self.assertEqual((isbn_code, work_code, author_code), (0, 0, 0)) self.assertEqual(req.call_count, 4) self.assertEqual(work_data["authors"][0], "OL118077A") self.assertEqual(json.loads(author_out)["key"], "OL118077A") def test_work_pipeline_handoff_fields_have_stable_json_types(self): with mock.patch.object(requests, "get", return_value=FakeResponse(200, WORK_RECORD)): _, out, _ = run_cli("--json", "work", WORK_KEY) data = json.loads(out) self.assertIsInstance(data["key"], str) self.assertIsInstance(data["title"], str) self.assertIsInstance(data["authors"], list) self.assertTrue(all(isinstance(key, str) for key in data["authors"])) self.assertIsInstance(data["subjects"], list) def test_work_record_with_non_dict_author_entries_is_tolerated(self): # Malformed wiki payloads can smuggle bare strings into authors[]; # tolerate-and-filter beats crash. record = dict(WORK_RECORD, authors=[{"author": {"key": AUTHOR_KEY}}, None]) with mock.patch.object(requests, "get", return_value=FakeResponse(200, record)): code, out, _ = run_cli("--json", "work", WORK_KEY) self.assertEqual(code, 0) self.assertEqual(json.loads(out)["authors"], ["OL118077A"]) def test_isbn_handoff_fields_have_stable_json_types(self): with mock.patch.object(requests, "get", return_value=FakeResponse( 200, dict(EDITION_RECORD, authors=None), url="https://openlibrary.org/books/x.json")): _, out, _ = run_cli("--json", "isbn", "9780451524935") data = json.loads(out) for key in ("edition_key", "title", "description"): self.assertIsInstance(data[key], str) for key in ("authors", "work_keys", "publishers", "subjects"): self.assertIsInstance(data[key], list) def test_merge_redirect_stub_in_http_200_is_followed_with_json_suffix(self): # Merged-away keys answer 200 with {type:/type/redirect, location}, # NOT a 3xx — the client must detect the stub and refetch. Stub # locations are bare keys without .json; extension-less URLs redirect # to HTML pages, so the client must append the suffix itself. stub = FakeResponse(200, {"type": {"key": "/type/redirect"}, "location": WORK_KEY}) work = FakeResponse(200, WORK_RECORD) with mock.patch.object(requests, "get", side_effect=[stub, work]) as req: code, out, _ = run_cli("--json", "work", "OL24776360W") self.assertEqual(code, 0) self.assertEqual( req.call_args_list[1].args[0], "https://openlibrary.org" + WORK_KEY + ".json") self.assertEqual(json.loads(out)["title"], "Nineteen Eighty-Four") def test_redirect_stub_chain_beyond_hop_budget_warns_instead_of_silence(self): # A work that keeps resolving into further merge stubs exhausts the # bounded walk; the CLI must say so (stderr warning) rather than emit # an unexplained /type/redirect payload. stub = FakeResponse(200, {"type": {"key": "/type/redirect"}, "location": WORK_KEY}) responses = [stub] * (ol_cli.MAX_REDIRECT_HOPS + 1) with mock.patch.object(requests, "get", side_effect=responses) as req: code, out, err = run_cli("--json", "work", "OL24776360W") self.assertEqual(code, 0) self.assertEqual(req.call_count, ol_cli.MAX_REDIRECT_HOPS + 1) self.assertIn("did not resolve", err) # Without the resolution the command degrades to an empty-shaped # record; the stderr warning is what keeps that from being silent. self.assertEqual(json.loads(out), { "key": "OL24776360W", "title": "?", "authors": [], "description": "", "subjects": [], "cover_url": None}) def test_text_wrapper_dict_is_unwrapped(self): self.assertEqual(ol_cli.unwrap_text({"type": "/type/text", "value": "hi"}), "hi") self.assertEqual(ol_cli.unwrap_text("plain"), "plain") self.assertEqual(ol_cli.unwrap_text(None), "") def test_normalize_olid_accepts_bare_and_path_forms(self): self.assertEqual(ol_cli.normalize_olid("/works/" + "OL123W"), "OL123W") self.assertEqual(ol_cli.normalize_olid("OL23919A"), "OL23919A") self.assertEqual(ol_cli.normalize_olid(""), "") def test_cover_url_rejects_negative_placeholder_ids(self): self.assertIsNone(ol_cli.cover_url("b/id", -1)) self.assertIsNone(ol_cli.cover_url("a/id", None)) self.assertTrue(ol_cli.cover_url("b/id", 12054527).startswith( "https://covers.openlibrary.org/b/id/")) def test_search_sends_query_and_sort_params(self): payload = {"numFound": 1, "docs": [ {"key": "/works/" + "OL1W", "title": "Dune", "author_name": ["Frank Herbert"], "first_publish_year": 1965, "edition_count": 90, "cover_edition_key": "OL1M", "has_fulltext": True}]} with mock.patch.object(requests, "get", return_value=FakeResponse(200, payload)) as req: code, out, _ = run_cli("--json", "search", "--query", "dune", "--limit", "2", "--sort", "editions") self.assertEqual(code, 0) req.assert_called_once() sent = req.call_args.kwargs["params"] self.assertEqual(sent["q"], "dune") self.assertEqual(sent["sort"], "editions") data = json.loads(out) self.assertEqual(data["total"], 1) self.assertEqual(data["results"][0]["key"], "/works/" + "OL1W") def test_empty_search_results_are_success_not_error(self): # Malformed queries parse loosely and return 200-empty; the CLI must # report zero results without failing. payload = {"numFound": 0, "docs": []} with mock.patch.object(requests, "get", return_value=FakeResponse(200, payload)): code, out, _ = run_cli("--json", "search", "--query", 'title:"unclosed') self.assertEqual(code, 0) self.assertEqual(json.loads(out)["total"], 0) def test_editions_parses_entries_and_computes_next_offset(self): payload = {"size": 6, "links": {"self": "/works/x/editions.json?limit=3", "next": "/works/x/editions.json?limit=3&offset=3"}, "entries": [ {"key": "/books/" + "OL1M", "title": "Ed. One", "publishers": ["Ace"], "publish_date": "1965", "isbn_13": ["9780000000002"]}, {"key": "/books/" + "OL2M", "title": "Ed. Two", "publishers": [], "publish_date": "1980", "isbn_13": []}, {"key": "/books/" + "OL3M", "title": "Ed. Three", "publishers": ["NEL"], "publish_date": "1974", "isbn_13": ["9781111111113"]}, ]} with mock.patch.object(requests, "get", return_value=FakeResponse(200, payload)) as req: code, out, _ = run_cli("--json", "editions", "OL81699W", "--limit", "3") self.assertEqual(code, 0) self.assertEqual(req.call_args.kwargs["params"]["offset"], 0) data = json.loads(out) self.assertEqual(data["size"], 6) self.assertEqual(len(data["editions"]), 3) self.assertEqual(data["next_offset"], 3) def test_ratings_joins_ratings_and_bookshelves(self): ratings = FakeResponse(200, {"summary": {"average": 3.97, "count": 119}, "counts": {"5": 56, "4": 29}}) shelves = FakeResponse(200, {"counts": {"want_to_read": 1191, "currently_reading": 97}}) with mock.patch.object(requests, "get", side_effect=[ratings, shelves]) as req: code, out, _ = run_cli("--json", "ratings", "OL45804W") self.assertEqual(code, 0) self.assertTrue(req.call_args_list[0].args[0].endswith("/ratings.json")) self.assertTrue(req.call_args_list[1].args[0].endswith("/bookshelves.json")) data = json.loads(out) self.assertAlmostEqual(data["average"], 3.97) self.assertEqual(data["bookshelves"]["want_to_read"], 1191) def test_404_reports_not_found_without_traceback(self): with mock.patch.object(requests, "get", return_value=FakeResponse(404, None)): code, out, _ = run_cli("work", "OL999999999W") self.assertEqual(code, 0) self.assertIn("not found", out.lower()) def test_server_error_names_status_and_dies(self): # run_cli captures SystemExit; a 500 must exit 1 with the status named. with mock.patch.object(requests, "get", return_value=FakeResponse(500, text="Internal Server Error")): code, _, err = run_cli("work", "OL1W") self.assertEqual(code, 1) self.assertIn("500", err) def test_user_agent_carries_mailto_when_email_configured(self): original = ol_cli.ENV_EMAIL try: ol_cli.ENV_EMAIL = "reader@example.org" resp = FakeResponse(200, WORK_RECORD) with mock.patch.object(requests, "get", return_value=resp) as req: run_cli("work", WORK_KEY) ua = req.call_args.kwargs["headers"]["User-Agent"] self.assertIn("(mailto:reader@example.org)", ua) finally: ol_cli.ENV_EMAIL = original # === Class 5: env-guarded live probes (keyless public API) === # Run only with OPENLIBRARY_LIVE_TESTS=1; skipped otherwise so the proxy-trap # rerun proves zero egress for everything above. @unittest.skipUnless(os.getenv("OPENLIBRARY_LIVE_TESTS") == "1", "live probes disabled (set OPENLIBRARY_LIVE_TESTS=1)") class LiveGuardedTests(unittest.TestCase): def test_live_isbn_resolves_through_302(self): code, out, _ = run_cli("--json", "isbn", "9780451524935") self.assertEqual(code, 0) data = json.loads(out) self.assertRegex(data["edition_key"], r"^OL\d+M$") self.assertRegex(data["work_keys"][0], r"^OL\d+W$") self.assertTrue(data["title"]) def test_live_editions_listing_returns_entries(self): code, out, _ = run_cli("--json", "editions", "OL81699W", "--limit", "3") self.assertEqual(code, 0) data = json.loads(out) self.assertGreaterEqual(data["size"], 1) if __name__ == "__main__": unittest.main()
-
-
README.md 3 KB
# Open Library — Book Metadata from the Terminal Search books and authors, resolve ISBNs, walk the edition/work/author graph, enumerate editions, and read community ratings from the public Open Library API. No API key exists — every read is keyless. ## Why Install This Skill When your agent loads this skill, it gets **structured access to 50M+ book records** without any signup or credentials: - **Search anything** — keyword queries with sort by edition count, date, or title; field-scoped lookups by title, author, subject, publisher, or ISBN - **Resolve any identifier** — turn an ISBN/LCCN/OCLC into a canonical edition record and follow it up to the abstract work and its author - **Enumerate editions** — every published version of a work with dates, publishers, and ISBNs - **Read community signals** — star ratings and want-to-read counts per work - **Get cover images correctly** — proper URLs on Open Library's dedicated image host, with existence checks that actually return 404 instead of blank placeholders The skill also encodes where agents typically trip: ISBN endpoints that answer 302 redirects, merged-record keys that hide redirect stubs inside HTTP 200 responses, the OL…M/W/A key-suffix system, `{type,value}`-wrapped text fields, and rate-limit etiquette that keeps you unblocked. ## What You Get | Path | Purpose | |------|---------| | `SKILL.md` | Command reference: setup, intent-grouped commands, pipeline recipes, jq guidance, known gotchas | | `scripts/openlibrary` | CLI tool for the Open Library API (`--json`, `--dry-run`, automatic redirect resolution) | | `scripts/test_openlibrary.py` | Offline test suite for the CLI (help/errors/dry-run/mocked logic; live probes env-guarded) | | `references/api-overview-and-key-graph.md` | Access model, rate etiquette, OLID key graph, merge-stub behavior | | `references/search-api-guide.md` | Search parameters, sort keys, query syntax, sibling search endpoints, error model | | `references/books-isbn-and-covers.md` | ISBN/identifier resolution, view models, editions, ratings, covers-host rules | | `references/recipes-and-gotchas.md` | Worked curl/jq pipelines and a symptom-indexed gotcha table | | `evals/evals.json` | Behavioral eval cases covering searches, pipelines, gotchas | ## Quick Start ```bash openlibrary search --query "dune" # find works openlibrary isbn 9780451524935 # resolve an ISBN to its edition openlibrary editions OL81699W # list every edition of a work openlibrary ratings OL45804W # community signals ``` Optional politeness knob: ```bash export OL_EMAIL="you@example.com" # adds contact to User-Agent; ~3x rate budget ``` ## Triggers Load this for book research, ISBN or OLID lookups, author biographies, edition enumeration, reading-level community stats, cover-image URL assembly, or any question about the Open Library catalog itself. ## Requirements - Python 3.8+ with the `requests` library - No API key, no account — reads are fully public - `jq` recommended for processing `--json` output -
SKILL.md 12.8 KB
--- name: openlibrary description: >- Query the Open Library catalog from the terminal: search books and authors, look up works, editions, and ISBNs, enumerate every edition of a work, read community ratings, and resolve cover-image URLs. Fully keyless public API. Includes the OL…M/W/A key-graph reference, ISBN 302-redirect resolution, search query syntax, covers-host rules, and worked pipelines. Do not use for library-IT administration (Koha/MARC/ILS migration), commercial book-data feeds, or managing your reading account on Open Library itself. license: MIT compatibility: Python 3.8+ and the `requests` library. No API key or registration required — reads are fully public. Optional OL_EMAIL env var adds a contact to the User-Agent for better rate-limit treatment. metadata: tags: open-library, books, authors, isbn, library, book-search, api-client, catalog sources: https://openlibrary.org/developers/api, https://openlibrary.org/dev/docs/api/books --- # openlibrary — Book Metadata from Open Library Query Open Library's 50M+ record catalog over its public HTTP API: search works and authors, walk the edition/work/author graph by ISBN or OLID, enumerate editions, and read community ratings. No API key exists; everything here is a keyless GET. ## Setup Nothing to authenticate: **the public Open Library API requires no API key** for reads. There is no token, no registration step, no lazy-auth dance. ```bash # Optional etiquette: identified User-Agent gets ~3x rate budget (~3 req/s vs ~1) export OL_EMAIL="you@example.com" ``` Requires Python 3.8+ and `requests` only. `--help` and `--dry-run` work with no environment at all. Write endpoints exist upstream but require an authenticated Internet Archive session and are effectively internal — treat this surface as read-only. Three hosts matter and they serve different things: | Host | Serves | |------|--------| | `openlibrary.org` | metadata JSON: search, records, ratings | | `covers.openlibrary.org` | cover images + author photos (separate service) | | `archive.org` | scan content / bulk dumps (redirect targets) | ## Essential Commands ### find books — search ```bash openlibrary search --query "dune" # relevance order openlibrary search --query "dune" --sort editions # most editions first openlibrary search --query "dune" --sort new # newest first openlibrary search --query "foundation" --lang fr # prefer French editions openlibrary search --query "dune" --limit 5 --offset 10 # paginate openlibrary search --query "dune" --json # machine-readable ``` Results carry title, authors, `first_publish_year`, edition count, and the work key (`/works/OL…W`) that feeds the other commands. The CLI validates `--sort` choices client-side because an unknown sort value makes the server return a plain-text HTTP 500. ### find people who write — author search ```bash openlibrary search-authors --query "asimov" # name candidates openlibrary search-authors --query "le guin" --limit 5 openlibrary search-authors --query "asimov" --json # includes top_work, work_count ``` Author docs arrive with bare keys (`OL23919A`), unlike book search's path keys. ### inspect records — author / work ```bash openlibrary author OL23919A # bio, dates, photo URL openlibrary work OL1168083W # description, subjects, cover URL openlibrary work OL1168083W --json # full record ``` Keys may be passed bare (`OL23919A`) or as paths (`/authors/OL23919A`) — the CLI normalizes both. ### resolve an ISBN ```bash openlibrary isbn 9780451524935 # ISBN-10 or ISBN-13 openlibrary isbn 9780451524935 --json # + resolved edition_key, work_keys, cover_url ``` Upstream, `/isbn/<isbn>.json` answers a **302 redirect** to the canonical edition JSON (`/books/<OL…M>.json`). The CLI follows it and reports which edition matched via `edition_key`. If the edition record lacks author names (some ship `authors:null`), the CLI recovers them from the linked work. ### list every edition of a work ```bash openlibrary editions OL81699W # publisher/date/ISBN per edition openlibrary editions OL81699W --limit 50 --offset 50 # page through large sets openlibrary editions OL81699W --json # + next_offset when more exist ``` Backs onto `/works/<key>/editions.json`; `next_offset` is computed from the server's prebuilt next-page link. ### read the room — community signals ```bash openlibrary ratings OL45804W # average rating + shelf counts openlibrary ratings OL45804W --json # full distribution + bookshelves ``` Joins `/works/<key>/ratings.json` (`summary.average`, per-star `counts`) with `/works/<key>/bookshelves.json` (`want_to_read`, `currently_reading`, `already_read`). ## Global Flags Position-independent; put them before or after the subcommand: ```bash openlibrary --json search --query "dune" # machine output anywhere openlibrary --dry-run isbn 9780451524935 # preview the exact URL, no network openlibrary --quiet search --query "dune" # suppress diagnostics ``` | Flag | Effect | |------|--------| | `--json` | One JSON object on stdout instead of human text | | `--dry-run` | Print the planned request as JSON without executing it | | `--quiet` | Suppress non-essential output | | `--verbose` | Verbose logging | ## Multi-Step Pipeline Recipes ### ISBN → edition → work → author bio The canonical walk across all three key types: ```bash openlibrary isbn 9780451524935 --json | jq -r '.work_keys[0]' # OL1168083W openlibrary work OL1168083W --json | jq -r '.authors[0]' # bare OL…A key openlibrary author OL118077A # bio, dates ``` The CLI keeps this pipeline type-safe: every command that emits an `authors` field under `--json` (`isbn`, `work`) uses the same shape — an array of bare OL…A keys — while human output renders comma-separated labels instead. Each hop uses a different key suffix (M → W → A); see Known Gotchas before hand-assembling these URLs yourself. ### Rank a series by community love ```bash for w in $(openlibrary search --query 'series:dune' --limit 8 --json | jq -r '.results[].key'); do openlibrary ratings "${w##*/}" --json \ | jq -c '{work: .key, avg: .average, rated: .ratings_count, want_to_read: .bookshelves.want_to_read}' done ``` ### Find readable ebooks, then their print editions ```bash openlibrary search --query 'title:"moby dick"' --sort editions --json \ | jq '.results[] | select(.has_fulltext) | {title, key}' openlibrary editions OL81699W --json | jq '.editions[] | {key, isbn_13}' ``` ## Using --json with jq ```bash openlibrary search --query "voracious" --json | jq '.results[] | {title, first_publish_year}' openlibrary search-authors --query "butler" --json | jq '.results[0] | {name, key, top_work}' openlibrary isbn 9780451524935 --json | jq -r '.cover_url' # covers host URL openlibrary editions OL81699W --json | jq '[.editions[].publish_date]' openlibrary ratings OL45804W --json | jq '.rating_distribution' ``` ## Known Gotchas - **ISBN endpoints answer 302, not JSON** — `/isbn/<isbn>.json`, `/lccn/*.json`, `/oclc/*.json` redirect to `/books/<OL…M>.json`. Clients must follow redirects (`curl -L`; `requests` does by default) or they parse an HTML redirect page. Direct `/books/<OLID>.json` calls return 200 immediately. - **Merged keys return redirect stubs inside HTTP 200** — Open Library is a wiki; when duplicate works merge, the old key keeps answering 200 with `{"type":{"key":"/type/redirect"},"location":"/works/<master>"}` instead of a 3xx. Detect the stub type in success responses and re-fetch. The bundled CLI does this automatically (and appends `.json` to stub locations, since extension-less URLs redirect to HTML pages). - **Key types are encoded in the suffix** — `OL…M` = edition (`/books/`), `OL…W` = work (`/works/`), `OL…A` = author (`/authors/`). Works link to authors double-nested (`authors[].author.key`); editions nest flat (`authors[].key`) — or ship `authors:null` entirely, recovering names from the linked work. Requesting a key under the wrong collection yields a 301 reroute. - **Search empties are not errors** — malformed queries parse loosely and come back HTTP 200 with `numFound:0`; conversely a bad `sort=` enum is a plain-text HTTP 500 and non-integer `limit` is a FastAPI 422. Branch on bodies, not just status codes. - **`availability` needs `ia`** — in raw `/search.json` calls, requesting `fields=availability` silently returns nothing unless `ia` is also requested. - **Covers live on another host** — image URLs are always `covers.openlibrary.org/b/id/<cover_id>-{S,M,L}.jpg` (or `/b/isbn/...`, `/a/id/...` for author photos). Missing covers return a blank placeholder with HTTP 200 unless you add `?default=false`; non-ID/non-OLID lookups cap at 100 req/IP per 5 min then 403; cover URLs often 302 into archive.org zip shards. - **Rate limits are policy, not headers** — no Retry-After/X-Rate-Limit headers exist. Anonymous ≈1 req/s; a `User-Agent: App (email)` (set `OL_EMAIL`) raises it to ≈3 req/s. Batch with one search rather than hundreds of lookups; bulk belongs in monthly dumps. - **Text fields nest `{type,value}` objects** — older records wrap `bio`, `description`, `notes` as `{"type":"/type/text","value":"..."}` while newer ones use plain strings. Handle both; the CLI unwraps centrally. - **OLID ≠ long-term identity** — deleted keys can be reassigned to unrelated books, and merged-away keys become redirect stubs. Pair OLIDs with title/ISBN in any cache. - **`.json` placement matters for slugged URLs** — append `.json` to the bare key (`/authors/OL23919A.json`), never after a slug path. ## When to use - Any question about books, authors, or works Open Library's public catalog can answer - Resolving ISBNs/OLID keys to canonical records and walking edition/work/author graphs - Finding readable or borrowable scans, cover images, community ratings/shelf counts ## When not to use Do not use this skill for local library-catalog administration — Koha/Evergreen ILS configuration, MARC batch processing, patron management — or for licensed commercial data feeds (ISBNdb, Google Books), citation formatting, or managing your own Open Library reading account/lists (that requires site login this skill deliberately does not handle). For movie/TV metadata use tmdb instead. ## Reference Files | File | Topic | Read when | |------|-------|-----------| | [references/api-overview-and-key-graph.md](references/api-overview-and-key-graph.md) | Access model, rate-limit etiquette, OLID M/W/A key graph, cross-collection 301s, merge-stub handling, `{type,value}` text wrapping | Assembling record URLs by hand, handling merges/wrong-key errors, or planning request pacing | | [references/search-api-guide.md](references/search-api-guide.md) | `/search.json` parameters, sort keys, field scopes (`title:`, `isbn:`, …), `fields=` projection incl. the availability/ia interaction, `/search/authors.json`, `/subjects/<name>.json`, inside-book search, error model | Building precise queries, paginating deep result sets, or debugging empty results | | [references/books-isbn-and-covers.md](references/books-isbn-and-covers.md) | Identifier endpoints and their 302 resolution, raw records vs legacy `/api/books` view models, editions listing, ratings/bookshelves shapes, covers-host URL rules and limits | Working with ISBNs/LCCNs/OCLCs, enumerating editions, or fetching images | | [references/recipes-and-gotchas.md](references/recipes-and-gotchas.md) | End-to-end curl/jq pipelines (ISBN→work→author chain, ebook discovery, cover assembly, disambiguation) plus a symptom-indexed gotcha table | Wiring multi-step workflows or diagnosing an unexpected response | ## Available Scripts | Script | Purpose | Invocation | |---|---|---| | `scripts/openlibrary` | The CLI this skill drives: `search`, `search-authors`, `author`, `work`, `isbn`, `editions`, `ratings` — all with `--json`/`--dry-run`, automatic 302 + merge-stub resolution, `{type,value}` unwrapping, covers-host URL assembly, and client-side sort validation. Run it for every book-metadata question above. | `scripts/openlibrary search --query "dune" --json` | | `scripts/test_openlibrary.py` | Offline pytest/unittest suite covering help, argument errors, dry-run plans, mocked-client logic (redirect stubs, author fallback, editions paging), plus two env-guarded live probes (`OPENLIBRARY_LIVE_TESTS=1`). Zero egress otherwise. | `.venv/bin/python3 -m pytest -p no:cacheprovider --strict-markers scripts/test_openlibrary.py` | ## Prerequisites - Python 3.8+ with `requests` (stdlib otherwise); invoke as `python3 scripts/openlibrary ...` if not executable directly - No credentials of any kind; optional `OL_EMAIL` for rate-limit etiquette - `jq` recommended for `--json` post-processing
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.