- Koa server on port 10000 with WebSocket live feed from 112-nu.nl RSS - PM2 watch mode for auto-restart on file changes - Dark navy UI with per-type color accents (fire/ambulance/police/rescue) - Slide-in filter panel with service type + 12 Dutch province filters - Card click opens detail modal: parsed priority (A1/A2/MGS), vehicle number, rit/bon number, alarm type, meldkamer, and eenheden - Server-side Nominatim geocoder (cached, rate-limited) powering an interactive Leaflet/OpenStreetMap map in the modal (CartoDB Voyager tiles) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
142 lines
4.2 KiB
JavaScript
142 lines
4.2 KiB
JavaScript
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;
|