mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
docs(openlibrary): add API references for key graph, search, ISBN, recipes
Four cited reference files distilled from live-verified research against openlibrary.org developer docs: OL...M/W/A key graph with merge-stub behavior, search query syntax and error model, ISBN 302 redirect resolution and covers host rules, plus worked curl/jq pipelines with a symptom-indexed gotcha table. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
3e0453b118
commit
ce95392915
@@ -0,0 +1,175 @@
|
||||
# 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
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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
|
||||
@@ -0,0 +1,143 @@
|
||||
# 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 for description + subjects; note double-nested author refs
|
||||
curl -s "https://openlibrary.org${WORK}.json" > work.json
|
||||
AUTHOR=$(jq -r '.authors[0].author.key' work.json) # /authors/OL23919A
|
||||
|
||||
# 4. Author record; bio may be {type,value}-wrapped or a plain string
|
||||
curl -s "https://openlibrary.org${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;
|
||||
step 3's `.authors[0].author.key` is wrong on *edition* records (flat `.authors[0].key`
|
||||
there); 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
|
||||
@@ -0,0 +1,210 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user