presseportale/app/Http/Controllers/Web/FeedController.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- 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>
2026-10-02 13:09:00 +00:00

238 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;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();
}
}