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:
parent
9d5e5a0b54
commit
ff75159b8f
22 changed files with 577 additions and 55 deletions
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'',
|
||||
|
|
|
|||
28
app/Http/Controllers/Web/RssPageController.php
Normal file
28
app/Http/Controllers/Web/RssPageController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue