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:
parent
ee267c622e
commit
4c17a17254
18 changed files with 434 additions and 17 deletions
|
|
@ -265,6 +265,8 @@ TRANSPARENCY_FREE_PORTALS_CHECKED_AT=
|
|||
|
||||
# Doppelveröffentlichung (Roadmap Anhang 2, 1c.8): zweite Fassung verweist per
|
||||
# rel=canonical auf die Erstquelle, Zuordnung je Kategorie in config/canonical.php
|
||||
# (Entscheidung E9). Aus = jede Fassung bleibt ihr eigenes Canonical.
|
||||
CANONICAL_ASSIGNMENT_ENABLED=false
|
||||
# (E9, entschieden 18.09.2026 — Standard an). Aus = jede Fassung bleibt ihr
|
||||
# eigenes Canonical. ACHTUNG Reihenfolge beim ersten Deploy: migrate →
|
||||
# press-releases:hash-content → press-releases:assign-canonicals.
|
||||
CANONICAL_ASSIGNMENT_ENABLED=true
|
||||
CANONICAL_FALLBACK=first
|
||||
|
|
|
|||
|
|
@ -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'] ?? '');
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
34
app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php
Normal file
34
app/Http/Middleware/RedirectPortalOnlyPagesFromHub.php
Normal 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);
|
||||
}
|
||||
}
|
||||
116
app/Support/StaticPageCanonical.php
Normal file
116
app/Support/StaticPageCanonical.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Kanonischer Host statischer Seiten (Roadmap Anhang 2, E10).
|
||||
*
|
||||
* faq, hilfe, Transparenzseiten und die API-Doku werden auf Hub und beiden
|
||||
* Portalen mit identischem Text ausgeliefert. Damit nicht drei Adressen um
|
||||
* denselben Text konkurrieren, führt je Seite ein Host; die beiden anderen
|
||||
* verweisen per rel=canonical dorthin. Rechtstexte sind bewusst
|
||||
* ausgenommen — sie gehören zu ihrer Domain (siehe config/canonical.php).
|
||||
*/
|
||||
class StaticPageCanonical
|
||||
{
|
||||
/**
|
||||
* Kanonische Adresse der aktuellen Seite oder null, wenn die Seite
|
||||
* self-canonical bleibt.
|
||||
*/
|
||||
public static function urlFor(?string $routeName, string $path): ?string
|
||||
{
|
||||
$base = self::baseFor($routeName);
|
||||
|
||||
return $base === null ? null : $base.$path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basis-URL des führenden Hosts einer Route, oder null.
|
||||
*/
|
||||
public static function baseFor(?string $routeName): ?string
|
||||
{
|
||||
if ($routeName === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$domainKey = config('canonical.static_page_host.'.$routeName);
|
||||
|
||||
if (! is_string($domainKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = config('domains.domains.'.$domainKey.'.url');
|
||||
|
||||
return is_string($url) && $url !== '' ? rtrim($url, '/') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basis der Selbst-Referenz: die konfigurierte URL des Hosts, unter dem
|
||||
* die Seite tatsächlich aufgerufen wurde.
|
||||
*
|
||||
* Der geteilte `$domainUrl` aus dem ThemeServiceProvider taugt dafür
|
||||
* nicht immer — er wird beim Booten aus dem damaligen Host abgeleitet
|
||||
* und von `WebPortalContext::fromRequest()` auf ein Portal umgestellt,
|
||||
* auf dem Hub also auf businessportal24. Maßgeblich ist der Host der
|
||||
* Anfrage — außer bei Portal-Inhalten, die über den Hub aufgerufen
|
||||
* wurden ($portalContextApplied).
|
||||
*/
|
||||
public static function selfBase(?string $domainUrl = null, bool $portalContextApplied = false): string
|
||||
{
|
||||
$host = request()->getHost();
|
||||
|
||||
// Portal-Inhalte (Kategorie, Archiv, Meldung, Newsroom) sind auch
|
||||
// über den Hub erreichbar und rendern dort das Portal. Ihr Canonical
|
||||
// gehört auf das Portal, nicht auf pressekonto.
|
||||
if ($portalContextApplied && is_string($domainUrl) && $domainUrl !== '') {
|
||||
return rtrim($domainUrl, '/');
|
||||
}
|
||||
|
||||
if (is_string($domainUrl) && $domainUrl !== '' && parse_url($domainUrl, PHP_URL_HOST) === $host) {
|
||||
return rtrim($domainUrl, '/');
|
||||
}
|
||||
|
||||
foreach ((array) config('domains.domains', []) as $domain) {
|
||||
$url = $domain['url'] ?? null;
|
||||
|
||||
if (is_string($url) && parse_url($url, PHP_URL_HOST) === $host) {
|
||||
return rtrim($url, '/');
|
||||
}
|
||||
}
|
||||
|
||||
return rtrim(request()->getSchemeAndHttpHost(), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse für einen Link auf eine benannte Seite: normalerweise die des
|
||||
* aufgerufenen Hosts — bei Portalseiten auf dem Hub direkt die des
|
||||
* führenden Portals, damit der Besucher nicht über eine Weiterleitung
|
||||
* läuft.
|
||||
*/
|
||||
public static function publicUrlFor(string $routeName): string
|
||||
{
|
||||
$url = route($routeName);
|
||||
$portalBase = self::portalOnlyRedirect($routeName);
|
||||
|
||||
if ($portalBase !== null && str_contains(request()->getHost(), 'pressekonto')) {
|
||||
return $portalBase.(string) parse_url($url, PHP_URL_PATH);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seiten, die es nur auf den Portalen gibt — auf dem Hub führen sie auf
|
||||
* das führende Portal statt auf eine Fassung mit fremdem Branding.
|
||||
*/
|
||||
public static function portalOnlyRedirect(?string $routeName): ?string
|
||||
{
|
||||
if ($routeName === null || ! in_array($routeName, (array) config('canonical.portal_only_pages', []), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = config('domains.domains.'.config('canonical.portal_only_host', 'presseecho').'.url');
|
||||
|
||||
return is_string($url) && $url !== '' ? rtrim($url, '/') : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Support;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use Illuminate\Support\Facades\View;
|
||||
|
||||
/**
|
||||
* Öffentlicher Portal-Kontext (Presseecho/Businessportal24) einer Web-Landingpage,
|
||||
|
|
@ -39,6 +40,12 @@ class WebPortalContext
|
|||
$portal = self::portalFromHost();
|
||||
$domainKey = $portal === Portal::Presseecho ? 'presseecho' : 'businessportal24';
|
||||
|
||||
// Merker fürs Layout: Diese Seite rendert Portal-Inhalte, die
|
||||
// Domain-Config zeigt also auf ein Portal — auch wenn der Aufruf über
|
||||
// den Hub kam. Der Self-Canonical muss dann dem Portal folgen, nicht
|
||||
// dem aufgerufenen Host (Anhang 2, E10).
|
||||
View::share('webPortalContextApplied', true);
|
||||
|
||||
return new self(
|
||||
domainKey: $domainKey,
|
||||
portal: $portal,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Http\Middleware\BasicAuthMiddleware;
|
||||
use App\Http\Middleware\LogApiUsage;
|
||||
use App\Http\Middleware\RecordMailLinkClick;
|
||||
use App\Http\Middleware\RedirectPortalOnlyPagesFromHub;
|
||||
use App\Http\Middleware\RedirectWwwToApex;
|
||||
use App\Http\Middleware\RejectLegacyApiKeys;
|
||||
use App\Http\Middleware\SecurityHeaders;
|
||||
|
|
@ -50,6 +51,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
// Ausgabe/Sprache (de|en) aus dem URL-Präfix ableiten und URL-Defaults setzen.
|
||||
$middleware->append(SetEdition::class);
|
||||
|
||||
// Portalseiten auf dem Hub (Anhang 2, E10): /de/preise & Co. gibt es
|
||||
// nur auf den Portalen — 301 statt einer Fassung mit fremdem Branding
|
||||
// unter pressekonto.com. In der web-Gruppe, nicht global: die
|
||||
// Middleware liest den Routennamen, und der steht erst nach dem
|
||||
// Routing fest.
|
||||
$middleware->web(append: [RedirectPortalOnlyPagesFromHub::class]);
|
||||
|
||||
// Wohin eingeloggte User von Gast-Routen (/login, /register) gelenkt
|
||||
// werden: rollen- und verifizierungsbewusst statt fix auf /dashboard,
|
||||
// sonst landet ein Customer dort im 403 und sitzt fest.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
return [
|
||||
|
||||
'enabled' => (bool) env('CANONICAL_ASSIGNMENT_ENABLED', false),
|
||||
'enabled' => (bool) env('CANONICAL_ASSIGNMENT_ENABLED', true),
|
||||
|
||||
// DE-Slug der Hauptkategorie => Portal, das die Erstquelle führt.
|
||||
// Vorschlag zu E9; Kategorien ohne Eintrag fallen auf `fallback`.
|
||||
|
|
@ -44,4 +44,46 @@ return [
|
|||
// bleibt Erstquelle; alternativ ein Portalname als feste Vorgabe.
|
||||
'fallback' => env('CANONICAL_FALLBACK', 'first'),
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| Statische Seiten auf drei Hosts (E10, entschieden 18.09.2026)
|
||||
|----------------------------------------------------------------------
|
||||
|
|
||||
| faq, hilfe, Transparenzseiten & Co. werden auf Hub und beiden Portalen
|
||||
| mit identischem Text ausgeliefert — dieselbe Doppelung wie bei den
|
||||
| Meldungen, nur dreifach. Die Portale führen (ältere Domains),
|
||||
| presseecho ist der Standard; die API-Dokumentation gehört zum Hub.
|
||||
|
|
||||
| Routenname => Domain-Schlüssel aus config/domains.php. Nicht gelistete
|
||||
| Seiten bleiben self-canonical je Host.
|
||||
|
|
||||
| BEWUSST NICHT gelistet: impressum, datenschutz, agb, richtlinien,
|
||||
| melden, kuendigung. Jede Domain ist ein eigenes Angebot mit eigener
|
||||
| Anbieterkennzeichnung, eigenem Meldeweg und eigenen Bedingungen —
|
||||
| diese Seiten müssen je Host für sich stehen und auffindbar bleiben.
|
||||
|
|
||||
*/
|
||||
|
||||
'static_page_host' => [
|
||||
'faq' => 'presseecho',
|
||||
'hilfe' => 'presseecho',
|
||||
'gute-pressemitteilung' => 'presseecho',
|
||||
'was-kostet-eine-pressemitteilung' => 'presseecho',
|
||||
'pressemitteilung-veroeffentlichen-ohne-abo' => 'presseecho',
|
||||
'pressemitteilung-loeschen-oder-aendern-lassen' => 'presseecho',
|
||||
'pressemitteilung-kostenlos-veroeffentlichen' => 'presseecho',
|
||||
'api' => 'pressekonto',
|
||||
],
|
||||
|
||||
/*
|
||||
| Seiten, die es nur auf den Portalen gibt. Auf dem Hub wurden sie bisher
|
||||
| mit Portal-Branding unter pressekonto.com ausgeliefert (der Portal-
|
||||
| Kontext fällt dort auf businessportal24 zurück) — jetzt 301 auf das
|
||||
| führende Portal.
|
||||
*/
|
||||
|
||||
'portal_only_pages' => ['preise', 'veroeffentlichen', 'newsrooms', 'suche'],
|
||||
|
||||
'portal_only_host' => 'presseecho',
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -241,8 +241,8 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
|
|||
| E6 | Bleibt die Ablehnungsstrecke dauerhaft frei von kommerziellen Angeboten | 1d.1, 1d.6 |
|
||||
| ~~E7~~ | **Entschieden 17.09.2026 (Anhang 1):** keine kostenfreie Einreichung, dauerhaft | 2.4, 2.6 |
|
||||
| E8 | Kundenansicht des Indexierungsstatus — gebaut, zu (`GSC_CUSTOMER_VISIBLE`), Entscheidung nach Verteilung aus 1c.4 (Anhang 1) | 1c.5 |
|
||||
| E9 | Kanonische Zuordnung je Kategorie — Vorschlag in `config/canonical.php`, Mechanismus gebaut und aus (`CANONICAL_ASSIGNMENT_ENABLED`) (Anhang 2) | 1c.8 |
|
||||
| E10 | Transparenz-/Statikseiten: Hub oder Portale als Canonical — Befund: dreifach vorhanden (Anhang 2, Protokoll Teil 7) | 2.4 |
|
||||
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung je Kategorie wie vorgeschlagen, Mechanismus aktiv (Anhang 2, Protokoll Teil 8) | 1c.8 |
|
||||
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** (presseecho), API-Doku beim Hub, Rechtstexte je Host (Anhang 2, Protokoll Teil 8) | 2.4 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
| Änderung | Betrifft | Art |
|
||||
|---|---|---|
|
||||
| Crawler-Abruf-Protokoll als eigenes Arbeitspaket | 1c.0 | neu |
|
||||
| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.6 | ersetzt |
|
||||
| Absenderansicht in Stufen zerlegt, Indexierungsstatus zunächst intern | 1c.1, 1c.4 bis 1c.N9A59YXJai6 | ersetzt |
|
||||
| Erstquelle gegen Kopie als Argument für die Direktveröffentlichung | 1d | neu |
|
||||
| Transparenzseiten ausformuliert, kein Wettbewerbervergleich | 2.4 | ersetzt |
|
||||
| Zuschriften an Portalübersichten ausformuliert, zwei konkrete Ziele | 2.6 | ersetzt |
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ Zwei Portale bedeuten zwei Fundstellen für Leser, nicht zwei Fundstellen in der
|
|||
Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nicht gebraucht — das Tageskontingent geht an die beiden Portale. Zwei Dinge sind dort trotzdem zu prüfen:
|
||||
|
||||
- ~~Stehen Konto- und Login-Bereiche auf `noindex`?~~ **17.09.2026: ja, jetzt per Meta-Tag in allen Portal-Layouts.** Die Indexierungsübersicht der neuen Property zeigt, was Google von dort erfasst hat.
|
||||
- **Offene Entscheidung E10:** Wohin gehören die Transparenzseiten aus 2.4 — auf den Hub oder auf die Portale? Die Portale haben die deutlich ältere Domain, der Hub die thematisch passendere Adresse.
|
||||
- ~~**Offene Entscheidung E10**~~ **Entschieden 18.09.2026: Portale führen, presseecho als Standard; die API-Doku bleibt beim Hub, Rechtstexte je Host.** Dabei aufgefallen und behoben: `/de/preise`, `/de/veroeffentlichen`, `/de/newsrooms` und `/de/suche` wurden unter pressekonto.com mit dem Branding von businessportal24 ausgeliefert — jetzt 301 auf presseecho. Offen: Kategorie-, Archiv- und Meldungsadressen sind auf dem Hub weiterhin erreichbar (Canonical zeigt aufs Portal, keine Sitemap-Einträge) — Empfehlung 301 auf businessportal24, ~34.000 Adressen, wartet auf Freigabe.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -107,5 +107,5 @@ Die Property ist angelegt, wird für die Indexierungsprüfung aus 1c.4 aber nich
|
|||
|
||||
| Nr. | Frage | Betrifft |
|
||||
|---|---|---|
|
||||
| E9 | Kanonische Zuordnung je Kategorie: welches Portal führt welches Themenfeld — *Vorschlag liegt in `config/canonical.php`, Mechanismus wartet auf `CANONICAL_ASSIGNMENT_ENABLED=true`* | 1c.8 |
|
||||
| E10 | Wohin gehören die Transparenzseiten aus 2.4 — Hub oder Portale. *Befund 17.09.2026: alle statischen Seiten liegen auf drei Hosts mit je eigenem Canonical; Empfehlung Portale (Protokoll Teil 7)* | 2.4 |
|
||||
| ~~E9~~ | **Entschieden 18.09.2026:** Zuordnung wie vorgeschlagen (businessportal24: finanzen, handel, it, recht, wissenschaft-forschung, motor-verkehr; presseecho: alle übrigen inkl. sonstiges). Zuordnung ist aktiv, `CANONICAL_ASSIGNMENT_ENABLED` steht standardmäßig auf true | 1c.8 |
|
||||
| ~~E10~~ | **Entschieden 18.09.2026: Portale führen** — presseecho für faq, hilfe, gute-pressemitteilung und die vier Transparenzseiten, pressekonto für die API-Doku. Rechtstexte bleiben bewusst je Host (eigene Anbieterkennzeichnung, eigener Meldeweg). Sitemap und llms.txt nennen nur die führende Adresse (Protokoll Teil 8) | 2.4 |
|
||||
|
|
@ -1998,3 +1998,93 @@ entkoppelt. Pint, Larastan ohne Befund.
|
|||
Hintergrund), dann `press-releases:duplicates-report`. Erst nach E9:
|
||||
`CANONICAL_ASSIGNMENT_ENABLED=true`, `press-releases:assign-canonicals
|
||||
--dry-run`, dann ohne Flag, `view:cache`, Sitemaps prüfen.
|
||||
|
||||
## Teil 8 — E9 und E10 entschieden, Hub-Erreichbarkeit geprüft (18.09.2026)
|
||||
|
||||
### E9 — kanonische Zuordnung aktiv
|
||||
|
||||
Zuordnung wie vorgeschlagen übernommen (`config/canonical.php`):
|
||||
businessportal24 führt finanzen, handel, it, recht, wissenschaft-forschung,
|
||||
motor-verkehr; presseecho führt haus, kultur, medizin, mode, politik,
|
||||
reisen, sport, sonstiges. `CANONICAL_ASSIGNMENT_ENABLED` steht jetzt
|
||||
standardmäßig auf `true` — ohne `.env`-Eintrag ist die Zuordnung an.
|
||||
|
||||
Auf der Dev-Kopie verifiziert: 9.092 Paare zugeordnet, die Kopie trägt
|
||||
`rel=canonical` auf die Erstquelle, steht nicht mehr in der Sitemap ihres
|
||||
Portals, die Erstquelle schon.
|
||||
|
||||
### E10 — eine Adresse je Text, Rechtstexte ausgenommen
|
||||
|
||||
Befund: faq, hilfe, gute-pressemitteilung, die vier Transparenzseiten, api
|
||||
und alle Rechtstexte liegen auf **allen drei Hosts** mit identischem Text
|
||||
(nur Markenname getauscht, per Prüfsumme bestätigt) und trugen je einen
|
||||
eigenen Self-Canonical.
|
||||
|
||||
Umgesetzt (`config/canonical.static_page_host`, `App\Support\StaticPageCanonical`):
|
||||
|
||||
| Seiten | Führender Host |
|
||||
|---|---|
|
||||
| faq, hilfe, gute-pressemitteilung, 4 Transparenzseiten | presseecho |
|
||||
| api (Doku des Publisher-Bereichs) | pressekonto |
|
||||
| impressum, datenschutz, agb, richtlinien, melden, kuendigung | **je Host selbst** |
|
||||
|
||||
**Rechtstexte bewusst ausgenommen** — das ist eine Abweichung von „alles auf
|
||||
die Portale". Jede Domain ist ein eigenes Angebot mit eigener
|
||||
Anbieterkennzeichnung und eigenem Meldeweg; wer auf businessportal24.com
|
||||
das Impressum aufruft, muss das Impressum dieser Domain bekommen, und es
|
||||
muss für sich auffindbar bleiben. Ein Canonical auf eine fremde Domain
|
||||
würde dem widersprechen. Inhaltlich sind die Texte identisch — das ist bei
|
||||
Pflichtangaben unkritisch.
|
||||
|
||||
Mitgezogen: Sitemap listet je Seite nur noch den führenden Host (Hub
|
||||
behält api + Rechtstexte), `llms.txt` nennt die führende Adresse, `og:url`
|
||||
folgt dem Canonical.
|
||||
|
||||
### Erreichbarkeit pressekonto.com — geprüft, drei Befunde
|
||||
|
||||
Alle 24 Hub-Adressen und jeder Link der Hub-Startseite geprüft: **alles
|
||||
200**, unbekannte Pfade 404, `sitemap.xml`, `sitemap-news.xml`,
|
||||
`llms.txt`, `robots.txt`, `/docs/api/v1` in Ordnung.
|
||||
|
||||
1. **Portalseiten unter pressekonto.com** (behoben): `/de/preise`,
|
||||
`/de/veroeffentlichen`, `/de/newsrooms`, `/de/suche` wurden auf dem Hub
|
||||
ausgeliefert — mit dem Branding und Titel von **businessportal24**,
|
||||
weil `WebPortalContext::fromRequest()` auf dem Hub auf dieses Portal
|
||||
zurückfällt. Jetzt 301 auf presseecho
|
||||
(`RedirectPortalOnlyPagesFromHub`, web-Gruppe). Die
|
||||
Transparenz-Navigation verlinkt „Tarife & Preise" auf dem Hub direkt
|
||||
auf presseecho, ohne Umweg über die Weiterleitung.
|
||||
2. **Weiterleitungskette** (behoben): `/de/kontakt` und `/de/ueber-uns`
|
||||
zeigten auf `/de`, das auf dem Hub selbst auf `/` weiterleitet — zwei
|
||||
Sprünge. Jetzt direkt auf die Startseite des jeweiligen Hosts.
|
||||
3. **Portal-Inhalte unter pressekonto.com** (offen, Empfehlung unten):
|
||||
`/de/category/{slug}`, `/de/category/{slug}/archiv/{jahr}` und
|
||||
`/de/press-release/{slug}` sind auf dem Hub erreichbar und rendern das
|
||||
Portal (businessportal24), weil die Ausgabe-Routen für alle Hosts
|
||||
gelten. Ihr Canonical zeigt auf das Portal — sie stehen in keiner
|
||||
Sitemap und werden nicht zusätzlich indexiert. **Empfehlung:** auch
|
||||
diese Routen auf dem Hub per 301 auf businessportal24 legen (dasselbe
|
||||
Portal, das der Hub ohnehin rendert; presseecho-only-Meldungen laufen
|
||||
heute wie danach ins 404). Ein Eintrag in
|
||||
`canonical.portal_only_pages` würde nicht reichen, weil das Ziel dort
|
||||
fest auf presseecho steht — braucht eine eigene Zeile. Nicht ohne
|
||||
Freigabe gemacht, weil es rund 34.000 Adressen betrifft.
|
||||
|
||||
Technischer Nebeneffekt: Der Self-Canonical kommt jetzt aus dem
|
||||
tatsächlich aufgerufenen Host (`StaticPageCanonical::selfBase()`) statt aus
|
||||
der geteilten Domain-Config. Portal-Inhalte behalten ihr Portal —
|
||||
`WebPortalContext::fromRequest()` setzt dafür den Merker
|
||||
`webPortalContextApplied`.
|
||||
|
||||
### Tests / Deploy
|
||||
|
||||
`HubPortalPageRedirectTest` (19), angepasste Sitemap-Erwartungen in
|
||||
`StaticPagesTest` und `TransparencyPagesTest`. Gesamtlauf 1.804 grün
|
||||
(eine Ausnahme: `ManualInvoicePdfTest` schlägt unabhängig von diesen
|
||||
Änderungen fehl — mit und ohne Stash reproduziert, Anrede „Frau Erika
|
||||
Muster" fehlt; gehört zum Rechnungs-Paket).
|
||||
|
||||
**Deploy:** `view:cache`/`route:cache` neu, `php artisan cache:clear` für
|
||||
die Sitemap-Caches (1 h TTL). E9 ist ohne `.env`-Eintrag aktiv — davor
|
||||
müssen `press-releases:hash-content` und `press-releases:assign-canonicals`
|
||||
gelaufen sein, sonst passiert schlicht nichts.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
@if ($routeName === $current)
|
||||
<span class="inline-flex items-center px-3 py-1.5 text-[12.5px] font-semibold border border-brand bg-brand text-white" aria-current="page">{{ $label }}</span>
|
||||
@else
|
||||
<a href="{{ route($routeName) }}" class="inline-flex items-center px-3 py-1.5 text-[12.5px] font-semibold border border-bg-rule-strong bg-white text-ink hover:bg-bg-elev transition-colors">{{ $label }} →</a>
|
||||
<a href="{{ \App\Support\StaticPageCanonical::publicUrlFor($routeName) }}" class="inline-flex items-center px-3 py-1.5 text-[12.5px] font-semibold border border-bg-rule-strong bg-white text-ink hover:bg-bg-elev transition-colors">{{ $label }} →</a>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
|
|
|
|||
|
|
@ -14,8 +14,13 @@
|
|||
$pageDescription = html_entity_decode(trim($__env->yieldContent('meta_description')), ENT_QUOTES, 'UTF-8') ?: $siteName;
|
||||
// Self-Canonical als Default aus der Portal-URL + Request-Pfad (ohne Query):
|
||||
// pro Portal/Domain getrennt und unabhängig von URL::forceRootUrl – verhindert Cross-Portal-Duplicate.
|
||||
$portalBase = rtrim($domainUrl ?? config('app.url'), '/');
|
||||
$canonicalUrl = trim($__env->yieldContent('canonical')) ?: ($portalBase.request()->getPathInfo());
|
||||
$portalBase = \App\Support\StaticPageCanonical::selfBase($domainUrl ?? null, ($webPortalContextApplied ?? false) === true);
|
||||
// Statische Seiten liegen auf Hub UND beiden Portalen mit identischem
|
||||
// Text (Anhang 2, E10): dort führt ein Host, die anderen verweisen
|
||||
// dorthin. Rechtstexte sind ausgenommen — siehe config/canonical.php.
|
||||
$canonicalUrl = trim($__env->yieldContent('canonical'))
|
||||
?: (\App\Support\StaticPageCanonical::urlFor(request()->route()?->getName(), request()->getPathInfo())
|
||||
?: $portalBase.request()->getPathInfo());
|
||||
$ogType = trim($__env->yieldContent('og_type')) ?: 'website';
|
||||
$ogImage = trim($__env->yieldContent('og_image'));
|
||||
@endphp
|
||||
|
|
|
|||
|
|
@ -244,8 +244,15 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func
|
|||
// (Platzhalter-Adresse/-Team); Alt-Links landen per 301 auf der Startseite.
|
||||
// Der Kontakt-POST bleibt für das Hub-Kontakt-Modal erhalten.
|
||||
Route::view('faq', 'web.faq')->name('faq');
|
||||
Route::get('kontakt', fn (string $edition) => redirect(request()->getSchemeAndHttpHost().'/'.$edition, 301));
|
||||
Route::get('ueber-uns', fn (string $edition) => redirect(request()->getSchemeAndHttpHost().'/'.$edition, 301));
|
||||
// Ziel ist die Startseite des jeweiligen Hosts. Auf dem Hub liegt die
|
||||
// unter `/` — ein Umweg über `/de` wäre dort eine zweite Weiterleitung.
|
||||
$homeRedirect = fn (string $edition) => redirect(rtrim(
|
||||
request()->getSchemeAndHttpHost().(str_contains(request()->getHost(), 'pressekonto') ? '/' : '/'.$edition),
|
||||
'/'
|
||||
) ?: request()->getSchemeAndHttpHost().'/', 301);
|
||||
|
||||
Route::get('kontakt', $homeRedirect);
|
||||
Route::get('ueber-uns', $homeRedirect);
|
||||
Route::post('kontakt', ContactController::class)
|
||||
->middleware('throttle:10,1')
|
||||
->name('kontakt.store');
|
||||
|
|
|
|||
74
tests/Feature/Web/HubPortalPageRedirectTest.php
Normal file
74
tests/Feature/Web/HubPortalPageRedirectTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('portal-only pages redirect from the hub to the leading portal', function (string $path) {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://pressekonto.test'.$path)
|
||||
->assertStatus(301)
|
||||
->assertRedirect('https://presseecho.test'.$path);
|
||||
})->with(['/de/preise', '/de/veroeffentlichen', '/de/newsrooms', '/de/suche', '/en/preise']);
|
||||
|
||||
test('the portals keep serving those pages themselves', function (string $host) {
|
||||
/** @var TestCase $this */
|
||||
$this->get($host.'/de/preise')->assertSuccessful();
|
||||
})->with(['https://presseecho.test', 'https://businessportal24.test']);
|
||||
|
||||
test('shared info pages name one leading host on all three domains', function (string $host) {
|
||||
/** @var TestCase $this */
|
||||
// Anhang 2, E10: Informationsseiten liegen dreifach vor — presseecho
|
||||
// führt, die API-Doku gehört zum Hub.
|
||||
$this->get($host.'/de/faq')->assertSuccessful()
|
||||
->assertSee('<link rel="canonical" href="https://presseecho.test/de/faq">', false);
|
||||
$this->get($host.'/de/was-kostet-eine-pressemitteilung')->assertSuccessful()
|
||||
->assertSee('<link rel="canonical" href="https://presseecho.test/de/was-kostet-eine-pressemitteilung">', false)
|
||||
->assertSee('<meta property="og:url" content="https://presseecho.test/de/was-kostet-eine-pressemitteilung">', false);
|
||||
$this->get($host.'/de/api')->assertSuccessful()
|
||||
->assertSee('<link rel="canonical" href="https://pressekonto.test/de/api">', false);
|
||||
})->with(['https://pressekonto.test', 'https://presseecho.test', 'https://businessportal24.test']);
|
||||
|
||||
test('legal pages stay with their own domain', function (string $host, string $path) {
|
||||
/** @var TestCase $this */
|
||||
// Jede Domain ist ein eigenes Angebot: Impressum, AGB, Datenschutz,
|
||||
// Richtlinien und Meldeweg gehören zu dem Host, auf dem sie stehen.
|
||||
$this->get($host.$path)->assertSuccessful()
|
||||
->assertSee('<link rel="canonical" href="'.$host.$path.'">', false);
|
||||
})->with([
|
||||
['https://pressekonto.test', '/de/impressum'],
|
||||
['https://businessportal24.test', '/de/agb'],
|
||||
['https://businessportal24.test', '/de/datenschutz'],
|
||||
['https://presseecho.test', '/de/melden'],
|
||||
['https://businessportal24.test', '/de/richtlinien'],
|
||||
]);
|
||||
|
||||
test('the english edition keeps the leading host too', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->get('https://businessportal24.test/en/hilfe')->assertSuccessful()
|
||||
->assertSee('<link rel="canonical" href="https://presseecho.test/en/hilfe">', false);
|
||||
});
|
||||
|
||||
test('the retired contact and about pages redirect to the home page in one hop', function (string $host, string $target) {
|
||||
/** @var TestCase $this */
|
||||
$this->get($host.'/de/kontakt')->assertStatus(301)->assertRedirect($target);
|
||||
$this->get($host.'/de/ueber-uns')->assertStatus(301)->assertRedirect($target);
|
||||
})->with([
|
||||
// Auf dem Hub liegt die Startseite unter `/` — der frühere Weg über
|
||||
// `/de` war eine zweite Weiterleitung.
|
||||
['https://pressekonto.test', 'https://pressekonto.test'],
|
||||
['https://presseecho.test', 'https://presseecho.test/de'],
|
||||
]);
|
||||
|
||||
test('portal content served through the hub keeps the portal as canonical', function () {
|
||||
/** @var TestCase $this */
|
||||
// Die Ausgabe-Routen gelten für alle Hosts, deshalb sind Kategorien und
|
||||
// Meldungen auch unter pressekonto.com erreichbar. Sie rendern dort das
|
||||
// Portal — der Canonical muss dorthin zeigen, nie auf den Hub.
|
||||
$category = Category::factory()->create(['portal' => Portal::Both]);
|
||||
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
|
||||
|
||||
$this->get('https://pressekonto.test/de/category/handel')->assertSuccessful()
|
||||
->assertSee('<link rel="canonical" href="https://businessportal24.test/de/category/handel">', false)
|
||||
->assertDontSee('<link rel="canonical" href="https://pressekonto.test/de/category/handel">', false);
|
||||
});
|
||||
|
|
@ -263,7 +263,9 @@ test('the good-release help page explains the check without selling anything', f
|
|||
$body = Str::between($response->getContent(), 'class="pm-body', 'aria-labelledby="partner-heading"');
|
||||
expect($body)->not->toContain('/preise')->not->toContain('Credits');
|
||||
|
||||
// Aus dem Hilfe-Center und der Sitemap erreichbar.
|
||||
// Aus dem Hilfe-Center erreichbar; in der Sitemap steht sie beim
|
||||
// führenden Host (Anhang 2, E10) — presseecho, nicht businessportal24.
|
||||
$this->get('https://businessportal24.test/de/hilfe')->assertSee('/de/gute-pressemitteilung', false);
|
||||
$this->get('https://businessportal24.test/sitemap.xml')->assertSee('/de/gute-pressemitteilung', false);
|
||||
$this->get('https://presseecho.test/sitemap.xml')->assertSee('/de/gute-pressemitteilung', false);
|
||||
$this->get('https://businessportal24.test/sitemap.xml')->assertDontSee('/de/gute-pressemitteilung', false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -123,6 +123,9 @@ test('the pages are linked from the pricing page, the help centre and the sitema
|
|||
$this->get('https://presseecho.test/llms.txt')->assertSuccessful()
|
||||
->assertSee('/de/pressemitteilung-kostenlos-veroeffentlichen', false);
|
||||
|
||||
// Die Seiten liegen auf allen drei Hosts; in der Sitemap steht nur die
|
||||
// führende Adresse (Anhang 2, E10) — beim Hub bleibt die API-Doku.
|
||||
$this->get('https://pressekonto.test/sitemap.xml')->assertSuccessful()
|
||||
->assertSee('<loc>https://pressekonto.test/de/pressemitteilung-loeschen-oder-aendern-lassen</loc>', false);
|
||||
->assertDontSee('/de/pressemitteilung-loeschen-oder-aendern-lassen', false)
|
||||
->assertSee('<loc>https://pressekonto.test/de/api</loc>', false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue