Files
shelfmark/tests/metadata/test_moly_parse.py
T
FlyinPancakeandCopilot Autofix powered by AI 3a9cff9816 feat(metadata): add Moly.hu metadata provider (#1172)
First of all, I don't know if you even want to merge a scraper-based
metadata provider. I made this just for my use-case. If you'd rather
not, I completely understand it.

An alternative would be adopting [Audiobookshelf's Metadata Provider
API](https://audiobookshelf.org/docs/documentation/community/community-providers)
which I contributed to it for exactly the reason to not have scrapers.

## What

Adds [Moly.hu](https://moly.hu) — the Hungarian community book catalog —
as a metadata provider, following the existing provider plugin
architecture (`@register_provider` + settings tab with enable checkbox
and Test Connection button, disabled by default).

## Why

None of the current providers cover Hungarian editions well: Hardcover
and Open Library rarely index them, and Google Books coverage is spotty.
Moly.hu is the de-facto catalog for Hungarian books (local editions
*and* Hungarian translations of foreign works). With this provider,
Universal mode works end-to-end for Hungarian titles: moly search →
localized title/author feed the release search → indexers that carry
Hungarian content can actually match. Related pain points: #595 (books
missing from metadata providers), #1035 (interest in niche sources).

## How

- HTML scraping with BeautifulSoup (already a dependency), no API key
needed
- Scraping approach (search URL, page structure, language-tag mapping)
adapted from the long-lived Calibre `Moly_hu` plugin (GPL v3, credited
in the module docstring), with fallback selector chains inherited from
it
- Sliding-window rate limit (30 req/min) to stay polite to a small
community site
- Standard `@cacheable` decorators; fetch failures return `None` so they
are not cached (same behavior as the Google Books provider)
- Search results carry cover thumbnails, rating and series info as
display fields; `get_book` parses title (zero-width chars stripped,
nested series link excluded), authors, ISBN-13/10, publisher, publish
year, description (spoiler-warning prefix stripped), tags/genres, cover,
and language (from moly's language tags, defaulting to `hu`)
- ISBN search resolves through moly's site search

## Testing

- `tests/metadata/test_moly_parse.py`: offline tests with fixture HTML
mirroring live moly.hu markup — search parsing/dedup, pagination guard,
failure-not-cached behavior, book-page parsing, ISBN resolution, ISBN
validation helper
- `uv run pytest tests/metadata` green (45 passed), `ruff check` / `ruff
format` clean
- Verified live against moly.hu (search, get_book, ISBN lookup) and
running in Docker alongside Hardcover

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-11 00:46:32 -04:00

203 lines
7.1 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import requests
from shelfmark.core.cache import get_metadata_cache
from shelfmark.metadata_providers import MetadataSearchOptions, SearchType
from shelfmark.metadata_providers.moly import MolyProvider, _valid_isbn
SEARCH_HTML = """
<html><body>
<div id="content">
<div class="search_area">
<div class="book_with_shop">
<a href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">
<img alt="Mocsidzuki Mai: A Telihold kávézó" class="tooltip"
src="https://assets.moly.hu/system/covers/normal/covers_864003.jpg" />
</a>
</div>
<p>
<a class="book_selector" data-id="1"
href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">Mocsidzuki Mai:
<strong class="highlight">A Telihold</strong> kávézó</a>
<span class="like_count">82%</span>
<a rel="modal" class="action" href="/sorozatok/a-telihold-kavezo">(A Telihold kávézó 1.)</a>
</p>
<p>
<a class="book_selector" data-id="2"
href="/konyvek/mocsidzuki-mai-a-telihold-kavezo">Mocsidzuki Mai: A Telihold kávézó</a>
</p>
<p>
<a class="book_selector" data-id="3"
href="/konyvek/mocsidzuki-mai-az-igazi-kivansag">Mocsidzuki Mai: Az igazi kívánság</a>
</p>
<p>
<a class="book_selector" data-id="4"
href="/konyvek/lisa-jewell-a-fold-nyelte-el">Lis<strong class="highlight">a</strong> Jew<strong class="highlight">el</strong>l: <strong class="highlight">A föld nyelte el</strong></a>
</p>
<p><a class="book_selector" data-id="5" href="/konyvek/broken">Not a book row</a></p>
</div>
</div>
</body></html>
"""
BOOK_HTML = """
<html><body>
<div id="content">
<div class="head_title">
<h1><span class="item">A ​Telihold kávézó
<a href="/sorozatok/a-telihold-kavezo">(A Telihold kávézó 1.)</a></span></h1>
<div class="rating"><span class="like_count">82%</span></div>
</div>
<div class="authors"><a href="/alkotok/mocsidzuki-mai">Mocsidzuki Mai</a></div>
<div class="coverbox">
<a rel="light" class="zoom" href="/system/covers/big/covers_864003.jpg?1712583436"></a>
</div>
<div id="full_description">
<p>Vigyázat! Cselekményleírást tartalmaz.</p>
<p>A japánok úgy tartják, hogy ha gondoskodsz egy macskáról,
az egy napon meghálálja.</p>
</div>
<div class="items">
<div class="edition">
<div><a href="/kiadok/athenaeum">Athenaeum</a>, Budapest, <abbr>2024</abbr></div>
<div>180 oldal · ISBN: 9789635434435 · Fordította: Nagy Anita</div>
</div>
</div>
<div id="book_tags">
<a class="tag" href="/cimkek/asztrologia">asztrológia</a>
<a class="tag" href="/cimkek/japan">japán</a>
</div>
</div>
</body></html>
"""
class _MolyResponse:
def __init__(self, text):
self.text = text
def raise_for_status(self):
return None
class _MolySession:
"""Serves canned HTML per URL substring."""
def __init__(self, pages):
self.pages = pages
self.calls = 0
self.headers = {}
def get(self, url, **kwargs):
self.calls += 1
for fragment, html in self.pages.items():
if fragment in url:
return _MolyResponse(html)
raise requests.HTTPError(f"unexpected URL: {url}")
class _FlakyMolySession(_MolySession):
def get(self, url, **kwargs):
if self.calls == 0:
self.calls += 1
raise requests.Timeout
return super().get(url, **kwargs)
def _provider(session):
provider = MolyProvider()
provider.session = session
return provider
class TestMolySearch:
def test_search_parses_results(self):
get_metadata_cache().clear()
provider = _provider(_MolySession({"/kereses": SEARCH_HTML}))
books = provider.search(MetadataSearchOptions(query="telihold kávézó"))
assert len(books) == 3 # duplicate slug deduped, colon-less row skipped
# Mid-word <strong class="highlight"> wrapping must not split words
highlighted = books[2]
assert highlighted.title == "A föld nyelte el"
assert highlighted.authors == ["Lisa Jewell"]
book = books[0]
assert book.provider == "moly"
assert book.provider_id == "mocsidzuki-mai-a-telihold-kavezo"
assert book.title == "A Telihold kávézó"
assert book.authors == ["Mocsidzuki Mai"]
assert book.cover_url == "https://assets.moly.hu/system/covers/normal/covers_864003.jpg"
assert book.source_url == "https://moly.hu/konyvek/mocsidzuki-mai-a-telihold-kavezo"
labels = {f.label: f.value for f in book.display_fields}
assert labels["Rating"] == "82%"
assert labels["Series"] == "A Telihold kávézó 1."
def test_search_second_page_is_empty(self):
provider = _provider(_MolySession({"/kereses": SEARCH_HTML}))
assert provider.search(MetadataSearchOptions(query="telihold", page=2)) == []
def test_search_does_not_cache_request_failures(self):
get_metadata_cache().clear()
session = _FlakyMolySession({"/kereses": SEARCH_HTML})
provider = _provider(session)
options = MetadataSearchOptions(query="telihold kávézó")
assert provider.search(options) == []
books = provider.search(options)
assert session.calls == 2
assert [book.provider_id for book in books] == [
"mocsidzuki-mai-a-telihold-kavezo",
"mocsidzuki-mai-az-igazi-kivansag",
"lisa-jewell-a-fold-nyelte-el",
]
class TestMolyGetBook:
def test_get_book_parses_details(self):
get_metadata_cache().clear()
provider = _provider(_MolySession({"/konyvek/": BOOK_HTML}))
book = provider.get_book("mocsidzuki-mai-a-telihold-kavezo")
assert book is not None
# Zero-width space stripped, nested series link excluded from title
assert book.title == "A Telihold kávézó"
assert book.authors == ["Mocsidzuki Mai"]
assert book.isbn_13 == "9789635434435"
assert book.publisher == "Athenaeum"
assert book.publish_year == 2024
assert book.language == "hu"
assert book.cover_url == "https://moly.hu/system/covers/big/covers_864003.jpg?1712583436"
assert "asztrológia" in book.genres
# Spoiler warning stripped from the description
assert book.description is not None
assert not book.description.startswith("Vigyázat!")
assert "japánok" in book.description
def test_isbn_search_resolves_first_result(self):
get_metadata_cache().clear()
provider = _provider(_MolySession({"/kereses": SEARCH_HTML, "/konyvek/": BOOK_HTML}))
book = provider.search(
MetadataSearchOptions(query="9789635434435", search_type=SearchType.ISBN)
)[0]
assert book.provider_id == "mocsidzuki-mai-a-telihold-kavezo"
assert book.isbn_13 == "9789635434435"
class TestValidIsbn:
def test_isbn_13(self):
assert _valid_isbn("978-963-543-443-5") == "9789635434435"
def test_isbn_10_with_check_x(self):
assert _valid_isbn("963-543-443-X") == "963543443X"
def test_rejects_page_counts_and_years(self):
assert _valid_isbn("2024") is None
assert _valid_isbn("1234567") is None