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 23811ad4d2
commit 44d0bb6f29
18 changed files with 794 additions and 13 deletions

View file

@ -251,6 +251,8 @@ REPORT_EMAIL_BUSINESSPORTAL=
# Crawler-Abruf-Protokoll (Roadmap 1c.0): Tag, ab dem Abrufe durch Such- und
# KI-Dienste je Meldung erfasst werden — auf den Deploy-Tag setzen.
CRAWLER_HITS_RECORDED_SINCE=2026-09-18
# Abruf-Protokoll der RSS-Feeds (Roadmap 3.1): beim Deploy auf den Deploy-Tag setzen.
FEED_FETCHES_RECORDED_SINCE=2026-09-21
# Google Search Console, URL Inspection API (Roadmap 1c.4). Läuft nur mit
# Service-Konto (JSON-Schlüssel außerhalb von public/), das in beiden

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);
}
}

46
config/feeds.php Normal file
View file

@ -0,0 +1,46 @@
<?php
/*
|--------------------------------------------------------------------------
| RSS-Feeds (Roadmap 3.1) — Abruf-Protokoll
|--------------------------------------------------------------------------
|
| Jeder Feed-Abruf wird ohne IP protokolliert (feed_fetches). Der
| Reader-Dienst wird am User-Agent erkannt; `patterns` werden
| case-insensitiv als Teilzeichenkette geprüft, der erste Treffer in
| Reihenfolge der Liste gewinnt. Unbekannte Programme zählen als
| „Sonstige". Die Abonnentenzahl kommt, wenn ein Dienst sie im User-Agent
| nennt („12 subscribers"), sonst bleibt sie leer — es ist eine Angabe des
| Dienstes, keine eigene Zählung.
|
*/
return [
// Ab wann das Protokoll läuft — davor gibt es keine Daten.
'recorded_since' => env('FEED_FETCHES_RECORDED_SINCE', '2026-09-21'),
'readers' => [
'feedly' => ['label' => 'Feedly', 'patterns' => ['Feedly']],
'inoreader' => ['label' => 'Inoreader', 'patterns' => ['Inoreader']],
'newsblur' => ['label' => 'NewsBlur', 'patterns' => ['NewsBlur']],
'feedbin' => ['label' => 'Feedbin', 'patterns' => ['Feedbin']],
'theoldreader' => ['label' => 'The Old Reader', 'patterns' => ['theoldreader']],
'bazqux' => ['label' => 'BazQux', 'patterns' => ['BazQux']],
'netnewswire' => ['label' => 'NetNewsWire', 'patterns' => ['NetNewsWire']],
'reeder' => ['label' => 'Reeder', 'patterns' => ['Reeder']],
'thunderbird' => ['label' => 'Thunderbird', 'patterns' => ['Thunderbird']],
'outlook' => ['label' => 'Outlook', 'patterns' => ['Microsoft Office', 'Outlook']],
'miniflux' => ['label' => 'Miniflux', 'patterns' => ['Miniflux']],
'freshrss' => ['label' => 'FreshRSS', 'patterns' => ['FreshRSS']],
'ttrss' => ['label' => 'Tiny Tiny RSS', 'patterns' => ['Tiny Tiny RSS']],
'nextcloud' => ['label' => 'Nextcloud News', 'patterns' => ['NextCloud-News', 'Nextcloud-News']],
'feeder' => ['label' => 'Feeder', 'patterns' => ['Feeder']],
'rssapp' => ['label' => 'RSS.app', 'patterns' => ['RSS.app', 'rss.app']],
'slack' => ['label' => 'Slack', 'patterns' => ['Slackbot', 'Slack-ImgProxy']],
'wordpress' => ['label' => 'WordPress', 'patterns' => ['WordPress']],
'google-feedfetcher' => ['label' => 'Google FeedFetcher', 'patterns' => ['FeedFetcher-Google']],
'browser' => ['label' => 'Browser', 'patterns' => ['Mozilla/5.0 (Windows', 'Mozilla/5.0 (Macintosh', 'Mozilla/5.0 (X11', 'Mozilla/5.0 (iPhone', 'Mozilla/5.0 (Linux; Android']],
],
];

