presseportale/app/Console/Commands/ReportPressReleaseViews.php
Kevin Adametz 748a0d4b7e reports: Aufrufe nach Alter aufschlüsseln und Basiswerte festhalten
Der Gesamtmedian über ein Zeitfenster taugt nicht als Basiswert. `hits` ist ein
mitlaufender Gesamtzähler, und die Aufrufe wachsen nach der Veröffentlichung
über Wochen weiter — auf dem Bestand liegt der Schnitt in der ersten Woche bei
rund 75 und nach drei Wochen bei rund 230, ohne dass ein Plateau erreicht wäre.
Gemessen wird damit nicht, wie viele Aufrufe eine Meldung bekommt, sondern wie
viel eine zufällig alte Meldung bisher gesammelt hat. Dieser Wert verschiebt
sich mit der Altersmischung des Fensters; zwei Messungen zu verschiedenen
Zeitpunkten wären nicht vergleichbar, und genau das braucht ein Basiswert.

Der Report weist die Aufrufe deshalb zusätzlich nach Alterskohorten aus. Erst
die einzelne Kohorte ("Median der Meldungen im Alter von 28 bis 55 Tagen") ist
in einem halben Jahr identisch erhebbar. Der Altbestand bleibt außen vor — seine
Zähler stammen aus dem Altsystem und haben keine hier nachvollziehbare
Vorgeschichte.

Dazu die am 01.09. auf Live erhobenen Basiswerte und die daraus folgende
Empfehlung zu E2: Rohzahlen. Es gibt keinen Bodensatz, den eine Einordnung
kaschieren müsste (unter 25 Aufrufen liegen 2 % beziehungsweise 0 % der
Meldungen), die Verteilungen sind nahezu symmetrisch, und eine Einordnung gegen
den Portaldurchschnitt wäre wegen der Wachstumskurve sogar falsch — sie
verglich eine zwei Tage alte Meldung gegen einen Schnitt aus drei Monaten.

Die Roadmap führte openPR als zweite Zulaufquelle. Das ist korrigiert: openPR
ist der Presse-API-Verteiler von connektar, also derselbe Zulauf. In den Daten
hat genau ein API-Token je einen Zugriff gemacht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 12:40:51 +00:00

125 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use App\Services\PressRelease\ViewDistributionReport;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Basiswert der Aufrufzahlen (Roadmap 0.1).
*
* Muss gemessen sein, BEVOR die erste Maßnahme aus Block 1 live geht — ohne
* Vergleichspunkt wird jede spätere Bewertung eine Vermutung. Die Verteilung
* entscheidet zugleich E2: ob die Absenderansicht Rohzahlen zeigt oder eine
* Einordnung gegen den Portalschnitt.
*/
class ReportPressReleaseViews extends Command
{
protected $signature = 'press-releases:view-distribution
{--months=3 : Zeitraum in Monaten rückwärts}
{--from= : Startdatum, überschreibt --months}
{--to= : Enddatum (Standard: heute)}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Basiswert: Verteilung der Aufrufzahlen je Portal (Median, Quartile, Häufigkeiten).';
public function handle(ViewDistributionReport $report): int
{
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to')) : Carbon::now();
$from = $this->option('from') !== null
? Carbon::parse((string) $this->option('from'))
: $to->copy()->subMonths(max(1, (int) $this->option('months')));
$data = $report->build($from, $to);
if ($this->option('json')) {
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info("Aufrufzahlen je Portal — veröffentlicht {$data['from']} bis {$data['to']}");
foreach ($data['portals'] as $portal) {
$this->newLine();
$this->line("<comment>{$portal['label']}</comment>");
$rows = [];
foreach ($portal['segments'] as $segment) {
$s = $segment['stats'];
if ($s['anzahl'] === 0) {
$rows[] = [$segment['segment'], 0, '–', '–', '–', '–', '–', '–', '–'];
continue;
}
$rows[] = [
$segment['segment'],
$s['anzahl'],
$s['min'],
$s['p10'],
$s['p25'],
$s['median'],
$s['p75'],
$s['p90'],
$s['max'],
];
}
$this->table(['Bestand', 'PMs', 'Min', 'P10', 'P25', 'Median', 'P75', 'P90', 'Max'], $rows);
$gesamt = $portal['segments'][0]['stats'];
if ($gesamt['anzahl'] === 0) {
continue;
}
$this->line(sprintf(
' Mittelwert %s gegenüber Median %d%s',
$gesamt['mittelwert'],
$gesamt['median'],
// Die Schiefe ist die eigentliche Antwort auf E2: Zieht eine
// Handvoll Ausreißer den Schnitt nach oben, liegt der typische
// Absender darunter und eine Rohzahl liest sich für ihn als
// Misserfolg.
$gesamt['mittelwert'] > $gesamt['median'] * 1.25
? ' ← rechtsschief, Rohzahlen wirken für die Mehrheit enttäuschend'
: '',
));
$verteilung = [];
foreach ($gesamt['verteilung'] as $klasse => $anzahl) {
$anteil = $gesamt['anzahl'] > 0 ? round($anzahl / $gesamt['anzahl'] * 100) : 0;
$verteilung[] = [$klasse.' Aufrufe', $anzahl, $anteil.' %'];
}
$this->table(['Klasse', 'PMs', 'Anteil'], $verteilung);
$kohorten = [];
foreach ($portal['cohorts'] as $cohort) {
$kohorten[] = [
$cohort['alter'],
$cohort['anzahl'],
$cohort['median'] ?? '–',
$cohort['mittelwert'] ?? '–',
];
}
if ($kohorten !== []) {
$this->line(' <comment>Aufrufe nach Alter (nur Meldungen seit dem Relaunch)</comment>');
$this->table(['Alter der Meldung', 'PMs', 'Median', 'Mittelwert'], $kohorten);
// Der Gesamtmedian oben hängt an der Altersmischung des
// Fensters und ist zwischen zwei Messzeitpunkten nicht
// vergleichbar. Die einzelne Kohorte ist es.
$this->line(' Als Basiswert die KOHORTE festhalten, nicht den Gesamtmedian — nur sie ist später identisch erhebbar.');
}
}
return self::SUCCESS;
}
}