Cache Goodreads data and slow down bulk requests
This commit is contained in:
1 parent
af73bb5b26
commit
7ef3a29ece
6 files changed
+162
-13
No files matched your search
@@ -32,4 +32,5 @@ blogs/
|
|||||||
|
|
||||||
# Content tool
|
# Content tool
|
||||||
tools/content/.venv/
|
tools/content/.venv/
|
||||||
|
tools/content/.cache/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import goodreads
|
||||||
|
from jsonfile import PROJECT_ROOT
|
||||||
|
|
||||||
|
SCRAPE_CACHE_DIR = PROJECT_ROOT / "tools" / "content" / ".cache" / "goodreads"
|
||||||
|
COVERS_DIR = PROJECT_ROOT / "static" / "covers" / "books"
|
||||||
|
COVERS_PUBLIC_PATH = "/covers/books"
|
||||||
|
IMAGE_EXTENSION_PATTERN = re.compile(r"\.(jpe?g|png|webp|gif)(?:$|\?)", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_cache_path(identifier: str):
|
||||||
|
return SCRAPE_CACHE_DIR / f"{identifier}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def cached_scrape(identifier: str) -> dict | None:
|
||||||
|
path = scrape_cache_path(identifier)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def store_scrape(scraped: dict) -> None:
|
||||||
|
SCRAPE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
scrape_cache_path(scraped["goodreadsId"]).write_text(json.dumps(scraped, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def scrape_book(url: str, fresh: bool = False) -> dict:
|
||||||
|
identifier = goodreads.book_id(url)
|
||||||
|
cached = None if fresh or not identifier else cached_scrape(identifier)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
scraped = goodreads.scrape_book(url)
|
||||||
|
if scraped.get("title"):
|
||||||
|
store_scrape(scraped)
|
||||||
|
return scraped
|
||||||
|
|
||||||
|
|
||||||
|
def is_local_cover(cover: str | None) -> bool:
|
||||||
|
return bool(cover) and cover.startswith(COVERS_PUBLIC_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def existing_cover(identifier: str):
|
||||||
|
return next(iter(sorted(COVERS_DIR.glob(f"{identifier}.*"))), None) if COVERS_DIR.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def cache_cover(identifier: str, source_url: str | None, fresh: bool = False) -> str | None:
|
||||||
|
existing = existing_cover(identifier)
|
||||||
|
if existing and not fresh:
|
||||||
|
return f"{COVERS_PUBLIC_PATH}/{existing.name}"
|
||||||
|
if not source_url or is_local_cover(source_url):
|
||||||
|
return f"{COVERS_PUBLIC_PATH}/{existing.name}" if existing else None
|
||||||
|
|
||||||
|
match = IMAGE_EXTENSION_PATTERN.search(source_url)
|
||||||
|
extension = match.group(1).lower().replace("jpeg", "jpg") if match else "jpg"
|
||||||
|
request = urllib.request.Request(source_url, headers=goodreads.REQUEST_HEADERS)
|
||||||
|
with urllib.request.urlopen(request, timeout=15) as response:
|
||||||
|
image = response.read()
|
||||||
|
|
||||||
|
COVERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if existing:
|
||||||
|
existing.unlink()
|
||||||
|
(COVERS_DIR / f"{identifier}.{extension}").write_bytes(image)
|
||||||
|
return f"{COVERS_PUBLIC_PATH}/{identifier}.{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def remove_cover(identifier: str) -> None:
|
||||||
|
existing = existing_cover(identifier)
|
||||||
|
if existing:
|
||||||
|
existing.unlink()
|
||||||
+26
-3
@@ -1,5 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
import book_cache
|
||||||
import goodreads
|
import goodreads
|
||||||
import jsonfile
|
import jsonfile
|
||||||
|
|
||||||
@@ -47,13 +48,21 @@ def short_description(text: str | None) -> str | None:
|
|||||||
return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..."
|
return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def local_cover(scraped: dict) -> str | None:
|
||||||
|
try:
|
||||||
|
return book_cache.cache_cover(scraped["goodreadsId"], scraped["cover"]) or scraped["cover"]
|
||||||
|
except OSError:
|
||||||
|
return scraped["cover"]
|
||||||
|
|
||||||
|
|
||||||
def stored_fields(scraped: dict) -> dict:
|
def stored_fields(scraped: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
"goodreadsId": scraped["goodreadsId"],
|
"goodreadsId": scraped["goodreadsId"],
|
||||||
"url": scraped["url"],
|
"url": scraped["url"],
|
||||||
"title": scraped["title"],
|
"title": scraped["title"],
|
||||||
"authors": scraped["authors"],
|
"authors": scraped["authors"],
|
||||||
"cover": scraped["cover"],
|
"cover": local_cover(scraped),
|
||||||
|
"coverSource": scraped["cover"],
|
||||||
"description": short_description(scraped["description"]),
|
"description": short_description(scraped["description"]),
|
||||||
"pages": scraped["pages"],
|
"pages": scraped["pages"],
|
||||||
"published": scraped["published"],
|
"published": scraped["published"],
|
||||||
@@ -107,7 +116,7 @@ def add_book(url: str, shelf: str = "read", rating: int | None = None) -> dict:
|
|||||||
if find_index(load_books(), identifier) is not None:
|
if find_index(load_books(), identifier) is not None:
|
||||||
raise ValueError(f"{identifier} is already listed")
|
raise ValueError(f"{identifier} is already listed")
|
||||||
|
|
||||||
scraped = goodreads.scrape_book(url)
|
scraped = book_cache.scrape_book(url)
|
||||||
if not scraped["title"]:
|
if not scraped["title"]:
|
||||||
raise ValueError("Could not read the book from Goodreads")
|
raise ValueError("Could not read the book from Goodreads")
|
||||||
|
|
||||||
@@ -130,6 +139,7 @@ def remove_book(text: str) -> dict:
|
|||||||
raise ValueError(f"{text} is not listed")
|
raise ValueError(f"{text} is not listed")
|
||||||
removed = books.pop(index)
|
removed = books.pop(index)
|
||||||
save_books(books)
|
save_books(books)
|
||||||
|
book_cache.remove_cover(removed["goodreadsId"])
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
@@ -156,5 +166,18 @@ def refresh_book(text: str) -> dict:
|
|||||||
index = find_index(books, text)
|
index = find_index(books, text)
|
||||||
if index is None:
|
if index is None:
|
||||||
raise ValueError(f"{text} is not listed")
|
raise ValueError(f"{text} is not listed")
|
||||||
scraped = goodreads.scrape_book(books[index]["url"])
|
scraped = book_cache.scrape_book(books[index]["url"], fresh=True)
|
||||||
return update_book(text, lambda book: {**book, **stored_fields(scraped)})
|
return update_book(text, lambda book: {**book, **stored_fields(scraped)})
|
||||||
|
|
||||||
|
|
||||||
|
def books_missing_local_covers() -> list[dict]:
|
||||||
|
return [book for book in load_books() if not book_cache.is_local_cover(book.get("cover"))]
|
||||||
|
|
||||||
|
|
||||||
|
def cache_book_cover(identifier: str) -> dict:
|
||||||
|
def change(book: dict) -> dict:
|
||||||
|
source = book.get("coverSource") or book.get("cover")
|
||||||
|
cover = book_cache.cache_cover(identifier, source)
|
||||||
|
return {**book, "cover": cover or book.get("cover"), "coverSource": source}
|
||||||
|
|
||||||
|
return update_book(identifier, change)
|
||||||
@@ -22,6 +22,10 @@ REQUEST_HEADERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PageResult:
|
class PageResult:
|
||||||
def __init__(self, status: int, final_url: str, text: str):
|
def __init__(self, status: int, final_url: str, text: str):
|
||||||
self.status = status
|
self.status = status
|
||||||
@@ -168,10 +172,14 @@ def scrape_book(url: str) -> dict:
|
|||||||
if not identifier:
|
if not identifier:
|
||||||
raise ValueError(f"not a Goodreads book url: {url}")
|
raise ValueError(f"not a Goodreads book url: {url}")
|
||||||
page = download_page(url)
|
page = download_page(url)
|
||||||
|
raise_if_blocked(page)
|
||||||
|
return parse_book(page.text, identifier)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_if_blocked(page: PageResult):
|
||||||
reason = blocked_reason(page.text)
|
reason = blocked_reason(page.text)
|
||||||
if reason:
|
if reason:
|
||||||
raise ValueError(reason)
|
raise BlockedError(f"Goodreads is rate limiting requests ({reason}), wait a few minutes and try again")
|
||||||
return parse_book(page.text, identifier)
|
|
||||||
|
|
||||||
|
|
||||||
def print_diagnostics(url: str, save_path: Path | None):
|
def print_diagnostics(url: str, save_path: Path | None):
|
||||||
|
|||||||
@@ -143,9 +143,7 @@ def scrape_series(url: str) -> dict:
|
|||||||
if not identifier:
|
if not identifier:
|
||||||
raise ValueError(f"not a Goodreads series url: {url}")
|
raise ValueError(f"not a Goodreads series url: {url}")
|
||||||
page = goodreads.download_page(url)
|
page = goodreads.download_page(url)
|
||||||
reason = goodreads.blocked_reason(page.text)
|
goodreads.raise_if_blocked(page)
|
||||||
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 = entries_from_react_props(page.text) or entries_from_next_data(page.text) or entries_from_links(page.text)
|
||||||
entries = unique_entries(entries)
|
entries = unique_entries(entries)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
from rich.markup import escape
|
from rich.markup import escape
|
||||||
from textual import on, work
|
from textual import on, work
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
@@ -6,11 +8,14 @@ from textual.containers import Horizontal, VerticalScroll
|
|||||||
from textual.widgets import DataTable, Static
|
from textual.widgets import DataTable, Static
|
||||||
|
|
||||||
import books
|
import books
|
||||||
|
import goodreads
|
||||||
import goodreads_series
|
import goodreads_series
|
||||||
from tui.content_pane import ContentPane
|
from tui.content_pane import ContentPane
|
||||||
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
||||||
from tui.series_picker import SeriesPicker
|
from tui.series_picker import SeriesPicker
|
||||||
|
|
||||||
|
REQUEST_DELAY_SECONDS = 3
|
||||||
|
|
||||||
|
|
||||||
def book_details(book: dict) -> str:
|
def book_details(book: dict) -> str:
|
||||||
rows = [
|
rows = [
|
||||||
@@ -40,6 +45,7 @@ class BooksPane(ContentPane):
|
|||||||
Binding("d", "delete", "Delete"),
|
Binding("d", "delete", "Delete"),
|
||||||
Binding("u", "refresh", "Refresh"),
|
Binding("u", "refresh", "Refresh"),
|
||||||
Binding("U", "refresh_all", "Refresh all"),
|
Binding("U", "refresh_all", "Refresh all"),
|
||||||
|
Binding("C", "cache_covers", "Cache covers"),
|
||||||
Binding("r", "reload", "Reload"),
|
Binding("r", "reload", "Reload"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -151,19 +157,48 @@ class BooksPane(ContentPane):
|
|||||||
self.notify("Refreshing every book from Goodreads...")
|
self.notify("Refreshing every book from Goodreads...")
|
||||||
self.refresh_every_book()
|
self.refresh_every_book()
|
||||||
|
|
||||||
|
def report_blocked(self, error: Exception):
|
||||||
|
self.app.call_from_thread(self.notify, str(error), title="Stopped", severity="error", timeout=15)
|
||||||
|
|
||||||
@work(thread=True, exclusive=True, group="refresh")
|
@work(thread=True, exclusive=True, group="refresh")
|
||||||
def refresh_every_book(self):
|
def refresh_every_book(self):
|
||||||
failures = 0
|
refreshed = 0
|
||||||
all_books = books.load_books()
|
all_books = books.load_books()
|
||||||
for book in all_books:
|
for index, book in enumerate(all_books):
|
||||||
|
if index:
|
||||||
|
time.sleep(REQUEST_DELAY_SECONDS)
|
||||||
try:
|
try:
|
||||||
books.refresh_book(book["goodreadsId"])
|
books.refresh_book(book["goodreadsId"])
|
||||||
|
except goodreads.BlockedError as error:
|
||||||
|
self.report_blocked(error)
|
||||||
|
break
|
||||||
except (OSError, ValueError) as error:
|
except (OSError, ValueError) as error:
|
||||||
failures += 1
|
|
||||||
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
|
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
|
||||||
|
continue
|
||||||
|
refreshed += 1
|
||||||
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
|
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
|
||||||
summary = f"Refreshed {len(all_books) - failures} of {len(all_books)} books"
|
self.app.call_from_thread(self.notify, f"Refreshed {refreshed} of {len(all_books)} books")
|
||||||
self.app.call_from_thread(self.notify, summary)
|
|
||||||
|
def action_cache_covers(self):
|
||||||
|
missing = books.books_missing_local_covers()
|
||||||
|
if not missing:
|
||||||
|
self.notify("Every cover is already saved in static/covers/books")
|
||||||
|
return
|
||||||
|
self.notify(f"Saving {len(missing)} covers...")
|
||||||
|
self.cache_missing_covers(missing)
|
||||||
|
|
||||||
|
@work(thread=True, exclusive=True, group="covers")
|
||||||
|
def cache_missing_covers(self, missing: list[dict]):
|
||||||
|
saved = 0
|
||||||
|
for book in missing:
|
||||||
|
try:
|
||||||
|
books.cache_book_cover(book["goodreadsId"])
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
|
||||||
|
continue
|
||||||
|
saved += 1
|
||||||
|
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
|
||||||
|
self.app.call_from_thread(self.notify, f"Saved {saved} of {len(missing)} covers")
|
||||||
|
|
||||||
def action_add_series(self):
|
def action_add_series(self):
|
||||||
fields = [FormField("link", "Goodreads series link", placeholder="https://www.goodreads.com/series/92037-wings-of-fire", required=True)]
|
fields = [FormField("link", "Goodreads series link", placeholder="https://www.goodreads.com/series/92037-wings-of-fire", required=True)]
|
||||||
@@ -185,12 +220,21 @@ class BooksPane(ContentPane):
|
|||||||
@work(thread=True, exclusive=True, group="series")
|
@work(thread=True, exclusive=True, group="series")
|
||||||
def add_series_books(self, series: dict, choices: dict[str, str]):
|
def add_series_books(self, series: dict, choices: dict[str, str]):
|
||||||
added = 0
|
added = 0
|
||||||
|
already_listed = set(books.library_shelves())
|
||||||
|
fetched_any = False
|
||||||
for entry in series["books"]:
|
for entry in series["books"]:
|
||||||
shelf = choices.get(entry["goodreadsId"])
|
shelf = choices.get(entry["goodreadsId"])
|
||||||
if not shelf:
|
if not shelf:
|
||||||
continue
|
continue
|
||||||
|
needs_fetch = entry["goodreadsId"] not in already_listed
|
||||||
|
if needs_fetch and fetched_any:
|
||||||
|
time.sleep(REQUEST_DELAY_SECONDS)
|
||||||
|
fetched_any = fetched_any or needs_fetch
|
||||||
try:
|
try:
|
||||||
book = books.add_series_book(series, entry, shelf)
|
book = books.add_series_book(series, entry, shelf)
|
||||||
|
except goodreads.BlockedError as error:
|
||||||
|
self.report_blocked(error)
|
||||||
|
break
|
||||||
except (OSError, ValueError) as error:
|
except (OSError, ValueError) as error:
|
||||||
self.app.call_from_thread(self.notify, f"{entry['title']}: {error}", title="Skipped", severity="warning")
|
self.app.call_from_thread(self.notify, f"{entry['title']}: {error}", title="Skipped", severity="warning")
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in new issue
Block a user