presseportale/app/Services/PressRelease/VisibilityStats.php
Kevin Adametz fc700677ff web: Abruf-Protokoll der RSS-Feeds — Reader-Dienst, gemeldete Abonnenten, Admin-Bericht, Panel (Roadmap 3.1)
- feed_fetches: jeder Feed-Abruf mit Art, Schlüssel, Portal, Sprache,
  erkanntem Reader (config/feeds.php, FeedReaderIdentifier), vom Dienst
  gemeldeter Abonnentenzahl und gekürztem User-Agent — keine IP. Schreiben
  per defer() nach der Antwort, auch bei Cache-Treffer.
- Aufbewahrung 395 Tage über press-releases:purge-events;
  FEED_FETCHES_RECORDED_SINCE.
- Admin ▸ Reports ▸ Indexierung & Abrufe: Block „Abrufe der RSS-Feeds"
  (Summen je Feed-Art, je Reader-Dienst, meistabgerufene Feeds;
  FeedFetchReport, Verdichtung in PHP für MySQL und SQLite).
- Panel „Auslieferung": Abrufe je Feed in 30 Tagen; RSS-Seite nennt die
  Zählung ohne IP.
- Tests: FeedFetchLogTest (5), IndexStatusCheckTest erweitert.
- dev/2026-09-01: Nachtrag Protokoll Teil 11, Roadmap 3.1; 0.7 gilt auch
  für Crawler- und Feed-Log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:00:09 +00:00

299 lines
12 KiB
PHP

