diff --git a/.env.example b/.env.example index 99e3b80f..f18a9e52 100644 --- a/.env.example +++ b/.env.example @@ -262,3 +262,9 @@ GSC_CUSTOMER_VISIBLE=false # Transparenzseite „kostenlos veröffentlichen": Prüfdatum der Portalliste # (Einträge in config/transparency.php). Leer = Abschnitt erscheint nicht. 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 +CANONICAL_FALLBACK=first diff --git a/app/Console/Commands/AssignCanonicalPressReleases.php b/app/Console/Commands/AssignCanonicalPressReleases.php new file mode 100644 index 00000000..25d6707b --- /dev/null +++ b/app/Console/Commands/AssignCanonicalPressReleases.php @@ -0,0 +1,49 @@ +option('dry-run'); + + if (! $assigner->enabled() && ! $dryRun) { + $this->warn('Zuordnung ist aus (CANONICAL_ASSIGNMENT_ENABLED). Mit --dry-run lässt sich das Ergebnis trotzdem ansehen.'); + + return self::SUCCESS; + } + + $verbose = (bool) $this->option('verbose-pairs'); + + $result = $assigner->assignAll($dryRun, function (int $canonicalId, int $duplicateId, bool $changed) use ($verbose): void { + if ($verbose) { + $this->line(sprintf(' %d ← %d%s', $canonicalId, $duplicateId, $changed ? '' : ' (unverändert)')); + } + }); + + $this->info(sprintf( + '%s%d Paare, %d %s.', + $dryRun ? '[Dry-Run] ' : '', + $result['pairs'], + $result['changed'], + $dryRun ? 'würden geändert' : 'geändert', + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/HashPressReleaseContent.php b/app/Console/Commands/HashPressReleaseContent.php new file mode 100644 index 00000000..54327fed --- /dev/null +++ b/app/Console/Commands/HashPressReleaseContent.php @@ -0,0 +1,70 @@ +option('chunk')); + $all = (bool) $this->option('all'); + + $query = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->withTrashed() + ->select(['id', 'title', 'text', 'content_hash']) + ->when(! $all, fn (Builder $q) => $q->whereNull('content_hash')); + + $total = (clone $query)->toBase()->getCountForPagination(); + + if ($total === 0) { + $this->info('Alle Meldungen tragen eine Prüfsumme — nichts zu tun.'); + + return self::SUCCESS; + } + + $this->info(sprintf('%s: %d Meldungen.', $all ? 'Neu berechnet' : 'Ergänzt', $total)); + $bar = $this->output->createProgressBar($total); + $done = 0; + + $query->chunkById($chunk, function ($releases) use (&$done, $bar): void { + foreach ($releases as $release) { + $hash = ContentFingerprint::hash($release->title, $release->text); + + if ($hash !== $release->content_hash) { + DB::table('press_releases')->where('id', $release->id)->update(['content_hash' => $hash]); + } + } + + $done += $releases->count(); + $bar->advance($releases->count()); + }); + + $bar->finish(); + $this->newLine(2); + $this->info("Fertig: {$done} Meldungen geprüft."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ReportDuplicatePressReleases.php b/app/Console/Commands/ReportDuplicatePressReleases.php new file mode 100644 index 00000000..21e087e6 --- /dev/null +++ b/app/Console/Commands/ReportDuplicatePressReleases.php @@ -0,0 +1,73 @@ +pairsQuery(); + + $total = (clone $pairs)->count(); + $recent = (clone $pairs)->where('a.created_at', '>=', '2026-07-01')->count(); + $assigned = (clone $pairs)->where(fn ($q) => $q->whereColumn('a.canonical_press_release_id', '=', 'b.id')->orWhereColumn('b.canonical_press_release_id', '=', 'a.id'))->count(); + $unhashed = PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereNull('content_hash')->count(); + + $visible = PressRelease::query()->withoutGlobalScope(PortalScope::class)->visibleInArchive() + ->selectRaw('portal, count(*) as total')->groupBy('portal')->toBase()->pluck('total', 'portal'); + + $this->info('Doppelveröffentlichung — Stand '.now()->format('d.m.Y H:i')); + $this->newLine(); + $this->line(sprintf('Sichtbare Meldungen: presseecho %s, businessportal24 %s, both %s', + number_format((int) ($visible['presseecho'] ?? 0), 0, ',', '.'), + number_format((int) ($visible['businessportal24'] ?? 0), 0, ',', '.'), + number_format((int) ($visible['both'] ?? 0), 0, ',', '.'))); + $this->line(sprintf('Paare gleichen Inhalts auf beiden Portalen: %s (%s URLs)', number_format($total, 0, ',', '.'), number_format($total * 2, 0, ',', '.'))); + $this->line(sprintf(' davon seit 07/2026 (neues System): %s', number_format($recent, 0, ',', '.'))); + $this->line(sprintf(' davon mit kanonischer Zuordnung: %s', number_format($assigned, 0, ',', '.'))); + $this->line(sprintf('Meldungen ohne Prüfsumme: %s%s', number_format($unhashed, 0, ',', '.'), $unhashed > 0 ? ' — erst press-releases:hash-content laufen lassen' : '')); + $this->line(sprintf('Zuordnung (config canonical.enabled): %s', $assigner->enabled() ? 'aktiv' : 'aus')); + $this->newLine(); + + $byCategory = (clone $pairs) + ->leftJoin('categories as c', 'c.id', '=', 'a.category_id') + ->leftJoin('categories as root', 'root.id', '=', DB::raw('coalesce(c.parent_id, c.id)')) + ->leftJoin('category_translations as t', fn ($join) => $join->on('t.category_id', '=', 'root.id')->where('t.locale', '=', 'de')) + ->select(DB::raw('t.slug as slug, count(*) as pairs')) + ->groupBy('t.slug') + ->orderByDesc('pairs') + ->get(); + + $rows = $byCategory->map(function (object $row): array { + $slug = (string) ($row->slug ?? '—'); + $portal = config('canonical.portal_by_category.'.$slug); + + return [ + $slug, + number_format((int) $row->pairs, 0, ',', '.'), + is_string($portal) ? (Portal::tryFrom($portal)?->label() ?? $portal) : 'Fallback ('.config('canonical.fallback', 'first').')', + ]; + })->all(); + + $this->table(['Hauptkategorie', 'Paare', 'Erstquelle laut Zuordnung (E9)'], $rows); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Web/NewsSitemapController.php b/app/Http/Controllers/Web/NewsSitemapController.php index 7a296406..e75a5a70 100644 --- a/app/Http/Controllers/Web/NewsSitemapController.php +++ b/app/Http/Controllers/Web/NewsSitemapController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Middleware\SetEdition; use App\Models\PressRelease; use App\Scopes\PortalScope; +use App\Services\PressRelease\CanonicalAssigner; use App\Support\StructuredData; use App\Support\WebPortalContext; use Illuminate\Http\Request; @@ -50,18 +51,26 @@ class NewsSitemapController extends Controller $context = WebPortalContext::fromRequest(); $publication = (string) StructuredData::publisher($base)['name']; + // Bei Doppelveröffentlichung nur die Erstquelle (Anhang 2, 1c.8) — + // Google News spielt ohnehin nur eine Fassung aus. + $assigner = app(CanonicalAssigner::class); $releases = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->visibleInArchive() ->forPortalValues($context->portalValues) + ->when($assigner->enabled(), fn ($query) => $query->whereNull('canonical_press_release_id')) ->where('published_at', '>=', now()->subHours(self::WINDOW_HOURS)) ->orderByDesc('published_at') ->limit(self::MAX_URLS) - ->get(['slug', 'title', 'language', 'published_at']); + ->get(['id', 'slug', 'title', 'portal', 'language', 'category_id', 'canonical_press_release_id', 'published_at']); $entries = []; foreach ($releases as $release) { + if (! $assigner->isCanonicalOn($release, $context->portal)) { + continue; + } + $edition = in_array($release->language, SetEdition::EDITIONS, true) ? $release->language : SetEdition::DEFAULT_EDITION; $entries[] = [ diff --git a/app/Http/Controllers/Web/SitemapController.php b/app/Http/Controllers/Web/SitemapController.php index 31adfaea..bd5d5009 100644 --- a/app/Http/Controllers/Web/SitemapController.php +++ b/app/Http/Controllers/Web/SitemapController.php @@ -7,6 +7,7 @@ use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; use App\Scopes\PortalScope; +use App\Services\PressRelease\CanonicalAssigner; use App\Services\Web\CategoryArchivePageData; use App\Support\WebPortalContext; use Illuminate\Http\Request; @@ -120,15 +121,23 @@ class SitemapController extends Controller ]; } - // Alle im Archiv sichtbaren Pressemitteilungen des Portals. + // Alle im Archiv sichtbaren Pressemitteilungen des Portals — bei + // Doppelveröffentlichung nur die Erstquelle (Anhang 2, 1c.8): eine + // Sitemap soll nur kanonische Adressen nennen. + $assigner = app(CanonicalAssigner::class); $releases = PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->visibleInArchive() ->forPortalValues($context->portalValues) + ->when($assigner->enabled(), fn ($query) => $query->whereNull('canonical_press_release_id')) ->orderByDesc('published_at') - ->get(['slug', 'published_at', 'updated_at']); + ->get(['id', 'slug', 'portal', 'language', 'category_id', 'canonical_press_release_id', 'published_at', 'updated_at']); foreach ($releases as $release) { + if (! $assigner->isCanonicalOn($release, $context->portal)) { + continue; + } + $urls[] = [ 'loc' => $base.'/de/press-release/'.$release->slug, 'lastmod' => ($release->updated_at ?? $release->published_at)?->toAtomString(), diff --git a/app/Models/PressRelease.php b/app/Models/PressRelease.php index e9014658..1583055d 100644 --- a/app/Models/PressRelease.php +++ b/app/Models/PressRelease.php @@ -16,6 +16,7 @@ use App\Models\Concerns\HasUniqueSlug; use App\Scopes\PortalScope; use App\Services\ActivityLog; use App\Services\PressRelease\PressReleaseHtmlSanitizer; +use App\Support\ContentFingerprint; use Closure; use Database\Factories\PressReleaseFactory; use Illuminate\Database\Eloquent\Builder; @@ -60,6 +61,14 @@ class PressRelease extends Model $pressRelease->placeholder_variant = PressReleasePlaceholder::fromSeed($seed); } }); + + // Prüfsumme über den Inhalt (Anhang 2, 1c.7) — bei jeder Änderung an + // Titel oder Text, und nachträglich für Zeilen ohne Summe. + static::saving(function (self $pressRelease): void { + if ($pressRelease->content_hash === null || $pressRelease->isDirty(['title', 'text'])) { + $pressRelease->content_hash = ContentFingerprint::hash($pressRelease->title, $pressRelease->text); + } + }); } protected $fillable = [ @@ -73,6 +82,8 @@ class PressRelease extends Model 'subtitle', 'slug', 'text', + 'content_hash', + 'canonical_press_release_id', 'boilerplate_override', 'placeholder_variant', 'backlink_url', @@ -407,6 +418,33 @@ class PressRelease extends Model } } + /** + * Maßgebliche Fassung bei Doppelveröffentlichung (Anhang 2, 1c.8): + * gesetzt auf der zweiten Fassung, zeigt auf die erste. Scope-frei, weil + * die Erstquelle auf dem anderen Portal liegt. + * + * @return BelongsTo + */ + public function canonical(): BelongsTo + { + return $this->belongsTo(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScopes(); + } + + /** + * Fassungen, die auf diese Meldung als Erstquelle verweisen. + * + * @return HasMany + */ + public function duplicates(): HasMany + { + return $this->hasMany(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScopes(); + } + + public function isCanonicalDuplicate(): bool + { + return $this->canonical_press_release_id !== null; + } + /** * Crawler-Abruf-Protokoll (Roadmap 1c.0). * diff --git a/app/Models/PressReleaseIndexCheck.php b/app/Models/PressReleaseIndexCheck.php index 589a9c6e..ba238d5a 100644 --- a/app/Models/PressReleaseIndexCheck.php +++ b/app/Models/PressReleaseIndexCheck.php @@ -34,6 +34,7 @@ class PressReleaseIndexCheck extends Model 'page_fetch_state', 'last_crawl_time', 'google_canonical', + 'user_canonical', 'raw', 'checked_at', ]; diff --git a/app/Services/PressRelease/CanonicalAssigner.php b/app/Services/PressRelease/CanonicalAssigner.php new file mode 100644 index 00000000..dba37830 --- /dev/null +++ b/app/Services/PressRelease/CanonicalAssigner.php @@ -0,0 +1,288 @@ + Kategorie-ID → DE-Slug der Hauptkategorie */ + private array $categorySlugs = []; + + public function enabled(): bool + { + return (bool) config('canonical.enabled'); + } + + /** + * Portal, das für die Kategorie der Meldung die Erstquelle führt — null + * ohne Eintrag in der Zuordnung. + */ + public function preferredPortal(PressRelease $pressRelease): ?Portal + { + if (! $pressRelease->category_id) { + return null; + } + + $slug = $this->categorySlug((int) $pressRelease->category_id); + $portal = $slug !== null ? config('canonical.portal_by_category.'.$slug) : null; + + return is_string($portal) ? Portal::tryFrom($portal) : null; + } + + /** + * Öffentliche Adresse, auf die rel=canonical der Meldung zeigt, wenn sie + * auf `$viewedOn` ausgeliefert wird. Ohne Freigabe (config) oder ohne + * Zuordnung: die eigene Adresse auf dem aufgerufenen Portal. + */ + public function canonicalUrlFor(PressRelease $pressRelease, Portal $viewedOn): string + { + $self = $this->publicUrl($pressRelease, $viewedOn); + + if (! $this->enabled()) { + return $self; + } + + if ($pressRelease->canonical_press_release_id !== null) { + $canonical = PressRelease::query()->withoutGlobalScopes()->find($pressRelease->canonical_press_release_id); + + if ($canonical !== null && $canonical->isVisibleInArchive()) { + return $this->publicUrl($canonical, $canonical->portal === Portal::Both ? ($this->preferredPortal($canonical) ?? $viewedOn) : $canonical->portal); + } + + return $self; + } + + if ($pressRelease->portal === Portal::Both) { + return $this->publicUrl($pressRelease, $this->preferredPortal($pressRelease) ?? $viewedOn); + } + + return $self; + } + + /** + * Ob die Meldung auf `$portal` als Erstquelle gilt — nur die gehört in + * Sitemap und News-Sitemap dieses Portals. + */ + public function isCanonicalOn(PressRelease $pressRelease, Portal $portal): bool + { + if (! $this->enabled()) { + return true; + } + + if ($pressRelease->canonical_press_release_id !== null) { + return false; + } + + if ($pressRelease->portal === Portal::Both) { + return ($this->preferredPortal($pressRelease) ?? $portal) === $portal; + } + + return true; + } + + /** + * Zuordnung für eine Meldung und ihre Gegenstücke auf dem anderen Portal + * neu bestimmen — nach jeder Veröffentlichung. + */ + public function assignFor(PressRelease $pressRelease): void + { + if (! $this->enabled() || $pressRelease->content_hash === null || $pressRelease->portal === Portal::Both) { + return; + } + + $counterparts = PressRelease::query() + ->withoutGlobalScope(PortalScope::class) + ->published() + ->where('content_hash', $pressRelease->content_hash) + ->where('company_id', $pressRelease->company_id) + ->whereKeyNot($pressRelease->getKey()) + ->where('portal', '!=', $pressRelease->portal->value) + ->get(); + + if ($counterparts->isEmpty()) { + $this->write($pressRelease->getKey(), null); + + return; + } + + foreach ($counterparts as $counterpart) { + $this->assignPair($pressRelease, $counterpart); + } + } + + /** + * Alle Paare im Bestand zuordnen. + * + * @param callable(int $canonicalId, int $duplicateId, bool $changed): void|null $each + * @return array{pairs: int, changed: int} + */ + public function assignAll(bool $dryRun = false, ?callable $each = null): array + { + $pairs = 0; + $changed = 0; + + $this->pairsQuery()->orderBy('a.id')->chunk(500, function ($rows) use ($dryRun, $each, &$pairs, &$changed): void { + $ids = $rows->flatMap(fn (object $row): array => [(int) $row->a_id, (int) $row->b_id])->unique()->all(); + $releases = PressRelease::query()->withoutGlobalScopes()->findMany($ids)->keyBy('id'); + + foreach ($rows as $row) { + $a = $releases->get((int) $row->a_id); + $b = $releases->get((int) $row->b_id); + + if (! $a || ! $b) { + continue; + } + + $pairs++; + $canonical = $this->resolveCanonical($a, $b); + $duplicate = $canonical->is($a) ? $b : $a; + $wouldChange = $duplicate->canonical_press_release_id !== $canonical->getKey() || $canonical->canonical_press_release_id !== null; + + if ($wouldChange) { + $changed++; + + if (! $dryRun) { + $this->write($duplicate->getKey(), $canonical->getKey()); + $this->write($canonical->getKey(), null); + } + } + + if ($each !== null) { + $each($canonical->getKey(), $duplicate->getKey(), $wouldChange); + } + } + }); + + return ['pairs' => $pairs, 'changed' => $changed]; + } + + /** + * Veröffentlichte Paare gleichen Inhalts derselben Firma auf verschiedenen + * Portalen (a = kleinere ID). + */ + public function pairsQuery(): QueryBuilder + { + return DB::table('press_releases as a') + ->join('press_releases as b', function ($join): void { + $join->on('a.content_hash', '=', 'b.content_hash') + ->on('a.company_id', '=', 'b.company_id') + ->whereColumn('a.id', '<', 'b.id') + ->whereColumn('a.portal', '!=', 'b.portal'); + }) + ->whereNotNull('a.content_hash') + ->whereNull('a.deleted_at')->whereNull('b.deleted_at') + ->where('a.status', 'published')->where('b.status', 'published') + ->whereNull('a.gone_at')->whereNull('b.gone_at') + ->whereNotIn('a.portal', ['both'])->whereNotIn('b.portal', ['both']) + ->select(['a.id as a_id', 'b.id as b_id', 'a.category_id', 'a.company_id', 'a.published_at as a_published_at', 'b.published_at as b_published_at']); + } + + /** + * Welche der beiden Fassungen Erstquelle ist: die auf dem Kategorie- + * Portal, sonst die zuerst veröffentlichte (bei Gleichstand die ältere ID). + */ + public function resolveCanonical(PressRelease $a, PressRelease $b): PressRelease + { + $preferred = $this->preferredPortal($a); + + if ($preferred !== null) { + if ($a->portal === $preferred && $b->portal !== $preferred) { + return $a; + } + + if ($b->portal === $preferred && $a->portal !== $preferred) { + return $b; + } + } + + $fallback = (string) config('canonical.fallback', 'first'); + $fallbackPortal = Portal::tryFrom($fallback); + + if ($fallbackPortal !== null) { + if ($a->portal === $fallbackPortal) { + return $a; + } + + if ($b->portal === $fallbackPortal) { + return $b; + } + } + + $aAt = $a->published_at?->getTimestamp() ?? PHP_INT_MAX; + $bAt = $b->published_at?->getTimestamp() ?? PHP_INT_MAX; + + if ($aAt !== $bAt) { + return $aAt < $bAt ? $a : $b; + } + + return $a->getKey() < $b->getKey() ? $a : $b; + } + + private function assignPair(PressRelease $a, PressRelease $b): void + { + $canonical = $this->resolveCanonical($a, $b); + $duplicate = $canonical->is($a) ? $b : $a; + + $this->write($duplicate->getKey(), $canonical->getKey()); + $this->write($canonical->getKey(), null); + } + + private function write(int $id, ?int $canonicalId): void + { + DB::table('press_releases') + ->where('id', $id) + ->where(fn ($query) => $canonicalId === null + ? $query->whereNotNull('canonical_press_release_id') + : $query->where('canonical_press_release_id', '!=', $canonicalId)->orWhereNull('canonical_press_release_id')) + ->update(['canonical_press_release_id' => $canonicalId]); + } + + private function publicUrl(PressRelease $pressRelease, Portal $portal): string + { + if ($portal === Portal::Both) { + $portal = Portal::Presseecho; + } + + $edition = in_array($pressRelease->language, SetEdition::EDITIONS, true) ? $pressRelease->language : 'de'; + $base = rtrim((string) match ($portal) { + Portal::Presseecho => config('domains.domain_presseecho_url'), + default => config('domains.domain_businessportal_url'), + }, '/'); + + return sprintf('%s/%s/press-release/%s', $base, $edition, $pressRelease->slug); + } + + private function categorySlug(int $categoryId): ?string + { + if (array_key_exists($categoryId, $this->categorySlugs)) { + return $this->categorySlugs[$categoryId]; + } + + $category = Category::query()->withoutGlobalScopes()->with(['parent.translations', 'translations'])->find($categoryId); + $root = $category !== null && $category->parent !== null ? $category->parent : $category; + $slug = $root?->translations->firstWhere('locale', 'de')?->slug; + + return $this->categorySlugs[$categoryId] = is_string($slug) ? $slug : null; + } +} diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index db43c610..114b8c56 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -316,6 +316,15 @@ class PressReleaseService ); } + // Doppelveröffentlichung (Anhang 2, 1c.8): liegt derselbe Text der + // Firma schon auf dem anderen Portal, bestimmt jetzt das Portal die + // Erstquelle. Nebensache — darf die Veröffentlichung nie brechen. + try { + app(CanonicalAssigner::class)->assignFor($pressRelease); + } catch (\Throwable $e) { + report($e); + } + $this->notifyAuthor($pressRelease, 'published'); } diff --git a/app/Services/SearchConsole/IndexStatusChecker.php b/app/Services/SearchConsole/IndexStatusChecker.php index 072f069d..c0332dbd 100644 --- a/app/Services/SearchConsole/IndexStatusChecker.php +++ b/app/Services/SearchConsole/IndexStatusChecker.php @@ -111,6 +111,7 @@ class IndexStatusChecker 'page_fetch_state' => Str::limit((string) ($indexStatus['pageFetchState'] ?? ''), 60, '') ?: null, 'last_crawl_time' => ! empty($indexStatus['lastCrawlTime']) ? Carbon::parse((string) $indexStatus['lastCrawlTime']) : null, 'google_canonical' => Str::limit((string) ($indexStatus['googleCanonical'] ?? ''), 500, '') ?: null, + 'user_canonical' => Str::limit((string) ($indexStatus['userCanonical'] ?? ''), 500, '') ?: null, 'raw' => $indexStatus, 'checked_at' => now(), ]); @@ -133,6 +134,37 @@ class IndexStatusChecker ->all(); } + /** + * Quellenauskunft (Anhang 2, 1c.9): welche Adresse Google als maßgeblich + * führt, verglichen mit der eigenen Fassung. Sagt nichts darüber, warum. + * + * @return array{kind: 'self'|'own_other'|'foreign'|'unknown', url: ?string, host: ?string} + */ + public function sourceVerdict(PressReleaseIndexCheck $check): array + { + $google = $check->google_canonical; + + if ($google === null || $google === '') { + return ['kind' => 'unknown', 'url' => null, 'host' => null]; + } + + $normalize = fn (string $url): string => rtrim(strtolower((string) preg_replace('#^https?://(www\.)?#i', '', $url)), '/'); + $host = strtolower((string) parse_url($google, PHP_URL_HOST)); + + if ($normalize($google) === $normalize($check->url)) { + return ['kind' => 'self', 'url' => $google, 'host' => $host]; + } + + $ownHosts = array_filter(array_map( + fn (?string $url): string => strtolower((string) parse_url((string) $url, PHP_URL_HOST)), + [config('domains.domain_presseecho_url'), config('domains.domain_businessportal_url')], + ), fn (string $own): bool => $own !== ''); + + $isOwn = collect($ownHosts)->contains(fn (string $own): bool => str_ends_with($host, $own) || str_ends_with($own, $host)); + + return ['kind' => $isOwn ? 'own_other' : 'foreign', 'url' => $google, 'host' => $host]; + } + /** * @return Builder */ diff --git a/app/Support/ContentFingerprint.php b/app/Support/ContentFingerprint.php new file mode 100644 index 00000000..58475089 --- /dev/null +++ b/app/Support/ContentFingerprint.php @@ -0,0 +1,23 @@ + (bool) env('CANONICAL_ASSIGNMENT_ENABLED', false), + + // DE-Slug der Hauptkategorie => Portal, das die Erstquelle führt. + // Vorschlag zu E9; Kategorien ohne Eintrag fallen auf `fallback`. + 'portal_by_category' => [ + 'finanzen' => 'businessportal24', + 'handel' => 'businessportal24', + 'it' => 'businessportal24', + 'recht' => 'businessportal24', + 'wissenschaft-forschung' => 'businessportal24', + 'motor-verkehr' => 'businessportal24', + 'haus' => 'presseecho', + 'kultur' => 'presseecho', + 'medizin' => 'presseecho', + 'mode' => 'presseecho', + 'politik' => 'presseecho', + 'reisen' => 'presseecho', + 'sport' => 'presseecho', + 'sonstiges' => 'presseecho', + ], + + // Ohne Kategorie-Eintrag: 'first' = die zuerst veröffentlichte Fassung + // bleibt Erstquelle; alternativ ein Portalname als feste Vorgabe. + 'fallback' => env('CANONICAL_FALLBACK', 'first'), + +]; diff --git a/database/migrations/2026_09_18_100000_add_content_hash_and_canonical_to_press_releases_table.php b/database/migrations/2026_09_18_100000_add_content_hash_and_canonical_to_press_releases_table.php new file mode 100644 index 00000000..234c140a --- /dev/null +++ b/database/migrations/2026_09_18_100000_add_content_hash_and_canonical_to_press_releases_table.php @@ -0,0 +1,48 @@ +char('content_hash', 64)->nullable()->after('keywords'); + $table->unsignedBigInteger('canonical_press_release_id')->nullable()->after('content_hash'); + }); + + Schema::table('press_releases', function (Blueprint $table) { + $table->index(['content_hash', 'company_id'], 'press_releases_content_hash_company_idx'); + $table->index('canonical_press_release_id', 'press_releases_canonical_idx'); + }); + } + + public function down(): void + { + Schema::table('press_releases', function (Blueprint $table) { + $table->dropIndex('press_releases_content_hash_company_idx'); + $table->dropIndex('press_releases_canonical_idx'); + }); + + Schema::table('press_releases', function (Blueprint $table) { + $table->dropColumn(['content_hash', 'canonical_press_release_id']); + }); + } +}; diff --git a/database/migrations/2026_09_18_100100_add_user_canonical_to_press_release_index_checks_table.php b/database/migrations/2026_09_18_100100_add_user_canonical_to_press_release_index_checks_table.php new file mode 100644 index 00000000..18d46696 --- /dev/null +++ b/database/migrations/2026_09_18_100100_add_user_canonical_to_press_release_index_checks_table.php @@ -0,0 +1,27 @@ +string('user_canonical', 500)->nullable()->after('google_canonical'); + }); + } + + public function down(): void + { + Schema::table('press_release_index_checks', function (Blueprint $table) { + $table->dropColumn('user_canonical'); + }); + } +}; diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index c9ea773a..5a95ca10 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -239,6 +239,10 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig. | ~~E4~~ | **Entschieden 15.09.2026: der Magic-Link-Hinweis, am Ende der Mail, als Hinweis, nicht als Angebot.** Umgesetzt in 1b.4. Der Link auf die Infoseite (1d.2) bekommt damit keinen Platz in der Veröffentlichungsmail — ein Element pro Mail. | 1b.4, 1d.2 | | ~~E5~~ | **Geschlossen 17.09.2026: bleibt bei FAQ, kein Slug-Mapping.** Werden die bestehenden Ratgeberseiten konsolidiert oder ergänzt — **Befund 17.09.2026:** `/de/presse/*` leitet seit R3.2 auf `/faq`; offen ist nur, ob Schreib-Slugs stattdessen auf `/gute-pressemitteilung` zeigen sollen (Protokoll Teil 5) | 2.5 | | 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 | --- diff --git a/dev/2026-09-01/roadmap-anhang2-2026.md b/dev/2026-09-01/roadmap-anhang2-2026.md new file mode 100644 index 00000000..4af03e8c --- /dev/null +++ b/dev/2026-09-01/roadmap-anhang2-2026.md @@ -0,0 +1,111 @@ +# Anhang 2 zur Roadmap Nutzergewinnung — Doppelveröffentlichung und Quellenauskunft + +**Stand:** 17.09.2026 +**Ergänzt:** Block 1c und 1d +**Anlass:** Über die API wird für beide Portale eingereicht, häufig dieselbe Meldung. Die Search-Console-Properties sind seit 17.09.2026 für presseecho.de, businessportal24.com und pressekonto.com bestätigt; damit wird der Effekt erstmals messbar. + +--- + +## 1. Sachlage + +Liegt derselbe Text auf zwei Adressen, wählt Google eine davon als maßgeblich und filtert die andere aus dem Index. Das ist kein Nachteil im Sinne einer Abwertung, sondern eine Auswahl. Die Folge betrifft zwei Seiten unterschiedlich stark: + +| Betroffen | Auswirkung | +|---|---| +| Die Portale | Crawl-Aufwand für Seiten, die nicht erscheinen; über den Bestand ein hoher Anteil ausgefilterter URLs | +| Die einreichende Firma | Die Meldung existiert in der Suche einmal statt zweimal, ohne Einfluss darauf, welche Fundstelle das ist | + +Für Leser, Feeds, interne Suche und Newsroom zählen weiterhin beide Fassungen. Nur in der Suchmaschine zählt eine. + +**Derzeitiger Zustand:** Die Auswahl trifft der Algorithmus. Das Portal erfährt nicht, wie sie ausgefallen ist. + +--- + +## 2. Sichtbar wird es über zwei Felder + +Die URL-Prüfung der Search Console liefert: + +| Feld | Bedeutung | +|---|---| +| `userCanonical` | die Adresse, die das Portal als maßgeblich angibt | +| `googleCanonical` | die Adresse, die Google als maßgeblich behandelt | + +Weichen beide voneinander ab, hat Google eine andere Quelle gewählt. Das kann die eigene zweite Fassung sein oder ein fremdes Portal aus derselben Verteilerausspielung. + +Schneller als über die API geht es zunächst über eine Bestandsabfrage nach identischen Titeln oder Textprüfsummen auf beiden Portalen. Das beantwortet die Mengenfrage, ohne auf Crawl-Daten zu warten. + +--- + +## 3. Neue Arbeitspakete in 1c + +| Nr. | Arbeitspaket | Aufwand | Abhängigkeit | +|---|---|---|---| +| ~~1c.7~~ | Doppelveröffentlichung erheben — **gebaut 17.09.2026**: `content_hash`, `press-releases:hash-content`, `press-releases:duplicates-report`. Befund: 9.092 Paare = 53 % des Archivs (Protokoll Teil 7) | S | keine | +| ~~1c.8~~ | Kanonische Zuordnung — **gebaut 17.09.2026, ausgeschaltet bis E9**: `CanonicalAssigner`, `canonical_press_release_id`, Zuordnung bei Veröffentlichung + `press-releases:assign-canonicals`; rel=canonical, mainEntityOfPage, Sitemaps nur Erstquelle. Schalter `CANONICAL_ASSIGNMENT_ENABLED` | M | 1c.7 | +| ~~1c.9~~ | Quellenauskunft — **gebaut 17.09.2026, hinter `GSC_CUSTOMER_VISIBLE`**: `user_canonical` gespeichert, `sourceVerdict()` (eigene / andere eigene Fassung / fremdes Portal), Text im Block „Google-Index" | S | 1c.4, 1c.8 | + +### Zur Auswahl in 1c.8 + +| Weg | Wirkung | Preis | +|---|---|---| +| Nur ein Portal je Meldung, Zuordnung über die Kategorie | nichts wird ausgefiltert | eine Veröffentlichung weniger | +| **Beide Portale, zweite Fassung mit `rel=canonical`** | Portal bestimmt die Erstquelle, Leser sehen weiterhin beide | Umsetzungsaufwand | +| So lassen | Google entscheidet, ohne Rückmeldung | Kontrollverlust | + +Empfohlen ist der mittlere Weg. Die Zuordnung folgt der Kategorie: Wirtschafts- und Unternehmensthemen kanonisch auf businessportal24.com, breite Themen auf presseecho.de, die jeweils andere Fassung verweist darauf. + +Technisch am Import ansetzen: Prüfsumme über den Text bilden, bei Treffer wird die zweite Fassung mit Verweis angelegt. Wirkt auch rückwirkend über den Bestand. + +**Offene Entscheidung E9:** welche Kategorie welchem Portal kanonisch zugeordnet wird. *Vorschlag in `config/canonical.php`; „sonstiges" (3.087 Paare) entscheidet das Verhältnis.* + +--- + +## 4. Reihenfolge ist nicht verhandelbar + +1c.8 steht vor 1c.9 und vor jeder Auskunft an Kunden. Solange im eigenen Haus zwei identische Fassungen ohne Zuordnung stehen, kann kein Hinweis an Einreicher gegeben werden, ohne unglaubwürdig zu wirken. + +--- + +## 5. Folge für die Beschreibung des Angebots + +Zwei Portale bedeuten zwei Fundstellen für Leser, nicht zwei Fundstellen in der Suche. Wo das bisher anders dargestellt ist — Preisseite, Leistungsbeschreibung, Benachrichtigungstexte —, gehört es korrigiert, bevor die Quellenauskunft live geht. + +--- + +## 6. Die Auskunft als Service (Ergänzung zu 1d) + +**Quellenauskunft als Service:** Der Abgleich von `googleCanonical` und `userCanonical` zeigt nicht nur, welche der eigenen Fassungen gewonnen hat, sondern auch, wenn ein fremdes Portal als Quelle geführt wird. Diese Auskunft gibt sonst niemand und beantwortet die Frage, warum eine Meldung trotz vieler Fundstellen nicht auffindbar ist. + +**Wo sie steht** + +| Ort | Inhalt | +|---|---| +| Absenderansicht | welche Adresse Google als maßgeblich führt, mit Datum | +| Hilfeseite (1d.1) | Abschnitt: warum eine Meldung mehrfach im Netz steht und nur einmal in den Ergebnissen | +| Benachrichtigungsmail | nichts davon, sonst kippt der Charakter der Mail | + +**Die Empfehlung, die dazugehört:** Die Erstveröffentlichung gehört auf die eigene Website des Unternehmens; die eigene Domain ist die stabilste Quelle, die eine Firma hat. Portale kommen danach. Das schmälert das eigene Geschäft nicht, weil über die eigene Website niemand Verbreitung bekommt. + +**Grenze der Auskunft** + +- Keine Aussage darüber, *warum* Google eine bestimmte Fassung gewählt hat. Das ist nicht bekannt. +- Keine Formulierung, die nahelegt, dass eine Direktveröffentlichung das Problem löst. Sie verbessert die Ausgangslage, weil eine geprüfte Erstfassung mit strukturierten Daten und stabiler Adresse bessere Voraussetzungen hat als eine Verteilerkopie. Garantiert ist nichts. +- Der offene Satz, dass sich das nicht garantieren lässt, macht die Auskunft glaubwürdiger, nicht schwächer. + +--- + +## 7. Nebenbefund: pressekonto.com + +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. + +--- + +## 8. Neue offene Entscheidungen + +| 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 | \ No newline at end of file diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index 4fb5ba89..f3a53455 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1890,3 +1890,111 @@ Entscheidung: bleibt bei der 301 auf `/faq`, kein Slug-Mapping. Die Inhalte trägt das Hilfe-Center. Dafür steht **Hilfe** jetzt in der Topleiste der Portale neben FAQ (Desktop) und im Burger-Menü unter den Service-Links (Mobile); der Hub hatte den Link bereits. + +## Teil 7 — Anhang 2: Doppelveröffentlichung und Quellenauskunft (17.09.2026) + +Grundlage: `roadmap-anhang2-2026.md`. Commits siehe unten. **Der +Mechanismus ist gebaut und getestet, aber ausgeschaltet** +(`CANONICAL_ASSIGNMENT_ENABLED=false`), weil E9 — welche Kategorie auf +welches Portal — eine Betreiberentscheidung ist. Der Vorschlag steht in +`config/canonical.php`. + +### Befund (Dev-Kopie, Stand 17.09.2026) + +- 34.597 sichtbare Meldungen, davon **9.092 Paare gleichen Textes derselben + Firma auf beiden Portalen = 18.184 URLs, 53 % des Archivs**. Seit dem + neuen System (07/2026): 1.338 Paare — die API reicht praktisch jede + Meldung zweimal ein, je Portal eine eigene Zeile mit gleichem Slug. +- `portal = both` wird bei Meldungen nicht genutzt (0), nur bei Firmen und + Kategorien (alle 14 Kategorien sind `both`, Paare teilen die + `category_id`). +- Je Hauptkategorie: sonstiges 3.087, it 1.190, politik 1.117, haus 739, + handel 572, reisen 419, kultur 404, sport 356, mode 323, motor-verkehr + 319, finanzen 170, medizin 149, wissenschaft-forschung 142, recht 105. +- Bisher: jede Fassung self-canonical je Host, beide in beiden Sitemaps — + Google wählt. + +### 1c.7 Prüfsumme + +`press_releases.content_hash` (SHA-256 über Titel + Text ohne Markup, +Leerraum zusammengezogen, Kleinschreibung — `App\Support\ContentFingerprint`), +gesetzt im Model beim Speichern; Bestand per +`press-releases:hash-content` (Query-Builder, Blöcke, keine Model-Events; +Dev: 179.242 Zeilen in 8:47 min). Migration in zwei Statements: ADD COLUMN +INSTANT, ADD INDEX INPLACE — kein Rebuild der 1-GB-Tabelle (MySQL 8.0.45). +`press-releases:duplicates-report` liefert die Zahlen oben. + +### 1c.8 Kanonische Zuordnung + +`press_releases.canonical_press_release_id` (kein FK, siehe Migration). +`CanonicalAssigner`: Paar = gleicher Hash, gleiche Firma, verschiedene +Portale, beide veröffentlicht. Erstquelle = Fassung auf dem +Kategorie-Portal (`portal_by_category`, DE-Slug der Hauptkategorie), sonst +`fallback` (`first` = zuerst veröffentlicht; oder ein Portalname). Die +zweite Fassung bekommt den Verweis, die erste wird geleert. Läuft bei +jeder Veröffentlichung (`PressReleaseService::publish`, fehlertolerant) +und über den Bestand mit `press-releases:assign-canonicals` (Dev: 9.092 +Paare in Sekunden; `--dry-run` zeigt die Wirkung auch bei ausgeschaltetem +Flag). + +Wirkung bei eingeschaltetem Flag: Detailseite der Kopie trägt +`rel=canonical` und `mainEntityOfPage` auf die Erstquelle; Sitemap und +News-Sitemap nennen nur die Erstquelle; Leser, Feeds, Newsroom und interne +Suche sehen weiterhin beide. `portal = both`-Meldungen folgen derselben +Zuordnung beim Rendern (Canonical auf das Kategorie-Portal), obwohl es +derzeit keine gibt. + +**E9-Vorschlag** (in `config/canonical.php`): businessportal24 für +finanzen, handel, it, recht, wissenschaft-forschung, motor-verkehr; +presseecho für haus, kultur, medizin, mode, politik, reisen, sport, +sonstiges. Damit gehen ~6.600 Erstquellen an presseecho und ~2.500 an +businessportal24 — „sonstiges" (3.087) ist der Hebel; wer es auf +businessportal24 legt, dreht das Verhältnis. + +### 1c.9 Quellenauskunft + +`press_release_index_checks.user_canonical` wird seit diesem Stand +mitgespeichert. `IndexStatusChecker::sourceVerdict()` unterscheidet: Google +führt diese Adresse / die eigene Fassung auf dem anderen Portal / ein +fremdes Portal / keine Angabe. Anzeige im Block „Google-Index" der +Absenderansicht — **hinter `GSC_CUSTOMER_VISIBLE`, also zu**, mit dem Satz, +dass das Warum nicht bekannt ist. Reihenfolge aus Abschnitt 4 ist damit +eingehalten: erst Zuordnung (Flag), dann Auskunft (Flag). + +### Abschnitt 5 — Beschreibung des Angebots + +Geprüft: Preisseite, Transparenzseiten, FAQ, Veröffentlichungsmail. Alle +Stellen sagen „erscheint auf beiden Portalen" — Fundstellen für Leser, +nirgends „zweimal in der Suche". Keine Korrektur nötig. Ergänzt: Abschnitt +„Warum eine Meldung mehrfach im Netz steht und nur einmal in den +Suchergebnissen" auf `/gute-pressemitteilung` (Verteiler → viele Kopien, +eine Quelle; eigene Website zuerst; keine Garantie). Der Satz „bei uns +verweist die zweite Fassung auf die erste" erscheint erst mit +eingeschaltetem Flag. + +### Abschnitt 7 — pressekonto.com + +`` jetzt in allen +Portal-Layouts (Admin, Kundenbereich unter `/admin/me`, Login/ +Registrierung). robots.txt hatte `/admin`, `/login`, `/register` schon +ausgeschlossen — Disallow allein verhindert keine Aufnahme über Links. + +**E10 (offen), mit Befund:** Die Transparenzseiten und alle anderen +statischen Seiten (faq, hilfe, agb, …) existieren auf **drei Hosts** mit +je eigenem Self-Canonical — dieselbe Doppelung wie bei den Meldungen, nur +dreifach. Empfehlung: Canonical der Transparenzseiten auf die Portale +(ältere Domains), der Hub verweist; welches Portal, hängt an E9-Logik +nicht, hier reicht presseecho als Standard. Nicht umgesetzt, wartet auf +die Entscheidung. + +### Tests / Deploy + +`CanonicalAssignmentTest` (9), `UmamiSnippetTest` von der lokalen .env +entkoppelt. Pint, Larastan ohne Befund. + +**Deploy:** `migrate` (INSTANT/INPLACE auf `press_releases`, trotzdem +`DB_LOCK_WAIT_TIMEOUT=300` mitgeben), danach +`php artisan press-releases:hash-content` (läuft ~10 min, kann im +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. diff --git a/lang/de/static.php b/lang/de/static.php index a4615cf3..5cad6dfb 100644 --- a/lang/de/static.php +++ b/lang/de/static.php @@ -160,6 +160,12 @@ return [ 'images_format' => 'Format', 'images_format_text' => 'JPG, PNG oder WebP, ausreichend groß für die Verwendung durch Dritte; Querformat funktioniert in den meisten Layouts am besten.', + 'source_heading' => 'Warum eine Meldung mehrfach im Netz steht und nur einmal in den Suchergebnissen', + 'source_p1' => 'Wer eine Pressemitteilung über einen Verteiler ausspielt, findet sie danach gleichlautend auf vielen Portalen. Für Leser sind das viele Fundstellen. Für eine Suchmaschine ist es ein Text, und sie wählt eine Adresse davon als maßgeblich aus — die anderen erscheinen in den Ergebnissen nicht. Das ist keine Abwertung, sondern eine Auswahl. Welche Fassung sie trifft, entscheidet der Algorithmus; Portale und Absender erfahren nur das Ergebnis, nicht den Grund.', + 'source_p2' => 'Bei uns gilt dasselbe zwischen presseecho und businessportal24: Erscheint eine Meldung auf beiden Portalen, sind das zwei Fundstellen für Leser, nicht zwei Fundstellen in der Suche.', + 'source_p2_assigned' => 'Damit nicht der Zufall entscheidet, verweist bei uns die zweite Fassung auf die erste — die Erstquelle richtet sich nach dem Themenfeld der Meldung.', + 'source_p3' => 'Die stabilste Quelle, die ein Unternehmen hat, ist die eigene Website. Wer eine Meldung zuerst dort veröffentlicht und danach auf Portale gibt, hat die Erstfassung unter eigener Kontrolle. Das schmälert nichts von dem, was Portale leisten: Verbreitung bekommt über die eigene Website niemand.', + 'source_p4' => 'Eine direkt eingereichte, geprüfte Meldung mit strukturierten Daten und stabiler Adresse hat bessere Voraussetzungen, als Quelle geführt zu werden, als eine Verteilerkopie. Garantieren lässt sich das nicht — von niemandem.', 'more_heading' => 'Weiterführend', 'more_guidelines' => 'Veröffentlichungsrichtlinien — die Kategorien, gegen die geprüft wird', 'more_publish' => 'Veröffentlichen — der Weg vom Entwurf zur Meldung', diff --git a/lang/en/static.php b/lang/en/static.php index c7784835..3c6beb5e 100644 --- a/lang/en/static.php +++ b/lang/en/static.php @@ -159,6 +159,12 @@ return [ 'images_format' => 'Format', 'images_format_text' => 'JPG, PNG or WebP, large enough for third-party use; landscape works best in most layouts.', + 'source_heading' => 'Why a release appears many times on the web and only once in search results', + 'source_p1' => 'Anyone who sends a press release through a distribution service will find it word for word on many portals afterwards. For readers, that is many places to find it. For a search engine it is one text, and it picks one address as the authoritative one — the others do not appear in the results. That is a selection, not a penalty. Which version is chosen is decided by the algorithm; portals and senders only see the outcome, not the reason.', + 'source_p2' => 'The same applies between presseecho and businessportal24: if a release appears on both portals, that is two places for readers, not two places in search.', + 'source_p2_assigned' => 'So that chance does not decide, the second version here points to the first — the original source follows the subject area of the release.', + 'source_p3' => 'The most stable source a company has is its own website. Publishing a release there first and then on portals keeps the original version under your own control. That takes nothing away from what portals do: nobody gets distribution through their own website.', + 'source_p4' => 'A directly submitted, reviewed release with structured data and a stable address has better prerequisites to be treated as the source than a distributed copy. That cannot be guaranteed — by anyone.', 'more_heading' => 'Further reading', 'more_guidelines' => 'Publishing guidelines — the categories we check against', 'more_publish' => 'Publishing — from draft to release', diff --git a/resources/views/components/layouts/auth/pressekonto.blade.php b/resources/views/components/layouts/auth/pressekonto.blade.php index db93dfcd..1ce9c8a1 100644 --- a/resources/views/components/layouts/auth/pressekonto.blade.php +++ b/resources/views/components/layouts/auth/pressekonto.blade.php @@ -37,6 +37,8 @@ + {{-- Login/Registrierung gehören nicht in den Index (Anhang 2, Abschnitt 7). --}} + {{-- Der Auth-Bereich ist bewusst immer im Hellmodus; Dark/Light gibt es nur im eingeloggten Portal. Eine aus dem Portal übernommene .dark-Klasse diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 0aa1d84a..ae42ad38 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -1631,15 +1631,27 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @if ($indexChecks !== [])
{{ __('Google-Index') }}
-
    +
      @foreach ($indexChecks as $check) -
    • - {{ $check->portal->label() }}: {{ $check->state->label() }} - {{ __('Stand :date', ['date' => $check->checked_at->format('d.m.Y')]) }}@if ($check->last_crawl_time) · {{ __('zuletzt gecrawlt :date', ['date' => $check->last_crawl_time->format('d.m.Y')]) }}@endif + @php $source = app(IndexStatusChecker::class)->sourceVerdict($check); @endphp +
    • +
      + {{ $check->portal->label() }}: {{ $check->state->label() }} + {{ __('Stand :date', ['date' => $check->checked_at->format('d.m.Y')]) }}@if ($check->last_crawl_time) · {{ __('zuletzt gecrawlt :date', ['date' => $check->last_crawl_time->format('d.m.Y')]) }}@endif +
      + {{-- Quellenauskunft (Anhang 2, 1c.9): welche Adresse Google als + maßgeblich führt — ohne Aussage darüber, warum. --}} + @if ($source['kind'] === 'self') +
      {{ __('Google führt diese Adresse als maßgebliche Quelle.') }}
      + @elseif ($source['kind'] === 'own_other') +
      {{ __('Google führt die Fassung auf :host als maßgebliche Quelle; diese Adresse gilt als Kopie.', ['host' => $source['host']]) }}
      + @elseif ($source['kind'] === 'foreign') +
      {{ __('Google führt eine Fassung auf :host als maßgebliche Quelle — ein anderes Portal mit demselben Text. Diese Adresse gilt dort als Kopie.', ['host' => $source['host']]) }}
      + @endif
    • @endforeach
    -

    {{ __('Momentaufnahme aus der Google Search Console. Ob und wann Google eine Seite aufnimmt, entscheidet Google.') }}

    +

    {{ __('Momentaufnahme aus der Google Search Console. Ob und wann Google eine Seite aufnimmt und welche Fassung sie als Quelle führt, entscheidet Google — warum, ist nicht bekannt.') }}

