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 e2ef4dee31
commit 9d5e5a0b54
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',
'',

View file

@ -2,9 +2,11 @@
namespace App\Services\PressRelease;
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Enums\PressReleaseShareChannel;
use App\Enums\PressReleaseStatus;
use App\Http\Middleware\SetEdition;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
@ -133,7 +135,7 @@ class VisibilityStats
/**
* Einzelwerte einer Meldung für das Panel „Auslieferung".
*
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null}
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null, feeds: list<array{label: string, url: string}>}
*/
public function releaseSummary(PressRelease $pressRelease): array
{
@ -167,9 +169,49 @@ class VisibilityStats
'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
'crawlers' => $this->crawlerFetches($pressRelease),
'crawlers_since' => $this->crawlerHitsSince(),
'feeds' => $this->feedLinks($pressRelease, $links),
];
}
/**
* 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
* Meldungen — eine Aussage über uns, nicht über Abonnenten.
*
* @param list<array{portal: Portal, label: string, url: string}> $links
* @return list<array{label: string, url: string}>
*/
private function feedLinks(PressRelease $pressRelease, array $links): array
{
if ($links === []) {
return [];
}
$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);
$feeds = [];
foreach ($links as $link) {
$base = (string) parse_url($link['url'], PHP_URL_SCHEME).'://'.(string) parse_url($link['url'], PHP_URL_HOST);
if ($categorySlug !== null) {
$feeds[] = [
'label' => $link['label'].' · '.$categoryName,
'url' => $base.'/'.$edition.'/category/'.$categorySlug.'/feed',
];
}
$feeds[] = [
'label' => $link['label'],
'url' => $base.'/'.$edition.'/feed',
];
}
return $feeds;
}
/**
* Abrufe durch Such- und KI-Dienste je Meldung (Roadmap 1c.6), zuletzt
* abgerufener Dienst zuerst. Belegt nur den Abruf durch den Crawler des