presseportale/app/Http/Controllers/Web/FeedController.php
Kevin Adametz 9d5e5a0b54 web: RSS-Feeds je Branche und je Portal (Roadmap 3.1); dev: Sichtung des Ordners, Protokoll-Teile umnummeriert
- /{edition}/feed und /{edition}/category/{slug}/feed (FeedController,
  RSS 2.0 mit atom:link self, dc:creator, category; jüngste 50 der Ausgabe,
  Branche inkl. aktiver Unterkategorien, nur visibleInArchive).
- Cache über WebPageCache (Key-Gruppe feed), Hub leitet per 301 aufs Portal
  (canonical.portal_only_content_routes).
- Autodiscovery über @yield('feed_links') auf Start-, Branchen- und
  Archivseiten, sichtbarer Link in der Archiv-Leiste, llms.txt nennt die
  Feed-Muster, Block „RSS-Feeds" im Panel „Auslieferung"
  (VisibilityStats::feedLinks).
- Übersetzungen unter web.rss (web.feed ist die Startseiten-Liste).
- Tests: FeedTest (7), VisibilityStatsTest erweitert.
- dev/2026-09-01: Sichtung 18.09. (Teil 11), zweite Teil-Serie des
  Protokolls jetzt 7–10 mit nachgezogenen Verweisen, Anhang 1 abgeglichen,
  E6 abgehakt, 2.6 extern, 1c.5/E8 wartet auf Daten, 3.1 abgehakt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 13:57:09 +00:00

125 lines
5.1 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\PressRelease;
use App\Scopes\PortalScope;
use App\Support\StructuredData;
use App\Support\WebPageCache;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
/**
* RSS-Feeds je Branche und je Portal (Roadmap 3.1): `/{edition}/feed` und
* `/{edition}/category/{slug}/feed`. Die jüngsten Meldungen der Ausgabe
* (Portal × Sprache), bei einer Branche inklusive Unterkategorien — wie die
* Branchenseite selbst. 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 __invoke(Request $request, string $edition, ?string $slug = null): Response
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$xml = WebPageCache::remember(
WebPageCache::key('feed', [$context->portal->value, $context->language, $slug ?? 'all']),
fn (): string => $this->render($context, $base, $slug),
);
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}
private function render(WebPortalContext $context, string $base, ?string $slug): string
{
$language = $context->language;
$publisher = (string) StructuredData::publisher($base)['name'];
$brand = (array) ($context->domainConfig['brand'] ?? []);
$categoryIds = null;
$category = null;
if ($slug !== null) {
$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 = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->forPortalValues($context->portalValues)
->forLanguage($language)
->visibleInArchive()
->when($categoryIds !== null, fn (Builder $query) => $query->whereIn('category_id', $categoryIds))
->with([
'company:id,name',
'category.translations' => fn ($query) => $query->where('locale', $language),
])
->orderByDesc('published_at')
->limit(self::MAX_ITEMS)
->get(['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', 'published_at']);
$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();
$translation = $category?->translations->first();
$categoryName = $translation?->name;
$channel = [
'title' => $categoryName !== null
? __('web.rss.category_title', ['category' => $categoryName, 'brand' => $publisher])
: __('web.rss.portal_title', ['brand' => $publisher]),
'link' => $categoryName !== null
? $base.'/'.$language.'/category/'.$slug
: $base.'/'.$language,
'self' => $categoryName !== null
? $base.'/'.$language.'/category/'.$slug.'/feed'
: $base.'/'.$language.'/feed',
'description' => filled($translation?->description)
? (string) $translation->description
: (string) ($brand['tagline_long'] ?? __('web.rss.default_description', ['brand' => $publisher])),
'language' => $language,
'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(),
];
return view('web.feed', ['channel' => $channel, 'items' => $items])->render();
}
}