presseportale/app/Services/PressRelease/FeedFetchReport.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

211 lines
8.7 KiB
PHP

<?php
namespace App\Services\PressRelease;
use App\Enums\Portal;
use App\Models\Category;
use App\Models\Company;
use App\Models\FeedFetch;
use App\Scopes\PortalScope;
use App\Support\FeedReaderIdentifier;
use Illuminate\Support\Carbon;
/**
* Admin-Auswertung des Feed-Abruf-Protokolls (Roadmap 3.1): welche Feeds
* wie oft geholt werden, von welchen Reader-Diensten, und welche
* Abonnentenzahlen die Dienste selbst melden. Zählt Abrufe — Abonnenten
* sind eine Angabe des Dienstes (Feedly, Inoreader, NewsBlur, Feedbin
* nennen sie im User-Agent), keine eigene Zählung.
*/
class FeedFetchReport
{
/**
* Je Reader-Dienst: Abrufe und betroffene Feeds im Zeitraum, Summe der
* je Feed höchsten gemeldeten Abonnentenzahl, letzter Abruf.
*
* @return list<array{reader: string|null, label: string, fetches: int, feeds: int, subscribers: int|null, last_at: Carbon}>
*/
public function byReader(int $days = 30): array
{
// Je Reader und Feed eine Zeile (Abrufe, höchste gemeldete Abonnenten,
// letzter Abruf); die Verdichtung je Reader passiert in PHP — das
// bleibt auf MySQL und SQLite gleich. Abonnenten: je Feed das Maximum,
// dann summiert — ein Dienst holt einen Feed viele Male, die Zahl darf
// nicht mitwachsen.
$rows = FeedFetch::query()
->where('created_at', '>=', now()->subDays($days))
->selectRaw('reader, feed, feed_key, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at')
->groupBy('reader', 'feed', 'feed_key')
->toBase()
->get()
->groupBy(fn (object $row): string => (string) $row->reader);
return $rows
->map(function ($feeds, string $reader): array {
$withSubscribers = $feeds->whereNotNull('subscribers');
return [
'reader' => $reader === '' ? null : $reader,
'label' => FeedReaderIdentifier::label($reader === '' ? null : $reader),
'fetches' => (int) $feeds->sum('fetches'),
'feeds' => $feeds->count(),
'subscribers' => $withSubscribers->isEmpty() ? null : (int) $withSubscribers->sum('subscribers'),
'last_at' => Carbon::parse((string) $feeds->max('last_at')),
];
})
->sortByDesc('fetches')
->values()
->all();
}
/**
* Am häufigsten abgerufene Feeds im Zeitraum, mit aufgelöstem Namen
* (Branche bzw. Firma) und der höchsten gemeldeten Abonnentenzahl.
*
* @return list<array{feed: string, feed_key: string|null, portal: string, language: string, label: string, fetches: int, readers: int, subscribers: int|null, last_at: Carbon}>
*/
public function topFeeds(int $days = 30, int $limit = 20): array
{
$rows = FeedFetch::query()
->where('created_at', '>=', now()->subDays($days))
->selectRaw('feed, feed_key, portal, language, reader, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at')
->groupBy('feed', 'feed_key', 'portal', 'language', 'reader')
->toBase()
->get()
->groupBy(fn (object $row): string => implode('|', [(string) $row->feed, (string) $row->feed_key, (string) $row->portal, (string) $row->language]))
->map(fn ($readers): object => (object) [
'feed' => (string) $readers->first()->feed,
'feed_key' => $readers->first()->feed_key,
'portal' => (string) $readers->first()->portal,
'language' => (string) $readers->first()->language,
'fetches' => (int) $readers->sum('fetches'),
'readers' => $readers->count(),
'subscribers' => $readers->whereNotNull('subscribers')->max('subscribers'),
'last_at' => $readers->max('last_at'),
])
->sortByDesc('fetches')
->take($limit)
->values();
$categoryNames = $this->categoryNames($rows->where('feed', FeedFetch::FEED_CATEGORY)->pluck('feed_key')->filter()->unique()->values()->all());
$companyNames = $this->companyNames($rows->where('feed', FeedFetch::FEED_NEWSROOM)->pluck('feed_key')->filter()->unique()->values()->all());
return $rows->map(function (object $row) use ($categoryNames, $companyNames): array {
$portal = Portal::tryFrom((string) $row->portal)?->label() ?? (string) $row->portal;
$key = $row->feed_key === null ? null : (string) $row->feed_key;
$label = match ((string) $row->feed) {
FeedFetch::FEED_CATEGORY => __('Branche').' · '.($categoryNames[$key.'|'.$row->language] ?? $key),
FeedFetch::FEED_NEWSROOM => __('Newsroom').' · '.($companyNames[$key] ?? $key),
default => __('Alle Meldungen'),
};
return [
'feed' => (string) $row->feed,
'feed_key' => $key,
'portal' => (string) $row->portal,
'language' => (string) $row->language,
'label' => $portal.' · '.strtoupper((string) $row->language).' · '.$label,
'fetches' => (int) $row->fetches,
'readers' => (int) $row->readers,
'subscribers' => $row->subscribers === null ? null : (int) $row->subscribers,
'last_at' => Carbon::parse((string) $row->last_at),
];
})->all();
}
/**
* Abrufe gesamt und je Feed-Art im Zeitraum.
*
* @return array{total: int, portal: int, category: int, newsroom: int, since_total: int}
*/
public function totals(int $days = 30): array
{
$byFeed = FeedFetch::query()
->where('created_at', '>=', now()->subDays($days))
->selectRaw('feed, count(*) as fetches')
->groupBy('feed')
->toBase()
->pluck('fetches', 'feed');
return [
'total' => (int) $byFeed->sum(),
'portal' => (int) ($byFeed[FeedFetch::FEED_PORTAL] ?? 0),
'category' => (int) ($byFeed[FeedFetch::FEED_CATEGORY] ?? 0),
'newsroom' => (int) ($byFeed[FeedFetch::FEED_NEWSROOM] ?? 0),
'since_total' => FeedFetch::query()->count(),
];
}
/**
* Abrufe je Feed für die Absenderansicht einer Meldung (30 Tage):
* Schlüssel `feed:key:portal:language`.
*
* @param list<array{feed: string, feed_key: string|null, portal: string, language: string}> $feeds
* @return array<string, int>
*/
public function fetchesFor(array $feeds, int $days = 30): array
{
if ($feeds === []) {
return [];
}
$since = now()->subDays($days);
$counts = [];
foreach ($feeds as $feed) {
$key = implode(':', [$feed['feed'], (string) $feed['feed_key'], $feed['portal'], $feed['language']]);
$counts[$key] = FeedFetch::query()
->where('feed', $feed['feed'])
->when($feed['feed_key'] === null, fn ($query) => $query->whereNull('feed_key'), fn ($query) => $query->where('feed_key', $feed['feed_key']))
->where('portal', $feed['portal'])
->where('language', $feed['language'])
->where('created_at', '>=', $since)
->count();
}
return $counts;
}
/**
* @param list<string> $slugs
* @return array<string, string> `slug|locale` => Name
*/
private function categoryNames(array $slugs): array
{
if ($slugs === []) {
return [];
}
return Category::query()
->join('category_translations', 'category_translations.category_id', '=', 'categories.id')
->whereIn('category_translations.slug', $slugs)
->toBase()
->get(['category_translations.slug', 'category_translations.locale', 'category_translations.name'])
->mapWithKeys(fn (object $row): array => [$row->slug.'|'.$row->locale => (string) $row->name])
->all();
}
/**
* @param list<string> $slugs
* @return array<string, string>
*/
private function companyNames(array $slugs): array
{
if ($slugs === []) {
return [];
}
// Nur der PortalScope entfällt (Feeds beider Portale im Bericht);
// entfernte oder gelöschte Firmen bleiben mit ihrem Namen lesbar.
return Company::query()
->withoutGlobalScope(PortalScope::class)
->withTrashed()
->whereIn('slug', $slugs)
->toBase()
->pluck('name', 'slug')
->map(fn ($name): string => (string) $name)
->all();
}
}