132 lines
3.9 KiB
PHP
132 lines
3.9 KiB
PHP
<?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));
|
|
}
|
|
}
|