import html import json import re import goodreads SERIES_URL_PATTERN = re.compile(r"goodreads\.com/series/(\d+)") REACT_PROPS_PATTERN = re.compile(r"data-react-props=\"([^\"]*)\"", re.S) BOOK_LINK_PATTERN = re.compile(r"]+href=\"(?:https://www\.goodreads\.com)?/book/show/(\d+)[^\"]*\"[^>]*>\s*(?:]*>)?\s*([^<]+?)\s*<", re.S) SERIES_TITLE_SUFFIX = re.compile(r"\s+(?:Series\s+)?by\s+.*$", re.I) def series_id(url: str) -> str | None: match = SERIES_URL_PATTERN.search(url) return match.group(1) if match else None def first_text(data: dict, keys: list[str]) -> str: for key in keys: value = data.get(key) if isinstance(value, str) and value.strip(): return html.unescape(value.strip()) return "" def author_names(book: dict) -> list[str]: author = book.get("author") if isinstance(author, dict) and author.get("name"): return [html.unescape(author["name"])] authors = book.get("authors") if isinstance(authors, list): return [html.unescape(item["name"]) for item in authors if isinstance(item, dict) and item.get("name")] name = first_text(book, ["authorName"]) return [name] if name else [] def entry_from_book(book: dict, position: str) -> dict | None: identifier = str(book.get("bookId") or book.get("legacyId") or book.get("id") or "") link = first_text(book, ["bookUrl", "url", "webUrl"]) if not identifier.isdigit(): identifier = goodreads.book_id(link) or "" if not identifier: return None return { "goodreadsId": identifier, "title": first_text(book, ["bookTitleBare", "title", "titleComplete"]), "position": position, "authors": author_names(book), "cover": first_text(book, ["imageUrl", "image"]) or None, "url": f"https://www.goodreads.com/book/show/{identifier}", } def clean_header(value) -> str: return html.unescape(value).strip() if isinstance(value, str) else "" def own_header(item: dict) -> str: return next((clean_header(value) for key, value in item.items() if "header" in key.lower() and isinstance(value, str)), "") def is_book_list(value) -> bool: return isinstance(value, list) and bool(value) and all(isinstance(item, dict) and isinstance(item.get("book"), dict) for item in value) def collect_entries(node, found: list[dict]): if isinstance(node, list): for item in node: collect_entries(item, found) return if not isinstance(node, dict): return if isinstance(node.get("book"), dict): entry = entry_from_book(node["book"], own_header(node)) if entry: found.append(entry) return header_lists = [value for key, value in node.items() if "header" in key.lower() and isinstance(value, list)] for value in node.values(): if not is_book_list(value): collect_entries(value, found) continue headers = next((headers for headers in header_lists if len(headers) == len(value)), []) for index, item in enumerate(value): position = clean_header(headers[index]) if index < len(headers) else "" entry = entry_from_book(item["book"], position or own_header(item)) if entry: found.append(entry) def entries_from_react_props(page: str) -> list[dict]: found: list[dict] = [] for raw in REACT_PROPS_PATTERN.findall(page): try: collect_entries(json.loads(html.unescape(raw)), found) except ValueError: continue return found def entries_from_next_data(page: str) -> list[dict]: state = goodreads.apollo_state(page) books = [value for value in state.values() if isinstance(value, dict) and value.get("__typename") == "Book"] entries = [] for book in books: entry = entry_from_book(book, "") if entry: entry["authors"] = goodreads.book_authors(state, book) or entry["authors"] entries.append(entry) return entries def entries_from_links(page: str) -> list[dict]: entries = [] for identifier, title in BOOK_LINK_PATTERN.findall(page): title = html.unescape(title).strip() if title: entries.append({"goodreadsId": identifier, "title": title, "position": "", "authors": [], "cover": None, "url": f"https://www.goodreads.com/book/show/{identifier}"}) return entries def unique_entries(entries: list[dict]) -> list[dict]: seen = set() unique = [] for entry in entries: if entry["goodreadsId"] in seen: continue seen.add(entry["goodreadsId"]) unique.append(entry) return unique def series_title(page: str) -> str: meta = goodreads.meta_tags(page) title = meta.get("og:title") or goodreads.page_title(page) return SERIES_TITLE_SUFFIX.sub("", title).strip() def scrape_series(url: str) -> dict: identifier = series_id(url) if not identifier: raise ValueError(f"not a Goodreads series url: {url}") page = goodreads.download_page(url) reason = goodreads.blocked_reason(page.text) if reason: raise ValueError(reason) entries = entries_from_react_props(page.text) or entries_from_next_data(page.text) or entries_from_links(page.text) entries = unique_entries(entries) if not entries: raise ValueError("Could not find any books on the series page") return { "seriesId": identifier, "name": series_title(page.text), "url": f"https://www.goodreads.com/series/{identifier}", "books": entries, } def print_series(series: dict): print(f"{series['name']} ({len(series['books'])} books)") print("-" * 60) for entry in series["books"]: position = entry["position"] or "-" authors = ", ".join(entry["authors"]) or "unknown author" print(f"{position:<12} {entry['title']} by {authors} [{entry['goodreadsId']}]")