View file

@ -0,0 +1,47 @@
<?php
namespace Database\Factories;
use App\Models\FeedFetch;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<FeedFetch>
*/
class FeedFetchFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'feed' => FeedFetch::FEED_PORTAL,
'feed_key' => null,
'portal' => 'presseecho',
'language' => 'de',
'reader' => 'feedly',
'subscribers' => 3,
'user_agent' => 'Feedly/1.0 (+http://www.feedly.com/fetcher.html; 3 subscribers; like FeedFetcher-Google)',
];
}
public function category(string $slug): static
{
return $this->state(['feed' => FeedFetch::FEED_CATEGORY, 'feed_key' => $slug]);
}
public function newsroom(string $slug): static
{
return $this->state(['feed' => FeedFetch::FEED_NEWSROOM, 'feed_key' => $slug]);
}
public function reader(?string $reader, ?int $subscribers = null, ?string $userAgent = null): static
{
return $this->state([
'reader' => $reader,
'subscribers' => $subscribers,
'user_agent' => $userAgent ?? (($reader ?? 'unknown').'-fetcher/1.0'),
]);
}
}

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Abruf-Protokoll der RSS-Feeds (Roadmap 3.1): jeder Abruf eines Feeds mit
* Art (portal, category, newsroom), Schlüssel (Kategorie- bzw. Firmen-Slug),
* Portal, Sprache, erkanntem Reader-Dienst, der vom Dienst gemeldeten
* Abonnentenzahl und gekürztem User-Agent.
*
* Reines Append-Log ohne Personenbezug: keine IP-Adresse. Abonnenten sind
* eine Angabe des Reader-Dienstes (z. B. „Feedly/1.0 (12 subscribers)"),
* keine eigene Zählung.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('feed_fetches', function (Blueprint $table) {
$table->id();
$table->string('feed', 20);
$table->string('feed_key', 191)->nullable();
$table->string('portal', 20);
$table->string('language', 5);
$table->string('reader', 40)->nullable();
$table->unsignedInteger('subscribers')->nullable();
$table->string('user_agent', 255);
$table->timestamp('created_at')->nullable();
$table->index(['feed', 'feed_key', 'created_at'], 'ff_feed_key_created_idx');
$table->index(['reader', 'created_at'], 'ff_reader_created_idx');
$table->index('created_at', 'ff_created_idx');
});
}
public function down(): void
{
Schema::dropIfExists('feed_fetches');
}
};

View file

@ -206,7 +206,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|---|---|---|---|
| ~~3.1~~ | RSS-Feeds je Branche — **gebaut 18.09.2026**: `/{edition}/feed` und `/{edition}/category/{slug}/feed` (RSS 2.0, jüngste 50, inkl. Unterkategorien), Autodiscovery auf Start-, Branchen- und Archivseiten, Link in der Archiv-Leiste, Block im Panel „Auslieferung", Hub leitet aufs Portal; **21.09.2026:** Feed je Newsroom (`/newsroom/{slug}/feed`), Übersichtsseite `/rss` (Was ist RSS, wie abonnieren, alle Feeds), „RSS" in der Topleiste (Protokoll Teil 11) | S | 2.3 |
| ~~3.1~~ | RSS-Feeds je Branche — **gebaut 18.09.2026**: `/{edition}/feed` und `/{edition}/category/{slug}/feed` (RSS 2.0, jüngste 50, inkl. Unterkategorien), Autodiscovery auf Start-, Branchen- und Archivseiten, Link in der Archiv-Leiste, Block im Panel „Auslieferung", Hub leitet aufs Portal; **21.09.2026:** Feed je Newsroom (`/newsroom/{slug}/feed`), Übersichtsseite `/rss` (Was ist RSS, wie abonnieren, alle Feeds), „RSS" in der Topleiste; Abruf-Protokoll `feed_fetches` (Reader-Dienst, gemeldete Abonnenten, keine IP) mit Block im Admin-Bericht und Abrufzahl im Panel (Protokoll Teil 11) | S | 2.3 |
| 3.2 | Opt-in E-Mail-Digest je Branche, Double-Opt-in | M | 3.1, Bounce-Suppression live |
| 3.3 | Agenturmodell: mehrere Firmen unter einem Konto, aus den Sammeladressen aus 1a.2 abgeleitet | L | 1a.2 |
| 3.4 | Gezielte Ansprache kleiner PR-Agenturen und freier Texter | L | 3.3 |

