- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
10 KiB
PHP
238 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Web;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Models\Category;
|
||
use App\Models\Company;
|
||
use App\Models\FeedFetch;
|
||
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;
|
||
|
||
use function Illuminate\Support\defer;
|
||
|
||
/**
|
||
* 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
|
||
* 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 __construct(private readonly MergedNewsroomLookup $mergedNewsrooms) {}
|
||
|
||
public function portal(Request $request, string $edition): Response
|
||
{
|
||
$context = WebPortalContext::fromRequest();
|
||
$base = rtrim($request->getSchemeAndHttpHost(), '/');
|
||
|
||
return $this->respond($context, [FeedFetch::FEED_PORTAL], function () use ($context, $base): string {
|
||
$releases = $this->releases($context)->get($this->columns());
|
||
$brand = (array) ($context->domainConfig['brand'] ?? []);
|
||
|
||
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)])),
|
||
]);
|
||
});
|
||
}
|
||
|
||
public function category(Request $request, string $edition, string $slug): Response
|
||
{
|
||
$context = WebPortalContext::fromRequest();
|
||
$base = rtrim($request->getSchemeAndHttpHost(), '/');
|
||
$language = $context->language;
|
||
|
||
return $this->respond($context, [FeedFetch::FEED_CATEGORY, $slug], function () use ($context, $base, $slug, $language): string {
|
||
$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 = $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);
|
||
}
|
||
|
||
return $this->respond($context, [FeedFetch::FEED_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)]),
|
||
]);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Antwort aus dem Cache (oder frisch gebaut) plus Abruf-Protokoll: jeder
|
||
* Feed-Abruf wird nach der Antwort ohne IP festgehalten (feed_fetches),
|
||
* auch bei warmem Cache — der Abruf ist die Messgröße, nicht der Aufbau.
|
||
*
|
||
* @param array{0: string, 1?: string} $keySegments Feed-Art und Schlüssel (Slug)
|
||
* @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,
|
||
);
|
||
|
||
[$feed, $portal, $language, $userAgent] = [$keySegments[0], $context->portal->value, $context->language, request()->userAgent()];
|
||
$feedKey = $keySegments[1] ?? null;
|
||
|
||
defer(static fn () => FeedFetch::recordDeferred($feed, $feedKey, $portal, $language, $userAgent));
|
||
|
||
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($context->language)
|
||
->visibleInArchive()
|
||
->with([
|
||
'company:id,name',
|
||
'category.translations' => fn ($query) => $query->where('locale', $context->language),
|
||
])
|
||
->orderByDesc('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,
|
||
'link' => $base.'/'.$language.'/press-release/'.$release->slug,
|
||
'published_at' => $release->published_at?->toRfc2822String() ?? '',
|
||
// Der gespeicherte Text ist HTML: Entities erst auflösen, sonst
|
||
// escaped Blade sie ein zweites Mal (&amp;) — R-11.
|
||
'description' => html_entity_decode($release->teaserText(self::TEASER_LENGTH), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||
'category' => $release->localizedCategoryName($language),
|
||
'creator' => $release->company?->name,
|
||
])->all();
|
||
|
||
$channel += [
|
||
'language' => $language,
|
||
'last_build' => ($releases->first()->published_at ?? Carbon::now())->toRfc2822String(),
|
||
];
|
||
|
||
return view('web.feed', ['channel' => $channel, 'items' => $items])->render();
|
||
}
|
||
}
|