web: E9 und E10 entschieden — Zuordnung aktiv, eine Adresse je statischer Seite, Hub-Portalseiten leiten weiter

E9: Kategorie-Zuordnung wie vorgeschlagen übernommen, Zuordnung ist
standardmäßig an (CANONICAL_ASSIGNMENT_ENABLED). Auf der Dev-Kopie
verifiziert: Kopie trägt rel=canonical auf die Erstquelle und fällt aus der
Sitemap ihres Portals.

E10: faq, hilfe, gute-pressemitteilung, die vier Transparenzseiten und die
API-Doku lagen auf allen drei Hosts mit identischem Text und je eigenem
Self-Canonical. Jetzt führt presseecho die Informationsseiten, der Hub die
API-Doku; Sitemap und llms.txt nennen nur die führende Adresse.
Rechtstexte (Impressum, Datenschutz, AGB, Richtlinien, Meldeweg,
Kündigung) bleiben bewusst je Host — jede Domain ist ein eigenes Angebot
mit eigener Anbieterkennzeichnung.

Bei der Erreichbarkeitsprüfung des Hubs gefunden und behoben:
/de/preise, /de/veroeffentlichen, /de/newsrooms und /de/suche wurden unter
pressekonto.com mit dem Branding von businessportal24 ausgeliefert (der
Portal-Kontext fällt dort darauf zurück) — jetzt 301 auf presseecho, die
Transparenz-Navigation verlinkt direkt dorthin. /de/kontakt und
/de/ueber-uns sprangen über /de auf die Startseite, jetzt in einem Schritt.

Der Self-Canonical kommt jetzt aus dem aufgerufenen Host; Portal-Inhalte,
die über den Hub laufen, behalten ihr Portal (webPortalContextApplied).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 07:52:09 +00:00
parent ee267c622e
commit 4c17a17254
18 changed files with 434 additions and 17 deletions

View file

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Support\StaticPageCanonical;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -32,6 +33,9 @@ class LlmsTxtController extends Controller
private function hub(string $base): string
{
// Seiten, die auf allen drei Hosts liegen, werden mit der führenden
// Adresse genannt (Anhang 2, E10) — eine Adresse je Text.
$shared = static fn (string $route): string => StaticPageCanonical::baseFor($route) ?? $base;
$presseecho = rtrim((string) config('domains.domains.presseecho.url'), '/');
$businessportal = rtrim((string) config('domains.domains.businessportal24.url'), '/');
@ -70,6 +74,8 @@ class LlmsTxtController extends Controller
private function portal(string $base): string
{
// Wie im Hub: gemeinsame Seiten mit der führenden Adresse nennen.
$shared = static fn (string $route): string => StaticPageCanonical::baseFor($route) ?? $base;
$context = WebPortalContext::fromRequest();
$brand = (array) ($context->domainConfig['brand'] ?? []);
$name = ($brand['name'] ?? '').($brand['accent'] ?? '');

View file

@ -9,6 +9,7 @@ use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\PressRelease\CanonicalAssigner;
use App\Services\Web\CategoryArchivePageData;
use App\Support\StaticPageCanonical;
use App\Support\WebPortalContext;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -53,6 +54,18 @@ class SitemapController extends Controller
return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
}
/**
* Ob dieser Host die Seite führt (Anhang 2, E10): gemeinsame
* Informationsseiten liegen auf allen drei Hosts, in die Sitemap gehört
* nur die kanonische Adresse. Rechtstexte bleiben je Host gelistet.
*/
private function leadsPage(string $base, string $page): bool
{
$canonicalBase = StaticPageCanonical::baseFor($page);
return $canonicalBase === null || parse_url($canonicalBase, PHP_URL_HOST) === parse_url($base, PHP_URL_HOST);
}
/**
* @return list<array{loc: string, lastmod: ?string}>
*/
@ -61,6 +74,10 @@ class SitemapController extends Controller
$urls = [['loc' => $base.'/', 'lastmod' => null]];
foreach (self::HUB_PAGES as $page) {
if (! $this->leadsPage($base, $page)) {
continue;
}
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}
@ -77,6 +94,10 @@ class SitemapController extends Controller
$urls = [['loc' => $base.'/de', 'lastmod' => null]];
foreach (self::PORTAL_PAGES as $page) {
if (! $this->leadsPage($base, $page)) {
continue;
}
$urls[] = ['loc' => $base.'/de/'.$page, 'lastmod' => null];
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Support\StaticPageCanonical;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Portalseiten auf dem Hub (Anhang 2, E10): Preise, Veröffentlichen,
* Newsrooms und Suche gibt es nur auf den Portalen. Unter pressekonto.com
* wurden sie bisher trotzdem ausgeliefert — mit dem Branding von
* businessportal24, weil der Portal-Kontext dort darauf zurückfällt. Statt
* einer Seite mit fremdem Namen unter fremder Domain: 301 auf das führende
* Portal.
*/
class RedirectPortalOnlyPagesFromHub
{
public function handle(Request $request, Closure $next): Response
{
if (! str_contains($request->getHost(), 'pressekonto')) {
return $next($request);
}
$base = StaticPageCanonical::portalOnlyRedirect($request->route()?->getName());
if ($base === null) {
return $next($request);
}
return redirect($base.$request->getPathInfo(), 301);
}
}