@endif diff --git a/resources/views/partials/admin-head.blade.php b/resources/views/partials/admin-head.blade.php index 83550657..5bd78f13 100644 --- a/resources/views/partials/admin-head.blade.php +++ b/resources/views/partials/admin-head.blade.php @@ -1,5 +1,7 @@ +{{-- Adminbereich gehört nicht in den Index (Anhang 2, Abschnitt 7). --}} + {{ $title ?? config('app.name') }} diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index 013cd681..14713fcc 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -1,5 +1,7 @@ +{{-- Konto- und Adminbereich gehören nicht in den Index (Anhang 2, Abschnitt 7). --}} + {{ $title ?? config('app.name') }} diff --git a/resources/views/web/gute-pressemitteilung.blade.php b/resources/views/web/gute-pressemitteilung.blade.php index 99348a71..85ed86cd 100644 --- a/resources/views/web/gute-pressemitteilung.blade.php +++ b/resources/views/web/gute-pressemitteilung.blade.php @@ -94,6 +94,14 @@
  • {{ __('static.good_release.images_format') }} — {{ __('static.good_release.images_format_text') }}
  • + {{-- Anhang 2, Abschnitt 6: warum eine Meldung mehrfach im Netz steht + und nur einmal in den Ergebnissen — ohne Zusage, ohne Verkauf. --}} +

    {{ __('static.good_release.source_heading') }}

    +

    {{ __('static.good_release.source_p1') }}

    +

    {{ __('static.good_release.source_p2') }}@if (config('canonical.enabled')) {{ __('static.good_release.source_p2_assigned') }}@endif

    +

    {{ __('static.good_release.source_p3') }}

    +

    {{ __('static.good_release.source_p4') }}

    +

    {{ __('static.good_release.more_heading') }}

    • {{ __('static.good_release.more_guidelines') }}
    • diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index b4856058..9d906d1f 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -1,6 +1,8 @@ @extends('web.layouts.web-master') @php + use App\Enums\Portal; + use App\Services\PressRelease\CanonicalAssigner; use App\Support\StructuredData; use Illuminate\Support\Str; @@ -89,6 +91,14 @@ $shareUrl = $isPreview ? request()->fullUrl() : route('release.detail', ['slug' => $release->slug]); $brandLabel = config('app.theme') === 'presseecho' ? 'presseecho' : 'businessportal24'; + // Doppelveröffentlichung (Anhang 2, 1c.8): rel=canonical zeigt auf die + // Erstquelle — die eigene Adresse, oder bei zweiter Fassung/portal=both + // das Portal, das laut Kategorie-Zuordnung führt. Geteilt wird weiterhin + // die Adresse, auf der der Leser gerade ist. + $canonicalUrl = $isPreview + ? $shareUrl + : app(CanonicalAssigner::class)->canonicalUrlFor($release, $brandLabel === 'presseecho' ? Portal::Presseecho : Portal::Businessportal24); + // SEO: NewsArticle + BreadcrumbList als JSON-LD — nur für die öffentliche // Seite (die Vorschau ist noindex und hat keine stabile URL). $jsonLd = null; @@ -116,7 +126,7 @@ '@graph' => [ array_filter([ '@type' => 'NewsArticle', - 'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $shareUrl], + 'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => $canonicalUrl], 'headline' => Str::limit($release->title, 110), 'description' => filled($lead) ? $lead : $teaser, 'image' => $imageUrl ? [$imageUrl] : null, @@ -160,7 +170,7 @@ @if ($isPreview) @section('robots', 'noindex,nofollow') @else - @section('canonical', $shareUrl) + @section('canonical', $canonicalUrl) @section('meta_extra') @if ($publishedAt) diff --git a/tests/Feature/CanonicalAssignmentTest.php b/tests/Feature/CanonicalAssignmentTest.php new file mode 100644 index 00000000..f7f82465 --- /dev/null +++ b/tests/Feature/CanonicalAssignmentTest.php @@ -0,0 +1,217 @@ +create(['portal' => Portal::Both]); + $category->translations()->create(['locale' => 'de', 'name' => ucfirst($categorySlug), 'slug' => $categorySlug]); + $company = Company::factory()->create(['portal' => Portal::Both, 'name' => 'Doppel GmbH']); + + $text = '

      Die Doppel GmbH eröffnet einen neuen Standort.

      '; + + $first = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company) + ->create(array_merge(['slug' => 'doppel-standort', 'title' => 'Neuer Standort', 'text' => $text, 'published_at' => now()->subHours(2)], $a)); + $second = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->for($category)->for($company) + ->create(array_merge(['slug' => 'doppel-standort', 'title' => 'Neuer Standort', 'text' => "

      Die Doppel GmbH eröffnet einen\nneuen Standort.

      ", 'published_at' => now()->subHour()], $b)); + + return [$first, $second, $category, $company]; +} + +test('the fingerprint ignores markup, whitespace and case and is set on save', function () { + expect(ContentFingerprint::hash('Titel', '

      Ein Text

      '))->toBe(ContentFingerprint::hash('titel', "ein\ntext")) + ->and(ContentFingerprint::hash('Titel', 'Ein Text'))->not->toBe(ContentFingerprint::hash('Titel', 'Ein anderer Text')); + + $release = PressRelease::factory()->create(['title' => 'Hallo', 'text' => 'Welt']); + expect($release->content_hash)->toBe(ContentFingerprint::hash('Hallo', 'Welt')); + + $release->update(['text' => 'Neue Welt']); + expect($release->fresh()->content_hash)->toBe(ContentFingerprint::hash('Hallo', 'Neue Welt')); +}); + +test('the backfill command fills missing fingerprints without model events', function () { + /** @var TestCase $this */ + $release = PressRelease::factory()->create(['title' => 'Alt', 'text' => 'Bestand']); + DB::table('press_releases')->where('id', $release->id)->update(['content_hash' => null]); + + $this->artisan(HashPressReleaseContent::class)->assertSuccessful(); + + expect($release->fresh()->content_hash)->toBe(ContentFingerprint::hash('Alt', 'Bestand')); +}); + +test('the category mapping decides which version is the source, otherwise the first published', function () { + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + config()->set('canonical.fallback', 'first'); + + [$pe, $b24] = duplicatePair('it'); + $assigner = app(CanonicalAssigner::class); + + expect($pe->content_hash)->toBe($b24->content_hash) + ->and($assigner->resolveCanonical($pe, $b24)->is($b24))->toBeTrue(); + + [$peOther, $b24Other] = duplicatePair('reisen', ['slug' => 'reise-a'], ['slug' => 'reise-b']); + expect($assigner->resolveCanonical($peOther, $b24Other)->is($peOther))->toBeTrue(); +}); + +test('assigning the backlog links the duplicate to the source and the sitemaps list only the source', function () { + /** @var TestCase $this */ + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'businessportal24']); + + [$pe, $b24] = duplicatePair('it'); + + $this->artisan(AssignCanonicalPressReleases::class, ['--dry-run' => true]) + ->expectsOutputToContain('[Dry-Run] 1 Paare, 1 würden geändert.') + ->assertSuccessful(); + expect($b24->fresh()->canonical_press_release_id)->toBeNull(); + + $this->artisan(AssignCanonicalPressReleases::class)->expectsOutputToContain('1 Paare, 1 geändert.')->assertSuccessful(); + + expect($pe->fresh()->canonical_press_release_id)->toBe($b24->id) + ->and($b24->fresh()->canonical_press_release_id)->toBeNull(); + + // Zweiter Lauf ändert nichts mehr. + $this->artisan(AssignCanonicalPressReleases::class)->expectsOutputToContain('1 Paare, 0 geändert.')->assertSuccessful(); + + // Detailseite: die Kopie zeigt per rel=canonical auf die Erstquelle, die Erstquelle auf sich selbst. + $this->get('https://presseecho.test/de/press-release/doppel-standort')->assertSuccessful() + ->assertSee('', false) + ->assertSee('"mainEntityOfPage":{"@type":"WebPage","@id":"https://businessportal24.test/de/press-release/doppel-standort"}', false); + $this->get('https://businessportal24.test/de/press-release/doppel-standort')->assertSuccessful() + ->assertSee('', false); + + // Sitemaps: nur die Erstquelle. + $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful() + ->assertDontSee('https://presseecho.test/de/press-release/doppel-standort', false); + $this->get('https://businessportal24.test/sitemap.xml')->assertSuccessful() + ->assertSee('https://businessportal24.test/de/press-release/doppel-standort', false); + $this->get('https://presseecho.test/sitemap-news.xml')->assertSuccessful() + ->assertDontSee('doppel-standort', false); + $this->get('https://businessportal24.test/sitemap-news.xml')->assertSuccessful() + ->assertSee('doppel-standort', false); + + $this->artisan(ReportDuplicatePressReleases::class) + ->expectsOutputToContain('Paare gleichen Inhalts auf beiden Portalen: 1 (2 URLs)') + ->expectsOutputToContain('davon mit kanonischer Zuordnung: 1') + ->assertSuccessful(); +}); + +test('without the flag every version stays its own canonical and nothing is assigned', function () { + /** @var TestCase $this */ + config()->set('canonical.enabled', false); + [$pe, $b24] = duplicatePair('it'); + + $this->artisan(AssignCanonicalPressReleases::class)->expectsOutputToContain('Zuordnung ist aus')->assertSuccessful(); + expect($pe->fresh()->canonical_press_release_id)->toBeNull(); + + $this->get('https://presseecho.test/de/press-release/doppel-standort')->assertSuccessful() + ->assertSee('', false); + $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful() + ->assertSee('https://presseecho.test/de/press-release/doppel-standort', false); +}); + +test('a release with portal both is canonical on the category portal only', function () { + /** @var TestCase $this */ + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['handel' => 'presseecho']); + + $category = Category::factory()->create(['portal' => Portal::Both]); + $category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']); + $release = PressRelease::factory()->published()->forPortal(Portal::Both)->for($category) + ->for(Company::factory()->create(['portal' => Portal::Both])) + ->create(['slug' => 'beide-portale', 'published_at' => now()->subHour()]); + + $this->get('https://businessportal24.test/de/press-release/beide-portale')->assertSuccessful() + ->assertSee('', false); + $this->get('https://businessportal24.test/sitemap.xml')->assertSuccessful()->assertDontSee('beide-portale', false); + $this->get('https://presseecho.test/sitemap.xml')->assertSuccessful()->assertSee('beide-portale', false); + + expect(app(CanonicalAssigner::class)->isCanonicalOn($release, Portal::Presseecho))->toBeTrue() + ->and(app(CanonicalAssigner::class)->isCanonicalOn($release, Portal::Businessportal24))->toBeFalse(); +}); + +test('publishing the second version assigns the pair immediately', function () { + /** @var TestCase $this */ + Mail::fake(); + config()->set('canonical.enabled', true); + config()->set('canonical.portal_by_category', ['it' => 'presseecho']); + + [$pe, $b24] = duplicatePair('it'); + DB::table('press_releases')->where('id', $b24->id)->update(['status' => PressReleaseStatus::Review->value, 'published_at' => null]); + $b24 = $b24->fresh(); + $b24->user->update(['is_active' => true]); + + app(PressReleaseService::class)->publish($b24, 'admin'); + + expect($b24->fresh()->canonical_press_release_id)->toBe($pe->id) + ->and($pe->fresh()->canonical_press_release_id)->toBeNull(); +}); + +test('the source verdict tells own, other-own and foreign canonicals apart and shows only behind the flag', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $company = Company::factory()->businessportal24()->create(); + $customer->companies()->attach($company->id, ['role' => 'member']); + $this->actingAs($customer); + + $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'slug' => 'quelle-pm', 'published_at' => now()->subDay()]); + $url = 'https://businessportal24.test/de/press-release/quelle-pm'; + $checker = app(IndexStatusChecker::class); + + $self = PressReleaseIndexCheck::factory()->create(['press_release_id' => $release->id, 'portal' => Portal::Businessportal24, 'url' => $url, 'google_canonical' => $url, 'user_canonical' => $url]); + $ownOther = PressReleaseIndexCheck::factory()->make(['url' => $url, 'google_canonical' => 'https://presseecho.test/de/press-release/quelle-pm']); + $foreign = PressReleaseIndexCheck::factory()->make(['url' => $url, 'google_canonical' => 'https://www.fremdes-portal.example/pm/quelle']); + $none = PressReleaseIndexCheck::factory()->make(['url' => $url, 'google_canonical' => null]); + + expect($checker->sourceVerdict($self)['kind'])->toBe('self') + ->and($checker->sourceVerdict($ownOther))->toMatchArray(['kind' => 'own_other', 'host' => 'presseecho.test']) + ->and($checker->sourceVerdict($foreign))->toMatchArray(['kind' => 'foreign', 'host' => 'www.fremdes-portal.example']) + ->and($checker->sourceVerdict($none)['kind'])->toBe('unknown'); + + config()->set('search_console.customer_visible', false); + LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])->assertDontSeeText('maßgebliche Quelle'); + + config()->set('search_console.customer_visible', true); + LivewireVolt::test('customer.press-releases.show', ['id' => $release->id]) + ->assertSeeText('Google führt diese Adresse als maßgebliche Quelle.') + ->assertSeeText('warum, ist nicht bekannt'); +}); + +test('portal and auth layouts carry noindex and the help page explains the single source', function () { + /** @var TestCase $this */ + $this->get('https://pressekonto.test/login')->assertSuccessful()->assertSee('', false); + + config()->set('canonical.enabled', false); + $this->get('https://presseecho.test/de/gute-pressemitteilung')->assertSuccessful() + ->assertSee('Warum eine Meldung mehrfach im Netz steht und nur einmal in den Suchergebnissen') + ->assertSee('nicht zwei Fundstellen in der Suche') + ->assertDontSee('verweist bei uns die zweite Fassung auf die erste'); + + config()->set('canonical.enabled', true); + $this->get('https://presseecho.test/de/gute-pressemitteilung')->assertSuccessful() + ->assertSee('verweist bei uns die zweite Fassung auf die erste'); +}); diff --git a/tests/Feature/Web/UmamiSnippetTest.php b/tests/Feature/Web/UmamiSnippetTest.php index 2f029904..847bacaa 100644 --- a/tests/Feature/Web/UmamiSnippetTest.php +++ b/tests/Feature/Web/UmamiSnippetTest.php @@ -4,7 +4,10 @@ use Tests\TestCase; test('the umami snippet stays off until the go-live switch is set', function () { /** @var TestCase $this */ - // Default (UMAMI_ENABLED=false): kein Analytics-Script vor dem Go-Live. + // Schalter aus: kein Analytics-Script. Explizit gesetzt, weil die lokale + // .env seit dem Go-Live UMAMI_ENABLED=true trägt. + config()->set('services.umami.enabled', false); + $this->get('https://presseecho.test/de') ->assertSuccessful() ->assertDontSee('sys.adametz.media', false);