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>
This commit is contained in:
Kevin Adametz 2026-09-18 13:57:09 +00:00
parent b423650d80
commit d0c7194491
20 changed files with 547 additions and 28 deletions

View file

@ -0,0 +1,125 @@
<?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();
}
}

View file

@ -106,6 +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',
'',
'## Kategorien',
'',