Claude Skill

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

LLM Mart · 0 points · 13 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download magnus919-agent-skills-openlibrary-addad86.zip · 39 KB
Part of magnus919/agent-skills — 145 skills

Install

skills CLI npx skills add https://github.com/magnus919/agent-skills/tree/main/openlibrary
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
Git 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 requests library
  • No API key, no account — reads are fully public
  • jq recommended for processing --json output

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/*.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 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 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
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.

No comments yet.

Reviews (0)

No reviews yet.

Related