#!/usr/bin/env node /** * Erzeugt die Datendateien fuer die Census-Seite aus dem Bestandsabzug. * * Zwei Dateien, aus einem bewussten Grund getrennt: * * 1. census-01-2026.json Aggregate und die Punktwolke. Klein, laedt sofort. * Die Punktwolke traegt KEINE Namen. Wir veroeffentlichen die Verteilung, * nicht eine durchsuchbare Liste stiller Plugins. Eine solche Liste waere der * unterhaltsamste und zugleich rufschaedigendste Teil des Reports. * * 2. census-slugs.tsv Nachschlagetabelle fuer die Selbstpruefung, wird erst * geladen, wenn jemand sie benutzt. Wer einen bestimmten Slug abfragt, * bekommt Auskunft. Das ist etwas anderes als eine Rangliste: es ist die * oeffentliche API, einmal sauber aufbereitet, auf Zuruf. * * Aufruf: * node bin/seekarte-seitendaten.mjs schreibt nach census/01-2026/ * node bin/seekarte-seitendaten.mjs --ziel PFAD schreibt JSON (und TSV) nach PFAD * node bin/seekarte-seitendaten.mjs --pruefen schreibt nichts, vergleicht mit dem Bestand * * Reproduzierbarkeit: Alle Tageswerte rechnen gegen T0, den Zeitpunkt der * Erhebung am 28.08.2026. Vorher stand hier Date.now(), damit haette jeder * spaetere Lauf andere Zahlen geliefert. T0 ist aus den Tageswerten der * veroeffentlichten TSV rekonstruiert: das Fenster, in dem alle 70.909 * Tageswerte stimmen, ist 21:56 bis 21:57 UTC (bin/seekarte-seitendaten.mjs * --pruefen belegt, dass TSV und JSON-Kernzahlen byte- bzw. wertgleich bleiben). * * Bandregel (SEEKARTE-BAUPLAN.md 4.12): active_installs ist die Untergrenze * eines Eimers mit einer signifikanten Stelle. Jede Summe steht als Band aus * Unter- und Obergrenzen, jeder Anteil als gegenlaeufige Schranke: * unter = sU / (sU + (totO - sO)), ober = sO / (sO + (totU - sU)). * Der Quotient der Untersummen (sU / totU) wird nur zur Einordnung mitgefuehrt. */ import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const BESTAND = join(ROOT, 'seekarte-daten', 'bestand'); const args = process.argv.slice(2); const pruefen = args.includes('--pruefen'); const zielIdx = args.indexOf('--ziel'); const ZIEL = zielIdx >= 0 ? (/^([a-zA-Z]:|\/)/.test(args[zielIdx + 1]) ? args[zielIdx + 1] : join(ROOT, args[zielIdx + 1])) : join(ROOT, 'census', '01-2026'); const BESTANDSDATEI = join(ROOT, 'census', '01-2026'); /** Referenzzeitpunkt der Erhebung (UTC). Siehe Kopfkommentar. */ const T0 = Date.UTC(2026, 7, 28, 21, 56, 30); const STICHTAG = '2026-08-28'; const TAG = 86400000; const KERN = '7.1'; // WordPress-Kernversion am Stichtag const alle = []; for (const s of readdirSync(BESTAND).filter(n => n.endsWith('.json'))) { alle.push(...JSON.parse(readFileSync(join(BESTAND, s), 'utf8'))); } const map = new Map(); for (const p of alle) { if (p.slug) { map.set(p.slug, p); } } const P = [...map.values()]; /* Quelle ist der SVN-Abzug vom 27.08.2026, als Textliste versioniert, das HTML liegt nur lokal. */ const svn = readFileSync(join(ROOT, 'seekarte-datenlage', 'svn-plugins-2026-08-27.txt'), 'utf8'); const jeVergeben = new Set(); for (const zeile of svn.split('\n')) { if (zeile) { jeVergeben.add(zeile); } } const svnGroesse = jeVergeben.size; /* Die SVN-Liste stammt vom 27.08.2026, der Bestand vom 28.08.: Plugins, die dazwischen aufgenommen wurden, stehen im Bestand, aber noch nicht in der Liste. Sie zaehlen als vergeben. Je vergeben = SVN-Liste vereinigt mit dem gelisteten Bestand; nicht gelistet = SVN-Liste ohne den Bestand (exakte Mengendifferenz, nicht Differenz der Groessen: 53.061 statt der in Fassung 1.0 und 1.1 genannten 53.012). */ for (const p of P) { jeVergeben.add(p.slug); } const svnNachtrag = jeVergeben.size - svnGroesse; const zeitpunkt = (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 h = Number(m[4]); const t = (m[6] || '').toLowerCase(); if (t === 'pm' && h !== 12) { h += 12; } if (t === 'am' && h === 12) { h = 0; } return Date.UTC(+m[1], +m[2] - 1, +m[3], h, +m[5]); }; const tage = (d) => { const z = zeitpunkt(d); return z === null ? null : Math.floor((T0 - z) / TAG); }; const band = (v) => { const n = Number(v) || 0; if (!n) { return [0, 9]; } const m = 10 ** Math.floor(Math.log10(n)); return [n, n + m - 1]; }; const vers = (w) => (typeof w === 'string' && /^\d/.test(w)) ? w.split('.').slice(0, 2).join('.') : null; const pz = (a, b) => +(a / b * 100).toFixed(1); /* HTML-Entities generisch aufloesen (dezimal, hex, die fuenf benannten), Tags entfernen, Leerraum glaetten. */ const NAMED = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>', nbsp: ' ' }; const entschaerfe = (s) => String(s) .replace(/<[^>]+>/g, '') .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))) .replace(/&([a-z]+);/gi, (m, n) => NAMED[n.toLowerCase()] ?? m) .replace(/\s+/g, ' ').trim(); const cmpVers = (a, b) => { const [a1, a2] = a.split('.').map(Number), [b1, b2] = b.split('.').map(Number); return a1 - b1 || a2 - b2; }; /* Gegenlaeufige Schranken eines Teilbestands (sU, sO) gegen den Gesamtbestand (totU, totO). */ const schranken = (sU, sO, totU, totO) => ({ unter: pz(sU, sU + (totO - sO)), ober: pz(sO, sO + (totU - sU)) }); /* ---------- Datensaetze anreichern ---------- */ const D = P.map(p => { const [u, o] = band(p.active_installs); return { slug: p.slug, u, o, eimer: Number(p.active_installs) || 0, t: tage(p.last_updated), luJahr: String(p.last_updated).slice(0, 4), addedJahr: p.added ? String(p.added).slice(0, 4) : null, tested: vers(p.tested), testedRoh: typeof p.tested === 'string' ? p.tested : '', php: vers(p.requires_php), phpRoh: typeof p.requires_php === 'string' ? p.requires_php : '', name: entschaerfe(p.name || p.slug), autor: entschaerfe(p.author || ''), wp: vers(p.requires), seit: p.added ? String(p.added).slice(0, 10) : null, bewertung: Number(p.rating) || 0, bewertungen: Number(p.num_ratings) || 0, faeden: Number(p.support_threads) || 0, geloest: Number(p.support_threads_resolved) || 0, downloads: Number(p.downloaded) || 0, tags: p.tags && typeof p.tags === 'object' ? Object.keys(p.tags) : [], tagNamen: p.tags && typeof p.tags === 'object' ? p.tags : {} }; }); const totU = D.reduce((s, d) => s + d.u, 0); const totO = D.reduce((s, d) => s + d.o, 0); const schwellen = [365, 730, 1095]; /* ---------- Stille je Schwelle ---------- */ const still = {}; for (const s of schwellen) { const S = D.filter(d => d.t !== null && d.t > s); const sU = S.reduce((a, d) => a + d.u, 0), sO = S.reduce((a, d) => a + d.o, 0); const sch = schranken(sU, sO, totU, totO); still[s] = { plugins: S.length, anteilPlugins: pz(S.length, D.length), installationenUnter: sU, installationenOber: sO, anteilInstallationenUnter: sch.unter, anteilInstallationenOber: sch.ober, quotientUntersummen: pz(sU, totU), quotientObersummen: pz(sO, totO) }; } /* ---------- Jahre, Kohorten, Gruppen ---------- */ const jahre = {}; for (const d of D) { if (d.t !== null) { jahre[d.luJahr] = (jahre[d.luJahr] || 0) + 1; } } const stillBlock = (S) => Object.fromEntries(schwellen.map(s => { const n = S.filter(d => d.t !== null && d.t > s).length; return [s, { plugins: n, quote: pz(n, S.length) }]; })); const kohorten = {}; for (const j of [...new Set(D.map(d => d.addedJahr).filter(Boolean))].sort()) { const K = D.filter(d => d.addedJahr === j); kohorten[j] = { plugins: K.length, still: stillBlock(K) }; } /* Supportfaeden je Teilbestand: Zahl der Plugins mit Faden, Summen, Loesungsquote. Zeitfenster laut Pluginseite: die letzten zwei Monate. */ const supportBlock = (S) => { const mit = S.filter(d => d.faeden > 0), f = S.reduce((a, d) => a + d.faeden, 0), g = S.reduce((a, d) => a + d.geloest, 0); return { mitFaden: mit.length, anteilMitFaden: pz(mit.length, S.length || 1), faeden: f, geloest: g, quoteGeloest: f ? pz(g, f) : null }; }; const GRUPPEN = [ ['unter1000', 0, 999], ['ab1000', 1000, 9999], ['ab10000', 10000, 99999], ['ab100000', 100000, 999999], ['ab1000000', 1000000, Infinity] ]; const gruppen = {}; for (const [name, von, bis] of GRUPPEN) { const G = D.filter(d => d.eimer >= von && d.eimer <= bis); const gU = G.reduce((a, d) => a + d.u, 0), gO = G.reduce((a, d) => a + d.o, 0); const sch = schranken(gU, gO, totU, totO); gruppen[name] = { von, bis: bis === Infinity ? null : bis, plugins: G.length, anteilPlugins: pz(G.length, D.length), medianTage: (() => { const tg = G.map(d => d.t).filter(x => x !== null).sort((a, b) => a - b); return tg.length ? tg[Math.floor(tg.length / 2)] : null; })(), support: supportBlock(G), ...(von >= 10000 ? { supportStill365: supportBlock(G.filter(d => d.t > 365)), supportAktiv365: supportBlock(G.filter(d => !(d.t > 365))) } : {}), installationenUnter: gU, installationenOber: gO, anteilInstallationenUnter: sch.unter, anteilInstallationenOber: sch.ober, still: stillBlock(G) }; } /* ---------- Eimerwerte ---------- */ const eimerwerte = [...new Set(D.map(d => d.eimer))].sort((a, b) => a - b); if (eimerwerte.length !== 54) { throw new Error(`eimerwerte: ${eimerwerte.length} statt 54`); } const oberst = eimerwerte[eimerwerte.length - 1]; /* ---------- Selbstauskuenfte ---------- */ const ohnePhp = D.filter(d => !d.php).length; const php5 = D.filter(d => d.php && /^5\./.test(d.php)); const getestetZaehler = {}; let keineAngabe = 0; for (const d of D) { if (!d.tested) { keineAngabe++; continue; } getestetZaehler[d.tested] = (getestetZaehler[d.tested] || 0) + 1; } const getestetVersionen = Object.keys(getestetZaehler).sort(cmpVers).reverse(); /* Verzeichniswarnung "not tested with the latest 3 major releases": tested liegt unter Kern minus zwei Zweige (7.1 -> 6.9), oder keine Angabe. */ const warnUnter = (() => { const [M, m] = KERN.split('.').map(Number); const z = M * 10 + m - 2; return `${Math.floor(z / 10)}.${z % 10}`; })(); // 7.1 -> 6.9: die letzten drei Hauptversionen sind 7.1, 7.0, 6.9 const warnung = D.filter(d => !d.tested || cmpVers(d.tested, warnUnter) < 0).length; const getestet = { kern: KERN, versionen: Object.fromEntries(getestetVersionen.map(v => [v, { plugins: getestetZaehler[v], anteilPlugins: pz(getestetZaehler[v], D.length) }])), keineAngabe: { plugins: keineAngabe, anteilPlugins: pz(keineAngabe, D.length) }, verzeichniswarnung: { regel: `getestet bis unter ${warnUnter} oder keine Angabe`, plugins: warnung, anteilPlugins: pz(warnung, D.length) } }; const nieAngefasstListe = D.filter(d => d.addedJahr && d.addedJahr === d.luJahr && (d.t || 0) > 730); /* ---------- Weitere Kennzahlen ---------- */ const tageSortiert = D.map(d => d.t).filter(t => t !== null).sort((a, b) => a - b); const median = tageSortiert[Math.floor(tageSortiert.length / 2)]; const zaehl = (f) => D.filter(f).length; const inst = (f) => { const S = D.filter(f); return [S.reduce((a, d) => a + d.u, 0), S.reduce((a, d) => a + d.o, 0)]; }; const [alt60U, alt60O] = inst(d => !d.tested || cmpVers(d.tested, '6.0') < 0); const [g100kU, g100kO] = inst(d => d.eimer >= 100000); const weitere = { medianTageSeitRelease: median, releaseInnerhalb90Tagen: { plugins: zaehl(d => d.t !== null && d.t <= 90), anteilPlugins: pz(zaehl(d => d.t !== null && d.t <= 90), D.length) }, eimer0: { plugins: zaehl(d => d.eimer === 0), anteilPlugins: pz(zaehl(d => d.eimer === 0), D.length) }, unter100: { plugins: zaehl(d => d.eimer < 100), anteilPlugins: pz(zaehl(d => d.eimer < 100), D.length) }, ab100000: { plugins: zaehl(d => d.eimer >= 100000), still365: zaehl(d => d.eimer >= 100000 && d.t > 365), still1095: zaehl(d => d.eimer >= 100000 && d.t > 1095), installationenUnter: g100kU, installationenOber: g100kO, anteilInstallationen: schranken(g100kU, g100kO, totU, totO), quotient: { unter: pz(g100kU, totU), ober: pz(g100kO, totO) } }, ab1000000: { plugins: zaehl(d => d.eimer >= 1000000), still365: zaehl(d => d.eimer >= 1000000 && d.t > 365) }, ab10000: { plugins: zaehl(d => d.eimer >= 10000), still365: zaehl(d => d.eimer >= 10000 && d.t > 365), still730: zaehl(d => d.eimer >= 10000 && d.t > 730), still1095: zaehl(d => d.eimer >= 10000 && d.t > 1095) }, getestetUnter60OderKeine: { plugins: zaehl(d => !d.tested || cmpVers(d.tested, '6.0') < 0), installationenUnter: alt60U, installationenOber: alt60O, anteilInstallationen: schranken(alt60U, alt60O, totU, totO) } }; /* ---------- Tags: Kategorie-Gesundheit, die 60 haeufigsten Schlagworte ---------- */ const tagZaehler = new Map(); for (const d of D) { for (const t of d.tags) { if (!tagZaehler.has(t)) { tagZaehler.set(t, []); } tagZaehler.get(t).push(d); } } const tagNamen = {}; for (const d of D) { for (const [k, v] of Object.entries(d.tagNamen)) { if (!tagNamen[k]) { tagNamen[k] = String(v); } } } const tags = {}; for (const [t, L] of [...tagZaehler.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 60)) { const tU = L.reduce((a, d) => a + d.u, 0), tO = L.reduce((a, d) => a + d.o, 0); const tt = L.map(d => d.t).filter(x => x !== null).sort((a, b) => a - b); tags[t] = { name: tagNamen[t] || t, plugins: L.length, installationenUnter: tU, installationenOber: tO, medianTage: tt.length ? tt[Math.floor(tt.length / 2)] : null, php5: L.filter(d => d.php && /^5\./.test(d.php)).length, still: stillBlock(L) }; } const anbieterZaehler = new Map(); for (const d of D) { if (d.autor) { if (!anbieterZaehler.has(d.autor)) { anbieterZaehler.set(d.autor, []); } anbieterZaehler.get(d.autor).push(d); } } const anbieterSortiert = [...anbieterZaehler.entries()].sort((a, b) => b[1].reduce((x, d) => x + d.u, 0) - a[1].reduce((x, d) => x + d.u, 0)); const top10U = anbieterSortiert.slice(0, 10).reduce((a, [, L]) => a + L.reduce((x, d) => x + d.u, 0), 0); const top10O = anbieterSortiert.slice(0, 10).reduce((a, [, L]) => a + L.reduce((x, d) => x + d.o, 0), 0); const anbieter = { hinweis: 'Anbieter = author-String der API ohne HTML, so wie der Autor ihn angibt; Firmen mit mehreren Autorennamen erscheinen getrennt. Installationsanteile als gegenlaeufige Schranken gegen den Rest des Bestands.', top10Anteil: schranken(top10U, top10O, totU, totO), nachAnzahl: [...anbieterZaehler.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 25).map(([name, L]) => ({ name, plugins: L.length, still365: L.filter(d => d.t > 365).length })), verschiedene: anbieterZaehler.size, mitEinemPlugin: [...anbieterZaehler.values()].filter(L => L.length === 1).length, top: [...anbieterZaehler.entries()].sort((a, b) => b[1].reduce((x, d) => x + d.u, 0) - a[1].reduce((x, d) => x + d.u, 0)).slice(0, 25).map(([name, L]) => ({ name, plugins: L.length, installationenUnter: L.reduce((a, d) => a + d.u, 0), installationenOber: L.reduce((a, d) => a + d.o, 0), anteilInstallationen: schranken(L.reduce((a, d) => a + d.u, 0), L.reduce((a, d) => a + d.o, 0), totU, totO), still365: L.filter(d => d.t > 365).length })) }; /* ---------- Die grossen Plugins mit Namen (ab 10.000 Installationen), eigene Datei fuer die Blasenkarte ---------- */ const top = D.filter(d => d.eimer >= 10000).sort((a, b) => b.eimer - a.eimer || a.name.localeCompare(b.name)).map(d => ({ slug: d.slug, name: d.name, autor: d.autor, eimer: d.eimer, tage: d.t, seit: d.seit, getestet: d.tested, wp: d.wp, php: d.php, bewertung: d.bewertung, bewertungen: d.bewertungen, faeden: d.faeden, geloest: d.geloest, downloads: d.downloads, tags: d.tags.slice(0, 5) })); /* Kontrollwerte der Namensdatei (CENSUS-BAUANWEISUNG-2.md 3.1): weicht einer ab, ist der Rohabzug nicht der vom 28.08.2026. */ const KONTROLLE = [['Eintraege', top.length, 2199], ['ab 100.000', top.filter(p => p.eimer >= 100000).length, 466], ['ab 100.000 still 365', top.filter(p => p.eimer >= 100000 && p.tage > 365).length, 22], ['ab 100.000 still 730', top.filter(p => p.eimer >= 100000 && p.tage > 730).length, 9], ['ab 100.000 still 1095', top.filter(p => p.eimer >= 100000 && p.tage > 1095).length, 5], ['ab 1 Mio', top.filter(p => p.eimer >= 1000000).length, 67], ['ab 1 Mio still 365', top.filter(p => p.eimer >= 1000000 && p.tage > 365).length, 1], ['still 365 gesamt', top.filter(p => p.tage > 365).length, 317], ['ohne PHP', top.filter(p => !p.php).length, 503], ['ohne tested', top.filter(p => !p.getestet).length, 3]]; for (const [k, ist, soll] of KONTROLLE) { if (ist !== soll) { throw new Error(`Kontrollwert ${k}: ${ist} statt ${soll}`); } } const FELDER = { slug: 'Kennung (Ordnername im Verzeichnis)', name: 'Name laut readme, Entities aufgeloest', autor: 'author-String der API ohne HTML', eimer: 'active_installs: Untergrenze eines Bands mit einer signifikanten Stelle', tage: 'Tage seit last_updated, gerechnet gegen den Referenzzeitpunkt', seit: 'added, Aufnahmedatum', getestet: 'tested, Selbstauskunft, auf Hauptversion gekuerzt', wp: 'requires, WP-Mindestversion, Selbstauskunft', php: 'requires_php, PHP-Mindestversion, Selbstauskunft', bewertung: 'rating 0 bis 100', bewertungen: 'num_ratings', faeden: 'support_threads der letzten zwei Monate laut Pluginseite', geloest: 'support_threads_resolved', downloads: 'downloaded, kumuliert seit Aufnahme inklusive Updates', tags: 'bis zu fuenf Schlagwort-Slugs' }; /* Nicht gelistete Kennungen: SVN-Liste minus gelisteter Bestand, nur Kennungen, kein weiteres Feld. */ const gelistetSet = new Set(P.map(p => p.slug)); const nichtGelistet = [...jeVergeben].filter(s => !gelistetSet.has(s)).sort(); if (nichtGelistet.length !== jeVergeben.size - D.length) { throw new Error(`nicht gelistet: ${nichtGelistet.length} statt ${jeVergeben.size - D.length}`); } /* ---------- Selbstmessung: die eigenen drei Eintraege ---------- */ const EIGENE = ['hafenstudios-ads', 'wellenbrecher', 'leadlotse']; const selbstmessung = EIGENE.map(s => { const d = D.find(x => x.slug === s); if (!d) { throw new Error(`Eigener Eintrag fehlt im Bestand: ${s}`); } return { slug: s, eimer: d.eimer, eimerOber: d.o, tage: d.t, getestet: d.tested, getestetRoh: d.testedRoh, phpMin: d.php, phpMinRoh: d.phpRoh }; }); /* ---------- Dichte: Plugins unter 1.000 Installationen, 60 log-Bins ueber log10(1 + Tage) ---------- */ const maxTage = Math.max(...tageSortiert); const BINS = 60; const maxLog = Math.log10(1 + maxTage); const grenzen = Array.from({ length: BINS + 1 }, (_, i) => Math.round(10 ** (maxLog * i / BINS) - 1)); const anzahl = new Array(BINS).fill(0); for (const d of D) { if (d.eimer >= 1000 || d.t === null) { continue; } let i = Math.min(BINS - 1, Math.floor(Math.log10(1 + d.t) / maxLog * BINS)); anzahl[i]++; } const dichte = { plugins: anzahl.reduce((a, b) => a + b, 0), maxTage, grenzenTage: grenzen, anzahl }; /* ---------- Aggregat ---------- */ const aggregat = { stichtag: STICHTAG, referenzzeitpunkt: new Date(T0).toISOString(), jeVergeben: jeVergeben.size, svnListe: { kennungen: svnGroesse, stand: '2026-08-27', nachtrag: svnNachtrag, hinweis: 'Kennungen, die zwischen SVN-Abzug und Bestandsabzug aufgenommen wurden; sie zaehlen als vergeben' }, gelistet: D.length, verschwunden: jeVergeben.size - D.length, anteilVerschwunden: pz(jeVergeben.size - D.length, jeVergeben.size), installationen: { unter: totU, ober: totO, bandbreiteProzent: pz(totO - totU, totU), obersterEimerOffen: true }, still, ohnePhpAngabe: ohnePhp, anteilOhnePhpAngabe: pz(ohnePhp, D.length), php5: { plugins: php5.length, anteilPlugins: pz(php5.length, D.length), installationenMindestens: php5.reduce((a, d) => a + d.u, 0), installationenOber: php5.reduce((a, d) => a + d.o, 0) }, nieAngefasst: { plugins: nieAngefasstListe.length, anteilPlugins: pz(nieAngefasstListe.length, D.length), definition: 'added-Jahr = last_updated-Jahr und mehr als 730 Tage seit Release' }, aufAktuellGetestet: getestetZaehler[KERN] || 0, getestet, eimerwerte, obersterEimer: { wert: oberst, plugins: zaehl(d => d.eimer === oberst), obergrenzeKonvention: band(oberst)[1] }, gruppen, kohorten, weitere, selbstmessung, dichte, tags, anbieter, top: { plugins: top.length, abInstallationen: 10000, datei: 'census-plugins-01-2026.json' }, nichtGelistet: { kennungen: nichtGelistet.length, datei: 'census-nicht-gelistet-01-2026.txt' }, support: { definition: 'support_threads und support_threads_resolved zaehlen Supportfaeden der letzten zwei Monate, so wie die Pluginseite sie als "x out of y issues resolved in the last two months" zeigt (geprueft 08.09.2026 an contact-form-7).' }, jahre }; /* ---------- Punktwolke, ohne Namen ---------- */ const wolke = P .filter(p => (Number(p.active_installs) || 0) >= 1000) .map(p => [Number(p.active_installs), tage(p.last_updated) ?? -1]) .filter(w => w[1] >= 0); /* ---------- Nachschlagetabelle ---------- */ const KOPF = 'slug\tinstallationsbereich\ttage_seit_release\tgetestet_bis\tphp_min'; const zeilen = P.map(p => [p.slug, Number(p.active_installs) || 0, tage(p.last_updated) ?? -1, vers(p.tested) || '', vers(p.requires_php) || ''].join('\t')); const tsv = KOPF + '\n' + zeilen.join('\n') + '\n'; const json = JSON.stringify({ aggregat, wolke }); /* ---------- Pruefung gegen den veroeffentlichten Bestand ---------- */ const sha = (s) => createHash('sha256').update(s).digest('hex'); const altJson = join(BESTANDSDATEI, 'census-01-2026.json'); const altTsv = join(BESTANDSDATEI, 'census-slugs.tsv'); if (existsSync(altJson)) { const alt = JSON.parse(readFileSync(altJson, 'utf8')).aggregat; const gleich = [ ['jeVergeben', alt.jeVergeben, aggregat.jeVergeben], ['gelistet', alt.gelistet, aggregat.gelistet], ['verschwunden', alt.verschwunden, aggregat.verschwunden], ['installationen.unter', alt.installationen.unter, totU], ['installationen.ober', alt.installationen.ober, totO], ...schwellen.map(s => [`still.${s}.plugins`, alt.still[s].plugins, still[s].plugins]), ['ohnePhpAngabe', alt.ohnePhpAngabe, ohnePhp], ['php5.plugins', alt.php5.plugins, php5.length], ['nieAngefasst', typeof alt.nieAngefasst === 'object' ? alt.nieAngefasst.plugins : alt.nieAngefasst, nieAngefasstListe.length], ['aufAktuellGetestet', alt.aufAktuellGetestet, aggregat.aufAktuellGetestet], ['jahre', JSON.stringify(alt.jahre), JSON.stringify(jahre)] ]; const abweichend = gleich.filter(([, a, b]) => a !== b); for (const [k, a, b] of abweichend) { console.log(`ABWEICHUNG ${k}: Bestand ${a}, neu ${b}`); } console.log(abweichend.length ? `${abweichend.length} Kernzahl(en) weichen vom Bestand ab.` : 'Kernzahlen wertgleich mit dem veroeffentlichten Bestand.'); if (existsSync(altTsv)) { const gleichTsv = sha(readFileSync(altTsv, 'utf8')) === sha(tsv); console.log(gleichTsv ? 'TSV byte-gleich mit dem Bestand.' : 'TSV WEICHT vom Bestand AB.'); } } console.log(`Kontrollwerte: still 365 ${still[365].anteilInstallationenUnter} bis ${still[365].anteilInstallationenOber} % (Quotient ${still[365].quotientUntersummen}); Gruppen ${Object.values(gruppen).map(g => g.plugins).join('/')}; still365 je Gruppe ${Object.values(gruppen).map(g => g.still[365].plugins).join('/')}; Median ${median}; Warnung ${warnung} (${getestet.verzeichniswarnung.anteilPlugins} %); keine Angabe ${keineAngabe}; dichte ${dichte.plugins}`); if (pruefen) { console.log('Probelauf, nichts geschrieben.'); process.exit(0); } mkdirSync(ZIEL, { recursive: true }); writeFileSync(join(ZIEL, 'census-01-2026.json'), json); writeFileSync(join(ZIEL, 'census-slugs.tsv'), tsv); const topJson = JSON.stringify({ stichtag: STICHTAG, referenzzeitpunkt: new Date(T0).toISOString(), abInstallationen: 10000, hinweis: 'Installationen (eimer) sind die Untergrenze eines Bands mit einer signifikanten Stelle; 10000 heisst 10000 bis 19999. Alle Felder sind Selbstauskunft der wordpress.org-API zum Stichtag.', lizenz: 'https://creativecommons.org/licenses/by/4.0/', felder: FELDER, plugins: top }); writeFileSync(join(ZIEL, 'census-plugins-01-2026.json'), topJson); const ngTxt = nichtGelistet.join('\n') + '\n'; writeFileSync(join(ZIEL, 'census-nicht-gelistet-01-2026.txt'), ngTxt); const kb = (s) => Math.round(Buffer.byteLength(s) / 1024); console.log(`census-01-2026.json ${kb(json)} KB (Aggregate + ${wolke.length} Punkte) SHA-256 ${sha(json)}`); console.log(`census-plugins-01-2026.json ${kb(topJson)} KB (${top.length} Plugins ab 10.000 Installationen, mit Namen) SHA-256 ${sha(topJson)}`); console.log(`census-nicht-gelistet-01-2026.txt ${kb(ngTxt)} KB (${nichtGelistet.length} Kennungen) SHA-256 ${sha(ngTxt)}`); console.log(`census-slugs.tsv ${kb(tsv)} KB (${zeilen.length} Slugs) SHA-256 ${sha(tsv)}`); console.log(`\nLeitzahlen: ${aggregat.verschwunden} von ${aggregat.jeVergeben} nicht mehr gelistet (${aggregat.anteilVerschwunden} %), ${still[365].anteilPlugins} % still nach Plugins, ${still[365].anteilInstallationenUnter} bis ${still[365].anteilInstallationenOber} % nach Installationen.`);