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

@ -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']);
}