diff --git a/.env.example b/.env.example index 60eb00a1..f7d815f9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Console/Commands/PurgePressReleaseEvents.php b/app/Console/Commands/PurgePressReleaseEvents.php index 2468a653..127d464b 100644 --- a/app/Console/Commands/PurgePressReleaseEvents.php +++ b/app/Console/Commands/PurgePressReleaseEvents.php @@ -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; } } diff --git a/app/Http/Controllers/Web/FeedController.php b/app/Http/Controllers/Web/FeedController.php index b99a7852..c9b76cb7 100644 --- a/app/Http/Controllers/Web/FeedController.php +++ b/app/Http/Controllers/Web/FeedController.php @@ -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 $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']); } diff --git a/app/Models/FeedFetch.php b/app/Models/FeedFetch.php new file mode 100644 index 00000000..8021b7d6 --- /dev/null +++ b/app/Models/FeedFetch.php @@ -0,0 +1,60 @@ + */ + 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); + } + } +} diff --git a/app/Services/PressRelease/FeedFetchReport.php b/app/Services/PressRelease/FeedFetchReport.php new file mode 100644 index 00000000..e1935a54 --- /dev/null +++ b/app/Services/PressRelease/FeedFetchReport.php @@ -0,0 +1,211 @@ + + */ + 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 + */ + 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 $feeds + * @return array + */ + 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 $slugs + * @return array `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 $slugs + * @return array + */ + 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(); + } +} diff --git a/app/Services/PressRelease/VisibilityStats.php b/app/Services/PressRelease/VisibilityStats.php index dbffeb41..0fdc4fcd 100644 --- a/app/Services/PressRelease/VisibilityStats.php +++ b/app/Services/PressRelease/VisibilityStats.php @@ -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, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list, crawlers_since: Carbon|null, feeds: list} + * @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list, crawlers_since: Carbon|null, feeds: list} */ public function releaseSummary(PressRelease $pressRelease): array { @@ -179,7 +180,7 @@ class VisibilityStats * Meldungen — eine Aussage über uns, nicht über Abonnenten. * * @param list $links - * @return list + * @return list */ 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); } /** diff --git a/app/Support/FeedReaderIdentifier.php b/app/Support/FeedReaderIdentifier.php new file mode 100644 index 00000000..4d618e46 --- /dev/null +++ b/app/Support/FeedReaderIdentifier.php @@ -0,0 +1,54 @@ +}> $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); + } +} diff --git a/config/feeds.php b/config/feeds.php new file mode 100644 index 00000000..2fef4305 --- /dev/null +++ b/config/feeds.php @@ -0,0 +1,46 @@ + 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']], + ], + +]; diff --git a/database/factories/FeedFetchFactory.php b/database/factories/FeedFetchFactory.php new file mode 100644 index 00000000..38057e78 --- /dev/null +++ b/database/factories/FeedFetchFactory.php @@ -0,0 +1,47 @@ + + */ +class FeedFetchFactory extends Factory +{ + /** + * @return array + */ + 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'), + ]); + } +} diff --git a/database/migrations/2026_09_21_100000_create_feed_fetches_table.php b/database/migrations/2026_09_21_100000_create_feed_fetches_table.php new file mode 100644 index 00000000..2b5b0795 --- /dev/null +++ b/database/migrations/2026_09_21_100000_create_feed_fetches_table.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 82e9b9db..405ab74a 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -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 | diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 2f0d6b06..e741782e 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -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). + diff --git a/lang/de/static.php b/lang/de/static.php index 29c22db1..9fa43814 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -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', diff --git a/lang/en/static.php b/lang/en/static.php index fd9f1ed3..a0cf024f 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -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', diff --git a/resources/views/livewire/admin/reports/index-status.blade.php b/resources/views/livewire/admin/reports/index-status.blade.php index 905703d1..f6a10fbe 100644 --- a/resources/views/livewire/admin/reports/index-status.blade.php +++ b/resources/views/livewire/admin/reports/index-status.blade.php @@ -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 + {{-- Feed-Abruf-Protokoll (3.1): Abrufe der RSS-Feeds, gemeldete Abonnenten. --}} +
+
+ {{ __('Abrufe der RSS-Feeds') }} + + {{ __(':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, ',', '.')]) }} + +
+ + + + {{ __('Reader-Dienst') }} + {{ __('Abrufe :days Tage', ['days' => $days]) }} + {{ __('Feeds') }} + {{ __('Gemeldete Abonnenten') }} + {{ __('Letzter Abruf') }} + + + @forelse ($feedReaders as $reader) + + {{ $reader['label'] }} + {{ number_format($reader['fetches'], 0, ',', '.') }} + {{ number_format($reader['feeds'], 0, ',', '.') }} + {{ $reader['subscribers'] === null ? '—' : number_format($reader['subscribers'], 0, ',', '.') }} + {{ $reader['last_at']->format('d.m.Y H:i') }} + + @empty + + +
+ {{ __('Noch kein Feed-Abruf protokolliert.') }} + @if ($feedsSince) + {{ __('Erfasst seit :date.', ['date' => $feedsSince->format('d.m.Y')]) }} + @endif +
+
+
+ @endforelse +
+
+ + @if ($feedTop !== []) +
+
{{ __('Am häufigsten abgerufene Feeds, :days Tage', ['days' => $days]) }}
+
    + @foreach ($feedTop as $top) +
  • + {{ $top['label'] }} + {{ 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 +
  • + @endforeach +
+
+ @endif + +
+ {{ __('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 +
+
+
{{ __('Jüngste Prüfungen') }} diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index b3332af9..96d1eb51 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -1604,10 +1604,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @if (($visibility['feeds'] ?? []) !== [])
    @foreach ($visibility['feeds'] as $feed) -
  • {{ $feed['label'] }}
  • +
  • + {{ $feed['label'] }} + {{ trans_choice(':n Abruf|:n Abrufe', $feed['fetches_30d'] ?? 0, ['n' => number_format($feed['fetches_30d'] ?? 0, 0, ',', '.')]) }} · {{ __('30 Tage') }} +
  • @endforeach
-

{{ __('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]) }}

+

{{ __('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]) }}

@else

{{ __('Derzeit in keinem Feed — die Meldung ist nicht öffentlich.') }}

@endif diff --git a/tests/Feature/IndexStatusCheckTest.php b/tests/Feature/IndexStatusCheckTest.php index e7ab1153..65dbc979 100644 --- a/tests/Feature/IndexStatusCheckTest.php +++ b/tests/Feature/IndexStatusCheckTest.php @@ -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'); }); diff --git a/tests/Feature/Web/FeedFetchLogTest.php b/tests/Feature/Web/FeedFetchLogTest.php new file mode 100644 index 00000000..7871efb1 --- /dev/null +++ b/tests/Feature/Web/FeedFetchLogTest.php @@ -0,0 +1,150 @@ +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); +});