web: Doppelveröffentlichung — Prüfsumme, kanonische Zuordnung (aus bis E9), Quellenauskunft, noindex im Portal (Anhang 2)

Über die API kommt praktisch jede Meldung zweimal, je Portal eine Zeile
mit gleichem Text: 9.092 Paare, 53 % des Archivs, bisher jede Fassung
self-canonical und in beiden Sitemaps — Google wählte.

1c.7: press_releases.content_hash (SHA-256 über Titel + Text ohne Markup),
gesetzt beim Speichern, Bestand per press-releases:hash-content
(Query-Builder, Blöcke). Migration in zwei Statements — ADD COLUMN INSTANT,
ADD INDEX INPLACE, kein Rebuild. press-releases:duplicates-report zählt
Paare je Hauptkategorie.

1c.8: canonical_press_release_id + CanonicalAssigner. Erstquelle folgt der
Kategorie (config/canonical.php, Vorschlag zu E9), sonst die zuerst
veröffentlichte Fassung. Zuordnung bei jeder Veröffentlichung und über den
Bestand (press-releases:assign-canonicals). Die Kopie trägt rel=canonical
und mainEntityOfPage auf die Erstquelle; Sitemap und News-Sitemap nennen
nur sie; Leser sehen weiterhin beide. Alles hinter
CANONICAL_ASSIGNMENT_ENABLED (false), bis E9 entschieden ist.

1c.9: user_canonical in den Index-Prüfungen, sourceVerdict() unterscheidet
eigene Adresse / andere eigene Fassung / fremdes Portal; Anzeige im Block
„Google-Index" hinter GSC_CUSTOMER_VISIBLE, ohne Aussage über das Warum.

Dazu: Abschnitt zur einen Quelle auf /gute-pressemitteilung (Verteiler →
viele Kopien, eigene Website zuerst, keine Garantie), noindex in Admin-,
Kunden- und Auth-Layouts, UmamiSnippetTest von der lokalen .env gelöst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 16:09:16 +00:00
parent 87558aba4f
commit 4edb915a9a
28 changed files with 1231 additions and 11 deletions

View file

@ -37,6 +37,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content="{{ csrf_token() }}" />
{{-- Login/Registrierung gehören nicht in den Index (Anhang 2, Abschnitt 7). --}}
<meta name="robots" content="noindex, nofollow" />
{{-- Der Auth-Bereich ist bewusst immer im Hellmodus; Dark/Light gibt es nur
im eingeloggten Portal. Eine aus dem Portal übernommene .dark-Klasse

View file

@ -1631,15 +1631,27 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@if ($indexChecks !== [])
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Google-Index') }}</div>
<ul class="mt-1 m-0 p-0 list-none space-y-1 text-[12.5px]">
<ul class="mt-1 m-0 p-0 list-none space-y-1.5 text-[12.5px]">
@foreach ($indexChecks as $check)
<li class="flex flex-wrap items-baseline justify-between gap-x-3">
<span class="text-[color:var(--color-ink)]">{{ $check->portal->label() }}: {{ $check->state->label() }}</span>
<span class="text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ __('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</span>
@php $source = app(IndexStatusChecker::class)->sourceVerdict($check); @endphp
<li>
<div class="flex flex-wrap items-baseline justify-between gap-x-3">
<span class="text-[color:var(--color-ink)]">{{ $check->portal->label() }}: {{ $check->state->label() }}</span>
<span class="text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ __('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</span>
</div>
{{-- Quellenauskunft (Anhang 2, 1c.9): welche Adresse Google als
maßgeblich führt — ohne Aussage darüber, warum. --}}
@if ($source['kind'] === 'self')
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Google führt diese Adresse als maßgebliche Quelle.') }}</div>
@elseif ($source['kind'] === 'own_other')
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Google führt die Fassung auf :host als maßgebliche Quelle; diese Adresse gilt als Kopie.', ['host' => $source['host']]) }}</div>
@elseif ($source['kind'] === 'foreign')
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('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']]) }}</div>
@endif
</li>
@endforeach
</ul>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Momentaufnahme aus der Google Search Console. Ob und wann Google eine Seite aufnimmt, entscheidet Google.') }}</p>
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('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.') }}</p>
</div>
@endif
</div>

View file

@ -1,5 +1,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{{-- Adminbereich gehört nicht in den Index (Anhang 2, Abschnitt 7). --}}
<meta name="robots" content="noindex, nofollow" />
<title>{{ $title ?? config('app.name') }}</title>

View file

@ -1,5 +1,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{{-- Konto- und Adminbereich gehören nicht in den Index (Anhang 2, Abschnitt 7). --}}
<meta name="robots" content="noindex, nofollow" />
<title>{{ $title ?? config('app.name') }}</title>

View file

@ -94,6 +94,14 @@
<li><strong>{{ __('static.good_release.images_format') }}</strong> — {{ __('static.good_release.images_format_text') }}</li>
</ul>
{{-- Anhang 2, Abschnitt 6: warum eine Meldung mehrfach im Netz steht
und nur einmal in den Ergebnissen — ohne Zusage, ohne Verkauf. --}}
<h2 id="quelle">{{ __('static.good_release.source_heading') }}</h2>
<p>{{ __('static.good_release.source_p1') }}</p>
<p>{{ __('static.good_release.source_p2') }}@if (config('canonical.enabled')) {{ __('static.good_release.source_p2_assigned') }}@endif</p>
<p>{{ __('static.good_release.source_p3') }}</p>
<p>{{ __('static.good_release.source_p4') }}</p>
<h2 id="weiter">{{ __('static.good_release.more_heading') }}</h2>
<ul>
<li><a href="{{ route('richtlinien') }}">{{ __('static.good_release.more_guidelines') }}</a></li>

View file

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