mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-14 09:28:28 +00:00
## What Adds [Databáze knih](https://www.databazeknih.cz), the Czech and Slovak community book catalog, as a metadata provider. It follows the Moly.hu provider (#1172): HTML scraping, no API key, disabled by default, and a settings tab with an enable checkbox and a Test Connection button. ## Why Hardcover, Open Library and Google Books cover Czech and Slovak editions poorly. Databáze knih is the de facto catalog for them, including Czech and Slovak translations of foreign works. With this provider, Universal mode works end to end for Czech and Slovak titles: the localized title and author from Databáze knih feed the release search. ## How - HTML scraping with BeautifulSoup, which is already a dependency. Book pages carry schema.org JSON-LD, so most fields come from structured data rather than CSS selectors. - **Title search** uses `/vyhledavani/knihy`, with the site's pagination (`page`) and language filter (`lang` for cs/sk/en/de). A search with a single match redirects straight to the book page, and that case is handled. - **Author search:** the site search only matches titles, so an author-only search finds the author (accent- and word-order-insensitive) and lists their books page, which is also paginated. Title + author filters the title results on the author. - **ISBN search** follows the site's redirect from `/search?q=<isbn>` to the book page. - **`get_book`** reads the full description (the JSON-LD one is truncated), ISBN-10/13, publisher, edition year, language, genres, cover (placeholder images dropped), series with id and position, and rating and rating count. It also reads the page count and original title from the more-info fragment. - The `RateLimiter` from Open Library (30 requests/min) and the standard `@cacheable` decorators. Fetch failures return `None`, so they are not cached. - `docs/environment-variables.md`: added the `DATABAZEKNIH_ENABLED` section. ## Testing - `tests/metadata/test_databazeknih_parse.py`: offline tests with shortened copies of real pages. They cover search parsing, pagination and language parameters, redirect-to-book, author listing, title + author filtering, uncached failures, book details, ISBN resolution and helpers. - `uv run pytest tests/metadata` is green (89 passed), and `ruff check` and `ruff format --check` are clean. - Verified live against databazeknih.cz: connection test, title search (pages 1 and 2), author books, title + author, `get_book`, and ISBN lookup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
12 KiB
Python
342 lines
12 KiB
Python
import requests
|
|
|
|
from shelfmark.core.cache import get_metadata_cache
|
|
from shelfmark.metadata_providers import MetadataSearchOptions, SearchType
|
|
from shelfmark.metadata_providers.databazeknih import (
|
|
DatabazeKnihProvider,
|
|
_id_from_link,
|
|
_names_match,
|
|
_valid_isbn,
|
|
)
|
|
|
|
# Shortened copies of real pages from https://www.databazeknih.cz
|
|
BOOK_HTML = r"""<html><head>
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "Book",
|
|
"name": "Krev elfů",
|
|
"url": "https://www.databazeknih.cz/prehled-knihy/zaklinac-saga-krev-elfu-30482",
|
|
"isbn": "80-85951-05-3",
|
|
"author": [
|
|
{
|
|
"@type": "Person",
|
|
"name": "Andrzej Sapkowski"
|
|
} ],
|
|
"image": "https:\/\/www.databazeknih.cz\/img\/books\/30_\/30482\/bmid_krev-elfu-Jqe-30482.jpg",
|
|
"aggregateRating": {
|
|
"@type": "AggregateRating",
|
|
"ratingValue": "4.6",
|
|
"ratingCount": "5790",
|
|
"bestRating": "5",
|
|
"worstRating": "1"
|
|
},
|
|
"description": "První část…",
|
|
"publisher": [
|
|
{
|
|
"@type": "Organization",
|
|
"name": "Leonardo"
|
|
} ],
|
|
"inLanguage": "cs",
|
|
"genre": [
|
|
"Literatura světová", "Romány", "Fantasy" ]}
|
|
</script></head><body>
|
|
<div class="lora book_detail_serie_info">
|
|
<p class="inline">
|
|
<a class="odright_pet" href='/serie/zaklinac-saga---7063?lang=cz' title='Zaklínač: sága'>
|
|
Zaklínač: sága
|
|
</a> série</p>
|
|
<span class="nowrap">
|
|
<span class="odright_pet">1. díl</span>
|
|
</span>
|
|
<br />
|
|
<p class="inline">
|
|
<a class="odright_pet" href='/serie/zaklinac-zaklinac-13665?lang=cz' title='Zaklínač'>
|
|
Zaklínač
|
|
</a> série</p>
|
|
3. díl
|
|
</div>
|
|
<h1 class="oddown_zero">Krev elfů</h1>
|
|
<div class="lora lineHeightMid">
|
|
<a href='/zanry/fantasy-21'>Fantasy</a><br />
|
|
1998
|
|
<span class='pozn'>,</span> <a href='/nakladatelstvi/leonardo-338'>Leonardo</a>
|
|
</div>
|
|
<div id='left'>
|
|
<h2>O knize <em class="nobold">Krev elfů</em></h2>
|
|
<p class='new2 odtop'>První část ságy o Zaklínači.
|
|
Geralt, Ciri, Yennefer a další.</p>
|
|
</div>
|
|
</body></html>"""
|
|
|
|
MORE_INFO_HTML = """
|
|
<div class='book-details__row'><dt>Originální název</dt><dd>Krew elfów, 1994</dd></div>
|
|
<div class='book-details__row'><dt>Počet stran</dt><dd><span itemprop='numberOfPages'>308</span></dd></div>
|
|
<div class='book-details__row'><dt>Jazyk vydání</dt><dd>český</dd></div>
|
|
<div class='book-details__row'><dt>ISBN</dt><dd>80-85951-05-3</dd></div>
|
|
"""
|
|
|
|
SEARCH_HTML = """<html><body>
|
|
<p class='new'>
|
|
<a href="/prehled-knihy/zaklinac-saga-krev-elfu-30482"><picture>
|
|
<img src="https://www.databazeknih.cz/img/books/30_/30482/krev-elfu-Jqe-30482.jpg?v=1577582533"
|
|
alt="Obálka knihy Krev elfů" class="img_39_left odtopm" /></picture></a>
|
|
<img src='img/content/squares/red.gif' title='91%' alt='91%' class='odr' /><a class='new' type='book'
|
|
href='/prehled-knihy/zaklinac-saga-krev-elfu-30482'>Krev elfů</a>
|
|
<br /><span class='pozn'>
|
|
1998,
|
|
Andrzej Sapkowski
|
|
</span>
|
|
</p>
|
|
<p class='new'>
|
|
<img src='img/content/squares/blue.gif' title='47%' alt='47%' class='odr' /><a class='new' type='book'
|
|
href='/prehled-knihy/john-sinclair-na-stope-hruzy-zaklinac-40113'>Zaklínač</a>
|
|
<br /><span class='pozn'>
|
|
1998,
|
|
Jason Dark (p)
|
|
</span>
|
|
</p>
|
|
<div class="pager"><div class="pagination">
|
|
<span class="current">1</span>
|
|
<span class="next"><a rel="next" href="/vyhledavani/knihy?q=krev&page=2">></a></span>
|
|
</div></div>
|
|
</body></html>"""
|
|
|
|
AUTHOR_SEARCH_HTML = """<html><body>
|
|
<a href="autori/andrzej-sapkowski-269">Andrzej Sapkowski</a>
|
|
<a href="autori/andrzej-sapkowski-ml-99999">Andrzej Nowak</a>
|
|
</body></html>"""
|
|
|
|
AUTHOR_BOOKS_HTML = """<html><body>
|
|
<h3 class='midlRowHeight oddown'>
|
|
<a href='/prehled-knihy/krev-elfu-433' title='Krev elfů'>Krev elfů</a>
|
|
<span class='pozn odl'> 2011 (1. vydání 1994) </span>
|
|
</h3>
|
|
<h3 class='midlRowHeight oddown'>
|
|
<a href='/prehled-knihy/magicke-stripky-30500' title='Magické střípky'>Magické střípky</a>
|
|
<span class='pozn odl'> 2002 (1. vydání) </span>
|
|
</h3>
|
|
<h3>Až vyjde nová kniha autora</h3>
|
|
</body></html>"""
|
|
|
|
|
|
class _DbkResponse:
|
|
def __init__(self, text):
|
|
self.text = text
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
|
|
class _DbkSession:
|
|
"""Serves canned HTML per URL substring, first match wins."""
|
|
|
|
def __init__(self, pages):
|
|
self.pages = pages
|
|
self.urls = []
|
|
self.headers = {}
|
|
|
|
@property
|
|
def calls(self):
|
|
return len(self.urls)
|
|
|
|
def get(self, url, **kwargs):
|
|
self.urls.append(url)
|
|
for fragment, html in self.pages.items():
|
|
if fragment in url:
|
|
return _DbkResponse(html)
|
|
raise requests.HTTPError(f"unexpected URL: {url}")
|
|
|
|
|
|
class _FlakyDbkSession(_DbkSession):
|
|
def get(self, url, **kwargs):
|
|
if not self.urls:
|
|
self.urls.append(url)
|
|
raise requests.Timeout
|
|
return super().get(url, **kwargs)
|
|
|
|
|
|
def _provider(session):
|
|
provider = DatabazeKnihProvider()
|
|
provider.session = session
|
|
return provider
|
|
|
|
|
|
class TestDatabazeKnihSearch:
|
|
def test_search_parses_results(self):
|
|
get_metadata_cache().clear()
|
|
provider = _provider(_DbkSession({"/vyhledavani/knihy": SEARCH_HTML}))
|
|
|
|
books = provider.search(MetadataSearchOptions(query="krev elfů"))
|
|
|
|
assert [b.provider_id for b in books] == ["30482", "40113"]
|
|
book = books[0]
|
|
assert book.provider == "databazeknih"
|
|
assert book.title == "Krev elfů"
|
|
assert book.authors == ["Andrzej Sapkowski"]
|
|
assert book.publish_year == 1998
|
|
assert book.cover_url.startswith("https://www.databazeknih.cz/img/books/30_/30482/")
|
|
assert book.source_url == "https://www.databazeknih.cz/prehled-knihy/kniha-30482"
|
|
assert {f.label: f.value for f in book.display_fields} == {"Rating": "91%"}
|
|
# Pseudonym marker removed, missing cover stays empty
|
|
assert books[1].authors == ["Jason Dark"]
|
|
assert books[1].cover_url is None
|
|
|
|
def test_search_passes_page_and_language(self):
|
|
get_metadata_cache().clear()
|
|
session = _DbkSession({"/vyhledavani/knihy": SEARCH_HTML})
|
|
provider = _provider(session)
|
|
|
|
provider.search(MetadataSearchOptions(query="krev", page=2, language="sk"))
|
|
|
|
assert "page=2" in session.urls[0]
|
|
assert "lang=sk" in session.urls[0]
|
|
|
|
def test_search_redirected_to_single_book(self):
|
|
get_metadata_cache().clear()
|
|
provider = _provider(_DbkSession({"/vyhledavani/knihy": BOOK_HTML}))
|
|
|
|
books = provider.search(MetadataSearchOptions(query="krev elfů"))
|
|
|
|
assert [b.provider_id for b in books] == ["30482"]
|
|
assert books[0].series_name == "Zaklínač: sága"
|
|
|
|
def test_title_and_author_filters_on_author(self):
|
|
get_metadata_cache().clear()
|
|
provider = _provider(_DbkSession({"/vyhledavani/knihy": SEARCH_HTML}))
|
|
|
|
books = provider.search(
|
|
MetadataSearchOptions(query="", fields={"title": "zaklínač", "author": "jason dark"})
|
|
)
|
|
|
|
assert [b.provider_id for b in books] == ["40113"]
|
|
|
|
def test_author_only_lists_author_books(self):
|
|
get_metadata_cache().clear()
|
|
session = _DbkSession(
|
|
{"in=authors": AUTHOR_SEARCH_HTML, "/vydane-knihy/": AUTHOR_BOOKS_HTML}
|
|
)
|
|
provider = _provider(session)
|
|
|
|
books = provider.search(
|
|
MetadataSearchOptions(query="", fields={"author": "Sapkowski Andrzej"})
|
|
)
|
|
|
|
assert session.urls[1] == "https://www.databazeknih.cz/vydane-knihy/autor-269"
|
|
assert [(b.provider_id, b.title, b.publish_year) for b in books] == [
|
|
("433", "Krev elfů", 1994),
|
|
("30500", "Magické střípky", 2002),
|
|
]
|
|
assert books[0].authors == ["Sapkowski Andrzej"]
|
|
|
|
def test_unknown_author_returns_nothing(self):
|
|
get_metadata_cache().clear()
|
|
session = _DbkSession({"in=authors": AUTHOR_SEARCH_HTML})
|
|
provider = _provider(session)
|
|
|
|
books = provider.search(
|
|
MetadataSearchOptions(query="Terry Pratchett", search_type=SearchType.AUTHOR)
|
|
)
|
|
|
|
assert books == []
|
|
assert session.calls == 1
|
|
|
|
def test_search_does_not_cache_request_failures(self):
|
|
get_metadata_cache().clear()
|
|
session = _FlakyDbkSession({"/vyhledavani/knihy": SEARCH_HTML})
|
|
provider = _provider(session)
|
|
options = MetadataSearchOptions(query="krev elfů")
|
|
|
|
assert provider.search(options) == []
|
|
|
|
books = provider.search(options)
|
|
|
|
assert session.calls == 2
|
|
assert [book.provider_id for book in books] == ["30482", "40113"]
|
|
|
|
|
|
class TestDatabazeKnihGetBook:
|
|
def test_get_book_parses_details(self):
|
|
get_metadata_cache().clear()
|
|
provider = _provider(
|
|
_DbkSession({"/prehled-knihy/": BOOK_HTML, "/book-detail-more-info/": MORE_INFO_HTML})
|
|
)
|
|
|
|
book = provider.get_book("30482")
|
|
|
|
assert book is not None
|
|
assert book.title == "Krev elfů"
|
|
assert book.authors == ["Andrzej Sapkowski"]
|
|
assert book.isbn_10 == "8085951053"
|
|
assert book.isbn_13 is None
|
|
assert book.publisher == "Leonardo"
|
|
assert book.publish_year == 1998
|
|
assert book.language == "cs"
|
|
assert book.genres == ["Literatura světová", "Romány", "Fantasy"]
|
|
assert book.cover_url.endswith("/30482/bmid_krev-elfu-Jqe-30482.jpg")
|
|
assert book.source_url.endswith("/prehled-knihy/zaklinac-saga-krev-elfu-30482")
|
|
# The whole description from the page, not the shortened JSON-LD one
|
|
assert book.description.startswith("První část ságy o Zaklínači.\nGeralt")
|
|
assert (book.series_id, book.series_name, book.series_position) == (
|
|
"7063",
|
|
"Zaklínač: sága",
|
|
1.0,
|
|
)
|
|
fields = {f.label: f.value for f in book.display_fields}
|
|
assert fields == {
|
|
"Rating": "4.6",
|
|
"Ratings": "5,790",
|
|
"Pages": "308",
|
|
"Original title": "Krew elfów, 1994",
|
|
}
|
|
|
|
def test_get_book_rejects_non_book_page(self):
|
|
get_metadata_cache().clear()
|
|
provider = _provider(_DbkSession({"/prehled-knihy/": SEARCH_HTML}))
|
|
|
|
assert provider.get_book("12345") is None
|
|
|
|
def test_isbn_search_resolves_redirected_book(self):
|
|
get_metadata_cache().clear()
|
|
session = _DbkSession(
|
|
{
|
|
"/search?": BOOK_HTML,
|
|
"/prehled-knihy/": BOOK_HTML,
|
|
"/book-detail-more-info/": MORE_INFO_HTML,
|
|
}
|
|
)
|
|
provider = _provider(session)
|
|
|
|
books = provider.search(
|
|
MetadataSearchOptions(query="80-85951-05-3", search_type=SearchType.ISBN)
|
|
)
|
|
|
|
assert "q=8085951053" in session.urls[0]
|
|
assert [b.provider_id for b in books] == ["30482"]
|
|
assert books[0].isbn_10 == "8085951053"
|
|
|
|
def test_isbn_search_ignores_invalid_isbn(self):
|
|
session = _DbkSession({})
|
|
provider = _provider(session)
|
|
|
|
assert provider.search_by_isbn("2024") is None
|
|
assert session.calls == 0
|
|
|
|
|
|
class TestHelpers:
|
|
def test_id_from_link(self):
|
|
assert _id_from_link("/prehled-knihy/krev-elfu-433") == "433"
|
|
assert _id_from_link("/serie/zaklinac-saga---7063?lang=cz") == "7063"
|
|
assert _id_from_link("autori/andrzej-sapkowski-269") == "269"
|
|
assert _id_from_link("/prehled-knihy/") == ""
|
|
|
|
def test_names_match_ignores_accents_and_order(self):
|
|
assert _names_match("Karel Čapek", "capek karel")
|
|
assert _names_match("Stanisław Lem", "Stanislaw Lem")
|
|
assert not _names_match("Andrzej Nowak", "Andrzej Sapkowski")
|
|
|
|
def test_valid_isbn(self):
|
|
assert _valid_isbn("978-80-257-1234-5") == "9788025712345"
|
|
assert _valid_isbn("80-85951-05-x") == "808595105X"
|
|
assert _valid_isbn("2024") is None
|