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>
This commit is contained in:
Kevin Adametz 2026-09-21 09:00:09 +00:00
parent ff75159b8f
commit fc700677ff
18 changed files with 794 additions and 13 deletions

View file

@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Models\AppSetting;
use App\Models\FeedFetch;
use App\Models\PressReleaseCrawlerHit;
use App\Models\PressReleaseEvent;
use Illuminate\Console\Command;
@ -111,6 +112,22 @@ class PurgePressReleaseEvents extends Command
$this->info(sprintf('Crawler-Abruf-Protokoll bereinigt: %d Einträge gelöscht.', $crawlerDeleted));
// Abruf-Protokoll der RSS-Feeds (3.1): gleiche Aufbewahrung, ebenfalls
// nirgends aufsummiert.
$feedDeleted = 0;
while (true) {
$removed = FeedFetch::query()->where('created_at', '<', $cutoff)->limit($chunk)->delete();
if ($removed === 0) {
break;
}
$feedDeleted += $removed;
}
$this->info(sprintf('Feed-Abruf-Protokoll bereinigt: %d Einträge gelöscht.', $feedDeleted));
return self::SUCCESS;
}
}

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Company;
use App\Models\FeedFetch;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Company\MergedNewsroomLookup;
@ -20,6 +21,8 @@ use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use function Illuminate\Support\defer;
/**
* RSS-Feeds (Roadmap 3.1): je Portal (`/{edition}/feed`), je Branche
* (`/{edition}/category/{slug}/feed`, inklusive Unterkategorien wie die
@ -46,7 +49,7 @@ class FeedController extends Controller
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
return $this->respond($context, ['all'], function () use ($context, $base): string {
return $this->respond($context, [FeedFetch::FEED_PORTAL], function () use ($context, $base): string {
$releases = $this->releases($context)->get($this->columns());
$brand = (array) ($context->domainConfig['brand'] ?? []);
@ -65,7 +68,7 @@ class FeedController extends Controller
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$language = $context->language;
return $this->respond($context, ['category', $slug], function () use ($context, $base, $slug, $language): string {
return $this->respond($context, [FeedFetch::FEED_CATEGORY, $slug], function () use ($context, $base, $slug, $language): string {
$category = Category::query()
->whereIn('portal', $context->portalValues)
->where('is_active', true)
@ -132,7 +135,7 @@ class FeedController extends Controller
abort(404);
}
return $this->respond($context, ['newsroom', $slug], function () use ($context, $base, $company, $language): string {
return $this->respond($context, [FeedFetch::FEED_NEWSROOM, $slug], function () use ($context, $base, $company, $language): string {
$releases = $this->releases($context)->where('company_id', $company->id)->get($this->columns());
if ($releases->isEmpty()) {
@ -153,7 +156,11 @@ class FeedController extends Controller
}
/**
* @param list<string> $keySegments
* Antwort aus dem Cache (oder frisch gebaut) plus Abruf-Protokoll: jeder
* Feed-Abruf wird nach der Antwort ohne IP festgehalten (feed_fetches),
* auch bei warmem Cache — der Abruf ist die Messgröße, nicht der Aufbau.
*
* @param array{0: string, 1?: string} $keySegments Feed-Art und Schlüssel (Slug)
* @param Closure(): string $build
*/
private function respond(WebPortalContext $context, array $keySegments, Closure $build): Response
@ -163,6 +170,11 @@ class FeedController extends Controller
$build,
);
[$feed, $portal, $language, $userAgent] = [$keySegments[0], $context->portal->value, $context->language, request()->userAgent()];
$feedKey = $keySegments[1] ?? null;
defer(static fn () => FeedFetch::recordDeferred($feed, $feedKey, $portal, $language, $userAgent));
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}

60
app/Models/FeedFetch.php Normal file
View file

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use App\Support\FeedReaderIdentifier;
use Database\Factories\FeedFetchFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Ein Abruf eines RSS-Feeds (Roadmap 3.1). Append-Log ohne IP; `reader` ist
* der Schlüssel aus config/feeds.php, `subscribers` die vom Reader-Dienst
* selbst gemeldete Abonnentenzahl.
*/
class FeedFetch extends Model
{
/** @use HasFactory<FeedFetchFactory> */
use HasFactory;
public const ?string UPDATED_AT = null;
public const FEED_PORTAL = 'portal';
public const FEED_CATEGORY = 'category';
public const FEED_NEWSROOM = 'newsroom';
protected $fillable = [
'feed',
'feed_key',
'portal',
'language',
'reader',
'subscribers',
'user_agent',
];
/**
* Schreibt einen Abruf nach der Antwort (defer) — fehlertolerant, weil
* die Messung den Abruf begleitet und ihn nie scheitern lassen darf.
*/
public static function recordDeferred(string $feed, ?string $feedKey, string $portal, string $language, ?string $userAgent): void
{
try {
$userAgent = trim((string) $userAgent);
self::query()->create([
'feed' => $feed,
'feed_key' => $feedKey,
'portal' => $portal,
'language' => $language,
'reader' => FeedReaderIdentifier::identify($userAgent),
'subscribers' => FeedReaderIdentifier::subscribers($userAgent),
'user_agent' => mb_substr($userAgent, 0, 255),
]);
} catch (\Throwable $e) {
report($e);
}
}
}

View file

@ -0,0 +1,211 @@
<?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();
}
}

View file

@ -8,6 +8,7 @@ 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;
@ -135,7 +136,7 @@ class VisibilityStats
/**
* 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}>}
* @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
{
@ -179,7 +180,7 @@ class VisibilityStats
* 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}>
* @return list<array{label: string, url: string, fetches_30d: int}>
*/
private function feedLinks(PressRelease $pressRelease, array $links): array
{
@ -196,11 +197,13 @@ class VisibilityStats
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,
];
}
@ -208,16 +211,26 @@ class VisibilityStats
$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,
];
}
return $feeds;
// 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);
}
/**

View file

@ -0,0 +1,54 @@
<?php
namespace App\Support;
/**
* Ordnet den User-Agent eines Feed-Abrufs einem bekannten Reader-Dienst zu
* (config/feeds.php) und liest die vom Dienst gemeldete Abonnentenzahl.
* Null, wenn kein Dienst passt — der Abruf wird trotzdem gezählt, als
* „Sonstige".
*/
class FeedReaderIdentifier
{
public static function identify(?string $userAgent): ?string
{
if ($userAgent === null || trim($userAgent) === '') {
return null;
}
/** @var array<string, array{label: string, patterns: list<string>}> $readers */
$readers = config('feeds.readers', []);
foreach ($readers as $key => $reader) {
foreach ($reader['patterns'] as $pattern) {
if (stripos($userAgent, $pattern) !== false) {
return $key;
}
}
}
return null;
}
/**
* Abonnentenzahl, wie Feedly, Inoreader, NewsBlur, Feedbin & Co. sie im
* User-Agent nennen („12 subscribers"). Null, wenn nichts angegeben ist.
*/
public static function subscribers(?string $userAgent): ?int
{
if ($userAgent === null || preg_match('/(\d{1,7})\s+subscribers?/i', $userAgent, $match) !== 1) {
return null;
}
return (int) $match[1];
}
public static function label(?string $reader): string
{
if ($reader === null) {
return __('Sonstige');
}
return (string) (config('feeds.readers.'.$reader.'.label') ?? $reader);
}
}