presseportale/app/Http/Controllers/Web/FeedController.php
Kevin Adametz 44d0bb6f29 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>
2026-09-21 09:00:09 +00:00

236 lines
9.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
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;
use App\Support\StructuredData;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
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
* Branchenseite) und je Newsroom (`/{edition}/newsroom/{slug}/feed`). Immer
* die jüngsten Meldungen der Ausgabe (Portal × Sprache). Bei
* Doppelveröffentlichung erscheint je Portal die eigene Fassung; Leser und
* Feeds sehen beide (Anhang 2), nur die Suchmaschine bekommt die Erstquelle.
*
* Gecacht wie die Seitendaten (WebPageCache: 5 min frisch, danach stale und
* Neuaufbau nach der Antwort); Entfernungen leeren ihn mit den übrigen
* Seiten-Caches. Der Hub hat keine Meldungen — die Routen leiten dort auf
* das Portal (config/canonical.php, portal_only_content_routes).
*/
class FeedController extends Controller
{
public const MAX_ITEMS = 50;
public const TEASER_LENGTH = 300;
public function __construct(private readonly MergedNewsroomLookup $mergedNewsrooms) {}
public function portal(Request $request, string $edition): Response
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
return $this->respond($context, [FeedFetch::FEED_PORTAL], function () use ($context, $base): string {
$releases = $this->releases($context)->get($this->columns());
$brand = (array) ($context->domainConfig['brand'] ?? []);
return $this->render($context, $base, $releases, [
'title' => __('web.rss.portal_title', ['brand' => $this->publisher($base)]),
'link' => $base.'/'.$context->language,
'self' => $base.'/'.$context->language.'/feed',
'description' => (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $this->publisher($base)])),
]);
});
}
public function category(Request $request, string $edition, string $slug): Response
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$language = $context->language;
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)
->whereHas('translations', fn (Builder $query) => $query->where('locale', $language)->where('slug', $slug))
->with(['translations' => fn ($query) => $query->where('locale', $language)])
->first();
if (! $category) {
abort(404);
}
$categoryIds = Category::query()
->where('parent_id', $category->id)
->where('is_active', true)
->pluck('id')
->prepend($category->id)
->unique()
->values()
->all();
$releases = $this->releases($context)->whereIn('category_id', $categoryIds)->get($this->columns());
$translation = $category->translations->first();
$brand = (array) ($context->domainConfig['brand'] ?? []);
return $this->render($context, $base, $releases, [
'title' => __('web.rss.category_title', ['category' => (string) $translation?->name, 'brand' => $this->publisher($base)]),
'link' => $base.'/'.$language.'/category/'.$slug,
'self' => $base.'/'.$language.'/category/'.$slug.'/feed',
'description' => filled($translation?->description)
? (string) $translation->description
: (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $this->publisher($base)])),
]);
});
}
/**
* Feed eines Newsrooms: dieselbe Sichtbarkeitsregel wie die Newsroom-Seite
* (aktive Firma, nicht entfernt, mindestens eine veröffentlichte Meldung).
* Die Prüfung läuft bewusst vor dem Cache, damit eine Entfernung sofort
* greift; eine in einer Zusammenführung aufgegangene Firma führt per 301
* zum bleibenden Newsroom (Roadmap 1a.3).
*/
public function newsroom(Request $request, string $edition, string $slug): Response|RedirectResponse
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$language = $context->language;
$company = Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $context->portalValues)
->where('slug', $slug)
->where('is_active', true)
->whereNull('gone_at')
->first(['id', 'name', 'slug', 'boilerplate']);
if (! $company) {
$survivor = $this->mergedNewsrooms->survivorFor($context->portalValues, $slug);
if ($survivor !== null && $survivor->is_active && $survivor->slug !== $slug) {
return redirect($base.'/'.$language.'/newsroom/'.$survivor->slug.'/feed', 301);
}
abort(404);
}
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()) {
abort(404);
}
$boilerplate = trim((string) Str::of(strip_tags((string) $company->boilerplate))->squish());
return $this->render($context, $base, $releases, [
'title' => __('web.rss.newsroom_title', ['company' => (string) $company->name, 'brand' => $this->publisher($base)]),
'link' => $base.'/'.$language.'/newsroom/'.$company->slug,
'self' => $base.'/'.$language.'/newsroom/'.$company->slug.'/feed',
'description' => $boilerplate !== ''
? Str::limit($boilerplate, 300)
: __('web.rss.newsroom_description', ['company' => (string) $company->name, 'brand' => $this->publisher($base)]),
]);
});
}
/**
* 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
{
$xml = WebPageCache::remember(
WebPageCache::key('feed', [$context->portal->value, $context->language, ...$keySegments]),
$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']);
}
/**
* @return Builder<PressRelease>
*/
private function releases(WebPortalContext $context): Builder
{
return PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($context->portalValues)
->forLanguage($context->language)
->visibleInArchive()
->with([
'company:id,name',
'category.translations' => fn ($query) => $query->where('locale', $context->language),
])
->orderByDesc('published_at')
->limit(self::MAX_ITEMS);
}
/**
* @return list<string>
*/
private function columns(): array
{
return ['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', 'published_at'];
}
private function publisher(string $base): string
{
return (string) StructuredData::publisher($base)['name'];
}
/**
* @param Collection<int, PressRelease> $releases
* @param array{title: string, link: string, self: string, description: string} $channel
*/
private function render(WebPortalContext $context, string $base, Collection $releases, array $channel): string
{
$language = $context->language;
$items = $releases->map(fn (PressRelease $release): array => [
'title' => (string) $release->title,
'link' => $base.'/'.$language.'/press-release/'.$release->slug,
'published_at' => $release->published_at?->toRfc2822String() ?? '',
'description' => $release->teaserText(self::TEASER_LENGTH),
'category' => $release->localizedCategoryName($language),
'creator' => $release->company?->name,
])->all();
$channel += [
'language' => $language,
'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(),
];
return view('web.feed', ['channel' => $channel, 'items' => $items])->render();
}
}