web: RSS-Feed je Newsroom, RSS-Übersichtsseite, „RSS" in der Topleiste (Roadmap 3.1, Nachtrag)

- /{edition}/newsroom/{slug}/feed: Sichtbarkeit wie die Newsroom-Seite
  (aktiv, nicht entfernt, mindestens eine Meldung), Prüfung vor dem Cache,
  301 bei zusammengeführter Firma; Autodiscovery und Link „RSS-Feed" auf
  der Newsroom-Seite; Hub leitet auf das Portal der Firma.
- /{edition}/rss (RssPageController, static.rss DE/EN): Was RSS ist, wie
  ein Reader funktioniert, Hauptfeed, alle Branchen-Feeds mit Adresse,
  Muster des Newsroom-Feeds, Hinweise. Portalseite mit Portal-Kontext,
  Hub → presseecho, in Sitemap und llms.txt.
- Topleiste, Burger-Menü und Footer verlinken RSS neben FAQ/Hilfe/API.
- FeedController in portal/category/newsroom aufgeteilt; Panel
  „Auslieferung" nennt zusätzlich den Newsroom-Feed.
- Tests: FeedTest (+3), RssPageTest (4), StaticPagesTest, VisibilityStatsTest.
- dev/2026-09-01: Nachtrag in Protokoll Teil 11 und Roadmap 3.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-21 08:27:14 +00:00
parent 9d5e5a0b54
commit ff75159b8f
22 changed files with 577 additions and 55 deletions

View file

@ -4,23 +4,29 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Company;
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;
/**
* 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.
* 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
@ -33,29 +39,33 @@ class FeedController extends Controller
public const TEASER_LENGTH = 300;
public function __invoke(Request $request, string $edition, ?string $slug = null): Response
public function __construct(private readonly MergedNewsroomLookup $mergedNewsrooms) {}
public function portal(Request $request, string $edition): 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 $this->respond($context, ['all'], function () use ($context, $base): string {
$releases = $this->releases($context)->get($this->columns());
$brand = (array) ($context->domainConfig['brand'] ?? []);
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
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)])),
]);
});
}
private function render(WebPortalContext $context, string $base, ?string $slug): string
public function category(Request $request, string $edition, string $slug): Response
{
$context = WebPortalContext::fromRequest();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$language = $context->language;
$publisher = (string) StructuredData::publisher($base)['name'];
$brand = (array) ($context->domainConfig['brand'] ?? []);
$categoryIds = null;
$category = null;
if ($slug !== null) {
return $this->respond($context, ['category', $slug], function () use ($context, $base, $slug, $language): string {
$category = Category::query()
->whereIn('portal', $context->portalValues)
->where('is_active', true)
@ -75,21 +85,125 @@ class FeedController extends Controller
->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);
}
$releases = PressRelease::query()
return $this->respond($context, ['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)]),
]);
});
}
/**
* @param list<string> $keySegments
* @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,
);
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($language)
->forLanguage($context->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),
'category.translations' => fn ($query) => $query->where('locale', $context->language),
])
->orderByDesc('published_at')
->limit(self::MAX_ITEMS)
->get(['id', 'slug', 'title', 'text', 'portal', 'language', 'category_id', 'company_id', '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,
@ -100,22 +214,7 @@ class FeedController extends Controller
'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])),
$channel += [
'language' => $language,
'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(),
];

View file

@ -106,7 +106,7 @@ class LlmsTxtController extends Controller
'- Newsroom eines Unternehmens: '.$base.'/de/newsroom/{slug} — Übersicht unter '.$base.'/de/newsrooms',
'- Kategorie: '.$base.'/de/category/{slug}',
'- Sitemap: '.$base.'/sitemap.xml — News-Sitemap (Meldungen der letzten 48 Stunden): '.$base.'/sitemap-news.xml',
'- RSS-Feed aller Meldungen: '.$base.'/de/feed — je Kategorie: '.$base.'/de/category/{slug}/feed',
'- RSS-Feeds: alle Meldungen '.$base.'/de/feed — je Kategorie '.$base.'/de/category/{slug}/feed — je Newsroom '.$base.'/de/newsroom/{slug}/feed — Übersicht: '.$base.'/de/rss',
'',
'## Kategorien',
'',

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Support\CategoryNavigation;
use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View;
/**
* RSS-Übersicht (Roadmap 3.1): erklärt Feeds und listet Hauptfeed,
* Branchen-Feeds und das Newsroom-Muster des Portals. Portalseite wie Preise
* oder Newsrooms — der Portal-Kontext wird angewendet, damit Branding und
* Adressen zum aufgerufenen Portal gehören; auf dem Hub leitet die Route auf
* das führende Portal (config/canonical.php, portal_only_pages).
*/
class RssPageController extends Controller
{
public function __invoke(): View
{
$context = WebPortalContext::fromRequest();
return view('web.rss', [
'portalContext' => $context,
'feedCategories' => CategoryNavigation::items(null),
]);
}
}

View file

@ -34,7 +34,7 @@ class SitemapController extends Controller
/** @var list<string> */
private const PORTAL_PAGES = [
'preise', 'veroeffentlichen', 'newsrooms', 'faq', 'hilfe', 'gute-pressemitteilung', 'api',
'preise', 'veroeffentlichen', 'newsrooms', 'faq', 'hilfe', 'gute-pressemitteilung', 'api', 'rss',
'was-kostet-eine-pressemitteilung', 'pressemitteilung-veroeffentlichen-ohne-abo', 'pressemitteilung-loeschen-oder-aendern-lassen', 'pressemitteilung-kostenlos-veroeffentlichen',
'impressum', 'datenschutz', 'agb', 'richtlinien', 'melden',
];

View file

@ -61,7 +61,7 @@ class RedirectPortalOnlyPagesFromHub
// Kategorie und Jahresarchiv gibt es auf beiden Portalen — dort
// entscheidet das Standard-Portal.
if (! is_string($slug) || ! in_array($routeName, ['release.detail', 'newsroom', 'newsroom.claim'], true)) {
if (! is_string($slug) || ! in_array($routeName, ['release.detail', 'newsroom', 'newsroom.claim', 'newsroom.feed'], true)) {
return StaticPageCanonical::defaultPortalBase();
}

View file

@ -175,7 +175,7 @@ class VisibilityStats
/**
* RSS-Feeds, in denen die Meldung erscheint (Roadmap 3.1): je Portal der
* Feed der Branche und der Portal-Feed. Beide führen die jüngsten
* Feed der Branche, der des Newsrooms und der Portal-Feed. Beide führen die jüngsten
* Meldungen — eine Aussage über uns, nicht über Abonnenten.
*
* @param list<array{portal: Portal, label: string, url: string}> $links
@ -190,6 +190,7 @@ class VisibilityStats
$edition = in_array($pressRelease->language, SetEdition::EDITIONS, true) ? $pressRelease->language : SetEdition::DEFAULT_EDITION;
$categorySlug = $pressRelease->category?->translations()->where('locale', $edition)->value('slug');
$categoryName = $pressRelease->localizedCategoryName($edition);
$companySlug = $pressRelease->company?->slug;
$feeds = [];
@ -203,6 +204,13 @@ class VisibilityStats
];
}
if ($companySlug !== null) {
$feeds[] = [
'label' => $link['label'].' · Newsroom',
'url' => $base.'/'.$edition.'/newsroom/'.$companySlug.'/feed',
];
}
$feeds[] = [
'label' => $link['label'],
'url' => $base.'/'.$edition.'/feed',