# Open Library — Book Data Extraction `https://openlibrary.org` — Internet Archive's free book catalog. All endpoints are public JSON APIs — no auth, no browser, no scraping required. ## Do this first **Every task is a direct HTTP call — never open the browser.** ```python import json from helpers import http_get # Search by title results = json.loads(http_get("https://openlibrary.org/search.json?q=dune&limit=5")) # results['numFound'] == 49090 # results['docs'] == list of work objects # results['start'] == 0 (offset for pagination) ``` The search API is your entry point for everything. It returns work-level records (all editions grouped). To get edition details, follow the `key` to the Works or Books API. --- ## Common workflows ### Search by query, author, title, or ISBN ```python import json from helpers import http_get # Free-text search r = json.loads(http_get("https://openlibrary.org/search.json?q=dune+frank+herbert&limit=5")) # Author search r = json.loads(http_get( "https://openlibrary.org/search.json?author=tolkien&limit=5" "&fields=title,author_name,first_publish_year,isbn" )) # fields=* returns all available fields; default returns ~15 # Title + author combined r = json.loads(http_get( "https://openlibrary.org/search.json?title=dune&author=frank+herbert&limit=3" "&fields=title,author_name,edition_count,first_publish_year" )) # r['docs'][0]['title'] == 'Dune' # r['docs'][0]['author_name'] == ['Frank Herbert'] # r['docs'][0]['first_publish_year'] == 1965 # r['docs'][0]['edition_count'] == 120 # ISBN lookup (returns 0–2 results for the same work) r = json.loads(http_get("https://openlibrary.org/search.json?isbn=9780743273565")) # r['numFound'] == 2 # r['docs'][0]['title'] == 'The Great Gatsby' # r['docs'][0]['key'] == '/works/OL468431W' ``` **Sort options** (`&sort=`): `new` (recently added), `old`, `random`, `editions` (most editions), `scans` (most scans). Default is relevance. **Language filter**: `&language=fre` (ISO 639-2/B codes: `eng`, `fre`, `ger`, `spa`, `ita`, etc.) **Pagination**: `&limit=N&offset=N`. Max limit not enforced but keep under 100 for reliability. #### Search doc fields (default — ~15 keys always present) | Field | Type | Notes | |---|---|---| | `key` | str | `/works/OL893415W` — use for Works API | | `title` | str | Work title | | `author_name` | list[str] | e.g. `['Frank Herbert']` | | `author_key` | list[str] | e.g. `['OL79034A']` | | `first_publish_year` | int | | | `edition_count` | int | Number of editions across all languages | | `cover_i` | int | Cover image ID — use with covers API | | `cover_edition_key` | str | e.g. `OL7353617M` | | `language` | list[str] | ISO codes of all editions | | `ia` | list[str] | Internet Archive identifiers (when has_fulltext=true) | | `ebook_access` | str | `'public'`, `'borrowable'`, `'no_ebook'` | | `has_fulltext` | bool | | #### Extra fields with `&fields=*` ```python # With fields=* you also get: # 'isbn' list[str] All ISBNs across editions # 'publisher' list[str] All publishers ever # 'publish_date' list[str] All publish dates (strings, inconsistent formats) # 'publish_year' list[int] Parsed years # 'subject' list[str] Subject headings # 'person' list[str] Subject persons (e.g. 'Big Brother') # 'place' list[str] Subject places # 'time' list[str] Subject times # 'number_of_pages_median' int Median page count across editions # 'ratings_average' float e.g. 4.29 # 'ratings_count' int # 'want_to_read_count' int # 'already_read_count' int # 'currently_reading_count'int # 'readinglog_count' int Total of all reading log entries # 'first_sentence' list[str] # 'id_goodreads' list[str] # 'id_librarything' list[str] # 'id_wikidata' list[str] # 'ddc' list[str] Dewey Decimal Classification # 'lcc' list[str] Library of Congress Classification # 'lccn' list[str] ``` --- ### Bulk ISBN lookups (parallel) ```python import json from helpers import http_get from concurrent.futures import ThreadPoolExecutor isbns = ['9780743273565', '9780451524935', '9780618346257'] def lookup_isbn(isbn): url = f"https://openlibrary.org/search.json?isbn={isbn}&fields=title,author_name,first_publish_year,key" r = json.loads(http_get(url)) if r['docs']: d = r['docs'][0] return {'isbn': isbn, 'title': d.get('title'), 'author': d.get('author_name', [None])[0], 'year': d.get('first_publish_year'), 'key': d.get('key')} return {'isbn': isbn, 'found': False} with ThreadPoolExecutor(max_workers=5) as ex: books = list(ex.map(lookup_isbn, isbns)) # [{'isbn': '9780743273565', 'title': 'The Great Gatsby', 'author': 'F. Scott Fitzgerald', 'year': 1920, ...}, # {'isbn': '9780451524935', 'title': 'Nineteen Eighty-Four', 'author': 'George Orwell', 'year': 1949, ...}, # {'isbn': '9780618346257', 'title': 'The Fellowship of the Ring', 'author': 'J.R.R. Tolkien', 'year': 1954, ...}] ``` --- ### Works API (editions grouped by title) Returns all metadata for a work (all editions combined). Get the work ID from `key` in search results. ```python import json from helpers import http_get work_id = 'OL893415W' # from search doc['key'] = '/works/OL893415W' work = json.loads(http_get(f"https://openlibrary.org/works/{work_id}.json")) # work['title'] == 'Dune' # work['key'] == '/works/OL893415W' # work['covers'] == [11481354, 12375564, 11157826] ← cover IDs for covers API # work['subjects'] == ['Dune (Imaginary place)', 'Fiction', ...] # work['subject_places'] == [...] ← geographic subjects (may be absent) # work['subject_people'] == [...] ← person subjects (may be absent) # work['subject_times'] == [...] ← time subjects (may be absent) # work['authors'] == [{'author': {'key': '/authors/OL79034A'}, 'type': {...}}] # work['description'] → either str OR {'type': '/type/text', 'value': str} ← see gotchas # work['created'] == {'type': '/type/datetime', 'value': '2009-10-15T11:34:21.437031'} # work['last_modified'] same shape as created ``` Helper for the description field (which has two possible shapes): ```python def get_description(work: dict) -> str: desc = work.get('description', '') if isinstance(desc, dict): return desc.get('value', '') return desc or '' ``` #### Works editions (paginated list of all editions) ```python editions_resp = json.loads(http_get( f"https://openlibrary.org/works/{work_id}/editions.json?limit=10&offset=0" )) # editions_resp['size'] == 120 (total edition count) # editions_resp['entries'] == [...] (up to limit items) # editions_resp['links'] == {'self': '...', 'work': '...', 'next': '...', 'prev': '...'} # ← use links['next'] for pagination when offset+limit < size e = editions_resp['entries'][0] # e['title'] == 'Duna' # e['publishers'] == ['Editora Aleph'] # e['publish_date'] == '19/08/2017' ← inconsistent format, string # e['isbn_13'] == ['9788576573135'] # e['isbn_10'] == ['857657313X'] # e['covers'] == [10368109] # e['number_of_pages'] == 680 # e['languages'] == [{'key': '/languages/por'}] # e['key'] == '/books/OL28969075M' # e['physical_format'] == 'Paperback' (often missing) # e['notes'] → str or {'value': str} (often missing) ``` --- ### Books API (specific edition) Two sub-APIs: direct JSON for raw data, or `api/books` for enriched data. #### Direct edition JSON ```python import json from helpers import http_get edition_id = 'OL7353617M' # from editions list e['key'] or cover_edition_key in search edition = json.loads(http_get(f"https://openlibrary.org/books/{edition_id}.json")) # edition['title'] == 'Fantastic Mr. Fox' # edition['publishers'] == ['Puffin'] # edition['publish_date'] == 'October 1, 1988' # edition['isbn_13'] == ['9780140328721'] # edition['isbn_10'] == ['0140328726'] # edition['number_of_pages'] == 96 # edition['covers'] == [...] ← cover IDs # edition['languages'] == [{'key': '/languages/eng'}] # edition['works'] == [{'key': '/works/OL45804W'}] # edition['authors'] == [{'key': '/authors/OL34184A'}] # edition['identifiers'] == {'goodreads': [...], 'librarything': [...]} # edition['first_sentence'] == {'value': '...'} or str (often missing) # edition['ocaid'] == 'fantast00dahl' ← Internet Archive ID (if available) ``` #### Bibkeys API (enriched, multiple books at once) ```python # jscmd=data: cleaned up dict with cover URLs pre-built r = json.loads(http_get( "https://openlibrary.org/api/books" "?bibkeys=ISBN:9780743273565,ISBN:9780451524935" "&format=json&jscmd=data" )) # r == {'ISBN:9780743273565': {...}, 'ISBN:9780451524935': {...}} book = r['ISBN:9780743273565'] # book['title'] == 'The Great Gatsby' # book['authors'] == [{'url': '...', 'name': 'F. Scott Fitzgerald'}] # book['publish_date'] == '2021' # book['publishers'] == [{'name': 'Independently Published'}] # book['number_of_pages'] == 208 # book['url'] == 'http://openlibrary.org/books/OL46773254M/The_Great_Gatsby' # book['key'] == '/books/OL46773254M' # book['cover'] == {'small': '...S.jpg', 'medium': '...M.jpg', 'large': '...L.jpg'} # book['identifiers'] == {'isbn_13': [...], 'openlibrary': [...]} # book['subjects'] == [{'name': 'Modern fiction', 'url': '...'}, ...] # book['subject_places'] == None ← often null even with jscmd=data # jscmd=details: raw edition JSON + extra fields r2 = json.loads(http_get( "https://openlibrary.org/api/books" "?bibkeys=ISBN:9780743273565&format=json&jscmd=details" )) item = r2['ISBN:9780743273565'] # item['bib_key'] == 'ISBN:9780743273565' # item['info_url'] == 'http://openlibrary.org/books/OL...' # item['preview'] == 'noview' | 'restricted' | 'full' # item['preview_url'] == URL to read on OL or IA # item['thumbnail_url']== 'https://covers.openlibrary.org/b/id/14314120-S.jpg' # item['details'] → raw edition JSON (same as /books/OL...M.json) # Supported bibkey prefixes: ISBN:, OCLC:, LCCN:, OLID: (e.g. OLID:OL46773254M) ``` --- ### Authors API ```python import json from helpers import http_get # Lookup by known author key author = json.loads(http_get("https://openlibrary.org/authors/OL26320A.json")) # OL26320A is J.R.R. Tolkien (note: not Frank Herbert as originally stated — verify with search) # author['name'] == 'J.R.R. Tolkien' # author['fuller_name'] == 'John Ronald Reuel Tolkien' # author['personal_name'] == 'J. R. R. Tolkien' # author['birth_date'] == '3 January 1892' ← string, not parsed # author['death_date'] == '2 September 1973' # author['bio'] → str or {'type': '/type/text', 'value': str} # author['photos'] == [6155606, 6433524, ...] ← photo IDs for covers API # author['links'] == [{'title': '...', 'url': '...', 'type': {...}}, ...] # author['remote_ids'] == {'wikidata': 'Q892', 'viaf': '95218067', ...} # author['alternate_names']== ['J. R. R. Tolkien', 'TOLKIEN', ...] # author['key'] == '/authors/OL26320A' # author['wikipedia'] → URL string or None # Author works (paginated) works = json.loads(http_get("https://openlibrary.org/authors/OL26320A/works.json?limit=5")) # works['size'] == 415 # works['entries'] == [{title, key, covers, authors, created, ...}, ...] # works['links'] == {'self': '...', 'next': '...'} ``` #### Author search ```python r = json.loads(http_get("https://openlibrary.org/search/authors.json?q=tolkien")) # r['numFound'] == 40 # r['docs'][0]: # 'name' == 'Christopher Tolkien' # 'key' == 'OL2623360A' ← NOTE: no /authors/ prefix here # 'birth_date' == '21 November 1924' # 'death_date' == '16 January 2020' # 'top_work' == 'The War of the Ring' # 'work_count' == 43 # 'top_subjects' == [...] # 'alternate_names' == [...] # 'ratings_average' float # 'want_to_read_count' int # 'already_read_count' int # 'currently_reading_count'int ``` --- ### Cover images Covers are served directly as JPEG — redirect to Internet Archive CDN. No auth needed. ``` # Book covers — three key types: https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg # by cover ID (most reliable) https://covers.openlibrary.org/b/isbn/{isbn}-{size}.jpg # by ISBN https://covers.openlibrary.org/b/olid/{edition_id}-{size}.jpg # by edition OLID (unreliable — see gotchas) # Author photos: https://covers.openlibrary.org/a/id/{photo_id}-{size}.jpg # Sizes: S (small), M (medium), L (large) ``` ```python import urllib.request def get_cover_bytes(cover_id: int, size: str = 'M') -> bytes | None: """Fetch cover image bytes. Returns None if no cover (43-byte GIF placeholder).""" url = f"https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg" req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=15) as resp: data = resp.read() return None if len(data) == 43 else data # 43-byte GIF = no cover placeholder # Or just get the URL for embedding: def cover_url(cover_id: int, size: str = 'M') -> str: return f"https://covers.openlibrary.org/b/id/{cover_id}-{size}.jpg" # Usage: from helpers import http_get import json work = json.loads(http_get("https://openlibrary.org/works/OL893415W.json")) if work.get('covers'): img = get_cover_bytes(work['covers'][0], 'L') # first cover, large # img is ~20–80KB JPEG bytes, redirected from ia*.archive.org ``` To get cover by ISBN directly (e.g. for UI without a full book lookup): ```python # Medium-size cover by ISBN: url = f"https://covers.openlibrary.org/b/isbn/{isbn}-M.jpg" # Redirects to Internet Archive CDN, content-type: image/jpeg # Use ?default=false to get 404 instead of 1×1 GIF placeholder for missing covers url_safe = f"https://covers.openlibrary.org/b/isbn/{isbn}-M.jpg?default=false" ``` --- ### Subjects API ```python import json from helpers import http_get # Subject slugs: lowercase, underscores for spaces r = json.loads(http_get("https://openlibrary.org/subjects/science_fiction.json?limit=5")) # r['name'] == 'science fiction' # r['subject_type'] == 'subject' ← also: 'person', 'place', 'time' # r['work_count'] == 20973 # r['works'] == [{title, key, cover_id, authors, edition_count, ...}, ...] w = r['works'][0] # w['title'] == 'Alice\'s Adventures in Wonderland' # w['key'] == '/works/OL138052W' # w['cover_id'] == 10527843 # w['cover_edition_key']== 'OL...' # w['authors'] == [{'key': '/authors/OL22098A', 'name': 'Lewis Carroll'}] # w['edition_count'] == 3546 # w['first_publish_year']== ... # w['has_fulltext'] == True | False # w['ia'] == 'identifier' (Internet Archive ID when available) # Pagination: &offset=N # Place subject: r2 = json.loads(http_get("https://openlibrary.org/subjects/place:london.json?limit=5")) # r2['subject_type'] == 'place', r2['work_count'] == 23927 # Person subject: # https://openlibrary.org/subjects/person:napoleon.json?limit=5 # Time subject: # https://openlibrary.org/subjects/time:middle_ages.json?limit=5 # Combine with ebooks=true to filter to only freely readable books: r3 = json.loads(http_get("https://openlibrary.org/subjects/science_fiction.json?limit=5&ebooks=true")) # r3['works'][i]['has_fulltext'] == True for all results ``` --- ### Trending books ```python import json from helpers import http_get for period in ['daily', 'weekly', 'monthly']: r = json.loads(http_get(f"https://openlibrary.org/trending/{period}.json?limit=10")) # r['works'] == list of search-doc-style objects # r['days'] == int (time window) # r['hours'] == int # Same fields as search docs (title, author_name, cover_i, key, ...) print(period, r['works'][0]['title']) # e.g. 'Atomic Habits' ``` --- ## Rate limits No authentication required. No API key. No explicit rate limit published. Observed in testing: 5 requests completed in ~1 second with no throttling, no 429s. The API is served from CDN/Solr — in practice you can make 10–20 parallel requests without issue. For bulk operations (hundreds of ISBNs), use `ThreadPoolExecutor(max_workers=5)` to be a good citizen. **No `User-Agent` override needed** — the default `Mozilla/5.0` from `http_get` is accepted by all Open Library endpoints (unlike Nominatim which blocks it). --- ## Gotchas **`description` field has two shapes.** Both are real — check at runtime: ```python desc = work.get('description', '') text = desc.get('value', '') if isinstance(desc, dict) else (desc or '') ``` **`/works/OL45804W` is Fantastic Mr. Fox, not Dune.** The OL IDs in the original prompt were placeholders. Always resolve real IDs via the search API rather than hardcoding them. **Author search `key` has no prefix.** `/search/authors.json` returns `key: 'OL26320A'`, but the Authors API and all other APIs use `/authors/OL26320A`. Add the prefix manually when constructing follow-up URLs. **Missing cover → 43-byte GIF placeholder, not 404.** Without `?default=false`, the covers API returns a 1×1 transparent GIF instead of HTTP 404 for unknown IDs. Check `len(data) == 43` to detect missing covers. **`covers.openlibrary.org/b/olid/{work_id}` is unreliable.** OLID-based cover URLs for work IDs (OL...W) return the placeholder even when covers exist. Always use `b/id/{cover_id}` (from `work['covers'][0]`) or `b/isbn/{isbn}` instead. **Bibkeys API picks one edition per ISBN.** When the same ISBN appears on multiple editions (reprint, reissue), `api/books?bibkeys=ISBN:...` returns one — and it may not be the most common edition. **`publish_date` is a raw string.** Values like `'October 1, 1988'`, `'19/08/2017'`, `'2021'`, and `'1965-01-01'` all appear. Don't parse without normalization. **`/works/.../editions.json` pagination uses `links.next`.** Unlike search (which uses `offset=`), check `links['next']` in the response to know if more pages exist: ```python resp = json.loads(http_get("https://openlibrary.org/works/OL893415W/editions.json?limit=50")) while 'next' in resp.get('links', {}): resp = json.loads(http_get("https://openlibrary.org" + resp['links']['next'])) # process resp['entries'] ``` **404 for non-existent IDs.** `/works/OL99999999W.json`, `/books/OL99999999M.json`, and `/authors/OL99999999A.json` all raise `HTTPError: HTTP Error 404: Not Found`. Wrap in try/except. **Search `docs` default fields are minimal.** The default response includes ~15 fields. Add `&fields=*` to get all 100+ Solr fields (ratings, ISBNs, publishers, subjects, Goodreads IDs, etc.). Alternatively specify exactly what you need: `&fields=title,isbn,ratings_average`.