#!/usr/bin/env node /** * Seekarte, Auswertung von Block 1 und 2. * * Liest den Bestandsabzug aus seekarte-daten/bestand/ und die SVN-Liste aller * je vergebenen Slugs, und rechnet daraus die Kennzahlen der Ausgabe 1. * * Zwei Regeln, die aus der Datenlagepruefung vom 27.08.2026 folgen und hier * fest eingebaut sind: * * 1. KEINE PUNKTZAHL BEI INSTALLATIONEN. active_installs traegt genau eine * signifikante Stelle und ist die UNTERGRENZE eines Eimers. Wert 10.000 * heisst real 10.000 bis 19.999. Jede installationsgewichtete Groesse wird * deshalb als Band aus Unter- und Obergrenze ausgewiesen, nie als Zahl. * * 2. TYPFALLE. tested, requires und requires_php sind bei Altbestand boolean * false statt Versionsstring (28 Prozent bei requires_php). Wer auf String * parst, zaehlt falsch oder stuerzt ab. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const BESTAND = join(ROOT, 'seekarte-daten', 'bestand'); /* Quelle ist der SVN-Abzug vom 27.08.2026, als Textliste versioniert, das HTML liegt nur lokal. */ const SVN = join(ROOT, 'seekarte-datenlage', 'svn-plugins-2026-08-27.txt'); /* Obergrenze eines Eimers: naechster Eimerwert minus eins. */ function band(v) { const n = Number(v) || 0; if (n === 0) { return [0, 9]; } const m = Math.pow(10, Math.floor(Math.log10(n))); return [n, n + m - 1]; } const version = (w) => (typeof w === 'string' && /^\d/.test(w)) ? w : null; const zweig = (v) => v ? v.split('.').slice(0, 2).join('.') : null; /* last_updated kommt als "2026-08-23 7:22pm GMT". Weder Date.parse noch ein naives Ersetzen von GMT durch UTC kommen damit klar, weil die Stunde einstellig und die Tageshaelfte kleingeschrieben ist. Deshalb von Hand. */ const tage = (d) => { const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})(am|pm)?\s*GMT$/i); if (!m) { return null; } let std = Number(m[4]); const haelfte = (m[6] || '').toLowerCase(); if (haelfte === 'pm' && std !== 12) { std += 12; } if (haelfte === 'am' && std === 12) { std = 0; } const t = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), std, Number(m[5])); return Math.floor((Date.now() - t) / 86400000); }; /* ---------- Einlesen ---------- */ if (!existsSync(BESTAND)) { throw new Error('Kein Bestandsabzug. Erst node bin/seekarte-bestand.mjs laufen lassen.'); } const seiten = readdirSync(BESTAND).filter(n => n.endsWith('.json')).sort(); const plugins = []; for (const s of seiten) { plugins.push(...JSON.parse(readFileSync(join(BESTAND, s), 'utf8'))); } /* Doppelte ausschliessen: browse=updated kann bei laufender Aktualisierung denselben Slug auf zwei Seiten zeigen. */ const nachSlug = new Map(); for (const p of plugins) { if (p.slug) { nachSlug.set(p.slug, p); } } const gelistet = [...nachSlug.values()]; const svnRoh = readFileSync(SVN, 'utf8'); const jeVergeben = new Set(); for (const zeile of svnRoh.split('\n')) { if (zeile) { jeVergeben.add(zeile); } } /* ---------- Kennzahlen ---------- */ const K = {}; K.jeVergeben = jeVergeben.size; K.gelistet = gelistet.length; K.seitenGeholt = seiten.length; K.doppelteInSeiten = plugins.length - gelistet.length; K.verschwunden = K.jeVergeben - K.gelistet; K.sterblichkeit = (K.verschwunden / K.jeVergeben * 100); /* Wartungsstille nach Alter der letzten Veroeffentlichung */ const schwellen = [365, 730, 1095]; K.wartung = {}; let unter = 0, ober = 0; const wartungBand = Object.fromEntries(schwellen.map(s => [s, [0, 0]])); let ohneDatum = 0; for (const p of gelistet) { const [u, o] = band(p.active_installs); unter += u; ober += o; const alter = tage(p.last_updated); if (alter === null) { ohneDatum++; continue; } for (const s of schwellen) { if (alter > s) { K.wartung[s] = (K.wartung[s] || 0) + 1; wartungBand[s][0] += u; wartungBand[s][1] += o; } } } K.installationenBand = [unter, ober]; K.bandbreiteProzent = ((ober - unter) / unter * 100); K.ohneDatum = ohneDatum; /* Kompatibilitaets-Rueckstand und PHP-Untergrenze, mit Typfalle */ let testedFalsch = 0, requiresPhpFalsch = 0; const testedZweige = new Map(), phpZweige = new Map(); for (const p of gelistet) { if (version(p.tested)) { const z = zweig(version(p.tested)); testedZweige.set(z, (testedZweige.get(z) || 0) + 1); } else { testedFalsch++; } if (version(p.requires_php)) { const z = zweig(version(p.requires_php)); phpZweige.set(z, (phpZweige.get(z) || 0) + 1); } else { requiresPhpFalsch++; } } K.testedOhneVersion = testedFalsch; K.requiresPhpOhneVersion = requiresPhpFalsch; K.testedTop = [...testedZweige].sort((a, b) => b[1] - a[1]).slice(0, 5); K.phpTop = [...phpZweige].sort((a, b) => b[1] - a[1]).slice(0, 5); /* Selbstmessung: die eigenen Eintraege */ const eigene = gelistet.filter(p => /hafenstudios/i.test(p.author || '') || ['hafenstudios-ads', 'leadlotse', 'wellenbrecher'].includes(p.slug)); /* ---------- Ausgabe ---------- */ const pf = (n) => n.toLocaleString('de-DE'); const proz = (n) => n.toFixed(1).replace('.', ',') + ' %'; console.log('SEEKARTE, Block 1 und 2, Stand ' + new Date().toISOString().slice(0, 10)); console.log('='.repeat(64)); console.log(`Seiten geholt ${K.seitenGeholt}`); console.log(`Doppelte zwischen Seiten ${K.doppelteInSeiten} (browse=updated bewegt sich waehrend des Abzugs)`); console.log(''); console.log('LEITZAHL'); console.log(` je vergebene Slugs ${pf(K.jeVergeben)}`); console.log(` heute gelistet ${pf(K.gelistet)}`); console.log(` nicht mehr gelistet ${pf(K.verschwunden)} = ${proz(K.sterblichkeit)}`); console.log(''); console.log('INSTALLATIONEN, immer als Band'); console.log(` Summe Untergrenzen ${pf(K.installationenBand[0])}`); console.log(` Summe Obergrenzen ${pf(K.installationenBand[1])}`); console.log(` bauartbedingte Bandbreite ${proz(K.bandbreiteProzent)}`); console.log(''); console.log('WARTUNGSSTILLE (letzte Veroeffentlichung laenger her als ...)'); for (const s of schwellen) { const n = K.wartung[s] || 0; const bp = [wartungBand[s][0] / unter * 100, wartungBand[s][1] / ober * 100]; console.log(` > ${s} Tage: ${String(pf(n)).padStart(7)} Plugins = ${proz(n / K.gelistet * 100).padStart(7)} | installationsgewichtet ${proz(bp[0])} bis ${proz(bp[1])}`); } console.log(` ohne auswertbares Datum ${K.ohneDatum}`); console.log(''); console.log('TYPFALLE, Felder ohne Versionsstring'); console.log(` tested ${pf(K.testedOhneVersion)} = ${proz(K.testedOhneVersion / K.gelistet * 100)}`); console.log(` requires_php ${pf(K.requiresPhpOhneVersion)} = ${proz(K.requiresPhpOhneVersion / K.gelistet * 100)}`); console.log(''); console.log('GETESTET BIS, haeufigste Zweige'); for (const [z, n] of K.testedTop) { console.log(` ${String(z).padEnd(6)} ${String(pf(n)).padStart(7)} = ${proz(n / K.gelistet * 100)}`); } console.log(''); console.log('PHP-UNTERGRENZE, haeufigste Zweige'); for (const [z, n] of K.phpTop) { console.log(` ${String(z).padEnd(6)} ${String(pf(n)).padStart(7)} = ${proz(n / K.gelistet * 100)}`); } console.log(''); console.log('SELBSTMESSUNG, eigene Eintraege'); for (const p of eigene) { console.log(` ${p.slug.padEnd(20)} getestet bis ${String(p.tested).padEnd(7)} letzte Veroeffentlichung vor ${tage(p.last_updated)} Tagen, Installations-Eimer ${p.active_installs}`); } if (!eigene.length) { console.log(' keine gefunden, Autorenfeld pruefen'); }