<?php
namespace App\Services\PressRelease;
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Enums\PressReleaseStatus;
use App\Http\Middleware\SetEdition;
use App\Models\Company;
use App\Models\FeedFetch;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Scopes\PortalScope;
use App\Support\CrawlerIdentifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* Sichtbarkeit belegen (Roadmap 1c): Aufrufe je Meldung und je Firma, als
* Rohzahlen — Entscheidung E2, keine Einordnung gegen den Portalschnitt.
*
* Zwei Quellen, bewusst getrennt gelesen:
*
* - `press_releases.hits` ist die Summe seit jeher, inklusive Altsystem.
* Sie wird vom Scheduler aus den Ereignissen nachgeführt und ist die
* einzige Zahl, die auch für Meldungen vor Juli 2026 stimmt.
* - `press_release_events` trägt je Aufruf einen Zeitstempel (seit
* 17.07.2026, 13 Monate Aufbewahrung) und liefert den Verlauf. Ein
* Tageswert älter als die Aufbewahrung existiert nicht mehr — die
* Verlaufsgrafik darf ihn nicht als „0 Aufrufe" ausgeben.
*
* Stufe 1 der Absenderansicht (Anhang 17.09.2026) verspricht nichts, was
* von Dritten abhängt: Überschrift „Auslieferung", Daten aus eigener Hand —
* veröffentlicht am, in der Sitemap seit, Aufrufe im Verlauf. Kein Link auf
* eine `site:`-Suche (unvollständig, ohne Einordnung). Der Indexierungs-
* status aus der Search Console (1c.4) läuft zunächst nur als Admin-
* Auswertung; die Kundenanzeige hängt an config search_console.customer_visible
* (E8). Abrufe durch Such- und KI-Dienste (1c.6) kommen aus dem
* Crawler-Abruf-Protokoll (1c.0) — ein Abruf ist kein Zitat.
*/
class VisibilityStats
{
/**
* Kennzahlen einer Firma über alle ihre Meldungen — unabhängig davon, wer
* sie eingereicht hat. Ein Beansprucher sieht damit auch, was connektar
* für ihn eingeliefert hat.
*
* @return array{releases: int, published: int, views_total: int, views_recent: int, last_published_at: Carbon|null}
*/
public function companySummary(Company $company, int $days = 30): array
{
$base = PressRelease::query()->withoutGlobalScope(PortalScope::class)->where('company_id', $company->id);
$row = $base->clone()
->selectRaw('count(*) as releases')
->selectRaw('sum(case when status = ? then 1 else 0 end) as published', [PressReleaseStatus::Published->value])
->selectRaw('coalesce(sum(hits), 0) as views_total')
->selectRaw('max(published_at) as last_published_at')
->toBase()
->first();
$recent = PressReleaseEvent::query()
->where('type', PressReleaseEventType::View->value)
->where('created_at', '>=', now()->subDays($days))
->whereIn('press_release_id', $base->clone()->select('id'))
->count();
return [
'releases' => (int) ($row->releases ?? 0),
'published' => (int) ($row->published ?? 0),
'views_total' => (int) ($row->views_total ?? 0),
'views_recent' => $recent,
'last_published_at' => ($row->last_published_at ?? null) ? Carbon::parse($row->last_published_at) : null,
];
}
/**
* Aufrufe der letzten N Tage je Meldung, für eine Liste.
*
* @param list<int> $pressReleaseIds
* @return array<int, int>
*/
public function recentViewsFor(array $pressReleaseIds, int $days = 30): array
{
if ($pressReleaseIds === []) {
return [];
}
return PressReleaseEvent::query()
->where('type', PressReleaseEventType::View->value)
->where('created_at', '>=', now()->subDays($days))
->whereIn('press_release_id', $pressReleaseIds)
->selectRaw('press_release_id, count(*) as views')
->groupBy('press_release_id')
->pluck('views', 'press_release_id')
->map(fn ($views): int => (int) $views)
->all();
}
/**
* Tagesreihe der Aufrufe, lückenlos über die letzten N Tage (heute
* eingeschlossen). Tage vor Beginn der Ereignisaufzeichnung oder vor der
* Veröffentlichung sind `null`, nicht 0 — es gab dort keine Messung.
*
* @return list<array{date: Carbon, views: int|null}>
*/
public function dailyViews(PressRelease $pressRelease, int $days = 30): array
{
$days = max(1, min($days, (int) config('press_releases.events.retention_days', 395)));
$start = now()->startOfDay()->subDays($days - 1);
$counts = $pressRelease->events()
->where('type', PressReleaseEventType::View->value)
->where('created_at', '>=', $start)
->selectRaw('date(created_at) as day, count(*) as views')
->groupBy('day')
->pluck('views', 'day');
$measuredFrom = $this->measurementStart($pressRelease);
$series = [];
for ($i = 0; $i < $days; $i++) {
$day = $start->copy()->addDays($i);
$series[] = [
'date' => $day,
'views' => $measuredFrom !== null && $day->lt($measuredFrom->copy()->startOfDay())
? null
: (int) ($counts[$day->toDateString()] ?? 0),
];
}
return $series;
}
/**
* Einzelwerte einer Meldung für das Panel „Auslieferung".
*
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null, feeds: list<array{label: string, url: string, fetches_30d: int}>}
*/
public function releaseSummary(PressRelease $pressRelease): array
{
$views = $pressRelease->events()
->where('type', PressReleaseEventType::View->value)
->selectRaw('min(created_at) as first_view, max(created_at) as last_view')
->toBase()
->first();
$shares = $pressRelease->events()
->where('type', PressReleaseEventType::Share->value)
->selectRaw('channel, count(*) as clicks')
->groupBy('channel')
->pluck('clicks', 'channel')
->mapWithKeys(fn ($clicks, $channel): array => [
PressReleaseShareChannel::tryFrom((string) $channel)?->label() ?? (string) $channel => (int) $clicks,
])
->sortDesc();
$links = $pressRelease->isVisibleInArchive() ? $pressRelease->publicPortalLinks() : [];
return [
'hits' => (int) $pressRelease->hits,
'first_view_at' => $views?->first_view ? Carbon::parse($views->first_view) : null,
'last_view_at' => $views?->last_view ? Carbon::parse($views->last_view) : null,
'shares' => $shares->all(),
'shares_total' => (int) $shares->sum(),
// Die Sitemap listet jede im Archiv sichtbare Meldung — ab dem
// Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht
// über Google.
'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
'crawlers' => $this->crawlerFetches($pressRelease),
'crawlers_since' => $this->crawlerHitsSince(),
'feeds' => $this->feedLinks($pressRelease, $links),
];
}
/**
* RSS-Feeds, in denen die Meldung erscheint (Roadmap 3.1): je Portal der
* Feed der Branche, der des Newsrooms und der Portal-Feed. Beide führen die jüngsten
* Meldungen — eine Aussage über uns, nicht über Abonnenten.
*
* @param list<array{portal: Portal, label: string, url: string}> $links
* @return list<array{label: string, url: string, fetches_30d: int}>
*/
private function feedLinks(PressRelease $pressRelease, array $links): array
{
if ($links === []) {
return [];
}
$edition = in_array($pressRelease->language, SetEdition::EDITIONS, true) ? $pressRelease->language : SetEdition::DEFAULT_EDITION;
$categorySlug = $pressRelease->category?->translations()->where('locale', $edition)->value('slug');
$categoryName = $pressRelease->localizedCategoryName($edition);
$companySlug = $pressRelease->company?->slug;
$feeds = [];
foreach ($links as $link) {
$base = (string) parse_url($link['url'], PHP_URL_SCHEME).'://'.(string) parse_url($link['url'], PHP_URL_HOST);
$portal = $link['portal']->value;
if ($categorySlug !== null) {
$feeds[] = [
'label' => $link['label'].' · '.$categoryName,
'url' => $base.'/'.$edition.'/category/'.$categorySlug.'/feed',
'feed' => FeedFetch::FEED_CATEGORY, 'feed_key' => $categorySlug, 'portal' => $portal, 'language' => $edition,
];
}
if ($companySlug !== null) {
$feeds[] = [
'label' => $link['label'].' · Newsroom',
'url' => $base.'/'.$edition.'/newsroom/'.$companySlug.'/feed',
'feed' => FeedFetch::FEED_NEWSROOM, 'feed_key' => $companySlug, 'portal' => $portal, 'language' => $edition,
];
}
$feeds[] = [
'label' => $link['label'],
'url' => $base.'/'.$edition.'/feed',
'feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => $portal, 'language' => $edition,
];
}
// Abrufe je Feed in den letzten 30 Tagen (Feed-Abruf-Protokoll, 3.1):
// eine Aussage über uns — wie oft der Feed geholt wurde, nicht von wem.
$fetches = app(FeedFetchReport::class)->fetchesFor($feeds);
return array_map(function (array $feed) use ($fetches): array {
$key = implode(':', [$feed['feed'], (string) $feed['feed_key'], $feed['portal'], $feed['language']]);
return ['label' => $feed['label'], 'url' => $feed['url'], 'fetches_30d' => (int) ($fetches[$key] ?? 0)];
}, $feeds);
}
/**
* Abrufe durch Such- und KI-Dienste je Meldung (Roadmap 1c.6), zuletzt
* abgerufener Dienst zuerst. Belegt nur den Abruf durch den Crawler des
* Dienstes — nicht, dass die Meldung dort zitiert oder angezeigt wird.
*
* @return list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>
*/
public function crawlerFetches(PressRelease $pressRelease): array
{
return $pressRelease->crawlerHits()
->selectRaw('service, count(*) as fetches, max(created_at) as last_at')
->groupBy('service')
->toBase()
->get()
->map(fn (object $row): array => [
'service' => (string) $row->service,
'label' => CrawlerIdentifier::label((string) $row->service),
'kind' => CrawlerIdentifier::kind((string) $row->service),
'fetches' => (int) $row->fetches,
'last_at' => Carbon::parse((string) $row->last_at),
])
->sortByDesc(fn (array $row): int => $row['last_at']->getTimestamp())
->values()
->all();
}
private function crawlerHitsSince(): ?Carbon
{
$since = config('crawlers.recorded_since');
return $since ? Carbon::parse((string) $since) : null;
}
/**
* Ab wann es für diese Meldung überhaupt Tageswerte geben kann: der
* spätere von Veröffentlichung und Beginn der Ereignisaufzeichnung.
*/
private function measurementStart(PressRelease $pressRelease): ?Carbon
{
$candidates = collect([
$pressRelease->published_at,
$this->eventsSince(),
])->filter();
return $candidates->isEmpty() ? null : $candidates->max();
}
private function eventsSince(): ?Carbon
{
$since = config('press_releases.events.recorded_since');
return $since ? Carbon::parse((string) $since) : null;
}
/**
* Umschaltbare Zeiträume der Verlaufsgrafik.
*
* @return Collection<int, int>
*/
public static function ranges(): Collection
{
return collect([30, 90]);
}
}