View file

@ -2144,7 +2144,9 @@ Befunde und was daraus wurde:
**Bleibt offen, bewusst nicht aus dem Code heraus erledigt:**
- 0.7: Absatz zur Ereignistabelle (Zweck, Umfang, 395 Tage, Rechtsgrundlage)
in der Datenschutzerklärung — Rechtstext, Betreiber.
in der Datenschutzerklärung — Rechtstext, Betreiber. Gilt seit dem
21.09.2026 ebenso für das Crawler- und das Feed-Abruf-Protokoll (beide
ohne IP, gleiche Aufbewahrung).
- 0.1: Kohortenwerte „28–55 Tage" vom Live-System (Fenster 01.06.–01.09.)
einmal erheben und ablegen: `php artisan press-releases:view-distribution --from=2026-06-01 --to=2026-09-01 --json > dev/2026-09-01/basiswert-0.1.json`.
- Abbruchbedingungen der Blöcke 1–3 tragen weiter Platzhalter für Zeitraum
@ -2234,3 +2236,50 @@ Firmenportal, Autodiscovery auf der Newsroom-Seite), `RssPageTest` (4),
`StaticPagesTest`-Datensatz, `VisibilityStatsTest`. Pint, Larastan ohne
Befund. Deploy unverändert: `view:cache`/`route:cache`.
### Nachtrag 21.09.2026 — Abruf-Protokoll der Feeds
Frage des Betreibers: lassen sich Feed-Abrufe im System messen? Ja —
gebaut nach dem Muster des Crawler-Protokolls (1c.0).
- Tabelle `feed_fetches`: Feed-Art (portal/category/newsroom), Schlüssel
(Kategorie- bzw. Firmen-Slug), Portal, Sprache, erkannter Reader-Dienst,
vom Dienst gemeldete Abonnentenzahl, gekürzter User-Agent, Zeitpunkt.
**Keine IP.** Reader-Erkennung über `config/feeds.php`
(`FeedReaderIdentifier`, 20 Dienste inkl. Browser); Abonnenten per
Muster „12 subscribers" aus dem User-Agent (Feedly, Inoreader, NewsBlur,
Feedbin nennen sie), sonst leer. Unbekannte Programme zählen als
„Sonstige".
- Schreiben per `defer()` nach der Antwort in `FeedController::respond()`,
fehlertolerant, **auch bei warmem Cache** — der Abruf ist die Messgröße.
Stolperstein: die Swoole-Erweiterung im Container definiert ein globales
`defer()`, Laravels Helfer muss per `use function
Illuminate\Support\defer` importiert werden (wie in
`ReleaseDetailPageData`), sonst „API must be called in the coroutine".
- Aufbewahrung wie Ereignis- und Crawler-Log (395 Tage), angehängt an
`press-releases:purge-events`. `FEED_FETCHES_RECORDED_SINCE` beim Deploy
auf den Deploy-Tag setzen.
- Admin ▸ Reports ▸ Indexierung & Abrufe: neuer Block „Abrufe der
RSS-Feeds" — Summen je Feed-Art im Zeitraum, Tabelle je Reader-Dienst
(Abrufe, Feeds, gemeldete Abonnenten = je Feed das Maximum, summiert,
letzter Abruf), meistabgerufene Feeds mit aufgelöstem Namen
(`FeedFetchReport`). Verdichtung in PHP, damit MySQL und SQLite gleich
rechnen.
- Panel „Auslieferung": je Feed die Abrufe der letzten 30 Tage; Satz
angepasst („Gezählt werden Abrufe der Feeds … wer sie abonniert hat, ist
uns nicht bekannt"). RSS-Seite sagt jetzt ebenfalls, dass Abrufe gezählt
werden und keine IP gespeichert wird.
- Was das Protokoll **nicht** kann: Abonnenten zählen (nur die von Diensten
gemeldete Zahl; Desktop-Reader holen einzeln) und Klicks aus dem Reader
auf die Meldung zuordnen (Reader schicken keinen Referrer; ein
Herkunftsparameter an den Feed-Links wurde bewusst nicht gesetzt, weil
er Adressvarianten erzeugt).
Tests: `FeedFetchLogTest` (5: Erkennung, Protokoll ohne IP inkl.
Cache-Treffer, Report je Reader/Feed, Panel, Bereinigung),
`IndexStatusCheckTest` erweitert. Pint, Larastan ohne Befund.
**Deploy:** `migrate` (neue Tabelle, keine Änderung an `press_releases`),
`FEED_FETCHES_RECORDED_SINCE` setzen, `view:cache`/`route:cache`,
`config:cache`. **Offen (0.7, Betreiber):** Datenschutzerklärung — derselbe
Absatz wie für Ereignis- und Crawler-Log (Zweck, Umfang ohne IP, 395 Tage).

View file

@ -464,7 +464,7 @@ return [
'how_step3' => 'Im Reader abonnieren',
'how_step3_text' => 'Adresse einfügen, fertig. Ab jetzt erscheinen neue Meldungen von selbst; abbestellen geht jederzeit im Reader, ohne dass wir davon erfahren.',
'how_p1' => 'Jeder Feed führt die jüngsten :count Meldungen, neueste zuerst. Eine Meldung erscheint wenige Minuten nach ihrer Veröffentlichung; wird eine Meldung zurückgezogen, verschwindet sie auch aus dem Feed.',
'how_p2' => 'Die Feeds sind kostenlos, brauchen kein Konto und setzen keine Cookies. Wer sie abonniert hat, wissen wir nicht – ein Reader ruft die Adresse ab wie ein Browser eine Seite.',
'how_p2' => 'Die Feeds sind kostenlos, brauchen kein Konto und setzen keine Cookies. Wie oft ein Feed abgerufen wird und mit welchem Programm, zählen wir; wer ihn abonniert hat, wissen wir nicht – ein Reader ruft die Adresse ab wie ein Browser eine Seite, ohne IP-Speicherung auf unserer Seite.',
'feeds_heading' => 'Feeds auf :brand',
'main_eyebrow' => 'Hauptfeed',

View file

@ -462,7 +462,7 @@ return [
'how_step3' => 'Subscribe in the reader',
'how_step3_text' => 'Paste the address – done. New releases now show up by themselves; unsubscribing happens in your reader at any time, without us ever knowing.',
'how_p1' => 'Every feed carries the latest :count releases, newest first. A release appears within minutes of publication; if a release is withdrawn, it disappears from the feed as well.',
'how_p2' => 'The feeds are free, need no account and set no cookies. We do not know who subscribes – a reader fetches the address the way a browser fetches a page.',
'how_p2' => 'The feeds are free, need no account and set no cookies. We count how often a feed is fetched and by which program; we do not know who subscribes – a reader fetches the address the way a browser fetches a page, and we store no IP addresses.',
'feeds_heading' => 'Feeds on :brand',
'main_eyebrow' => 'Main feed',

View file

@ -3,6 +3,7 @@
use App\Enums\IndexState;
use App\Enums\Portal;
use App\Services\PressRelease\CrawlerHitReport;
use App\Services\PressRelease\FeedFetchReport;
use App\Services\SearchConsole\IndexStatusChecker;
use App\Services\SearchConsole\IndexStatusReport;
use App\Services\SearchConsole\SearchConsoleClient;
@ -22,6 +23,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
* - Welche Adresse führt Google als Quelle? Quellenbefund über
* googleCanonical, nur für Erstquellen.
* - Welche Such- und KI-Dienste holen die Seiten? Crawler-Abruf-Protokoll.
* - Wie oft werden die RSS-Feeds geholt, von welchen Readern? Feed-Abruf-
* Protokoll (3.1) — Abrufe und gemeldete Abonnenten, keine eigene Zählung.
*
* Der Kunde sieht davon nur, was GSC_CUSTOMER_VISIBLE freigibt (E8).
*/
@ -91,6 +94,7 @@ new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class e
$report = app(IndexStatusReport::class);
$checker = app(IndexStatusChecker::class);
$crawlers = app(CrawlerHitReport::class);
$feedFetches = app(FeedFetchReport::class);
$days = in_array($this->days, [7, 30, 90], true) ? $this->days : 30;
return [
@ -108,6 +112,10 @@ new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class e
'crawlerDaily' => $crawlers->daily(14),
'crawlerTop' => $crawlers->topReleases($days),
'crawlersSince' => config('crawlers.recorded_since') ? \Illuminate\Support\Carbon::parse((string) config('crawlers.recorded_since')) : null,
'feedTotals' => $feedFetches->totals($days),
'feedReaders' => $feedFetches->byReader($days),
'feedTop' => $feedFetches->topFeeds($days),
'feedsSince' => config('feeds.recorded_since') ? \Illuminate\Support\Carbon::parse((string) config('feeds.recorded_since')) : null,
'days' => $days,
];
}
@ -339,6 +347,72 @@ new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class e
</div>
</article>
{{-- Feed-Abruf-Protokoll (3.1): Abrufe der RSS-Feeds, gemeldete Abonnenten. --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Abrufe der RSS-Feeds') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)] tabular-nums">
{{ __(':n Abrufe in :days Tagen', ['n' => number_format($feedTotals['total'], 0, ',', '.'), 'days' => $days]) }}
· {{ __('Portal :n', ['n' => number_format($feedTotals['portal'], 0, ',', '.')]) }}
· {{ __('Branchen :n', ['n' => number_format($feedTotals['category'], 0, ',', '.')]) }}
· {{ __('Newsrooms :n', ['n' => number_format($feedTotals['newsroom'], 0, ',', '.')]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Reader-Dienst') }}</flux:table.column>
<flux:table.column>{{ __('Abrufe :days Tage', ['days' => $days]) }}</flux:table.column>
<flux:table.column>{{ __('Feeds') }}</flux:table.column>
<flux:table.column>{{ __('Gemeldete Abonnenten') }}</flux:table.column>
<flux:table.column>{{ __('Letzter Abruf') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($feedReaders as $reader)
<flux:table.row :key="'feed-reader-'.($reader['reader'] ?? 'other')">
<flux:table.cell><span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $reader['label'] }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($reader['fetches'], 0, ',', '.') }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($reader['feeds'], 0, ',', '.') }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $reader['subscribers'] === null ? '—' : number_format($reader['subscribers'], 0, ',', '.') }}</span></flux:table.cell>
<flux:table.cell><span class="text-[12px] tabular-nums text-[color:var(--color-ink-3)]">{{ $reader['last_at']->format('d.m.Y H:i') }}</span></flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Noch kein Feed-Abruf protokolliert.') }}
@if ($feedsSince)
{{ __('Erfasst seit :date.', ['date' => $feedsSince->format('d.m.Y')]) }}
@endif
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if ($feedTop !== [])
<div class="border-t border-[color:var(--color-bg-rule)]">
<div class="px-5 pt-4 pb-2 text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Am häufigsten abgerufene Feeds, :days Tage', ['days' => $days]) }}</div>
<ul class="px-5 pb-4 m-0 list-none space-y-1">
@foreach ($feedTop as $top)
<li class="flex flex-wrap items-baseline justify-between gap-x-3 text-[12.5px]">
<span class="text-[color:var(--color-ink)] truncate max-w-[70%]">{{ $top['label'] }}</span>
<span class="tabular-nums text-[color:var(--color-ink-3)]">{{ trans_choice(':n Abruf|:n Abrufe', $top['fetches'], ['n' => $top['fetches']]) }} · {{ trans_choice(':n Reader|:n Reader', $top['readers'], ['n' => $top['readers']]) }}@if ($top['subscribers'] !== null) · {{ __(':n Abonnenten gemeldet', ['n' => $top['subscribers']]) }}@endif</span>
</li>
@endforeach
</ul>
</div>
@endif
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-4)]">
{{ __('Gezählt werden Abrufe der Feed-Adressen; ein Reader-Dienst holt einen Feed einmal für alle seine Nutzer. „Gemeldete Abonnenten" ist die Zahl, die Dienste wie Feedly oder Inoreader selbst im User-Agent nennen — je Feed das Maximum, summiert. Erkennung über den User-Agent (config/feeds.php), keine IP-Adressen.') }}
@if ($feedsSince)
{{ __('Protokoll seit :date.', ['date' => $feedsSince->format('d.m.Y')]) }}
@endif
</div>
</article>
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Jüngste Prüfungen') }}</span>

View file

@ -1604,10 +1604,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@if (($visibility['feeds'] ?? []) !== [])
<ul class="mt-1 m-0 p-0 list-none space-y-0.5 text-[12.5px]">
@foreach ($visibility['feeds'] as $feed)
<li><a href="{{ $feed['url'] }}" target="_blank" rel="noopener" class="text-[color:var(--color-ink-2)] underline underline-offset-2 hover:text-[color:var(--color-ink)]">{{ $feed['label'] }}</a></li>
<li class="flex flex-wrap items-baseline justify-between gap-x-3">
<a href="{{ $feed['url'] }}" target="_blank" rel="noopener" class="text-[color:var(--color-ink-2)] underline underline-offset-2 hover:text-[color:var(--color-ink)]">{{ $feed['label'] }}</a>
<span class="tabular-nums text-[11.5px] text-[color:var(--color-ink-3)]">{{ trans_choice(':n Abruf|:n Abrufe', $feed['fetches_30d'] ?? 0, ['n' => number_format($feed['fetches_30d'] ?? 0, 0, ',', '.')]) }} · {{ __('30 Tage') }}</span>
</li>
@endforeach
</ul>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Die Feeds führen die jüngsten :n Meldungen — wer sie abonniert hat, ist uns nicht bekannt.', ['n' => \App\Http\Controllers\Web\FeedController::MAX_ITEMS]) }}</p>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Die Feeds führen die jüngsten :n Meldungen. Gezählt werden Abrufe der Feeds (ein Reader-Dienst holt einmal für alle seine Nutzer) — wer sie abonniert hat, ist uns nicht bekannt.', ['n' => \App\Http\Controllers\Web\FeedController::MAX_ITEMS]) }}</p>
@else
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Derzeit in keinem Feed — die Meldung ist nicht öffentlich.') }}</p>
@endif

