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 @@
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