#!/usr/bin/env node /** * Build-time import: Bundes-Klinik-Atlas (TVERZ) XML -> data/clinics.db (SQLite + FTS5). * * node scripts/import-clinics.mjs [--source data/2026-06-30_TVERZ_Export.xml] [--out data/clinics.db] * * The XML is an open-data export of all German hospital Standorte. Structure (verified * against the shipped file): * * * * * * ... * * No XML dependency: every element we need is a self-closing tag with quoted attributes, * so a scoped tag matcher + attribute regex is exact and far faster than a DOM parse. */ import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import Database from 'better-sqlite3' import { domainFromUrl, domainFromEmail, isFreemailDomain, searchVariants } from '../lib/domains.mjs' const ROOT = path.resolve(import.meta.dirname, '..') const arg = (name, fallback) => { const i = process.argv.indexOf(`--${name}`) return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback } const SOURCE = path.resolve(ROOT, arg('source', 'data/2026-06-30_TVERZ_Export.xml')) const OUT = path.resolve(ROOT, arg('out', 'data/clinics.db')) // ---------------------------------------------------------------------------- XML helpers const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" } const decode = (s) => String(s ?? '').replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (m, ent) => { if (ent[0] === '#') { const code = ent[1] === 'x' || ent[1] === 'X' ? parseInt(ent.slice(2), 16) : parseInt(ent.slice(1), 10) return Number.isFinite(code) ? String.fromCodePoint(code) : m } return XML_ENTITIES[ent] ?? m }) /** All `` occurrences inside `xml`, as attribute maps. Quote-aware. */ const matchTag = (xml, tag) => { const re = new RegExp(`<${tag}\\b((?:[^"'>]|"[^"]*"|'[^']*')*)/?>`, 'g') const out = [] let m while ((m = re.exec(xml)) !== null) out.push(parseAttrs(m[1])) return out } const parseAttrs = (chunk) => { const attrs = {} const re = /([A-Za-z_][\w.:-]*)\s*=\s*"([^"]*)"|([A-Za-z_][\w.:-]*)\s*=\s*'([^']*)'/g let m while ((m = re.exec(chunk)) !== null) { const key = m[1] ?? m[3] const val = m[2] ?? m[4] attrs[key] = decode(val).trim() } return attrs } const num = (v) => { if (v === undefined || v === null || v === '') return null const n = Number(String(v).replace(',', '.')) return Number.isFinite(n) ? n : null } // ---------------------------------------------------------------------------- parse if (!fs.existsSync(SOURCE)) { console.error(`[import-clinics] Quelldatei nicht gefunden: ${SOURCE}`) process.exit(1) } console.log(`[import-clinics] Lese ${path.relative(ROOT, SOURCE)} …`) const xml = fs.readFileSync(SOURCE, 'utf8') const blocks = xml.split('').slice(1) const clinics = [] const seen = new Set() for (const raw of blocks) { const block = raw.split('')[0] const kontakt = matchTag(block, 'StandortKontaktDaten')[0] if (!kontakt || !kontakt.STOID) continue const struktur = matchTag(block, 'StandortStrukturDaten')[0] || {} const stoid = String(kontakt.STOID).trim() if (seen.has(stoid)) continue // defensive: the export should be unique per STOID seen.add(stoid) clinics.push({ stoid, name: kontakt.Name || '', street: kontakt.Strasse || '', plz: kontakt.PLZ || '', city: kontakt.Ort || '', land: kontakt.Land || '', url: kontakt.URL || '', email: kontakt.EMail || '', phone: kontakt.Telefon || '', traeger: kontakt.TraegerArt || '', beds: num(struktur.AnzahlBetten), lat: num(kontakt.Breitengrad), lon: num(kontakt.Laengengrad), }) } console.log(`[import-clinics] ${clinics.length} Standorte geparst.`) // ---------------------------------------------------------------------------- domains /** * Registrable e-mail domain(s) per Standort. The e-mail domain outranks the website * domain (it is what a registering user will actually type), but both are recorded so * a clinic whose website and mailserver differ still validates. * Freemail entries in the open data (a handful of small clinics really do list a * gmx.de address) are dropped — they would let anyone claim that Standort. */ let withDomain = 0 for (const c of clinics) { const fromEmail = domainFromEmail(c.email) const fromUrl = domainFromUrl(c.url) const list = [] for (const [domain, source] of [[fromEmail, 'email'], [fromUrl, 'url']]) { if (!domain || isFreemailDomain(domain)) continue if (list.some((d) => d.domain === domain)) continue list.push({ domain, source }) } c.domainList = list c.domain = list[0]?.domain || '' if (list.length) withDomain++ } // ---------------------------------------------------------------------------- write db fs.mkdirSync(path.dirname(OUT), { recursive: true }) if (fs.existsSync(OUT)) fs.rmSync(OUT) for (const suffix of ['-wal', '-shm']) { if (fs.existsSync(OUT + suffix)) fs.rmSync(OUT + suffix) } const db = new Database(OUT) db.pragma('journal_mode = DELETE') db.exec(` CREATE TABLE clinics ( id INTEGER PRIMARY KEY, stoid TEXT NOT NULL UNIQUE, name TEXT NOT NULL, street TEXT NOT NULL DEFAULT '', plz TEXT NOT NULL DEFAULT '', city TEXT NOT NULL DEFAULT '', land TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', traeger TEXT NOT NULL DEFAULT '', beds INTEGER, lat REAL, lon REAL, domain TEXT NOT NULL DEFAULT '', domains TEXT NOT NULL DEFAULT '' ); CREATE INDEX idx_clinics_domain ON clinics(domain); CREATE INDEX idx_clinics_plz ON clinics(plz); CREATE TABLE clinic_domains ( domain TEXT NOT NULL, stoid TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', PRIMARY KEY (domain, stoid) ); CREATE INDEX idx_clinic_domains_stoid ON clinic_domains(stoid); -- Contentless FTS index: rowid == clinics.id, one blob of search variants per Standort. CREATE VIRTUAL TABLE clinics_fts USING fts5( search_text, content='', tokenize="unicode61 remove_diacritics 2" ); CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); `) const insClinic = db.prepare(` INSERT INTO clinics (id, stoid, name, street, plz, city, land, url, email, phone, traeger, beds, lat, lon, domain, domains) VALUES (@id, @stoid, @name, @street, @plz, @city, @land, @url, @email, @phone, @traeger, @beds, @lat, @lon, @domain, @domains) `) const insDomain = db.prepare('INSERT OR IGNORE INTO clinic_domains (domain, stoid, source) VALUES (?, ?, ?)') const insFts = db.prepare('INSERT INTO clinics_fts (rowid, search_text) VALUES (?, ?)') const insMeta = db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)') /** Everything a user might type, incl. umlaut variants, as one indexable blob. */ const buildSearchText = (c) => { const parts = [c.name, c.city, c.street, c.plz, c.land, c.domain, c.stoid] const tokens = new Set() for (const part of parts) { for (const variant of searchVariants(part)) { for (const token of variant.split(/[^a-z0-9äöüß.-]+/i)) { if (token && token.length > 1) tokens.add(token) else if (token && /\d/.test(token)) tokens.add(token) } } } return [...tokens].join(' ') } const write = db.transaction(() => { let id = 0 for (const c of clinics) { id++ insClinic.run({ id, stoid: c.stoid, name: c.name, street: c.street, plz: c.plz, city: c.city, land: c.land, url: c.url, email: c.email, phone: c.phone, traeger: c.traeger, beds: c.beds, lat: c.lat, lon: c.lon, domain: c.domain, domains: c.domainList.map((d) => d.domain).join(' '), }) for (const d of c.domainList) insDomain.run(d.domain, c.stoid, d.source) insFts.run(id, buildSearchText(c)) } insMeta.run('source_file', path.basename(SOURCE)) insMeta.run('imported_at', new Date().toISOString()) insMeta.run('clinic_count', String(clinics.length)) insMeta.run('with_domain_count', String(withDomain)) }) write() // ---------------------------------------------------------------------------- report const distinctDomains = db.prepare('SELECT COUNT(DISTINCT domain) AS n FROM clinic_domains').get().n const sharedDomains = db.prepare(` SELECT COUNT(*) AS n FROM ( SELECT domain FROM clinic_domains GROUP BY domain HAVING COUNT(DISTINCT stoid) > 1 ) `).get().n const topShared = db.prepare(` SELECT domain, COUNT(DISTINCT stoid) AS n FROM clinic_domains GROUP BY domain ORDER BY n DESC, domain ASC LIMIT 8 `).all() db.exec('VACUUM') db.close() const pct = (n) => ((n / Math.max(clinics.length, 1)) * 100).toFixed(1) console.log('') console.log('╔════════════════════════════════════════════════════════════╗') console.log('║ Blut24 — Klinik-Import (Bundes-Klinik-Atlas) ║') console.log('╚════════════════════════════════════════════════════════════╝') console.log(` Datenbank : ${path.relative(ROOT, OUT)}`) console.log(` Standorte importiert : ${clinics.length}`) console.log(` mit E-Mail-Domain : ${withDomain} (${pct(withDomain)} %)`) console.log(` ohne Domain : ${clinics.length - withDomain} (${pct(clinics.length - withDomain)} %) → manuelle Freigabe`) console.log(` eindeutige Domains : ${distinctDomains}`) console.log(` Domains mit mehreren Standorten (Klinikkonzerne): ${sharedDomains}`) for (const row of topShared) console.log(` ${row.domain.padEnd(34)} ${String(row.n).padStart(4)} Standorte`) console.log('')