Owning the catalog: a faster search for the Dutch online libraryDe catalogus in eigen hand: sneller zoeken in de Nederlandse online bibliotheek
view on githubbekijk op github
The Dutch online library (onlinebibliotheek.nl) has a great collection and a search that gets in the way of it. Slow, thin filters, no good way to sort or browse. So I did the obvious thing: pull the entire catalog into a database I control and put a decent search in front of it.
The pipeline is four stages:
enumerate every title → fetch + parse each detail page → normalize into SQLite (+FTS5) → search in a minimal web UI.
Why this is harder than “just scrape it”
The site caps its result pager at 50 pages (~1000 results), and deep page URLs work only up to a hard 10,000-result cap per query. A naive crawl sees a fraction of the catalog and calls it done.
The fix is partitioning. --full enumerates per type × taal (format ×
language) — every title has both, so the partitions are exhaustive. Foreign
languages fit comfortably under the cap and paginate fully. Dutch is the only
language over 10k, so it gets split further by original publication year
(Dutch titles are ~98% year-filled), with a maker-sort window to mop up whatever
still overflows. When any partition is still capped, it recurses:
type → jaar → taal → nbcHoofdCategorie → doelgroep → subject code until every
piece fits under 10k. Records are de-duplicated by PPN, and the run is resumable
per (format, year) via a checkpoint file, so a crawl can stop and restart
without redoing work.
Storage: rebuild, never patch
The database is written by full rebuild, never per-row. normalize streams
the raw records into a temporary SQLite file and then atomically swaps it over
the live database. Readers keep seeing the previous catalog right up until the
swap — no half-written state, no locking the UI during a load. The whole
normalize takes about five seconds.
Search is SQLite FTS5 with an unicode61 remove_diacritics 2 tokenizer over
title, author, subjects, and summary, ranked with bm25 weighted toward title and
author. Facets (format, language, genre, year) and sorting sit on top. No search
server, no external index — one SQLite file.
The details that make it usable
- Publishers are canonicalised at normalize time: variants differing only in case, brackets, or spacing collapse to the most common spelling, with a curated alias list for imprints that share no words (Prometheus / Bert Bakker).
- Authors are split on
|/;into individual people, so a co-author is searchable on their own while the joined string still shows for display. - Curated lists are pluggable providers: the weekly Bestseller 60 and genre
toplists from debestseller60.nl, plus the New York Times Best Sellers via the
official Books API.
normalizematches list items to catalog PPNs by ISBN, else by title + author surname. There is a/listsoverview, per-list pages in rank order, a “Lijsten” filter, and cover ribbons. - Cover images are hotlinked straight from the library’s CDN; nothing is cached locally.
The UI is server-rendered FastAPI + Jinja2 with pages for search, book, author, series, lists, and stats, and a search bar that autocompletes titles, authors, publishers, genres, languages, and lists.
Keeping it fresh
The catalog sorts by license date, so new and relicensed titles appear first.
--sync pages newest-first and stops after a long run of already-known titles
(usually a few pages). It can’t see removals, so --reconcile does a full scan and
stamps removed_at on titles that are gone; the UI hides them.
Etiquette
This is personal-use harvesting of factual catalog metadata. robots.txt blocks
named bots and disallows /*.do; /catalogus/ is allowed for normal browsers. The
crawler keeps a low request rate, uses its own session, caches responses, and does
no parallel hammering. No login or borrow actions are automated.
The stack, in one line: Python 3.11+, httpx + beautifulsoup4/lxml for
fetching and parsing, tenacity for retries, SQLite + FTS5 for storage and
search, FastAPI + Jinja2 for the UI, and uv for packaging.
De Nederlandse online bibliotheek (onlinebibliotheek.nl) heeft een mooie collectie en een zoekfunctie die je ervan weghoudt. Traag, magere filters, geen goede manier om te sorteren of te bladeren. Dus deed ik het voor de hand liggende: de hele catalogus in een database trekken die ik zelf beheer, en er een fatsoenlijke zoekfunctie voor zetten.
De pijplijn bestaat uit vier fasen:
enumereren van elke titel → ophalen + parsen van elke detailpagina → normaliseren naar SQLite (+FTS5) → zoeken in een minimale web-UI.
Waarom dit lastiger is dan ‘gewoon scrapen’
De site kapt zijn resultatenpager af op 50 pagina’s (~1000 resultaten), en diepe pagina-URL’s werken maar tot een harde limiet van 10.000 resultaten per query. Een naïeve crawl ziet een fractie van de catalogus en denkt dat-ie klaar is.
De oplossing is partitioneren. --full enumereert per type × taal (formaat ×
taal) — elke titel heeft beide, dus de partities zijn uitputtend. Vreemde talen passen
ruim onder de limiet en pagineren volledig. Nederlands is de enige taal boven de 10k,
dus die wordt verder opgesplitst op oorspronkelijk publicatiejaar (Nederlandse
titels zijn ~98% van een jaar voorzien), met een maker-sorteervenster om op te ruimen
wat alsnog overloopt. Loopt een partitie nog steeds tegen de limiet, dan gaat het
recursief verder: type → jaar → taal → nbcHoofdCategorie → doelgroep → subjectcode
tot elk stuk onder de 10k past. Records worden ontdubbeld op PPN, en de run is
hervatbaar per (formaat, jaar) via een checkpoint-bestand, zodat een crawl kan
stoppen en herstarten zonder werk over te doen.
Opslag: herbouwen, nooit patchen
De database wordt geschreven via een volledige herbouw, nooit per rij. normalize
streamt de ruwe records naar een tijdelijk SQLite-bestand en wisselt dat atomair om
naar de live database. Lezers blijven de vorige catalogus zien tot precies het moment
van omwisselen — geen half-geschreven toestand, geen UI die vastloopt tijdens het
laden. De hele normalize duurt ongeveer vijf seconden.
Zoeken gebeurt met SQLite FTS5 en een unicode61 remove_diacritics 2-tokenizer
over titel, auteur, onderwerpen en samenvatting, gerankt met bm25 en gewogen richting
titel en auteur. Facetten (formaat, taal, genre, jaar) en sortering liggen daarbovenop.
Geen zoekserver, geen externe index — één SQLite-bestand.
De details die het bruikbaar maken
- Uitgevers worden bij het normaliseren gecanoniseerd: varianten die alleen verschillen in hoofdletters, haakjes of spaties vallen samen tot de meest voorkomende spelling, met een handmatige alias-lijst voor imprints die geen woorden delen (Prometheus / Bert Bakker).
- Auteurs worden gesplitst op
|/;in losse personen, zodat een co-auteur op zichzelf vindbaar is terwijl de samengevoegde string nog steeds getoond wordt. - Samengestelde lijsten zijn plugbare providers: de wekelijkse Bestseller 60 en
genre-toplijsten van debestseller60.nl, plus de New York Times Best Sellers via de
officiële Books API.
normalizematcht lijstitems aan catalogus-PPN’s op ISBN, anders op titel + achternaam van de auteur. Er is een/lists-overzicht, pagina’s per lijst in rangorde, een “Lijsten”-filter en cover-ribbons. - Cover-afbeeldingen worden rechtstreeks van de CDN van de bibliotheek gehotlinkt; er wordt lokaal niets gecachet.
De UI is server-rendered FastAPI + Jinja2 met pagina’s voor zoeken, boek, auteur, serie, lijsten en statistieken, en een zoekbalk die titels, auteurs, uitgevers, genres, talen en lijsten autocomplete.
Vers houden
De catalogus sorteert op licentiedatum, dus nieuwe en opnieuw gelicentieerde titels
komen eerst. --sync pagineert nieuwste-eerst en stopt na een lange reeks al bekende
titels (meestal een paar pagina’s). Verwijderingen ziet het niet, dus --reconcile
doet een volledige scan en stempelt removed_at op titels die weg zijn; de UI verbergt
ze.
Etiquette
Dit is harvesting voor persoonlijk gebruik van feitelijke catalogus-metadata.
robots.txt blokkeert benoemde bots en verbiedt /*.do; /catalogus/ is toegestaan
voor normale browsers. De crawler houdt een lage request-rate aan, gebruikt z’n eigen
sessie, cachet responses en hamert niet parallel. Er worden geen inlog- of leen-acties
geautomatiseerd.
De stack, in één regel: Python 3.11+, httpx + beautifulsoup4/lxml voor ophalen en
parsen, tenacity voor retries, SQLite + FTS5 voor opslag en zoeken, FastAPI +
Jinja2 voor de UI, en uv voor packaging.