Verlinkung & Backlinks: systemseitige rel-Auszeichnung (Decision-Update 11.06.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 15:24:20 +00:00
parent 5a8da0c1f4
commit 25ea91d85b
8 changed files with 239 additions and 3 deletions

View file

@ -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);

View file

@ -0,0 +1,132 @@
<?php
namespace App\Services\PressRelease;
use DOMDocument;
use DOMElement;
use Illuminate\Support\Str;
/**
* Systemseitige `rel`-Auszeichnung für Links in Pressemitteilungen
* (Decision-Update „Verlinkung & Backlinks", 11.06.2026):
*
* - **Externe Links** (Kundenseite, Landingpage, Social)
* `rel="sponsored nofollow noopener"` + `target="_blank"`.
* Google-konform: vom Kunden gesetzte Links sind keine redaktionelle
* Empfehlung des Portals.
* - **Portalinterne Links** (Unternehmensprofil/Newsroom auf den eigenen
* Domains, relative Pfade) **follow** (kein `rel`) redaktionelle
* interne Verlinkung stärkt die eigene Domain-Architektur.
* - `mailto:`/`tel:` kein `rel`, kein `target`.
*
* Die Auszeichnung ist bewusst NICHT kundenseitig wählbar: vom Autor
* mitgelieferte `rel`-Attribute werden grundsätzlich überschrieben.
* Die Policy greift beim Rendern Änderungen wirken damit rückwirkend
* auf alle gespeicherten Inhalte.
*/
class PressReleaseLinkPolicy
{
public function apply(string $html): string
{
if (trim($html) === '' || ! str_contains($html, '<a')) {
return $html;
}
$document = new DOMDocument;
// Wrapper + XML-Prolog: UTF-8 erzwingen und Fragment-Struktur
// erhalten (loadHTML ergänzt sonst html/body selbst).
$loaded = @$document->loadHTML(
'<?xml encoding="utf-8" ?><div id="pr-link-policy-root">'.$html.'</div>',
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<string>
*/
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));
}
}

View file

@ -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:

View file

@ -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".
---

View file

@ -117,4 +117,29 @@ ersetzt durch:
---
_SEO-/Richtlinien-Stand: Google-Spam-Policies inkl. Link-Spam-Enforcement 20242026; `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._
_SEO-/Richtlinien-Stand: Google-Spam-Policies inkl. Link-Spam-Enforcement 20242026; `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.

View file

@ -892,6 +892,10 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
/>
<flux:error name="text" />
<p class="mt-2 mb-0 text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
{{ __('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.') }}
</p>
<div class="pr-ai-hint mt-4">
<span class="ico">
<flux:icon name="sparkles" variant="micro" />

View file

@ -844,6 +844,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
/>
<flux:error name="text" />
<p class="mt-2 mb-0 text-[11.5px] leading-[1.5] text-[color:var(--color-ink-3)]">
{{ __('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.') }}
</p>
<div class="pr-ai-hint mt-4">
<span class="ico">
<flux:icon name="sparkles" variant="micro" />

View file

@ -123,3 +123,49 @@ test('PressRelease::renderedText uses the sanitizer', function () {
expect($rendered)->toContain('<p>Hallo</p>');
expect($rendered)->not->toContain('<script');
});
// ============================================================
// Link-Policy (Decision-Update „Verlinkung & Backlinks", 11.06.2026)
// ============================================================
test('rendered external links carry sponsored nofollow noopener', function () {
$html = (string) sanitizer()->render('<p><a href="https://kundenseite.example/produkt">Produkt</a></p>');
expect($html)->toContain('rel="sponsored nofollow noopener"')
->and($html)->toContain('target="_blank"');
});
test('rendered internal portal links stay follow', function () {
$html = (string) sanitizer()->render('<p><a href="https://presseecho.test/firma/alpha-gmbh">Unternehmensprofil</a></p>');
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('<p><a href="/firma/alpha-gmbh">Profil</a></p>');
expect($html)->not->toContain('nofollow')
->and($html)->not->toContain('target=');
});
test('author-supplied rel attributes are always overridden', function () {
$html = (string) sanitizer()->render('<p><a href="https://kundenseite.example" rel="dofollow">Link</a></p>');
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('<p><a href="mailto:presse@example.test">Mail</a></p>');
expect($html)->not->toContain('rel=')
->and($html)->not->toContain('target=');
});
test('www variants of portal domains count as internal', function () {
$html = (string) sanitizer()->render('<p><a href="https://www.presseecho.test/firma/alpha">Profil</a></p>');
expect($html)->not->toContain('nofollow');
});