Show Goodreads books on about page

This commit is contained in:
SirBlob committed 2026-11-01 20:46:34 -05:00
1 parent a2ffd45442
commit 0cfebc385f
12 files changed
+673 -213

No files matched your search

+1 -2
View File
@@ -75,6 +75,5 @@
{ "name": "Avatar: The Way of Water", "rating": 10, "url": "https://www.imdb.com/title/tt1630029/" },
{ "name": "Avatar: Fire and Ash", "rating": 8, "url": "https://www.imdb.com/title/tt1757678/" },
{ "name": "Blade Runner 2049", "rating": 10, "url": "https://www.imdb.com/title/tt1856101/" }
],
"books": []
]
}
+3
View File
@@ -0,0 +1,3 @@
[
{ "goodreadsId": "170448", "url": "https://www.goodreads.com/book/show/170448", "title": "Animal Farm", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/170448.jpg", "description": "Animal Farm is the most famous by far of all twentieth-century political allegories. Its account of a group of barnyard animals who revolt against their vicious human master, only to submit to a tyranny erected by their own kind, can fairly be said to have become a universal drama. Orwell is one of the very few modern satirists comparable to Jonathan Swift in power, artistry, and moral authority; in animal farm his spare prose and the logic of his dark comedy brilliantly highlight his stark message.\n \nTaking as his starting point the betrayed promise of the Russian Revolution, Orwell lays out...", "pages": 141, "published": "1996-04-01", "genres": ["Classics", "Fiction", "Dystopia", "Fantasy", "School"], "averageRating": 4.03, "ratingCount": 4853138, "shelf": "read", "rating": 10, "added": "2026-11-01" }
]
-24
View File
@@ -1,24 +0,0 @@
import type { Book } from '#lib/content';
const coverBaseUrl = 'https://covers.openlibrary.org/b/isbn';
const bookPageBaseUrl = 'https://openlibrary.org/isbn';
function cleanIsbn(isbn: string): string {
return isbn.replace(/[^0-9X]/gi, '');
}
export function bookCover(book: Book): string | null {
return book.isbn ? `${coverBaseUrl}/${cleanIsbn(book.isbn)}-L.jpg?default=false` : null;
}
export function bookLink(book: Book): string | null {
if (book.url) return book.url;
return book.isbn ? `${bookPageBaseUrl}/${cleanIsbn(book.isbn)}` : null;
}
export function sortBooks(books: Book[]): Book[] {
return [...books].sort((first, second) => {
const readingOrder = Number(second.status === 'reading') - Number(first.status === 'reading');
return readingOrder || (second.rating ?? 0) - (first.rating ?? 0);
});
}
-140
View File
@@ -1,140 +0,0 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import { bookCover, bookLink } from '#lib/books/books';
let { book }: { book: Book } = $props();
let coverFailed = $state(false);
const cover = $derived(bookCover(book));
const link = $derived(bookLink(book));
</script>
<svelte:element
this={link ? 'a' : 'div'}
class="book-card"
href={link}
target={link ? '_blank' : undefined}
rel={link ? 'noopener noreferrer' : undefined}
title="{book.title} by {book.author}"
>
<div class="cover">
{#if cover && !coverFailed}
<img src={cover} alt="" loading="lazy" data-no-viewer onerror={() => (coverFailed = true)} />
{:else}
<span class="placeholder">
<Icon icon="mdi:book-open-page-variant-outline" width="36" />
<span class="placeholder-title">{book.title}</span>
</span>
{/if}
{#if book.status === 'reading'}
<span class="badge reading"><Icon icon="mdi:bookmark" width="13" />reading</span>
{:else if book.rating}
<span class="badge"><Icon icon="mdi:star" width="13" />{book.rating}/10</span>
{/if}
</div>
<div class="text">
<span class="title">{book.title}</span>
<span class="author">{book.author}</span>
</div>
</svelte:element>
<style>
.book-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.cover {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
a.book-card:hover .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
a.book-card:hover .cover img {
transform: scale(1.05);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 100%;
padding: 0.75rem;
color: var(--muted);
text-align: center;
}
.placeholder-title {
font-size: 0.8rem;
}
.badge {
position: absolute;
top: 0.4rem;
right: 0.4rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
background: var(--background);
color: var(--yellow);
font-size: 0.78rem;
}
.badge.reading {
background: var(--green);
color: var(--background);
}
.text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.title,
.author {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
color: var(--accent);
font-weight: 700;
}
.author {
color: var(--muted);
font-size: 0.75rem;
}
@media (prefers-reduced-motion: reduce) {
.cover img {
transition: none;
}
}
</style>
-36
View File
@@ -1,36 +0,0 @@
<script lang="ts">
import type { Book } from '#lib/content';
import Pagination from '#lib/components/ui/Pagination.svelte';
import BookCard from './BookCard.svelte';
let { books }: { books: Book[] } = $props();
const booksPerPage = 12;
let currentPage = $state(1);
const totalPages = $derived(Math.max(1, Math.ceil(books.length / booksPerPage)));
const visibleBooks = $derived(books.slice((currentPage - 1) * booksPerPage, currentPage * booksPerPage));
</script>
<div class="book-grid">
{#each visibleBooks as book (`${book.title}-${book.author}`)}
<BookCard {book} />
{/each}
</div>
<Pagination bind:currentPage {totalPages} />
<style>
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem 0.85rem;
}
@container (max-width: 520px) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
}
</style>
@@ -0,0 +1,159 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
let { book }: { book: Book } = $props();
let coverFailed = $state(false);
const hasOwnRating = $derived(book.rating !== undefined);
</script>
<a class="book-card" href={book.url} target="_blank" rel="noopener noreferrer" title="{book.title} by {book.authors.join(', ')}">
<div class="cover">
{#if book.cover && !coverFailed}
<img src={book.cover} alt="" loading="lazy" onerror={() => (coverFailed = true)} />
{:else}
<span class="placeholder">
<Icon icon="mdi:book-open-page-variant-outline" width="32" />
<span class="placeholder-title">{book.title}</span>
</span>
{/if}
{#if hasOwnRating}
<span class="rating"><Icon icon="mdi:star" width="13" />{book.rating}/10</span>
{:else if book.averageRating}
<span class="rating community" title="Goodreads average"><Icon icon="mdi:star-outline" width="13" />{book.averageRating}</span>
{/if}
{#if book.description}
<p class="description">{book.description}</p>
{/if}
</div>
<span class="title">{book.title}</span>
<span class="meta">{book.authors.join(', ')}</span>
{#if book.genres.length}
<span class="meta">{book.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a>
<style>
.book-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.cover {
position: relative;
margin-bottom: 0.15rem;
aspect-ratio: 2 / 3;
overflow: hidden;
background: var(--dark-background);
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
transition: border-color 0.15s ease;
}
.book-card:hover .cover {
border-color: var(--accent);
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.book-card:hover img {
transform: scale(1.05);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 100%;
padding: 0.6rem;
color: var(--muted);
text-align: center;
}
.placeholder-title {
font-size: 0.75rem;
}
.rating {
position: absolute;
top: 0.35rem;
right: 0.35rem;
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0 0.35rem;
background: var(--background);
color: var(--yellow);
font-size: 0.75rem;
}
.rating.community {
color: var(--muted);
}
.description {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: -webkit-box;
margin: 0;
padding: 0.55rem;
overflow: hidden;
background: color-mix(in srgb, var(--background) 94%, transparent);
border-top: 1px solid var(--accent);
font-size: 0.72rem;
line-height: 1.45;
-webkit-line-clamp: 8;
line-clamp: 8;
-webkit-box-orient: vertical;
transform: translateY(100%);
transition: transform 0.25s ease;
}
.book-card:hover .description,
.book-card:focus-visible .description {
transform: translateY(0);
}
.title {
display: -webkit-box;
overflow: hidden;
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
line-height: 1.3;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta {
overflow: hidden;
color: var(--muted);
font-size: 0.72rem;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (prefers-reduced-motion: reduce) {
.cover,
.cover img,
.description {
transition: none;
}
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import BookCard from './BookCard.svelte';
let { title, icon, books }: { title: string; icon: string; books: Book[] } = $props();
const collapsedCount = 12;
let showAll = $state(false);
const shownBooks = $derived(showAll ? books : books.slice(0, collapsedCount));
</script>
<h3 class="subheading"><Icon {icon} width="16" /> {title} <span class="muted">{books.length}</span></h3>
<div class="book-grid">
{#each shownBooks as book (book.goodreadsId)}
<BookCard {book} />
{/each}
</div>
{#if books.length > collapsedCount}
<button class="toggle" onclick={() => (showAll = !showAll)}>
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{showAll ? 'Show less' : `Show all ${books.length}`}
</button>
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.9rem 0.75rem;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.75rem;
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
font-size: 0.85rem;
}
.toggle:hover {
border-color: var(--accent);
color: var(--accent);
}
@container (max-width: 520px) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -0,0 +1,180 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import Panel from '#lib/components/ui/Panel.svelte';
import BookShelfGrid from './BookShelfGrid.svelte';
let { books }: { books: Book[] } = $props();
const shelfColors = { read: 'green', reading: 'blue', 'want-to-read': 'magenta' };
const byRating = (first: Book, second: Book) => (second.rating ?? -1) - (first.rating ?? -1);
const read = $derived(books.filter((book) => book.shelf === 'read').sort(byRating));
const reading = $derived(books.filter((book) => book.shelf === 'reading'));
const wantToRead = $derived(books.filter((book) => book.shelf === 'want-to-read'));
const shelfCounts = $derived([
{ label: 'Read', count: read.length, color: shelfColors.read },
{ label: 'Reading', count: reading.length, color: shelfColors.reading },
{ label: 'Want to read', count: wantToRead.length, color: shelfColors['want-to-read'] }
]);
const shownCounts = $derived(shelfCounts.filter((shelf) => shelf.count > 0));
const pagesRead = $derived(read.reduce((total, book) => total + (book.pages ?? 0), 0));
const authorCount = $derived(new Set(read.flatMap((book) => book.authors)).size);
const topGenres = $derived.by(() => {
const counts = new Map<string, number>();
read.flatMap((book) => book.genres).forEach((genre) => counts.set(genre, (counts.get(genre) ?? 0) + 1));
return [...counts.entries()].sort((first, second) => second[1] - first[1]).slice(0, 3).map(([genre]) => genre);
});
const rows = $derived([
['Pages read', pagesRead.toLocaleString('en-US')],
['Authors', `${authorCount}`],
...(topGenres.length ? [['Top genres', topGenres.join(', ')]] : [])
]);
</script>
<div class="overview" class:single={!reading.length}>
<Panel title="bookshelf" icon="mdi:bookshelf">
<div class="fetch">
{#each shelfCounts as shelf (shelf.label)}
<div><span class="label">{shelf.label}</span>: <span style:color="var(--{shelf.color})">{shelf.count}</span></div>
{/each}
{#each rows as [label, value] (label)}
<div><span class="label">{label}</span>: {value}</div>
{/each}
{#if shownCounts.length}
<div class="breakdown" role="img" aria-label="Books by shelf">
{#each shownCounts as shelf (shelf.label)}
<span class="block" style:flex-grow={shelf.count} style:background="var(--{shelf.color})" title="{shelf.label}: {shelf.count}"></span>
{/each}
</div>
{/if}
</div>
</Panel>
{#if reading.length}
<Panel title="Currently reading" icon="mdi:book-open-variant">
<div class="reading">
{#each reading as book (book.goodreadsId)}
<a class="reading-row" href={book.url} target="_blank" rel="noopener noreferrer">
{#if book.cover}
<img src={book.cover} alt={book.title} loading="lazy" />
{:else}
<span class="cover-fallback"><Icon icon="mdi:book-outline" width="22" /></span>
{/if}
<span class="reading-text">
<span class="title">{book.title}</span>
<span class="muted detail">{book.authors.join(', ')}</span>
<span class="muted detail">
{[book.pages ? `${book.pages} pages` : '', book.published?.slice(0, 4) ?? ''].filter(Boolean).join(' · ')}
</span>
</span>
</a>
{/each}
</div>
</Panel>
{/if}
</div>
{#if read.length}
<BookShelfGrid title="Read" icon="mdi:check-circle-outline" books={read} />
{/if}
{#if wantToRead.length}
<BookShelfGrid title="Want to read" icon="mdi:bookmark-outline" books={wantToRead} />
{/if}
<style>
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.75rem;
}
.overview.single {
grid-template-columns: minmax(0, 1fr);
}
.fetch {
line-height: 1.5;
}
.label {
color: var(--blue);
font-weight: 700;
}
.breakdown {
display: flex;
margin-top: 0.75rem;
}
.block {
flex-basis: 0;
min-width: 0.5rem;
height: 1rem;
}
.reading {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reading-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.4rem;
border: 1px solid transparent;
}
.reading-row:hover {
border-color: var(--accent);
}
.reading-row img,
.cover-fallback {
flex-shrink: 0;
width: 52px;
height: 78px;
object-fit: cover;
}
.cover-fallback {
display: grid;
place-items: center;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--muted);
}
.reading-text {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.title,
.detail {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title {
font-size: 0.9rem;
}
.detail {
font-size: 0.75rem;
}
@container (max-width: 800px) {
.overview {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+3
View File
@@ -11,9 +11,11 @@ import hackathonsData from '#content/projects/hackathons.json';
import researchData from '#content/projects/research.json';
import organizationsData from '#content/projects/organizations.json';
import tcgDecksData from '#content/tcgDecks.json';
import booksData from '#content/books.json';
import type {
AboutContent,
BlogSettings,
Book,
DesktopContent,
Experience,
Leadership,
@@ -37,6 +39,7 @@ export const blogSettings: BlogSettings = blogData;
export const playlist: Playlist = musicData;
export const researchGroups: ResearchGroup[] = researchData;
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
export const books: Book[] = booksData as Book[];
function featuredFirst(projects: Project[]): Project[] {
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
+13 -6
View File
@@ -139,15 +139,23 @@ export interface RatedTitle {
url: string;
}
export type BookStatus = 'read' | 'reading';
export type BookShelf = 'read' | 'reading' | 'want-to-read';
export interface Book {
goodreadsId: string;
url: string;
title: string;
author: string;
authors: string[];
cover: string | null;
description: string | null;
pages: number | null;
published: string | null;
genres: string[];
averageRating: number | null;
ratingCount: number | null;
shelf: BookShelf;
rating?: number;
isbn?: string;
url?: string;
status?: BookStatus;
added?: string;
}
export interface LabeledValue {
@@ -172,7 +180,6 @@ export interface AboutContent {
games: Game[];
shows: RatedTitle[];
movies: RatedTitle[];
books?: Book[];
}
export type GameName = 'snake' | 'breakout';
+3 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { about, profile } from '#lib/content';
import { about, books, profile } from '#lib/content';
import PageHeader from '#lib/components/ui/PageHeader.svelte';
import ProfileIntro from '#lib/components/ui/ProfileIntro.svelte';
import SectionNav from '#lib/components/ui/SectionNav.svelte';
@@ -8,8 +8,7 @@
import AniListSection from '#lib/components/about/AniListSection.svelte';
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
import GameCard from '#lib/components/about/GameCard.svelte';
import BookGrid from '#lib/components/about/BookGrid.svelte';
import { sortBooks } from '#lib/books/books';
import BooksSection from '#lib/components/about/books/BooksSection.svelte';
import SetupSection from '#lib/components/about/setup/SetupSection.svelte';
import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte';
import type { PageProps } from './$types';
@@ -19,7 +18,6 @@
const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating;
const shows = [...about.shows].sort(byRating);
const movies = [...about.movies].sort(byRating);
const books = sortBooks(about.books ?? []);
const mediaGroups = [
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const },
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const }
@@ -76,7 +74,7 @@
{#if books.length}
<Section title="Books" id="books">
<BookGrid {books} />
<BooksSection {books} />
</Section>
{/if}
+244
View File
@@ -0,0 +1,244 @@
import argparse
import datetime
import html
import json
import re
import sys
import urllib.request
from pathlib import Path
BOOK_URL_PATTERN = re.compile(r"goodreads\.com/(?:\w+/)?book/show/(\d+)")
JSON_LD_PATTERN = re.compile(r"<script[^>]*type=[\"']application/ld\+json[\"'][^>]*>(.*?)</script>", re.S | re.I)
NEXT_DATA_PATTERN = re.compile(r"<script[^>]*id=[\"']__NEXT_DATA__[\"'][^>]*>(.*?)</script>", re.S | re.I)
META_TAG_PATTERN = re.compile(r"<meta\s[^>]*>", re.S | re.I)
ATTRIBUTE_PATTERN = re.compile(r"([\w:-]+)=\"([^\"]*)\"|([\w:-]+)='([^']*)'")
PAGE_TITLE_PATTERN = re.compile(r"<title[^>]*>(.*?)</title>", re.S | re.I)
TAG_PATTERN = re.compile(r"<[^>]+>")
BLOCKED_PAGE_HINTS = ["captcha", "awswaf", "robot check", "access denied", "are you a human", "unusual traffic"]
REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
}
class PageResult:
def __init__(self, status: int, final_url: str, text: str):
self.status = status
self.final_url = final_url
self.text = text
def book_id(url: str) -> str | None:
match = BOOK_URL_PATTERN.search(url)
return match.group(1) if match else None
def download_page(url: str) -> PageResult:
request = urllib.request.Request(url, headers=REQUEST_HEADERS)
with urllib.request.urlopen(request, timeout=15) as response:
return PageResult(response.status, response.geturl(), response.read().decode("utf-8", errors="replace"))
def page_title(page: str) -> str:
match = PAGE_TITLE_PATTERN.search(page)
return html.unescape(match.group(1)).strip() if match else ""
def blocked_reason(page: str) -> str | None:
lowered = page.lower()
hint = next((hint for hint in BLOCKED_PAGE_HINTS if hint in lowered), None)
has_book_data = JSON_LD_PATTERN.search(page) or NEXT_DATA_PATTERN.search(page)
if hint and not has_book_data:
return f"the page looks like a bot check (found '{hint}')"
return None
def json_ld_book(page: str) -> dict:
for block in JSON_LD_PATTERN.findall(page):
try:
data = json.loads(block)
except ValueError:
continue
candidates = data if isinstance(data, list) else data.get("@graph", [data]) if isinstance(data, dict) else []
for candidate in candidates:
if isinstance(candidate, dict) and candidate.get("@type") == "Book":
return candidate
return {}
def meta_tags(page: str) -> dict:
tags = {}
for tag in META_TAG_PATTERN.findall(page):
attributes = {}
for double_name, double_value, single_name, single_value in ATTRIBUTE_PATTERN.findall(tag):
attributes[(double_name or single_name).lower()] = double_value or single_value
name = attributes.get("property") or attributes.get("name")
if name and "content" in attributes:
tags[name] = html.unescape(attributes["content"])
return tags
def plain_text(text: str | None) -> str | None:
if not text:
return None
with_breaks = re.sub(r"<br\s*/?>", "\n", text)
return html.unescape(TAG_PATTERN.sub("", with_breaks)).strip() or None
def apollo_state(page: str) -> dict:
match = NEXT_DATA_PATTERN.search(page)
if not match:
return {}
try:
return json.loads(match.group(1))["props"]["pageProps"]["apolloState"]
except (ValueError, KeyError, TypeError):
return {}
def apollo_book(state: dict, identifier: str) -> tuple[dict, dict]:
books = [value for value in state.values() if isinstance(value, dict) and value.get("__typename") == "Book"]
book = next((value for value in books if str(value.get("legacyId")) == identifier), books[0] if books else {})
work_reference = (book.get("work") or {}).get("__ref")
work = state.get(work_reference, {}) if work_reference else {}
return book, work
def contributor_name(state: dict, edge: dict) -> str | None:
reference = (edge.get("node") or {}).get("__ref")
return (state.get(reference) or {}).get("name") if reference else None
def book_authors(state: dict, book: dict) -> list[str]:
edges = [book.get("primaryContributorEdge") or {}]
edges += [edge for edge in book.get("secondaryContributorEdges") or [] if edge.get("role") == "Author"]
names = [contributor_name(state, edge) for edge in edges]
return [html.unescape(name) for name in names if name]
def description_of(book: dict) -> str | None:
for key, value in book.items():
if key.startswith("description") and isinstance(value, str):
return plain_text(value)
return None
def publication_date(details: dict) -> str | None:
milliseconds = details.get("publicationTime")
if not isinstance(milliseconds, (int, float)):
return None
return datetime.datetime.fromtimestamp(milliseconds / 1000, tz=datetime.timezone.utc).date().isoformat()
def parse_book(page: str, identifier: str) -> dict:
linked_data = json_ld_book(page)
meta = meta_tags(page)
state = apollo_state(page)
book, work = apollo_book(state, identifier)
details = book.get("details") or {}
work_stats = work.get("stats") or {}
rating = linked_data.get("aggregateRating") or {}
linked_authors = linked_data.get("author") or []
if isinstance(linked_authors, dict):
linked_authors = [linked_authors]
authors = book_authors(state, book) or [html.unescape(author["name"]) for author in linked_authors if author.get("name")]
return {
"goodreadsId": identifier,
"url": f"https://www.goodreads.com/book/show/{identifier}",
"title": html.unescape(linked_data.get("name") or book.get("title") or meta.get("og:title") or ""),
"authors": authors,
"cover": book.get("imageUrl") or linked_data.get("image") or meta.get("og:image"),
"description": description_of(book) or meta.get("og:description") or meta.get("description"),
"pages": linked_data.get("numberOfPages") or details.get("numPages"),
"format": linked_data.get("bookFormat") or details.get("format"),
"published": publication_date(details),
"publisher": details.get("publisher"),
"isbn": linked_data.get("isbn") or details.get("isbn13") or details.get("isbn"),
"language": linked_data.get("inLanguage") or (details.get("language") or {}).get("name"),
"genres": [edge["genre"]["name"] for edge in book.get("bookGenres") or [] if (edge.get("genre") or {}).get("name")],
"averageRating": rating.get("ratingValue") or work_stats.get("averageRating"),
"ratingCount": rating.get("ratingCount") or work_stats.get("ratingsCount"),
"reviewCount": rating.get("reviewCount") or work_stats.get("textReviewsCount"),
}
def scrape_book(url: str) -> dict:
identifier = book_id(url)
if not identifier:
raise ValueError(f"not a Goodreads book url: {url}")
page = download_page(url)
reason = blocked_reason(page.text)
if reason:
raise ValueError(reason)
return parse_book(page.text, identifier)
def print_diagnostics(url: str, save_path: Path | None):
page = download_page(url)
print(f"Status {page.status}")
print(f"Final url {page.final_url}")
print(f"Size {len(page.text):,} characters")
print(f"Title {page_title(page.text) or '-'}")
print(f"JSON-LD {len(JSON_LD_PATTERN.findall(page.text))} blocks")
print(f"Next data {'found' if NEXT_DATA_PATTERN.search(page.text) else 'missing'}")
print(f"Meta tags {len(meta_tags(page.text))}")
print(f"Blocked {blocked_reason(page.text) or 'no sign of a bot check'}")
if save_path:
save_path.write_text(page.text, encoding="utf-8")
print(f"Saved html to {save_path}")
def print_summary(book: dict):
print(f"{book['title'] or 'unknown title'} by {', '.join(book['authors']) or 'unknown author'}")
print("-" * 60)
for label, key in [
("Cover", "cover"),
("Pages", "pages"),
("Format", "format"),
("Published", "published"),
("Publisher", "publisher"),
("ISBN", "isbn"),
("Language", "language"),
("Rating", "averageRating"),
("Ratings", "ratingCount"),
("Reviews", "reviewCount"),
]:
print(f"{label:<10} {book[key] if book[key] is not None else '-'}")
print(f"{'Genres':<10} {', '.join(book['genres']) or '-'}")
if book["description"]:
print(f"\n{book['description'][:400]}{'...' if len(book['description']) > 400 else ''}")
missing = [key for key, value in book.items() if value in (None, "", [])]
if missing:
print(f"\nNot found on the page: {', '.join(missing)}")
def main() -> int:
parser = argparse.ArgumentParser(description="Scrape book info from a Goodreads book page")
parser.add_argument("urls", nargs="+", help="Goodreads book urls, for example https://www.goodreads.com/book/show/170448.Animal_Farm")
parser.add_argument("--json", action="store_true", help="print the raw result as JSON")
parser.add_argument("--debug", action="store_true", help="show what the downloaded page contains instead of parsing it")
parser.add_argument("--save-html", type=Path, help="with --debug, save the downloaded page to this file")
arguments = parser.parse_args()
exit_code = 0
for url in arguments.urls:
try:
if arguments.debug:
print_diagnostics(url, arguments.save_html)
continue
book = scrape_book(url)
except (OSError, ValueError) as error:
print(f"Error for {url}: {error}", file=sys.stderr)
exit_code = 1
continue
if arguments.json:
print(json.dumps(book, indent="\t", ensure_ascii=False))
else:
print_summary(book)
print()
return exit_code
if __name__ == "__main__":
sys.exit(main())