# Goodreads — Book Data Extraction Field-tested against goodreads.com on 2026-04-18 via `http_get` (no browser required). All five URL types return full HTML with no bot-wall, CAPTCHA, or login gate. ## Access Summary | Page type | `http_get` works? | Data format | |--------------------|-------------------|--------------------------| | Book show page | Yes | `__NEXT_DATA__` + JSON-LD | | Search results | Yes | Server-rendered HTML (schema.org microdata) | | Author show page | Yes | Server-rendered HTML + OG meta | | Listopia list page | Yes | Server-rendered HTML (schema.org microdata) | Goodreads shut down its public API in 2020. All extraction is HTML-based. Open Library is a reliable supplement with a free JSON API (see [Open Library fallback](#open-library-api-fallback)). --- ## Book Page — Full Data (`__NEXT_DATA__`) URL pattern: `https://www.goodreads.com/book/show/{book_id}` or `/{book_id}.{Slug}` The slug is optional — numeric ID alone works and redirects cleanly. ```python import re, json from helpers import http_get def parse_book(book_id): html = http_get(f"https://www.goodreads.com/book/show/{book_id}") # Parse Apollo state from Next.js page nd = re.search(r'', html, re.DOTALL) ap = json.loads(nd.group(1))['props']['pageProps']['apolloState'] # The primary Book entity matches the URL's legacy ID book = next(v for v in ap.values() if v.get('__typename') == 'Book' and v.get('legacyId') == int(book_id)) work = next((v for v in ap.values() if v.get('__typename') == 'Work'), {}) author_ref = book['primaryContributorEdge']['node']['__ref'] author = ap.get(author_ref, {}) stats = work.get('stats', {}) work_details = work.get('details', {}) book_details = book.get('details', {}) return { 'title': book['title'], 'title_complete': book['titleComplete'], 'book_id': book['legacyId'], 'url': book['webUrl'], 'cover_url': book['imageUrl'], # Strip HTML tags from description 'description': re.sub(r'<[^>]+>', '', book.get('description({"stripped":true})', book.get('description', ''))).strip(), 'genres': [g['genre']['name'] for g in book.get('bookGenres', [])], 'series': [{'name': s['series']['title'], 'position': s.get('userPosition')} for s in book.get('bookSeries', [])], # Author 'author_name': author.get('name'), 'author_url': author.get('webUrl'), # Edition details 'format': book_details.get('format'), 'num_pages': book_details.get('numPages'), 'publisher': book_details.get('publisher'), 'language': (book_details.get('language') or {}).get('name'), 'isbn': book_details.get('isbn'), 'isbn13': book_details.get('isbn13'), 'pub_timestamp_ms': book_details.get('publicationTime'), # Ratings (from Work, not Book) 'avg_rating': stats.get('averageRating'), 'ratings_count': stats.get('ratingsCount'), 'text_reviews': stats.get('textReviewsCount'), # ratings_dist is list of counts for [1-star, 2-star, 3-star, 4-star, 5-star] 'ratings_dist': stats.get('ratingsCountDist'), # Awards 'awards': [a['name'] + (' — ' + a['category'] if a.get('category') else '') for a in work_details.get('awardsWon', [])], } # Example book = parse_book(149267) # The Stand by Stephen King # book['title'] => "The Stand" # book['avg_rating'] => 4.35 # book['ratings_count']=> 845591 # book['genres'] => ["Horror", "Fiction", "Fantasy", ...] # book['awards'] => ["Locus Award — Best SF Novel", ...] ``` **Field notes:** - `book['legacyId']` is the integer in the URL (e.g. `149267`). Use it to match the correct entity — the `apolloState` often contains 2-3 Book entries for different editions. - Ratings and awards live in the `Work` entity, not `Book`. The `Work` is always `__typename == 'Work'`. - `description` comes in two forms: `description` (HTML) and `description({"stripped":true})` (plain text). Prefer the stripped version. - `pub_timestamp_ms` is a Unix timestamp in **milliseconds**. Convert: `datetime.fromtimestamp(ts/1000)`. - `isbn` / `isbn13` are often `null` on older editions — the JSON-LD path (below) is no more reliable. --- ## Book Page — Fast Path (JSON-LD) Use when you only need title, author, rating, page count, and awards. ~3× less parsing code. ```python import re, json from helpers import http_get def parse_book_fast(book_id): html = http_get(f"https://www.goodreads.com/book/show/{book_id}") blocks = re.findall(r'', html, re.DOTALL) if not blocks: return None ld = json.loads(blocks[0]) return { 'title': ld.get('name'), 'author': ld['author'][0]['name'] if ld.get('author') else None, 'avg_rating': ld.get('aggregateRating', {}).get('ratingValue'), 'ratings_count':ld.get('aggregateRating', {}).get('ratingCount'), 'review_count': ld.get('aggregateRating', {}).get('reviewCount'), 'num_pages': ld.get('numberOfPages'), 'isbn': ld.get('isbn'), 'cover_url': ld.get('image'), 'awards': ld.get('awards'), # single string, comma-separated 'format': ld.get('bookFormat'), } book = parse_book_fast(149267) # book['avg_rating'] => 4.35 # book['ratings_count']=> 845591 ``` **JSON-LD does NOT include:** description, genres, series membership, per-star rating distribution, publisher, language. Use `parse_book()` (the `__NEXT_DATA__` path) when you need any of those. --- ## Search Results URL: `https://www.goodreads.com/search?q={query}&search_type=books&page={n}` Search uses server-rendered HTML with schema.org microdata `