From e60768ac8e8aa6172709972f352f544dd8c9323d Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:58:28 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 14 + README.md | 3 + commands/next.md | 110 ++++++++ commands/random.md | 85 ++++++ commands/series.md | 80 ++++++ commands/stats.md | 141 ++++++++++ commands/vibes.md | 125 +++++++++ plugin.lock.json | 69 +++++ skills/analyze-goodreads-export/SKILL.md | 150 +++++++++++ .../scripts/goodreads_lib.py | 243 ++++++++++++++++++ 10 files changed, 1020 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 commands/next.md create mode 100644 commands/random.md create mode 100644 commands/series.md create mode 100644 commands/stats.md create mode 100644 commands/vibes.md create mode 100644 plugin.lock.json create mode 100644 skills/analyze-goodreads-export/SKILL.md create mode 100644 skills/analyze-goodreads-export/scripts/goodreads_lib.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..92e847f --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "goodreads", + "description": "TODO: Add description", + "version": "0.0.1", + "author": { + "name": "TODO: Add author" + }, + "skills": [ + "./skills" + ], + "commands": [ + "./commands" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..21b810e --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# goodreads + +TODO: Add description diff --git a/commands/next.md b/commands/next.md new file mode 100644 index 0000000..99cfd16 --- /dev/null +++ b/commands/next.md @@ -0,0 +1,110 @@ +--- +description: Analyze my reading patterns and suggest what to read next from my TBR +--- + +You are helping the user decide what to read next from their Goodreads TBR list. + +## Analysis Steps + +Use the `analyze-goodreads-export` skill to perform the following analysis: + +### 1. Analyze Recent Reading Patterns + +Query the last 15 books read (sorted by date_read DESC): +- Calculate average page count of recent reads +- Identify if the user has been reading mostly long books (>600 pages) +- Look for series patterns in recent reads +- Use the `date_read` field to determine actual reading order +- Look at `my_rating` field to see what books the user liked + +### 2. Check for Series Continuity + +For each series found in recent reads: +- Check if there are unread books in that series on the TBR +- Prioritize the next book in sequence (series_index), especially if the previous book had a high rating +- This is important for maintaining reading momentum! + +### 3. Consider Reading Fatigue + +Based on recent page counts: +- If average recent reads > 600 pages: Suggest shorter books (< 300 pages) +- If average recent reads < 400 pages: User might be ready for something longer +- Look for highly-rated short books as "palate cleansers" + +### 4. Check Book Age in Library + +Query books by date_added: +- Find recently added books (last 30 days) that are on TBR +- Find old books (added >1 year ago) that may have been forgotten +- Use `date_added` field to determine when book was added + +### 5. Filter by Quality + +Prioritize books with: +- Goodreads rating >= 3.75 (if available) +- Consider page count relative to recent reading patterns +- Balance between series continuity and variety + +## Output Format + +Structure your response as a structured report with these categories: + +``` +# READING PATTERN SUMMARY +- Books read in last 30 days: X +- Average page count: Y pages +- Notable patterns: [e.g., "Completed The Carls series"] + +# RECOMMENDATIONS BY CATEGORY + +## 📚 SERIES CONTINUITY +Books that continue series you're currently reading: + +- **Book Title** by Author + Series: Series Name #X | Pages: XXX | Rating: X.X/5 | Added: [date/age] + +## 🆕 RECENTLY ADDED +Books added to your TBR in the last 30 days: + +- **Book Title** by Author + Pages: XXX | Rating: X.X/5 | Added: [date] + +## 💎 FORGOTTEN GEMS +Books on your TBR added over a year ago: + +- **Book Title** by Author + Pages: XXX | Rating: X.X/5 | Added: [date/years ago] + +## ⚡ QUICK READS +Shorter books (< 300 pages) for reading fatigue: + +- **Book Title** by Author + Pages: XXX | Rating: X.X/5 | Added: [age] + +## 🌟 HIGHLY RATED +Top-rated unread books from your TBR: + +- **Book Title** by Author + Pages: XXX | Rating: X.X/5 | Added: [age] +``` + +## Important Notes + +- Use `date_added` to determine when books were added to the library +- Calculate age from date_added (e.g., "2 days ago", "3 months ago", "2 years ago") +- Include 1-3 books per category (skip categories if no matches) +- ALWAYS check for incomplete series from recent reads first +- Balance series continuity with reading fatigue and variety +- Present data in a clean, scannable format +- Each category should help answer a different need: momentum, novelty, rediscovery, fatigue, or quality +- Only include books from the TBR list (where exclusive_shelf contains "to-read") + +## Implementation + +Write a Python script using the goodreads_lib to: +1. Get the last 15 read books +2. Analyze patterns (page count, series, ratings) +3. Query TBR for recommendations in each category +4. Format and display results + +Use the Bash tool to run your Python script. diff --git a/commands/random.md b/commands/random.md new file mode 100644 index 0000000..093c616 --- /dev/null +++ b/commands/random.md @@ -0,0 +1,85 @@ +--- +description: Pick a random book from TBR or library +--- + +You are helping the user pick a random book from their Goodreads library. + +## Task + +Use the `analyze-goodreads-skill` skill to pick a random book from their TBR list. + +## Implementation + +Write a Python script using goodreads_lib: + +```python +#!/usr/bin/env python3 +import sys +import random +sys.path.insert(0, '__SKILL_DIR__/scripts') +from goodreads_lib import GoodreadsLibrary + +lib = GoodreadsLibrary() + +# Get TBR books +tbr = lib.get_tbr_books() + +if not tbr: + print("No books in your TBR!") + sys.exit(0) + +# Pick random book +book = random.choice(tbr) + +# Display +print("\n# 🎲 RANDOM TBR PICK\n") +print(f"**{book.title}**") +print(f"by {book.author}\n") + +if book.series and book.series_index: + print(f"📚 Series: {book.series} #{book.series_index}") + +if book.num_pages: + print(f"📖 Pages: {book.num_pages}") + +if book.average_rating: + print(f"⭐ Goodreads Rating: {book.average_rating:.2f}/5") + +if book.date_added: + from datetime import datetime + days_ago = (datetime.now() - book.date_added).days + if days_ago < 30: + print(f"🆕 Added: {days_ago} days ago") + elif days_ago < 365: + months = days_ago // 30 + print(f"📅 Added: {months} months ago") + else: + years = days_ago // 365 + print(f"📅 Added: {years} years ago") + +print(f"\nTotal TBR books: {len(tbr)}") +``` + +## Output Format + +``` +# 🎲 RANDOM TBR PICK + +**Book Title** +by Author Name + +📚 Series: Series Name #X +📖 Pages: XXX +⭐ Goodreads Rating: X.XX/5 +📅 Added: X months/years ago + +Total TBR books: XX +``` + +## Important Notes + +- Pick from books where `is_tbr` is True +- Display relevant metadata (series, pages, rating, date added) +- Handle missing data gracefully +- Use the Bash tool to run your Python script +- Replace `__SKILL_DIR__` with the actual skill directory path diff --git a/commands/series.md b/commands/series.md new file mode 100644 index 0000000..a4f892b --- /dev/null +++ b/commands/series.md @@ -0,0 +1,80 @@ +--- +description: List unfinished series and the next book to read in each +--- + +You are helping the user find incomplete series in their Goodreads library. + +## Task + +Use the `analyze-goodreads-export` skill to find series where: +- The user has read at least one book in the series +- There are still unread books in the series +- Display the next book to read in each series + +## Implementation + +Write a Python script using goodreads_lib: + +```python +#!/usr/bin/env python3 +import sys +sys.path.insert(0, '__SKILL_DIR__/scripts') +from goodreads_lib import GoodreadsLibrary + +lib = GoodreadsLibrary() + +# Get incomplete series +incomplete = lib.get_incomplete_series() + +# Sort by series name +incomplete_sorted = sorted(incomplete.items(), key=lambda x: x[0]) + +# Display results +print("\n# UNFINISHED SERIES\n") +print(f"You have {len(incomplete_sorted)} incomplete series:\n") + +for i, (series_name, info) in enumerate(incomplete_sorted, 1): + read = info['read_count'] + total = info['total_count'] + next_book = info['next_book'] + + print(f"{i}. **{series_name}** ({read}/{total} books read)") + + if next_book: + title = next_book.title + author = next_book.author + index = next_book.series_index or '?' + pages = next_book.num_pages or '?' + rating = f"{next_book.average_rating:.2f}" if next_book.average_rating else "N/A" + + print(f" Next: **{title}** by {author}") + print(f" Book #{index} | {pages} pages | Goodreads: {rating}/5\n") + else: + print(f" Next: Unable to determine\n") +``` + +## Output Format + +``` +# UNFINISHED SERIES + +You have X incomplete series: + +1. **Series Name** (X/Y books read) + Next: **Book Title** by Author + Book #X | XXX pages | Goodreads: X.XX/5 + +2. **Another Series** (X/Y books read) + Next: **Book Title** by Author + Book #X | XXX pages | Goodreads: X.XX/5 +``` + +## Important Notes + +- Series information is parsed from book titles (e.g., "Title (Series, #1)") +- Books are sorted by series_index within each series +- Only show series where at least one book has been read +- The "next" book is the first unread book in series order +- Handle missing data gracefully (pages, ratings may be missing) +- Use the Bash tool to run your Python script +- Replace `__SKILL_DIR__` with the actual skill directory path diff --git a/commands/stats.md b/commands/stats.md new file mode 100644 index 0000000..6d8b497 --- /dev/null +++ b/commands/stats.md @@ -0,0 +1,141 @@ +--- +description: Show reading statistics (books per year/month, pages read, average rating, genre breakdown) +--- + +You are helping the user analyze their reading statistics from their Goodreads library. + +## Analysis to Perform + +Use the `analyze-goodreads-export` skill to gather and analyze the following statistics: + +### 1. Reading Velocity + +Query books read in different time periods: +- Books read this year (use `date_read.year == current_year`) +- Books read last 30 days +- Books read last 90 days +- Break down by month for current year + +Calculate: +- Books per month average (current year) +- Pages per month average +- Current reading pace vs yearly average + +### 2. Page Statistics + +Query all read books with page counts: +- Total pages read this year +- Total pages read all time +- Average pages per book +- Longest book read +- Shortest book read + +### 3. Rating Analysis + +Query all read books with ratings: +- Average rating given (your `my_rating` field) +- Average Goodreads rating of books read (`average_rating` field) +- Most common rating you give +- Distribution of ratings (how many 5-star, 4-star, etc.) + +### 4. Author Statistics + +Query all read books: +- Most read authors (count by author name) +- Total unique authors read + +### 5. Series Statistics + +Query all read books with series information: +- Books read that are part of series vs standalone +- Most read series +- Number of complete series finished + +### 6. To-Be-Read Statistics + +Query TBR list (where `is_tbr` is True): +- Total books in TBR +- Total pages in TBR +- Average Goodreads rating of TBR +- Oldest book in TBR (by date_added) +- Books added to TBR in last 30 days + +## Output Format + +Present statistics in a clean, organized report: + +``` +# READING STATISTICS + +## 📊 Reading Velocity +- **This Year**: X books (Y pages) +- **Last 30 Days**: X books (Y pages) +- **Average Pace**: X books/month, Y pages/month + +### Monthly Breakdown (YYYY) +Jan: X books | Feb: X books | Mar: X books | etc. + +## 📖 Page Statistics +- **Total Pages Read (All Time)**: X,XXX pages +- **Total Pages Read (This Year)**: X,XXX pages +- **Average Book Length**: XXX pages +- **Longest Book**: [Title] by [Author] (XXX pages) +- **Shortest Book**: [Title] by [Author] (XXX pages) + +## ⭐ Rating Analysis +- **Your Average Rating**: X.X / 5 +- **Goodreads Average of Books Read**: X.X / 5 +- **Most Common Rating**: X stars + +### Rating Distribution +★★★★★: XX books (XX%) +★★★★☆: XX books (XX%) +★★★☆☆: XX books (XX%) +★★☆☆☆: XX books (XX%) +★☆☆☆☆: XX books (XX%) + +## ✍️ Author Statistics +- **Total Authors Read**: XX unique authors +- **Most Read Authors**: + 1. [Author Name]: X books + 2. [Author Name]: X books + 3. [Author Name]: X books + +## 📚 Series Statistics +- **Books in Series**: XX books (XX% of total) +- **Standalone Books**: XX books (XX% of total) +- **Most Read Series**: + 1. [Series Name]: X books + 2. [Series Name]: X books + +## 📋 To-Be-Read Statistics +- **Total TBR Books**: XXX books (X,XXX pages) +- **Average TBR Rating**: X.X / 5 +- **Added Recently**: XX books in last 30 days +- **Oldest Unread**: [Title] (added X years/months ago) + +## 🎯 Reading Insights +[Provide 2-3 interesting insights, such as:] +- You're on track to read XX books this year +- Your reading pace has [increased/decreased] compared to last year +- You tend to rate books higher/lower than Goodreads average +- You're reading more/fewer series books than standalone +``` + +## Implementation + +Write a Python script using goodreads_lib to: +1. Query all read books +2. Calculate statistics for each category +3. Format and display results with proper formatting + +Use the Bash tool to run your Python script. + +## Important Notes + +- Use `date_read` field to determine if/when book was read +- Calculate percentages and averages from the data +- Present large numbers with thousand separators for readability +- Compare current year to all-time averages where interesting +- Handle missing data gracefully (some books may not have all fields) +- Round floating point values to 2 decimal places for readability diff --git a/commands/vibes.md b/commands/vibes.md new file mode 100644 index 0000000..d91a28a --- /dev/null +++ b/commands/vibes.md @@ -0,0 +1,125 @@ +--- +description: Find similar books in your library based on genre, author, or themes +--- + +You are helping the user find books similar to one they specify. + +## Task + +When the user asks to find books with similar "vibes" or similar to a specific book: + +1. Ask them what book they want to find similar books to (if not already specified) +2. Use the `analyze-goodreads-export` skill to search for that book in their library +3. Find similar books based on: + - Same author + - Books on the same custom shelves (genre indicators) + - Similar series (if applicable) + - Similar page count + - Similar ratings + +## Implementation + +Write a Python script using goodreads_lib: + +```python +#!/usr/bin/env python3 +import sys +sys.path.insert(0, '__SKILL_DIR__/scripts') +from goodreads_lib import GoodreadsLibrary + +lib = GoodreadsLibrary() + +# Get the reference book (user should specify) +search_term = "BOOK_TITLE" # Replace with user's input + +# Find the book +matches = [b for b in lib.books if search_term.lower() in b.title.lower()] + +if not matches: + print(f"Could not find '{search_term}' in your library") + sys.exit(1) + +ref_book = matches[0] + +print(f"\n# BOOKS SIMILAR TO: {ref_book.title}\n") + +# Find similar books +similar = [] + +# 1. Same author +if ref_book.author: + same_author = [b for b in lib.books + if b.author == ref_book.author + and b.book_id != ref_book.book_id + and not b.is_read] + if same_author: + print(f"## 📚 More by {ref_book.author}\n") + for book in same_author[:3]: + pages = f"{book.num_pages} pages" if book.num_pages else "? pages" + rating = f"{book.average_rating:.2f}/5" if book.average_rating else "N/A" + print(f"- **{book.title}** | {pages} | ⭐ {rating}") + print() + +# 2. Same shelves (genre indicators) +if ref_book.bookshelves: + shelves = ref_book.bookshelves.split(',') + for shelf in shelves[:2]: # Check first 2 shelves + shelf = shelf.strip() + if shelf: + same_shelf = [b for b in lib.books + if shelf in b.bookshelves + and b.book_id != ref_book.book_id + and not b.is_read] + if same_shelf: + print(f"## 🏷️ Similar (from '{shelf}' shelf)\n") + for book in same_shelf[:3]: + pages = f"{book.num_pages} pages" if book.num_pages else "? pages" + rating = f"{book.average_rating:.2f}/5" if book.average_rating else "N/A" + print(f"- **{book.title}** by {book.author} | {pages} | ⭐ {rating}") + print() + +# 3. Similar series (if in a series) +if ref_book.series: + series_books = lib.get_series_books(ref_book.series) + unread_in_series = [b for b in series_books if not b.is_read] + if unread_in_series: + print(f"## 📖 More in {ref_book.series}\n") + for book in unread_in_series[:3]: + idx = f"#{book.series_index}" if book.series_index else "" + pages = f"{book.num_pages} pages" if book.num_pages else "? pages" + rating = f"{book.average_rating:.2f}/5" if book.average_rating else "N/A" + print(f"- **{book.title}** {idx} | {pages} | ⭐ {rating}") + print() +``` + +## Output Format + +``` +# BOOKS SIMILAR TO: Reference Book Title + +## 📚 More by Author Name + +- **Book Title** | XXX pages | ⭐ X.XX/5 +- **Book Title** | XXX pages | ⭐ X.XX/5 + +## 🏷️ Similar (from 'genre' shelf) + +- **Book Title** by Author | XXX pages | ⭐ X.XX/5 +- **Book Title** by Author | XXX pages | ⭐ X.XX/5 + +## 📖 More in Series Name + +- **Book Title** #X | XXX pages | ⭐ X.XX/5 +``` + +## Important Notes + +- Ask the user which book they want to find similar books to +- Search is case-insensitive for the title +- Prioritize unread books in results +- Custom shelves can indicate genres (e.g., "mental-health", "favorites") +- Limit results to 3 per category for readability +- Handle missing data gracefully +- Use the Bash tool to run your Python script +- Replace `__SKILL_DIR__` with the actual skill directory path +- Replace `BOOK_TITLE` with the user's search term diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..effeef3 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,69 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:stbenjam/claude-nine:plugins/goodreads", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "93ad1989021eb8b2e8e61ea49cc22ff50b972d51", + "treeHash": "2b47db04774da8cda8df36651d106ddcf03b5eaeb1f43ca04afe36ba92437461", + "generatedAt": "2025-11-28T10:28:27.528112Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "goodreads", + "description": "TODO: Add description", + "version": "0.0.1" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "5c8002b7d9cc7a2b1144edf830a6615f95c94997835eb0d695a9e4d4d7904161" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "3636a3d839a22ce73a12f664f2b203b061dac49f52735a4c6f7c859a61a9d2e1" + }, + { + "path": "commands/vibes.md", + "sha256": "d5c8df84aba091f25324f475ef597e3a337414e1f22909cab2dc30fc0d43af78" + }, + { + "path": "commands/stats.md", + "sha256": "583390927b3b48dbf8d89d41a21f2ade6836d97d41d9dd7b3e2ece5600020329" + }, + { + "path": "commands/next.md", + "sha256": "4888e6ebda6326d23c28af706c6b317d3c4ca3774ee759a3771726f86a8d3dbb" + }, + { + "path": "commands/series.md", + "sha256": "78f928101a3df54eb1225a761b33bea2c1df40b2b8e50524cceec0f04f54b691" + }, + { + "path": "commands/random.md", + "sha256": "0b2fb6d2478921c0b77e030aeeec0bafbc5489e366371435dd822e25189c773c" + }, + { + "path": "skills/analyze-goodreads-export/SKILL.md", + "sha256": "4cbccda7dd17d74fdc89250619fcd09ce94f6e92e282f4f0478e7b0466ce4052" + }, + { + "path": "skills/analyze-goodreads-export/scripts/goodreads_lib.py", + "sha256": "420a562570a723724b9d4cd7d32ec0c24f4f4c053d83033cec2c67910e85b8ca" + } + ], + "dirSha256": "2b47db04774da8cda8df36651d106ddcf03b5eaeb1f43ca04afe36ba92437461" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/analyze-goodreads-export/SKILL.md b/skills/analyze-goodreads-export/SKILL.md new file mode 100644 index 0000000..e4a6c6e --- /dev/null +++ b/skills/analyze-goodreads-export/SKILL.md @@ -0,0 +1,150 @@ +--- +name: use-goodreads-export +description: Search and query Goodreads library from CSV export. Use when the user asks about books, TBR (to-be-read), reading lists, book searches, or mentions Goodreads. Also use for queries about book ratings, authors, reading status, or library statistics. +--- + +You are helping the user query their Goodreads library from a CSV +export. Use the script here! DO NOT write your own script + +## CSV Location + +The Goodreads export CSV is a file typically called: + +``` +goodreads_library_export.csv +``` + +You can prompt the user for its location if you can't find it. + +## Python Library + +A Python library is available at `__SKILL_DIR__/scripts/goodreads_lib.py` that provides: + +### Classes + +**GoodreadsBook** - Represents a single book with properties: +- `title`, `author`, `series`, `series_index` +- `my_rating` (1-5), `average_rating` (Goodreads rating) +- `num_pages`, `date_read`, `date_added` +- `exclusive_shelf` (e.g., "to-read", "currently-reading") +- `bookshelves` (custom shelves) +- `is_read`, `is_tbr`, `is_currently_reading` (properties) +- `has_shelf(shelf_name)` - Check if on specific shelf + +**GoodreadsLibrary** - Main query interface: +```python +from goodreads_lib import GoodreadsLibrary + +lib = GoodreadsLibrary() # Loads from default CSV path + +# Query methods: +lib.get_read_books(limit=15, sort_by_date=True) # Get read books +lib.get_tbr_books() # Get to-be-read list +lib.get_books_by_shelf('mental-health') # Get books on shelf +lib.get_books_read_in_period(30) # Books read in last 30 days +lib.get_books_read_in_year(2024) # Books read in year +lib.get_books_added_in_period(30) # Recently added books +lib.get_series_books('The Carls') # Books in series +lib.get_all_series() # All series with books +lib.get_incomplete_series() # Series partially read +lib.get_author_stats() # Author statistics +lib.get_rating_distribution() # Rating distribution +lib.query(lambda book: book.num_pages < 300) # Custom queries +``` + +## Usage Instructions + +When the user asks about their Goodreads library: + +1. **Determine the query type**: TBR list, read books, statistics, series info, etc. + +2. **Write a Python script** using the library: + ```python + #!/usr/bin/env python3 + import sys + sys.path.insert(0, '__SKILL_DIR__/scripts') + from goodreads_lib import GoodreadsLibrary + + lib = GoodreadsLibrary() + + # Your query logic here + ``` + +3. **Use the Bash tool** to run your script + +4. **Format results** nicely for the user + +## Common Query Patterns + +### TBR List +```python +tbr = lib.get_tbr_books() +for book in tbr[:10]: + print(f"- {book.title} by {book.author}") +``` + +### Recent Reads +```python +recent = lib.get_read_books(limit=15) +for book in recent: + print(f"- {book.title} by {book.author} ({book.date_read.strftime('%Y-%m-%d')})") +``` + +### Books on Specific Shelf +```python +books = lib.get_books_by_shelf('favorites') +for book in books: + print(f"- {book.title} by {book.author} (⭐ {book.my_rating}/5)") +``` + +### Series Analysis +```python +incomplete = lib.get_incomplete_series() +for series_name, info in incomplete.items(): + print(f"{series_name}: {info['read_count']}/{info['total_count']} read") + if info['next_book']: + print(f" Next: {info['next_book'].title}") +``` + +### Reading Statistics +```python +books_2024 = lib.get_books_read_in_year(2024) +pages_2024 = sum(b.num_pages or 0 for b in books_2024) +print(f"Books read in 2024: {len(books_2024)} ({pages_2024:,} pages)") +``` + +### Highly Rated Unread Books +```python +tbr = lib.get_tbr_books() +highly_rated = sorted( + [b for b in tbr if b.average_rating and b.average_rating >= 4.0], + key=lambda b: b.average_rating, + reverse=True +) +for book in highly_rated[:10]: + print(f"- {book.title} by {book.author} ({book.average_rating:.2f}⭐)") +``` + +## Important Notes + +- The CSV is read-only - no modifications to the Goodreads library +- Series information is parsed from book titles (e.g., "Title (Series, #1)") +- Date Read determines if a book has been read +- Exclusive Shelf contains values like "to-read", "currently-reading", "mental-health", "favorites" +- Users may have custom shelves in the Bookshelves field +- Handle missing data gracefully (not all books have all fields) +- Always use proper Python error handling when accessing optional fields + +## Troubleshooting + +If you get import errors, ensure the script includes: +```python +import sys +sys.path.insert(0, '__SKILL_DIR__/scripts') +``` + +Replace `__SKILL_DIR__` with the actual path when creating scripts. + +Important! You have a very serious bug, where you don't know how to find +the python scripts added by a skill. You must look in the "scripts" +folder of where this SKILL.md is located!! diff --git a/skills/analyze-goodreads-export/scripts/goodreads_lib.py b/skills/analyze-goodreads-export/scripts/goodreads_lib.py new file mode 100644 index 0000000..056994e --- /dev/null +++ b/skills/analyze-goodreads-export/scripts/goodreads_lib.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Library for parsing and querying Goodreads CSV exports.""" + +import csv +import os +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Dict, Optional, Callable +import re + + +class GoodreadsBook: + """Represents a book from Goodreads CSV export.""" + + def __init__(self, row: Dict[str, str]): + self.book_id = row.get('Book Id', '') + self.title = row.get('Title', '') + self.author = row.get('Author', '') + self.author_lf = row.get('Author l-f', '') + self.additional_authors = row.get('Additional Authors', '') + self.isbn = self._clean_isbn(row.get('ISBN', '')) + self.isbn13 = self._clean_isbn(row.get('ISBN13', '')) + self.my_rating = self._parse_int(row.get('My Rating', '')) + self.average_rating = self._parse_float(row.get('Average Rating', '')) + self.publisher = row.get('Publisher', '') + self.binding = row.get('Binding', '') + self.num_pages = self._parse_int(row.get('Number of Pages', '')) + self.year_published = self._parse_int(row.get('Year Published', '')) + self.original_publication_year = self._parse_int(row.get('Original Publication Year', '')) + self.date_read = self._parse_date(row.get('Date Read', '')) + self.date_added = self._parse_date(row.get('Date Added', '')) + self.bookshelves = row.get('Bookshelves', '') + self.bookshelves_with_positions = row.get('Bookshelves with positions', '') + self.exclusive_shelf = row.get('Exclusive Shelf', '') + self.my_review = row.get('My Review', '') + self.spoiler = row.get('Spoiler', '') + self.private_notes = row.get('Private Notes', '') + self.read_count = self._parse_int(row.get('Read Count', '')) + self.owned_copies = self._parse_int(row.get('Owned Copies', '')) + + # Parse series information from title + self.series, self.series_index = self._parse_series() + + def _clean_isbn(self, isbn: str) -> str: + """Remove Excel formatting from ISBN.""" + if isbn.startswith('="') and isbn.endswith('"'): + return isbn[2:-1] + return isbn + + def _parse_int(self, value: str) -> Optional[int]: + """Parse integer value, return None if empty or invalid.""" + if not value or value == '': + return None + try: + return int(value) + except ValueError: + return None + + def _parse_float(self, value: str) -> Optional[float]: + """Parse float value, return None if empty or invalid.""" + if not value or value == '': + return None + try: + return float(value) + except ValueError: + return None + + def _parse_date(self, value: str) -> Optional[datetime]: + """Parse date in YYYY/MM/DD format.""" + if not value or value == '': + return None + try: + return datetime.strptime(value, '%Y/%m/%d') + except ValueError: + return None + + def _parse_series(self) -> tuple[Optional[str], Optional[float]]: + """Extract series name and number from title. + + Examples: + - "An Absolutely Remarkable Thing (The Carls, #1)" -> ("The Carls", 1.0) + - "The Three-Body Problem (Remembrance of Earth's Past, #1)" -> ("Remembrance of Earth's Past", 1.0) + """ + # Match pattern: (Series Name, #Number) + match = re.search(r'\(([^,]+),\s*#([\d.]+)\)$', self.title) + if match: + series_name = match.group(1).strip() + try: + series_index = float(match.group(2)) + return series_name, series_index + except ValueError: + return series_name, None + return None, None + + @property + def is_read(self) -> bool: + """Check if book has been read.""" + return self.date_read is not None + + @property + def is_tbr(self) -> bool: + """Check if book is in to-be-read list.""" + return 'to-read' in self.exclusive_shelf + + @property + def is_currently_reading(self) -> bool: + """Check if currently reading.""" + return 'currently-reading' in self.exclusive_shelf + + def has_shelf(self, shelf_name: str) -> bool: + """Check if book is on a specific shelf.""" + return shelf_name in self.bookshelves or shelf_name in self.exclusive_shelf + + def __repr__(self): + return f"" + + +class GoodreadsLibrary: + """Main class for querying Goodreads library from CSV.""" + + def __init__(self, csv_path: Optional[str] = None): + """Initialize library from CSV file. + + Args: + csv_path: Path to goodreads_library_export.csv + Defaults to ~/Drive/Claude/books/goodreads_library_export.csv + """ + if csv_path is None: + csv_path = os.path.expanduser('~/Drive/Claude/books/goodreads_library_export.csv') + + self.csv_path = csv_path + self.books: List[GoodreadsBook] = [] + self._load_books() + + def _load_books(self): + """Load books from CSV file.""" + with open(self.csv_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + self.books.append(GoodreadsBook(row)) + + def query(self, filter_func: Callable[[GoodreadsBook], bool]) -> List[GoodreadsBook]: + """Query books with a custom filter function.""" + return [book for book in self.books if filter_func(book)] + + def get_read_books(self, limit: Optional[int] = None, + sort_by_date: bool = True) -> List[GoodreadsBook]: + """Get all read books, optionally sorted by date read.""" + books = [book for book in self.books if book.is_read] + if sort_by_date: + books.sort(key=lambda b: b.date_read or datetime.min, reverse=True) + if limit: + books = books[:limit] + return books + + def get_tbr_books(self) -> List[GoodreadsBook]: + """Get all to-be-read books.""" + return [book for book in self.books if book.is_tbr] + + def get_books_by_shelf(self, shelf_name: str) -> List[GoodreadsBook]: + """Get all books on a specific shelf.""" + return [book for book in self.books if book.has_shelf(shelf_name)] + + def get_books_read_in_period(self, days: int) -> List[GoodreadsBook]: + """Get books read in the last N days.""" + cutoff = datetime.now() - timedelta(days=days) + return [book for book in self.books + if book.date_read and book.date_read >= cutoff] + + def get_books_read_in_year(self, year: int) -> List[GoodreadsBook]: + """Get books read in a specific year.""" + return [book for book in self.books + if book.date_read and book.date_read.year == year] + + def get_books_added_in_period(self, days: int) -> List[GoodreadsBook]: + """Get books added to library in the last N days.""" + cutoff = datetime.now() - timedelta(days=days) + return [book for book in self.books + if book.date_added and book.date_added >= cutoff] + + def get_series_books(self, series_name: str) -> List[GoodreadsBook]: + """Get all books in a series, sorted by series index.""" + books = [book for book in self.books if book.series == series_name] + books.sort(key=lambda b: b.series_index or 0) + return books + + def get_all_series(self) -> Dict[str, List[GoodreadsBook]]: + """Get all series with their books.""" + series_dict = {} + for book in self.books: + if book.series: + if book.series not in series_dict: + series_dict[book.series] = [] + series_dict[book.series].append(book) + + # Sort books within each series + for series in series_dict: + series_dict[series].sort(key=lambda b: b.series_index or 0) + + return series_dict + + def get_incomplete_series(self) -> Dict[str, Dict]: + """Get series where at least one book is read but not all.""" + all_series = self.get_all_series() + incomplete = {} + + for series_name, books in all_series.items(): + read_count = sum(1 for b in books if b.is_read) + total_count = len(books) + + if read_count > 0 and read_count < total_count: + # Find next unread book + next_unread = None + for book in books: + if not book.is_read: + next_unread = book + break + + incomplete[series_name] = { + 'books': books, + 'read_count': read_count, + 'total_count': total_count, + 'next_book': next_unread + } + + return incomplete + + def get_author_stats(self) -> List[tuple[str, int]]: + """Get author statistics (author, book count) sorted by count.""" + author_counts = {} + for book in self.books: + if book.is_read: + author_counts[book.author] = author_counts.get(book.author, 0) + 1 + + return sorted(author_counts.items(), key=lambda x: x[1], reverse=True) + + def get_rating_distribution(self) -> Dict[int, int]: + """Get distribution of user ratings.""" + dist = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + for book in self.books: + if book.is_read and book.my_rating: + dist[book.my_rating] = dist.get(book.my_rating, 0) + 1 + return dist