presseportale/app/Http/Controllers/Web/SitemapController.php
Kevin Adametz 375138679d web: Erstquelle-Absatz in der Veröffentlichungsmail, vierte Transparenzseite „kostenlos veröffentlichen" (Anhang 1: 1d, 2.4, E7)
Meldungen, die über die API eingegangen sind, bekommen in der
Veröffentlichungsmail einen Absatz zum Unterschied Erstquelle/Kopie —
technische Tatsache, ohne Preis und Angebot; direkt eingereichte nicht.

/pressemitteilung-kostenlos-veroeffentlichen beantwortet die Suche nach
„kostenlos" ehrlich: kein kostenloser Weg (E7), was kostenlose Portale
leisten, warum Links aus Presseportalen kaum tragen (auch bei uns
sponsored nofollow), warum Annahme ohne Gegenwert und Prüfung sich
ausschließen, wer am Ende zahlt. Die Liste kostenloser Portale ist in
config/transparency.php vorbereitet und bleibt leer, bis geprüfte
Einträge mit Prüfdatum vorliegen.

Unter-Navigation, Hilfe, Sitemaps, llms.txt und die Modelltabelle der
Kostenseite verlinken die neue Seite. Anhang 1 und Protokoll Teil 6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:22:05 +00:00

140 lines
5.2 KiB
PHP

<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Web\CategoryArchivePageData;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* Host-aware XML-Sitemap: Der Hub listet Landing- und Rechts-/Info-Seiten,
* die Spokes (presseecho, businessportal24) zusätzlich Kategorien, Newsrooms
* und alle im Archiv sichtbaren Pressemitteilungen ihres Portals. Marketing-
* Platzhalterseiten (team, presse, karriere, partner, kontakt, ueber-uns)
* und die Suche bleiben bewusst draußen (R2.4/R3.2). Pro Host eine Stunde
* gecacht.
*/
class SitemapController extends Controller
{
/** @var list<string> */
private const HUB_PAGES = [
'faq', 'hilfe', 'gute-pressemitteilung', 'api', 'kuendigung',
'was-kostet-eine-pressemitteilung', 'pressemitteilung-veroeffentlichen-ohne-abo', 'pressemitteilung-loeschen-oder-aendern-lassen', 'pressemitteilung-kostenlos-veroeffentlichen',
'impressum', 'datenschutz', 'agb', 'richtlinien', 'melden',
];
/** @var list<string> */
private const PORTAL_PAGES = [
'preise', 'veroeffentlichen', 'newsrooms', 'faq', 'hilfe', 'gute-pressemitteilung', 'api',
'was-kostet-eine-pressemitteilung', 'pressemitteilung-veroeffentlichen-ohne-abo', 'pressemitteilung-loeschen-oder-aendern-lassen', 'pressemitteilung-kostenlos-veroeffentlichen',
'impressum', 'datenschutz', 'agb', 'richtlinien', 'melden',
];
public function __invoke(Request $request): Response
{
$host = $request->getHost();
$base = rtrim($request->getSchemeAndHttpHost(), '/');
$isHub = str_contains($host, 'pressekonto');
$xml = Cache::remember('web.sitemap.'.$host, 3600, function () use ($base, $isHub): string {
$urls = $isHub ? $this->hubUrls($base) : $this->portalUrls($base);
return view('web.sitemap', ['urls' => $urls])->render();
});
return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
}
/**
* @return list<array{loc: string, lastmod: ?string}>
*/
private function hubUrls(string $base): array
{
$urls = [['loc' => $base.'/', 'lastmod' => null]];
foreach (self::HUB_PAGES as $page) {
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}
return $urls;
}
/**
* @return list<array{loc: string, lastmod: ?string}>
*/
private function portalUrls(string $base): array
{
$context = WebPortalContext::fromRequest();
$urls = [['loc' => $base.'/de', 'lastmod' => null]];
foreach (self::PORTAL_PAGES as $page) {
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}
// Kategorien über die lokalisierten Slugs (CategoryTranslation),
// dazu je Kategorie die Jahresarchive (2.3).
$categories = Category::query()
->where('is_active', true)
->whereIn('portal', $context->portalValues)
->with(['translations', 'children' => fn ($query) => $query->where('is_active', true)])
->get();
foreach ($categories as $category) {
$slug = $category->translations->firstWhere('locale', 'de')?->slug;
if (! $slug) {
continue;
}
$urls[] = ['loc' => $base.'/de/category/'.$slug, 'lastmod' => null];
$categoryIds = $category->children->pluck('id')->prepend($category->id)->unique()->values()->all();
foreach (array_keys(CategoryArchivePageData::yearsWithCounts($categoryIds, $context->portalValues, 'de')) as $year) {
$urls[] = ['loc' => $base.'/de/category/'.$slug.'/archiv/'.$year, 'lastmod' => null];
}
}
// Öffentliche Newsrooms: aktive Firmen mit mindestens einer im Archiv
// sichtbaren PM des Portals (gleiche Regel wie NewsroomsPageData).
$companies = Company::query()
->where('is_active', true)
->whereHas('pressReleases', fn ($query) => $query
->withoutGlobalScope(PortalScope::class)
->visibleInArchive()
->forPortalValues($context->portalValues))
->get(['slug', 'updated_at']);
foreach ($companies as $company) {
$urls[] = [
'loc' => $base.'/de/newsroom/'.$company->slug,
'lastmod' => $company->updated_at?->toAtomString(),
];
}
// Alle im Archiv sichtbaren Pressemitteilungen des Portals.
$releases = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->visibleInArchive()
->forPortalValues($context->portalValues)
->orderByDesc('published_at')
->get(['slug', 'published_at', 'updated_at']);
foreach ($releases as $release) {
$urls[] = [
'loc' => $base.'/de/press-release/'.$release->slug,
'lastmod' => ($release->updated_at ?? $release->published_at)?->toAtomString(),
];
}
return $urls;
}
}