const EventEmitter = require('events'); const axios = require('axios'); const xml2js = require('xml2js'); // 112-nu.nl per-service feeds + combined fallback const FEED_URLS = [ 'https://112-nu.nl/hulpdiensten/rss', // all services combined 'https://112-nu.nl/brandweer/rss', 'https://112-nu.nl/ambulance/rss', 'https://112-nu.nl/politie/rss', ]; const COMBINED_FEED = FEED_URLS[0]; const POLL_INTERVAL_MS = 30_000; // 112-nu.nl category codes → internal type const CATEGORY_MAP = { BRA: 'fire', BRW: 'fire', BRAND: 'fire', AMB: 'ambulance', MKA: 'ambulance', POL: 'police', POLICE: 'police', KNRM: 'rescue', HELI: 'rescue', TRAUMA: 'rescue', }; // Keyword fallback for when category is missing const KEYWORD_MAP = [ { type: 'fire', words: ['brand', 'brandweer', 'uitslaande', 'bbrand', 'binnenbraand'] }, { type: 'ambulance', words: ['ambulance', 'ambu', 'medisch', 'reanimatie', 'a1 ', 'a2 '] }, { type: 'police', words: ['politie', 'prio 1', 'prio 2', 'prio1', 'prio2'] }, { type: 'rescue', words: ['reddingsbrigade', 'knrm', 'grip', 'trauma', 'helikopter'] }, ]; function detectType(category, title, description) { if (category) { const mapped = CATEGORY_MAP[category.toUpperCase().trim()]; if (mapped) return mapped; } const lower = `${title} ${description}`.toLowerCase(); for (const { type, words } of KEYWORD_MAP) { if (words.some(w => lower.includes(w))) return type; } return 'other'; } function extractId(item) { const raw = item.guid?.[0]; if (typeof raw === 'object') return raw._ ?? JSON.stringify(raw); return raw || item.link?.[0] || `${item.title?.[0]}|${item.pubDate?.[0]}`; } function extractLocation(link) { const m = link?.match(/\/melding\/\d+\/([^\/]+)\//); if (!m) return { display: null, slug: null }; const slug = m[1]; const display = slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); return { display, slug }; } function parseItems(rssData) { const items = rssData?.rss?.channel?.[0]?.item ?? []; return items.map(item => { const title = item.title?.[0] ?? ''; const description = item.description?.[0] ?? ''; const category = item.category?.[0] ?? ''; const link = item.link?.[0] ?? ''; const { display: location, slug: locationSlug } = extractLocation(link); return { id: extractId(item), title: title.trim(), description: description.trim(), pubDate: item.pubDate?.[0] ?? new Date().toISOString(), link, type: detectType(category, title, description), category: category || null, location, locationSlug, image: item.image?.[0] ?? null, }; }); } class P2000 extends EventEmitter { constructor() { super(); this._seenIds = new Set(); this._timer = null; this._running = false; } async _fetchFeed() { try { const { data } = await axios.get(COMBINED_FEED, { timeout: 12_000, headers: { 'User-Agent': 'p2000-monitor/1.0' }, responseType: 'text', }); const parsed = await xml2js.parseStringPromise(data, { explicitArray: true, trim: true }); return parseItems(parsed); } catch (err) { console.warn(`[p2000] Feed error: ${err.message}`); return []; } } async poll() { const messages = await this._fetchFeed(); const isFirstRun = this._seenIds.size === 0; const fresh = messages.filter(m => !this._seenIds.has(m.id)); for (const msg of fresh) this._seenIds.add(msg.id); if (isFirstRun) { this.emit('batch', messages.slice(0, 50)); console.log(`[p2000] Initial batch: ${messages.length} messages`); return; } if (fresh.length) { console.log(`[p2000] ${fresh.length} new message(s)`); } // Emit oldest first so UI prepends in correct order for (const msg of fresh.reverse()) { this.emit('message', msg); } } start() { if (this._running) return; this._running = true; this.poll(); this._timer = setInterval(() => this.poll(), POLL_INTERVAL_MS); console.log(`[p2000] Polling ${COMBINED_FEED} every ${POLL_INTERVAL_MS / 1000}s`); } stop() { if (this._timer) clearInterval(this._timer); this._running = false; } } module.exports = P2000;