Verlinkung & Backlinks: systemseitige rel-Auszeichnung (Decision-Update 11.06.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5a8da0c1f4
commit
25ea91d85b
8 changed files with 239 additions and 3 deletions
|
|
@ -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);
|
||||
|
|
|
|||
132
app/Services/PressRelease/PressReleaseLinkPolicy.php
Normal file
132
app/Services/PressRelease/PressReleaseLinkPolicy.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -118,3 +118,28 @@ 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._
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue