Download to Browser only ever sent the first file of a multi-file release (audiobook chapters, packs): the folder output returned final_paths[0] and /api/localdownload served that one path. The folder output now keeps every final path on the in-memory task (download_paths), and /api/localdownload streams them as one uncompressed ZIP named after the book when there is more than one. The archive is never staged on disk or held in memory, and ZIP64 covers large audiobooks. History still stores only the first path, so a re-download from history after a dismiss or restart serves a single file, as before. Also brings every linter back to green on all files: - pre-commit: bump the ruff hook to v0.17.1 to match uv.lock; ruff-format the Python examples in docs/dev and skills - ruff: fix the remaining issues in skills/shelfmark/download_books.py; format base_handler.py - basedpyright: Optional and attrs errors in databazeknih.py - knip: drop 31 unused exports, reuse ColumnColorHint in colorMaps - oxfmt 0.72: styles.css - direct download: fix the "Welib, mirrors" typo and the test that still expected the pre-OceanofPDF message Fixes #1467
8.3 KiB
name, description, license
| name | description | license |
|---|---|---|
| shelfmark | Tool for downloading books. | Complete terms in LICENSE.txt |
Shelfmark Book Download Skill
Use this skill to search for and download books from a local Shelfmark instance using Playwright.
Prerequisites
- Shelfmark must be running at
http://localhost:8084/ - Use
playwright-cliskill for browser automation capabilities - Python 3.10+ with
playwrightpackage installed
Quick Start
# Show help information for download script
python3 /home/username/.agents/skills/shelfmark/download_books.py -h
# Download a single book
python3 /home/username/.agents/skills/shelfmark/download_books.py '[{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}]'
# Download multiple books
python3 /home/username/.agents/skills/shelfmark/download_books.py '[{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"}, {"title": "Oliver Twist", "author": "Charles Dickens"}, {"title": "Frankenstein", "author": "Marry Shelley"}]'
# Check calibre database before downloading (skip if already present)
python3 /home/username/.agents/skills/shelfmark/download_books.py --check-calibre '[{"title": "Frankenstein", "author": "Marry Shelley"}]'
# Load books from a JSON file
python3 /home/username/.agents/skills/shelfmark/download_books.py --file books.json
The JSON file (books.json) should contain an array of book objects:
[
{"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
{"title": "Frankenstein", "author": "Mary Shelley"},
{"title": "Oliver Twist", "author": "Charles Dickens"}
]
Array format is also supported: [["The Great Gatsby", "F. Scott Fitzgerald"], ["Oliver Twist", "Charles Dickens"], ["Frankenstein", "Marry Shelley"]]
Title-only (no author) is supported: ["Frankenstein"]
Key Characteristics
- Shelfmark is a React SPA — raw HTML is a shell; JavaScript dynamically populates the DOM
- Desktop viewport required (
1280x900) — download buttons usehidden sm:flexand won't render on mobile - Download count pattern in HTML:
<span>•</span> <span>NUMBER</span> </div>(the last number before the download button) - Download button selector:
<button ... data-action="download" ...>Download</button> - Search input:
<input type="search" placeholder="Search Books"> - Results indicator:
<span class="text-sm font-medium whitespace-nowrap">Most relevant</span>
How It Works
1. Search for a Book
The script navigates to the main page, enters the search query, and waits for "Most relevant" to appear:
# Navigate to main page first to reset SPA state
page.goto("http://localhost:8084/")
page.wait_for_load_state("networkidle")
# Enter search query and submit
page.fill('input[type="search"]', f"{title} {author}")
page.press('input[type="search"]', "Enter")
# Wait for "Most relevant" to appear (indicates search results are fully rendered)
page.wait_for_selector('span.text-sm.font-medium:has-text("Most relevant")', timeout=60000)
- Always navigate to the main page before each search to reset React SPA state
- Include both title and author in the search query
- Wait for
span.text-sm.font-medium:has-text("Most relevant")to appear — this indicates search results are fully loaded - Do not use timers to wait for results — always wait for a specific page element
2. Find and Parse Download Buttons
def parse_books(page):
btns = page.query_selector_all('button[data-action="download"]')
books = []
for i, btn in enumerate(btns):
content = btn.evaluate_handle("el => el.parentElement.parentElement").inner_html()
title_match = re.search(r"<h3[^>]*>(.*?)</h3>", content, re.IGNORECASE | re.DOTALL)
title = title_match.group(1).strip() if title_match else "Unknown"
author_match = re.search(
r'class="min-w-0 truncate text-xs[^"]*"[^>]*>(.*?)<', content, re.IGNORECASE | re.DOTALL
)
author = author_match.group(1).strip() if author_match else "Unknown"
dl_match = re.search(r"<span>•</span>\s*<span>([\d,]+)</span>", content)
downloads = int(dl_match.group(1).replace(",", "")) if dl_match else 0
books.append({"index": i, "title": title, "author": author, "downloads": downloads})
return books
3. Select and Download the Book with Most Downloads
# Filter books matching the search criteria
matching_books = [b for b in books if matches_search(b["title"], b["author"], title, author)]
if matching_books:
best = max(matching_books, key=lambda x: x["downloads"])
# CRITICAL: Click on the article h3 to open detail view
h3s = page.query_selector_all("article h3")
if best["index"] < len(h3s):
h3s[best["index"]].click()
page.wait_for_timeout(300)
# Then click the download button
btn = page.query_selector_all('button[data-action="download"]')[best["index"]]
btn.click()
page.wait_for_timeout(500)
Important: The React SPA requires clicking on the article's <h3> element first to open the detail view. Simply clicking the download button directly often fails silently.
4. Wait for Download to Complete
for i in range(60):
page.wait_for_timeout(5000)
activity_text = page.inner_text("aside")
if "IN PROGRESS" in activity_text:
print("Download started!")
elif "Complete" in activity_text or "Saved" in activity_text:
print("Download complete!")
break
elif "No activity" in activity_text:
print("Download not started")
break
5. Clear Completed Downloads
# Click "Clear Completed" using JavaScript
page.evaluate("""
() => {
for (const b of document.querySelectorAll('button')) {
if (b.textContent.includes('Clear Completed')) {
b.click();
return;
}
}
}
""")
page.wait_for_timeout(2000)
Checking Calibre Database
Use --check-calibre (or -c) to check if books are already in your calibre database before downloading:
python3 download_books.py --check-calibre '[{"title": "Frankenstein", "author": "Mary Shelley"}]'
Books found in calibre are skipped with a message. If all books are already present, the script exits early without launching the browser.
Important Notes
- Always click the article element first before clicking the download button — the React SPA requires this to properly initialize the download workflow
- Wait for "Most relevant" text to appear after search — this indicates results are fully loaded (don't use timers)
- Navigate to main page (
http://localhost:8084/) before each new search to reset React SPA state - Some books may have different authors listed than what's in your source file — the script falls back to title-only search if author search fails
- The download count is the last number in the format
• NUMBERbefore the download button - Book titles may include series info in brackets, e.g.,
(The Locked Tomb Trilogy) - Use
page.evaluate_handleto get parent element HTML for parsing — the button'sparentElement.parentElementcontains the card content - Title matching prefers exact matches over partial matches (e.g., "Yesteryear" matches "Yesteryear: A Novel" but not "The Piers of Yesteryear")
- Books are passed as JSON — use
--check-calibreto optionally skip books already in your calibre database
Common Issues
| Issue | Solution |
|---|---|
| No download buttons found | Use desktop viewport (1280x900), wait for span.text-sm.font-medium:has-text("Most relevant") |
| Download button click does nothing | Click the article's <h3> element first, then click the download button |
| Search returns no results | The script falls back to title-only search automatically |
| Download count shows 0 | The parsing regex may need adjustment — check the HTML structure |
| Sidebar shows "No activity" after click | Ensure you clicked the <h3> element first, and wait at least 2 seconds before checking |
| Search results don't update between books | Navigate to http://localhost:8084/ before each new search to reset SPA state |
| Book downloaded is wrong title | The script prefers exact title matches — if the title is ambiguous, the author search will help narrow it down |
| Books passed incorrectly | Books must be valid JSON — use {"title": "...", "author": "..."} format, not Title: Author |