Bücher: Titel/Autor-Fallback + manuelle ISBN-Eingabe für Regal-Lookup (SW v39)
Bisher scheiterte "Metadaten abrufen" komplett, wenn im EPUB keine ISBN steckte (oder bei PDFs, die nie eine liefern). Der Server versucht jetzt bei fehlendem/ erfolglosem ISBN-Treffer automatisch eine DNB- bzw. Open-Library-Textsuche nach Titel+Autor (weniger präzise, daher als "dnb-title"/"openlibrary-title" markiert). Bleibt auch das ohne Treffer, fragt der Client einmalig nach einer manuell eingetippten ISBN (z.B. vom Buchrücken) und versucht es damit erneut. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
This commit is contained in:
parent
e6b3ee620e
commit
635fd7ef1c
3 changed files with 137 additions and 28 deletions
|
|
@ -120,27 +120,110 @@ def _lookup_openlibrary_shelf(isbn):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cql_phrase(value):
|
||||||
|
# CQL string literals are quoted; strip embedded quotes rather than escaping them
|
||||||
|
# (this only feeds a lookup heuristic, not a stored/displayed value).
|
||||||
|
return value.replace('"', ' ').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_dnb_shelf_by_title(title, author):
|
||||||
|
url = 'https://services.dnb.de/sru/dnb'
|
||||||
|
query = f'dnb.tit="{_cql_phrase(title)}"'
|
||||||
|
if author:
|
||||||
|
query += f' and dnb.per="{_cql_phrase(author)}"'
|
||||||
|
params = {
|
||||||
|
'version': '1.1',
|
||||||
|
'operation': 'searchRetrieve',
|
||||||
|
'query': query,
|
||||||
|
'recordSchema': 'MARC21-xml',
|
||||||
|
'maximumRecords': '1',
|
||||||
|
}
|
||||||
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
||||||
|
resp.raise_for_status()
|
||||||
|
root = ET.fromstring(resp.content)
|
||||||
|
for datafield in root.iter(f'{_MARC_NS}datafield'):
|
||||||
|
if datafield.get('tag') != '082':
|
||||||
|
continue
|
||||||
|
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
|
||||||
|
if subfield.get('code') == 'a' and subfield.text:
|
||||||
|
label = _ddc_label(subfield.text)
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_openlibrary_shelf_by_title(title, author):
|
||||||
|
url = 'https://openlibrary.org/search.json'
|
||||||
|
params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'}
|
||||||
|
if author:
|
||||||
|
params['author'] = author
|
||||||
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
||||||
|
resp.raise_for_status()
|
||||||
|
docs = resp.json().get('docs') or []
|
||||||
|
if not docs:
|
||||||
|
return None
|
||||||
|
doc = docs[0]
|
||||||
|
ddc = doc.get('ddc') or []
|
||||||
|
if ddc:
|
||||||
|
label = _ddc_label(ddc[0])
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
subjects = doc.get('subject') or []
|
||||||
|
if subjects:
|
||||||
|
return subjects[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@require_http_methods(['GET'])
|
@require_http_methods(['GET'])
|
||||||
def lookup_book_metadata(request):
|
def lookup_book_metadata(request):
|
||||||
err = _require_auth(request)
|
err = _require_auth(request)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
isbn = re.sub(r'[^0-9Xx]', '', request.GET.get('isbn', ''))
|
isbn_raw = request.GET.get('isbn', '').strip()
|
||||||
if len(isbn) not in (10, 13):
|
title = request.GET.get('title', '').strip()
|
||||||
|
author = request.GET.get('author', '').strip()
|
||||||
|
isbn = re.sub(r'[^0-9Xx]', '', isbn_raw)
|
||||||
|
|
||||||
|
if isbn_raw and len(isbn) not in (10, 13):
|
||||||
return JsonResponse({'error': 'invalid ISBN'}, status=400)
|
return JsonResponse({'error': 'invalid ISBN'}, status=400)
|
||||||
|
if not isbn and not title:
|
||||||
|
return JsonResponse({'error': 'isbn or title required'}, status=400)
|
||||||
|
|
||||||
label = None
|
label = None
|
||||||
source = None
|
source = None
|
||||||
|
|
||||||
|
# ISBN is the precise path — try it first when we have one.
|
||||||
|
if isbn:
|
||||||
try:
|
try:
|
||||||
label = _lookup_dnb_shelf(isbn)
|
label = _lookup_dnb_shelf(isbn)
|
||||||
source = 'dnb' if label else None
|
if label:
|
||||||
|
source = 'dnb'
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not label:
|
if not label:
|
||||||
try:
|
try:
|
||||||
label = _lookup_openlibrary_shelf(isbn)
|
label = _lookup_openlibrary_shelf(isbn)
|
||||||
source = 'openlibrary' if label else None
|
if label:
|
||||||
|
source = 'openlibrary'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# No ISBN (or it drew a blank) — fall back to a title/author text search. Less
|
||||||
|
# precise (wrong edition/translation is possible), so the source is tagged
|
||||||
|
# distinctly for the client to hint at that if it wants to.
|
||||||
|
if not label and title:
|
||||||
|
try:
|
||||||
|
label = _lookup_dnb_shelf_by_title(title, author)
|
||||||
|
if label:
|
||||||
|
source = 'dnb-title'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not label:
|
||||||
|
try:
|
||||||
|
label = _lookup_openlibrary_shelf_by_title(title, author)
|
||||||
|
if label:
|
||||||
|
source = 'openlibrary-title'
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3647,40 +3647,66 @@ async function _extractIsbnFromBookFile(bookId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _fetchShelfLookup(isbn, title, author) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (isbn) params.set('isbn', isbn);
|
||||||
|
if (title) params.set('title', title);
|
||||||
|
if (author) params.set('author', author);
|
||||||
|
const res = await fetch(`/books/metadata-lookup/?${params.toString()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// Manually triggered (never automatic) — looks up which library shelf/DDC category this
|
// Manually triggered (never automatic) — looks up which library shelf/DDC category this
|
||||||
// book falls under via the server-side DNB→Open Library proxy, purely as an organizational
|
// book falls under via the server-side DNB→Open Library proxy, purely as an organizational
|
||||||
// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is
|
// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is
|
||||||
// allowed to briefly touch the server with a plaintext ISBN.
|
// allowed to briefly touch the server with a plaintext ISBN/title/author.
|
||||||
|
//
|
||||||
|
// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g.
|
||||||
|
// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for
|
||||||
|
// EPUBs, (2) server-side title/author text search (less precise — could match the wrong
|
||||||
|
// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN
|
||||||
|
// by hand (from the book's cover/an online store) and retry once.
|
||||||
async function lookupBookMetadata(bookId) {
|
async function lookupBookMetadata(bookId) {
|
||||||
const book = _lastBookListData.find(b => b.id === bookId);
|
const book = _lastBookListData.find(b => b.id === bookId);
|
||||||
if (!book) return;
|
if (!book) return;
|
||||||
const cached = bookMetaCache[bookId] || {};
|
const cached = bookMetaCache[bookId] || {};
|
||||||
let isbn = cached.isbn || book.isbn || '';
|
let isbn = cached.isbn || book.isbn || '';
|
||||||
const isPdf = (cached.type || book.type) === 'pdf';
|
const isPdf = (cached.type || book.type) === 'pdf';
|
||||||
|
const title = cached.title || book.title || '';
|
||||||
|
const author = cached.author || book.author || '';
|
||||||
|
|
||||||
if (!isbn) {
|
if (!isbn && !isPdf) {
|
||||||
if (isPdf) {
|
|
||||||
await customAlert('Keine ISBN gespeichert — bei PDFs wird sie nicht automatisch erkannt.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Older upload without a stored ISBN — extract it from the book file itself (may take
|
// Older upload without a stored ISBN — extract it from the book file itself (may take
|
||||||
// a moment for large books, since it downloads/decrypts the full file if not cached).
|
// a moment for large books, since it downloads/decrypts the full file if not cached).
|
||||||
isbn = await _extractIsbnFromBookFile(bookId);
|
isbn = await _extractIsbnFromBookFile(bookId);
|
||||||
if (!isbn) {
|
|
||||||
await customAlert('Keine ISBN in diesem Buch gefunden.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/books/metadata-lookup/?isbn=${encodeURIComponent(isbn)}`);
|
let data = await _fetchShelfLookup(isbn, title, author);
|
||||||
const data = await res.json();
|
|
||||||
// Persist the (possibly newly-found) ISBN either way, so a repeat lookup never needs
|
|
||||||
// to re-download/decrypt the book file again.
|
|
||||||
await _updateBookMeta(bookId, data.label ? {isbn, shelfTag: data.label} : {isbn});
|
|
||||||
renderBookList(_lastBookListData);
|
|
||||||
if (!data.label) {
|
if (!data.label) {
|
||||||
await customAlert('Keine Regal-Kategorie gefunden (weder bei der DNB noch bei Open Library). Die ISBN wurde für spätere Versuche gespeichert.');
|
const manual = await customPrompt(
|
||||||
|
'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', ''
|
||||||
|
);
|
||||||
|
const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, '');
|
||||||
|
if (manualIsbn) {
|
||||||
|
isbn = manualIsbn;
|
||||||
|
data = await _fetchShelfLookup(isbn, title, author);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist whatever we ended up with either way, so a repeat lookup never needs to
|
||||||
|
// re-extract/re-search from scratch.
|
||||||
|
const overrides = {};
|
||||||
|
if (isbn) overrides.isbn = isbn;
|
||||||
|
if (data.label) overrides.shelfTag = data.label;
|
||||||
|
if (Object.keys(overrides).length) {
|
||||||
|
await _updateBookMeta(bookId, overrides);
|
||||||
|
renderBookList(_lastBookListData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.label) {
|
||||||
|
await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message);
|
await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* diora service worker — caches the app shell for offline use.
|
* diora service worker — caches the app shell for offline use.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE = 'diora-v38';
|
const CACHE = 'diora-v39';
|
||||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue