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