View file

@ -203,5 +203,6 @@ test('the admin report splits the distribution by origin and lists the latest ch
->assertSeeText('Kopie mit Verweis')
->assertSeeText('Quellenbefund')
->assertSeeText('Abrufe durch Such- und KI-Dienste')
->assertSeeText('Abrufe der RSS-Feeds')
->assertSeeText('Kundenansicht');
});

View file

@ -0,0 +1,150 @@
<?php
use App\Console\Commands\AggregatePressReleaseViews;
use App\Console\Commands\PurgePressReleaseEvents;
use App\Enums\Portal;
use App\Models\AppSetting;
use App\Models\Category;
use App\Models\Company;
use App\Models\FeedFetch;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\FeedFetchReport;
use App\Services\PressRelease\VisibilityStats;
use App\Support\FeedReaderIdentifier;
use Tests\TestCase;
/**
* Abruf-Protokoll der RSS-Feeds (Roadmap 3.1): jeder Feed-Abruf wird mit
* Art, Schlüssel, Reader-Dienst und gemeldeter Abonnentenzahl gespeichert —
* ohne IP. Abrufe zählen wir, Abonnenten meldet der Dienst.
*/
function feedLogCategory(): Category
{
$category = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
return $category;
}
function feedLogRelease(Category $category, Company $company, string $slug): PressRelease
{
return PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)
->create(['slug' => $slug, 'language' => 'de', 'published_at' => now()->subHour()]);
}
test('the identifier maps reader user agents and reads the reported subscriber count', function (?string $userAgent, ?string $reader, ?int $subscribers) {
expect(FeedReaderIdentifier::identify($userAgent))->toBe($reader)
->and(FeedReaderIdentifier::subscribers($userAgent))->toBe($subscribers);
})->with([
['Feedly/1.0 (+http://www.feedly.com/fetcher.html; 12 subscribers; like FeedFetcher-Google)', 'feedly', 12],
['Mozilla/5.0 (compatible; inoreader.com; 5 subscribers; feed-id=123)', 'inoreader', 5],
['NewsBlur Feed Fetcher - 3 subscribers - https://newsblur.com', 'newsblur', 3],
['Feedbin feed-id:99 - 1 subscriber', 'feedbin', 1],
['Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Thunderbird/115.0', 'thunderbird', null],
['NetNewsWire (RSS Reader; https://netnewswire.com/)', 'netnewswire', null],
['Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/537.36 Chrome/128.0 Safari/537.36', 'browser', null],
['curl/8.4.0', null, null],
['', null, null],
[null, null, null],
]);
test('every feed fetch is logged with feed, key, portal, language and reader but without an IP', function () {
/** @var TestCase $this */
$category = feedLogCategory();
$company = Company::factory()->presseecho()->create(['slug' => 'nordwind', 'is_active' => true]);
feedLogRelease($category, $company, 'nordwind-neu');
$feedly = 'Feedly/1.0 (+http://www.feedly.com/fetcher.html; 12 subscribers; like FeedFetcher-Google)';
$this->withHeaders(['User-Agent' => $feedly])->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
// Zweiter Abruf kommt aus dem Cache — und wird trotzdem gezählt.
$this->withHeaders(['User-Agent' => $feedly])->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
$this->withHeaders(['User-Agent' => 'curl/8.4.0'])->get('https://presseecho.test/de/feed')->assertSuccessful();
$this->withHeaders(['User-Agent' => 'NetNewsWire (RSS Reader; https://netnewswire.com/)'])->get('https://presseecho.test/en/newsroom/nordwind/feed')->assertNotFound();
$this->withHeaders(['User-Agent' => 'NetNewsWire (RSS Reader; https://netnewswire.com/)'])->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertSuccessful();
expect(FeedFetch::query()->count())->toBe(4);
$categoryFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_CATEGORY)->first();
expect($categoryFetch->feed_key)->toBe('handel')
->and($categoryFetch->portal)->toBe('presseecho')
->and($categoryFetch->language)->toBe('de')
->and($categoryFetch->reader)->toBe('feedly')
->and($categoryFetch->subscribers)->toBe(12)
->and($categoryFetch->getAttributes())->not->toHaveKey('ip');
$portalFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_PORTAL)->sole();
expect($portalFetch->feed_key)->toBeNull()
->and($portalFetch->reader)->toBeNull()
->and($portalFetch->subscribers)->toBeNull();
$newsroomFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_NEWSROOM)->sole();
expect($newsroomFetch->feed_key)->toBe('nordwind')
->and($newsroomFetch->reader)->toBe('netnewswire');
});
test('the report counts fetches per reader with subscribers as the per-feed maximum, and the top feeds by name', function () {
/** @var TestCase $this */
feedLogCategory();
Company::factory()->presseecho()->create(['name' => 'Nordwind Energie AG', 'slug' => 'nordwind', 'is_active' => true]);
FeedFetch::factory()->category('handel')->reader('feedly', 12)->count(3)->create();
FeedFetch::factory()->category('handel')->reader('feedly', 14)->create();
FeedFetch::factory()->newsroom('nordwind')->reader('feedly', 2)->create();
FeedFetch::factory()->reader('inoreader', 5)->count(2)->create();
FeedFetch::factory()->reader(null)->create();
FeedFetch::factory()->reader('feedly', 99)->create(['created_at' => now()->subDays(40)]);
$report = app(FeedFetchReport::class);
$readers = collect($report->byReader(30))->keyBy('reader');
expect($readers->get('feedly')['fetches'])->toBe(5)
->and($readers->get('feedly')['feeds'])->toBe(2)
->and($readers->get('feedly')['subscribers'])->toBe(16)
->and($readers->get('inoreader')['fetches'])->toBe(2)
->and($readers->get('inoreader')['subscribers'])->toBe(5)
->and($readers->get('')['label'])->toBe('Sonstige')
->and($readers->get('')['subscribers'])->toBeNull();
$top = collect($report->topFeeds(30))->keyBy(fn (array $row) => $row['feed'].':'.$row['feed_key']);
expect($top->get('category:handel')['label'])->toBe('Presseecho · DE · Branche · Handel')
->and($top->get('category:handel')['fetches'])->toBe(4)
->and($top->get('category:handel')['subscribers'])->toBe(14)
->and($top->get('newsroom:nordwind')['label'])->toBe('Presseecho · DE · Newsroom · Nordwind Energie AG')
->and($top->get('portal:')['fetches'])->toBe(3)
->and($report->totals(30))->toMatchArray(['total' => 8, 'portal' => 3, 'category' => 4, 'newsroom' => 1, 'since_total' => 9]);
});
test('the delivery panel shows the fetch count per feed for the last 30 days', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create(['slug' => 'nordwind', 'is_active' => true]);
$customer->companies()->attach($company->id, ['role' => 'member']);
$category = feedLogCategory();
$release = feedLogRelease($category, $company, 'nordwind-neu');
FeedFetch::factory()->category('handel')->count(7)->create();
FeedFetch::factory()->newsroom('nordwind')->count(2)->create();
FeedFetch::factory()->count(1)->create();
FeedFetch::factory()->category('handel')->create(['portal' => 'businessportal24']);
FeedFetch::factory()->category('handel')->create(['created_at' => now()->subDays(45)]);
$feeds = collect(app(VisibilityStats::class)->releaseSummary($release)['feeds'])->keyBy('url');
expect($feeds->get('https://presseecho.test/de/category/handel/feed')['fetches_30d'])->toBe(7)
->and($feeds->get('https://presseecho.test/de/newsroom/nordwind/feed')['fetches_30d'])->toBe(2)
->and($feeds->get('https://presseecho.test/de/feed')['fetches_30d'])->toBe(1);
});
test('the purge command removes feed fetches older than the retention period', function () {
/** @var TestCase $this */
AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, '0');
FeedFetch::factory()->create(['created_at' => now()->subDays(400)]);
FeedFetch::factory()->create(['created_at' => now()->subDays(10)]);
$this->artisan(PurgePressReleaseEvents::class, ['--days' => 395])->assertSuccessful();
expect(FeedFetch::query()->count())->toBe(1);
});