YouTube Publishing Analytics (oeffentlich)
Oeffentlich zugaengliche Tool-Seite mit datenbasierter Auswertung der besten Veroeffentlichungs-Zeitpunkte fuer YouTube-Videos. Zielt auf Long-Tail-SEO-Traffic fuer Anfragen wie "beste Uhrzeit YouTube Upload" oder "Wann Video veroeffentlichen".
Route
/{locale}/tools/youtube-publishing-analytics- Locales:
de(primaer, Default),en - Feature-Flag:
postbox.public_publishing_analytics.enabled(Default:true) - Rate-Limit: Named RateLimiter
public-tools(30/min/IP) - Navigation: "Tools"-Dropdown zwischen Explorer und Dashboard (Desktop + Mobile)
Datenquellen
Wochentag-Aggregation (youtube_publishing_stats)
Taeglich aggregierter View auf Veroeffentlichungen des letzten Jahres. Enthaelt pro (category, mode)-Kombination:
- Videos pro Wochentag
- VFR-Median (Views/Follower Ratio) pro Wochentag
- Engagement-Rate pro Wochentag
((likes + comments) / views) × 100— Branchen-Standard für YouTube (Denominator ist views, nicht subscribers/followers: YouTube ist discovery-first, Views kommen aus Recommendations/Search/Shorts-Feed, nicht aus dem Subscriber-Inbox). 0-View-Videos werden vom Median ausgeschlossen. Benchmark-Orientierung: ~2% Schnitt, 3–5% gut, >5% exzellent (Mega-Channels >500k typ. 1–2%). - Like-to-View-Ratio pro Wochentag
(likes / views) × 100 - Shorts-Anteil (Share in %)
- 7×24 Hour-Distribution (JSONB mit Wochentag→Stunde→Anzahl)
Cronjob: youtube:aggregate-publishing-stats laeuft woechentlich Montag 03:00 UTC (Heartbeat youtube_publishing_stats).
Monatliche Snapshots
Der Command youtube:aggregate-publishing-stats generiert immer automatisch einen Monats-Snapshot für den aktuellen Monat — kein separater Flag nötig. Die SQL-Aggregation summiert die Hour-Distributions serverseitig pro (weekday, hour) über alle Channels (jsonb_each_text() → GROUP BY weekday, hour mit SUM) und baut daraus erst danach mit jsonb_object_agg() das Stunden-Objekt.
Wichtig (Fix 2026-05-31):
jsonb_object_agg()dedupliziert in PostgreSQL doppelte Keys per Last-Value-Wins — es summiert NICHT. Die frühere Variante aggregierte direkt über alle Channels und kollabierte die Monats-hour_distributiondadurch auf die Verteilung eines einzelnen Channels (Monats-Heatmap fast leer). Deshalb MUSS zuerst pro(weekday, hour)summiert werden, bevorjsonb_object_agg()die bereits eindeutigen Paare zusammenfasst. Die gespeichertehour_distributionenthält damit die Summe über alle Channels; der Reader (heatmapFromSnapshots) teilt für "Ø Videos pro Channel" durch die in der Snapshot-Row gespeichertechannel_count(nicht durchCOUNT(*), das pro Snapshot-Row = 1 wäre).
Fuer historisches Backfilling:
# Snapshots fuer Januar bis April 2026 nachholen
php8.4 artisan youtube:aggregate-publishing-stats --from=2026-01-01 --to=2026-04-01
Iteriert ueber alle Kategorien plus einen NULL-Aggregate-Slot ("alle Kategorien").
Command-Uebersicht
# Alles berechnen (Weekly-Aggregation + Snapshot aktueller Monat)
php8.4 artisan youtube:aggregate-publishing-stats
# Nur ein bestimmtes Profil
php8.4 artisan youtube:aggregate-publishing-stats --profile=123
# Historische Monate nachholen (Aggregation + Snapshots fuer jeden Monat im Range)
php8.4 artisan youtube:aggregate-publishing-stats --from=2026-01-01 --to=2026-04-20
# Dry-Run (nur anzeigen, nichts schreiben)
php8.4 artisan youtube:aggregate-publishing-stats --dry-run
UI-Komponenten
Die Livewire-Komponente App\Livewire\Tools\YouTubePublishingAnalytics rendert:
| Bereich | Inhalt |
|---|---|
| Hero | Ueberschrift, Trust-Signal (Video-Anzahl), Data-Freshness |
| Filter-Bar | Kategorie (Dropdown), Video-Typ (Pill-Toggle: Alle/Regulaer/Shorts), Zeitraum (Monat-Dropdown) |
| KPI-Cards | Bester Tag (VFR), Beste Stunde, Aktivster Tag, Shorts-Anteil |
| Chart-Grid (2×2) | Videos/Wochentag, VFR-Median/Wochentag, Engagement-Rate/Wochentag, L2V-Ratio/Wochentag |
| Heatmap (7×24) | Durchschnittliche Videos pro Channel, UTC↔Lokalzeit-Toggle |
| Best-Hours-Badges | Top-Stunden mit Count, formatiert in gewaehlter Zeitzone |
| Share-Section | Twitter/X, LinkedIn, WhatsApp, Copy-Link |
| FAQ-Accordion | 6 Fragen (VFR, Datenquelle, Update-Frequenz, Timezone, Kategorien, Shorts) |
| CTA (Gaeste) | Register-Link fuer eigene Channel-Analyse |
Alpine-Integration
ApexCharts-Rendering via Alpine-Component ytPubAnalytics(). Key-Verhalten:
init()wartet aufApexCharts-Global (CDN-Load), rendert Charts + HeatmaprenderCharts()destroyed alte Chart-Instanzen vor Neuaufbau (bei Filter-Wechsel via Livewire re-render)renderHeatmap()berechnet Zeitzonen-Shift clientseitig: UTC-Rohdaten werden per-new Date().getTimezoneOffset() / 60verschobenlivewire:navigatedEvent-Listener re-rendert nach SPA-NavigationformatHour(hour, mode)/tzLabel()fuer konsistente Stunden-Labels
URL-State & Caching
- URL-bound Properties (
#[Url]) fuercategory,mode,month→ teilbare Links - Server-Cache via
Cache::remember('public:yt-pub:{category}:{mode}:{month}', 3600, …)pro Filter-Kombination - Cache-Invalidierung nach Aggregation via
DELETE WHERE key LIKE 'public:yt-pub:%'auf der Database-Cache-Tabelle (Postbox nutztCACHE_STORE=database)
Mode-Filter (regular/shorts/all) — Schema und Datenfluss
Der Mode-Filter wirkt seit 2026-05-25 ueberall (Counts, Heatmap, VFR/Engagement/L2V-Charts, KPI-Boxen). Vorher beruehrte er nur die Counts, weil der Aggregator Shorts via continue uebersprungen hat und keine parallelen Metrik-Spalten existierten.
Parallele Spalten in youtube_publishing_weekly_stats:
| Regular | Shorts (Parallel) |
|---|---|
hour_distribution (jsonb) | shorts_hour_distribution (jsonb) |
vfr_median | shorts_vfr_median |
vfr_p25 | shorts_vfr_p25 |
vfr_p75 | shorts_vfr_p75 |
engagement_rate_median | shorts_engagement_rate_median |
like_to_view_ratio_median | shorts_like_to_view_ratio_median |
best_hour_utc | shorts_best_hour_utc |
youtube_publishing_monthly_snapshots hat dieselben Parallel-Spalten (ausser _p25/_p75, die monthly nicht aggregiert werden).
Aggregator-Logik: AggregateYouTubePublishingStats::processProfile() berechnet alle Metriken symmetrisch fuer beide Video-Typen. Die Per-Wochentag-Aggregation lebt in einem WeekdayAgg-DTO (app/Support/YouTubePublishing/WeekdayAgg.php) mit explizit getypten public Properties (regularCount, shortsCount, hours, vfr, vfrByHour, shortsHours, shortsVfr, shortsVfrByHour, etc.) — vorher als mixed-Array, das PHPStan in verschiedenen Versionen unterschiedlich narrow-getrackt hat. shorts_best_hour_utc wird per derselben VFR-by-Hour-Logik wie regulaere Videos ermittelt.
Monthly-Snapshot-Rollup: Zwei separate CTE-Stages (regular_hours + shorts_hours) — jede summiert in einer Subquery zuerst pro (weekday, hour_key) über alle Channels (GROUP BY weekday, hour_key mit SUM) und faltet das Ergebnis erst dann mit jsonb_object_agg(hour_key, hour_total) zu einem Stunden-Objekt mit eindeutigen Keys. Die CTEs werden per LEFT JOIN auf weekday zusammengeführt. (Diese SUM-vor-Aggregat-Reihenfolge ist zwingend — siehe Hinweis unter "Monatliche Snapshots".)
Livewire-Component: modeColumnMap() liefert je nach $this->mode die richtigen Spalten-Namen — regular/all → reguläre Spalten, shorts → shorts_*-Spalten. mode=all wird als regular behandelt, weil ein Summieren der Veröffentlichungs-Stunden zwischen Regular und Shorts die unterschiedlichen Signal-Kanäle mischen würde (Standard-YT vs Kurzform haben inhärent andere Upload-Zeiten). heatmapFromSnapshots() teilt die gespeicherte Channel-Summe durch MAX(s.channel_count) (= Ø Videos pro Channel), konsistent mit dem Live-Pfad heatmapFromLive(), der direkt SUM(...)/COUNT(*) über die weekly_stats-Rows bildet.
OG-Image: YouTubePublishingAnalyticsOgRenderer::loadHeatmap() ist mode-aware — bei mode=shorts wird die shorts_hour_distribution-Spalte genutzt, sonst hour_distribution (konsistent mit dem modeBadge des Vorschaubilds).
SEO
- Sitemap-Eintrag (static/sub-sitemap) wird automatisch gesetzt wenn Feature-Flag aktiv (
SitemapGenerator::collectStaticUrls()) - hreflang-Alternates zwischen
deundenviawriteUrlSet()automatisch - Dynamische OG-Image (SVG) pro Filter-Kombination
- Strukturierte Daten (FAQ-Schema) im Template
Monitoring
| Heartbeat | Command | Schedule | Retry-Command |
|---|---|---|---|
youtube_publishing_stats | youtube:aggregate-publishing-stats | Mo 03:00 UTC (woechentlich) | identisch |
Heartbeat in config/postbox.php mit max_minutes, label und sla_weight eingetragen und in DailyPipelineStatus (Admin-UI) mit Retry-Command hinterlegt.
Dateien
app/Livewire/Tools/YouTubePublishingAnalytics.php— Livewire-Komponenteresources/views/livewire/tools/youtube-publishing-analytics.blade.php— View + Alpine-Componentapp/Console/Commands/AggregateYouTubePublishingStats.php— Aggregation + Monatliche Snapshots + Cache-Invalidierungapp/Services/Sitemap/SitemapGenerator.php— Sitemap-Integrationroutes/console.php— Schedulerconfig/postbox.php— Feature-Flag + Heartbeat-Konfigurationlang/de.json,lang/en.json— Uebersetzungen
Env
# Oeffentliche YouTube Publishing Analytics Seite aktivieren/deaktivieren.
# Bei false: Route 404, Sitemap-Eintrag entfaellt. Default: true.
POSTBOX_PUBLIC_PUBLISHING_ANALYTICS_ENABLED=true