From 25ea91d85b77138480229a8d7ad57077d60f1824 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 12 Jun 2026 15:24:20 +0000 Subject: [PATCH] Verlinkung & Backlinks: systemseitige rel-Auszeichnung (Decision-Update 11.06.) Co-Authored-By: Claude Fable 5 --- .../PressReleaseHtmlSanitizer.php | 8 +- .../PressRelease/PressReleaseLinkPolicy.php | 132 ++++++++++++++++++ dev/frontend/hub-flux/PROGRESS.md | 19 +++ ... Preisstruktur & Veröffentlichungs-Flow.md | 2 +- ...nkung & Backlinks in Pressemitteilungen.md | 27 +++- .../customer/press-releases/create.blade.php | 4 + .../customer/press-releases/edit.blade.php | 4 + .../Feature/PressReleaseHtmlSanitizerTest.php | 46 ++++++ 8 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 app/Services/PressRelease/PressReleaseLinkPolicy.php diff --git a/app/Services/PressRelease/PressReleaseHtmlSanitizer.php b/app/Services/PressRelease/PressReleaseHtmlSanitizer.php index 412c3cc..80ed517 100644 --- a/app/Services/PressRelease/PressReleaseHtmlSanitizer.php +++ b/app/Services/PressRelease/PressReleaseHtmlSanitizer.php @@ -17,6 +17,8 @@ class PressReleaseHtmlSanitizer { private const string PURIFIER_PROFILE = 'press_release'; + public function __construct(private readonly PressReleaseLinkPolicy $linkPolicy) {} + /** * Sanitize HTML before persisting to the database. */ @@ -44,6 +46,10 @@ class PressReleaseHtmlSanitizer /** * Produce a display-ready, safe HtmlString. + * + * Die Link-Policy (rel systemseitig: extern sponsored/nofollow, + * portalintern follow) greift hier beim Rendern — so wirken + * Regel-Änderungen rückwirkend auf alle gespeicherten Inhalte. */ public function render(?string $text): HtmlString { @@ -52,7 +58,7 @@ class PressReleaseHtmlSanitizer } if ($this->isHtml($text)) { - return new HtmlString($this->clean($text)); + return new HtmlString($this->linkPolicy->apply($this->clean($text))); } $escaped = e($text); diff --git a/app/Services/PressRelease/PressReleaseLinkPolicy.php b/app/Services/PressRelease/PressReleaseLinkPolicy.php new file mode 100644 index 0000000..7cbc812 --- /dev/null +++ b/app/Services/PressRelease/PressReleaseLinkPolicy.php @@ -0,0 +1,132 @@ +loadHTML( + '', + LIBXML_NOERROR | LIBXML_NOWARNING, + ); + + if (! $loaded) { + return $html; + } + + foreach ($document->getElementsByTagName('a') as $anchor) { + $this->applyToAnchor($anchor); + } + + $root = $document->getElementById('pr-link-policy-root'); + + if (! $root) { + return $html; + } + + $result = ''; + + foreach ($root->childNodes as $child) { + $result .= $document->saveHTML($child); + } + + return $result; + } + + private function applyToAnchor(DOMElement $anchor): void + { + $href = trim($anchor->getAttribute('href')); + + // rel ist systemgesteuert — Autoren-Eingaben zählen nie. + $anchor->removeAttribute('rel'); + + if ($href === '' || Str::startsWith($href, ['mailto:', 'tel:', '#'])) { + $anchor->removeAttribute('target'); + + return; + } + + if ($this->isInternal($href)) { + $anchor->removeAttribute('target'); + + return; + } + + $anchor->setAttribute('rel', 'sponsored nofollow noopener'); + $anchor->setAttribute('target', '_blank'); + } + + /** + * Intern = relative Pfade sowie absolute URLs auf eine der + * konfigurierten Portal-Domains (inkl. www-Variante). + */ + private function isInternal(string $href): bool + { + if (! preg_match('#^https?://#i', $href)) { + // Relative Pfade (/firma/...) — kein Protokoll, keine Domain. + return ! Str::startsWith($href, '//'); + } + + $host = strtolower((string) parse_url($href, PHP_URL_HOST)); + + if ($host === '') { + return false; + } + + $internalHosts = $this->internalHosts(); + + return in_array($host, $internalHosts, true) + || in_array(Str::after($host, 'www.'), $internalHosts, true); + } + + /** + * @return list + */ + private function internalHosts(): array + { + $hosts = []; + + foreach ((array) config('domains.domains', []) as $domain) { + foreach ([$domain['domain_name'] ?? null, parse_url((string) ($domain['url'] ?? ''), PHP_URL_HOST)] as $candidate) { + if (is_string($candidate) && $candidate !== '') { + $host = strtolower($candidate); + $hosts[] = $host; + $hosts[] = Str::after($host, 'www.'); + } + } + } + + return array_values(array_unique($hosts)); + } +} diff --git a/dev/frontend/hub-flux/PROGRESS.md b/dev/frontend/hub-flux/PROGRESS.md index 694c24c..9bab32a 100644 --- a/dev/frontend/hub-flux/PROGRESS.md +++ b/dev/frontend/hub-flux/PROGRESS.md @@ -5,6 +5,25 @@ --- +## 2026-06-12 · Verlinkung & Backlinks (Decision-Update 11.06.) ✅ + +- **Was**: Systemseitige `rel`-Auszeichnung für Links in PMs umgesetzt: + neue `PressReleaseLinkPolicy`, angewendet in + `PressReleaseHtmlSanitizer::render()` (wirkt rückwirkend auf alle + gespeicherten Inhalte). Externe Links → `sponsored nofollow noopener` + + `target="_blank"`, portalinterne Links (drei Domains inkl. www, + relative Pfade) → follow, mailto/tel ohne rel/target. Autoren-`rel` + wird immer überschrieben — kein kundenseitiger follow/nofollow-Hebel. + Editor-Hinweis unter der Schreibfläche (Create/Edit). §9-Korrektur im + Preisstruktur-Decision-Update eingearbeitet, Umsetzungsstand im + Verlinkungs-Dokument ergänzt. +- **Dateien**: `app/Services/PressRelease/PressReleaseLinkPolicy.php` + (neu), `PressReleaseHtmlSanitizer.php`, Editor-Views (Create/Edit), + beide Decision-Dokumente. +- **Build/Test**: 6 neue Policy-Tests in `PressReleaseHtmlSanitizerTest`. +- **Offene Fragen**: CTA-Box/Darstellungs-Stufung → Boost-Konzept (9I); + Link-Obergrenze pro PM und Anchor-Text-Soft-Check → Phase 2. + ## 2026-06-12 · PM-Vorschau-Umbau + Profil-Feinschliff ✅ - **Was**: (1) PM-Detailseite (Customer) nach Kevins Review umgebaut: diff --git a/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md b/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md index aecd4f7..7c1d0c6 100644 --- a/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md +++ b/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md @@ -98,7 +98,7 @@ Zum Launch greifen genau drei Credit-Posten – alle ohne KI-Abhängigkeit, alle **Credit-Anker:** 1 Credit = 1 € (Listenpreis), Volumenrabatt über Pakete. -**Bewusst ausgeschlossen:** Verkaufte Dofollow-Backlinks. Verstoßen gegen Googles Link-Spam-Richtlinien (Presse-Links gehören auf `nofollow`/`sponsored`) und widersprechen der Ehrlichkeits-Positionierung frontal. +**Verlinkung:** Links zur Kundenseite sind Standard-Bestandteil jeder PM, systemseitig als `sponsored`/`nofollow` ausgezeichnet. Hervorhebung der Linkdarstellung ist als Produkt-Feature möglich. **Tabu:** Verkauf von Dofollow-Backlinks und kundenseitige `rel`-Auswahl. Details siehe Decision-Update „Verlinkung & Backlinks". --- diff --git a/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md b/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md index 9137bd1..5be5c63 100644 --- a/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md +++ b/docs/weiteres/Decision-Update Verlinkung & Backlinks in Pressemitteilungen.md @@ -117,4 +117,29 @@ ersetzt durch: --- -_SEO-/Richtlinien-Stand: Google-Spam-Policies inkl. Link-Spam-Enforcement 2024–2026; `nofollow`/`sponsored`/`ugc` als Hinweise seit September 2019. Vor produktiver Umsetzung der `rel`-Logik einmal gegen die dann aktuelle Google-Search-Central-Doku gegenprüfen._ \ No newline at end of file +_SEO-/Richtlinien-Stand: Google-Spam-Policies inkl. Link-Spam-Enforcement 2024–2026; `nofollow`/`sponsored`/`ugc` als Hinweise seit September 2019. Vor produktiver Umsetzung der `rel`-Logik einmal gegen die dann aktuelle Google-Search-Central-Doku gegenprüfen._ +--- + +## 10. Umsetzungsstand (12.06.2026) + +**Umgesetzt:** + +- `PressReleaseLinkPolicy` (app/Services/PressRelease/): systemseitige + `rel`-Auszeichnung beim Rendern — extern → `sponsored nofollow noopener` + + `target="_blank"`; portalintern (konfigurierte Domains aus + `config/domains.php` inkl. www-Variante, relative Pfade) → **follow**; + `mailto:`/`tel:` → ohne `rel`/`target`. Autoren-`rel` wird immer + überschrieben (kein kundenseitiger Hebel). Greift in + `PressReleaseHtmlSanitizer::render()` und wirkt damit rückwirkend auf + alle gespeicherten Inhalte und auf jede Ausgabe (Panel-Vorschau heute, + Web-Detailseiten beim Relaunch). +- Editor-Hinweis (PM anlegen/bearbeiten): Links erwünscht, Auszeichnung + automatisch — bewusst ohne follow/nofollow-Auswahl im UI. +- §9-Korrektur im Decision-Update „Preisstruktur & Veröffentlichungs-Flow" + (Abschnitt 4) eingearbeitet. + +**Offen (wie in §8 vorgesehen):** CTA-Box/Darstellungs-Stufung (hängt am +Boost-Konzept, 9I), Link-Obergrenze pro PM, Anchor-Text-Soft-Check +(Phase 2). Die Linktyp-Auswahl im Editor ist fürs `rel` nicht nötig +(systemseitig aus der Ziel-URL abgeleitet) — sie wird relevant, sobald die +CTA-Box als Darstellungs-Option kommt. diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index f95ed37..31d7d46 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -892,6 +892,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex /> +

+ {{ __('Links im Text sind ausdrücklich erwünscht. Externe Links werden bei Veröffentlichung automatisch als sponsored/nofollow ausgezeichnet (Google-Vorgabe für Pressemitteilungen) — Links auf Ihr Unternehmensprofil im Portal bleiben follow.') }} +

+
diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 457a7ca..009fcb4 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -844,6 +844,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl /> +

+ {{ __('Links im Text sind ausdrücklich erwünscht. Externe Links werden bei Veröffentlichung automatisch als sponsored/nofollow ausgezeichnet (Google-Vorgabe für Pressemitteilungen) — Links auf Ihr Unternehmensprofil im Portal bleiben follow.') }} +

+
diff --git a/tests/Feature/PressReleaseHtmlSanitizerTest.php b/tests/Feature/PressReleaseHtmlSanitizerTest.php index 4038680..fed58b4 100644 --- a/tests/Feature/PressReleaseHtmlSanitizerTest.php +++ b/tests/Feature/PressReleaseHtmlSanitizerTest.php @@ -123,3 +123,49 @@ test('PressRelease::renderedText uses the sanitizer', function () { expect($rendered)->toContain('

Hallo

'); expect($rendered)->not->toContain('render('

Produkt

'); + + expect($html)->toContain('rel="sponsored nofollow noopener"') + ->and($html)->toContain('target="_blank"'); +}); + +test('rendered internal portal links stay follow', function () { + $html = (string) sanitizer()->render('

Unternehmensprofil

'); + + expect($html)->not->toContain('nofollow') + ->and($html)->not->toContain('sponsored') + ->and($html)->toContain('href="https://presseecho.test/firma/alpha-gmbh"'); +}); + +test('relative links are treated as internal and stay follow', function () { + $html = (string) sanitizer()->render('

Profil

'); + + expect($html)->not->toContain('nofollow') + ->and($html)->not->toContain('target='); +}); + +test('author-supplied rel attributes are always overridden', function () { + $html = (string) sanitizer()->render('

Link

'); + + expect($html)->not->toContain('dofollow') + ->and($html)->toContain('rel="sponsored nofollow noopener"'); +}); + +test('mailto and tel links get no rel and no target', function () { + $html = (string) sanitizer()->render('

Mail

'); + + expect($html)->not->toContain('rel=') + ->and($html)->not->toContain('target='); +}); + +test('www variants of portal domains count as internal', function () { + $html = (string) sanitizer()->render('

Profil

'); + + expect($html)->not->toContain('nofollow'); +});