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:
parent
87558aba4f
commit
4edb915a9a
28 changed files with 1231 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
49
app/Console/Commands/AssignCanonicalPressReleases.php
Normal file
49
app/Console/Commands/AssignCanonicalPressReleases.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\PressRelease\CanonicalAssigner;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Kanonische Zuordnung über den Bestand (Anhang 2, 1c.8). Neue Paare
|
||||
* ordnet die Veröffentlichung selbst zu; dieser Lauf holt den Bestand nach
|
||||
* und stellt nach einer geänderten Kategorie-Zuordnung (E9) alles neu ein.
|
||||
*/
|
||||
class AssignCanonicalPressReleases extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:assign-canonicals
|
||||
{--dry-run : Nur zählen, nichts schreiben}
|
||||
{--verbose-pairs : Jedes Paar ausgeben}';
|
||||
|
||||
protected $description = 'Ordnet bei Doppelveröffentlichung je Paar die Erstquelle zu (rel=canonical).';
|
||||
|
||||
public function handle(CanonicalAssigner $assigner): int
|
||||
{
|
||||
$dryRun = (bool) $this->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;
|
||||
}
|
||||
}
|
||||
70
app/Console/Commands/HashPressReleaseContent.php
Normal file
70
app/Console/Commands/HashPressReleaseContent.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Support\ContentFingerprint;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Prüfsumme für den Bestand nachtragen (Anhang 2, 1c.7).
|
||||
*
|
||||
* Neue und geänderte Meldungen bekommen die Summe beim Speichern; dieser
|
||||
* Lauf holt den Bestand nach. Schreibt in Blöcken über den Query-Builder
|
||||
* (keine Model-Events, kein Suchindex-Neuaufbau) — je Meldung ein kurzes
|
||||
* UPDATE auf die große Tabelle, nie ein Statement über alles.
|
||||
*/
|
||||
class HashPressReleaseContent extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:hash-content
|
||||
{--all : Auch vorhandene Summen neu berechnen}
|
||||
{--chunk=500 : Meldungen je Durchgang}';
|
||||
|
||||
protected $description = 'Trägt die Inhalts-Prüfsumme (content_hash) für Pressemitteilungen nach.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = max(50, (int) $this->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;
|
||||
}
|
||||
}
|
||||
73
app/Console/Commands/ReportDuplicatePressReleases.php
Normal file
73
app/Console/Commands/ReportDuplicatePressReleases.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\PressRelease\CanonicalAssigner;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Doppelveröffentlichung erheben (Anhang 2, 1c.7): wie viele Meldungen
|
||||
* liegen mit identischem Text derselben Firma auf beiden Portalen — gesamt,
|
||||
* seit dem neuen System, je Hauptkategorie, mit und ohne Zuordnung. Dazu
|
||||
* die Meldungen mit portal=both, die dieselbe Frage auf anderem Weg stellen.
|
||||
*/
|
||||
class ReportDuplicatePressReleases extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:duplicates-report';
|
||||
|
||||
protected $description = 'Zählt identische Pressemitteilungen auf beiden Portalen (Doppelveröffentlichung).';
|
||||
|
||||
public function handle(CanonicalAssigner $assigner): int
|
||||
{
|
||||
$pairs = $assigner->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<PressRelease, $this>
|
||||
*/
|
||||
public function canonical(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressRelease::class, 'canonical_press_release_id')->withoutGlobalScopes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fassungen, die auf diese Meldung als Erstquelle verweisen.
|
||||
*
|
||||
* @return HasMany<PressRelease, $this>
|
||||
*/
|
||||
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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class PressReleaseIndexCheck extends Model
|
|||
'page_fetch_state',
|
||||
'last_crawl_time',
|
||||
'google_canonical',
|
||||
'user_canonical',
|
||||
'raw',
|
||||
'checked_at',
|
||||
];
|
||||
|
|
|
|||
288
app/Services/PressRelease/CanonicalAssigner.php
Normal file
288
app/Services/PressRelease/CanonicalAssigner.php
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Http\Middleware\SetEdition;
|
||||
use App\Models\Category;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Kanonische Zuordnung bei Doppelveröffentlichung (Anhang 2, 1c.8).
|
||||
*
|
||||
* Liegt derselbe Text (content_hash) derselben Firma auf beiden Portalen,
|
||||
* bestimmt das Portal die Erstquelle statt Google: die zweite Fassung
|
||||
* bekommt `canonical_press_release_id` auf die erste und verweist per
|
||||
* rel=canonical dorthin. Welche Fassung Erstquelle ist, folgt der Kategorie
|
||||
* (config/canonical.php, E9); ohne Eintrag gewinnt die zuerst
|
||||
* veröffentlichte. Meldungen mit portal=both (eine Zeile, zwei Adressen)
|
||||
* folgen derselben Kategorie-Zuordnung beim Rendern.
|
||||
*
|
||||
* Schreibt über den Query-Builder, damit weder Suchindex noch Aktivitäts-
|
||||
* log anspringen — es ändert sich nur die Zuordnung, kein Inhalt.
|
||||
*/
|
||||
class CanonicalAssigner
|
||||
{
|
||||
/** @var array<int, string|null> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PressRelease>
|
||||
*/
|
||||
|
|
|
|||
23
app/Support/ContentFingerprint.php
Normal file
23
app/Support/ContentFingerprint.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Prüfsumme über den Inhalt einer Meldung (Anhang 2, 1c.7): Titel und
|
||||
* Text ohne Markup, Leerraum zusammengezogen, Kleinschreibung. Zwei
|
||||
* Einreichungen desselben Textes auf beiden Portalen ergeben so dieselbe
|
||||
* Summe, auch wenn der Editor Tags oder Umbrüche anders gesetzt hat.
|
||||
*/
|
||||
class ContentFingerprint
|
||||
{
|
||||
public static function hash(?string $title, ?string $text): string
|
||||
{
|
||||
$normalized = mb_strtolower(trim((string) preg_replace(
|
||||
'/\s+/u',
|
||||
' ',
|
||||
html_entity_decode(strip_tags((string) $title.' '.(string) $text), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
)));
|
||||
|
||||
return hash('sha256', $normalized);
|
||||
}
|
||||
}
|
||||
47
config/canonical.php
Normal file
47
config/canonical.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Kanonische Zuordnung bei Doppelveröffentlichung (Anhang 2, 1c.8)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Liegt dieselbe Meldung auf beiden Portalen, bestimmt das Portal die
|
||||
| Erstquelle: die zweite Fassung verweist per rel=canonical auf die erste,
|
||||
| Leser sehen weiterhin beide. Welche Fassung „erste" ist, folgt der
|
||||
| Kategorie (Entscheidung E9): Wirtschafts- und Unternehmensthemen auf
|
||||
| businessportal24, breite Themen auf presseecho. Meldungen mit
|
||||
| portal=both (eine Zeile, zwei Adressen) folgen derselben Zuordnung.
|
||||
|
|
||||
| Solange `enabled` aus ist, wird nichts zugeordnet und jede Fassung bleibt
|
||||
| ihr eigenes Canonical — genau der Zustand vor Anhang 2.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'enabled' => (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'),
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Doppelveröffentlichung (Roadmap Anhang 2, 1c.7/1c.8):
|
||||
*
|
||||
* - `content_hash`: SHA-256 über Titel und normalisierten Text, gesetzt beim
|
||||
* Speichern (Model) und per `press-releases:hash-content` für den Bestand.
|
||||
* Findet identische Meldungen auf beiden Portalen ohne Textvergleich.
|
||||
* - `canonical_press_release_id`: zeigt bei einer Doppelveröffentlichung
|
||||
* von der zweiten Fassung auf die maßgebliche. Kein Fremdschlüssel — ein
|
||||
* FK-Aufbau auf der über 1 GB großen Tabelle wäre ein Table-Rebuild;
|
||||
* die Zuordnung pflegt `CanonicalAssigner`.
|
||||
*
|
||||
* Bewusst zwei Statements: ADD COLUMN läuft in MySQL 8 als INSTANT, ADD
|
||||
* INDEX als INPLACE ohne Rebuild. In einem gemeinsamen ALTER würde MySQL
|
||||
* den strengeren Algorithmus für alles wählen.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_releases', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Quellenauskunft (Anhang 2, 1c.9): neben `googleCanonical` auch die vom
|
||||
* Portal angegebene Adresse speichern — weichen beide ab, hat Google eine
|
||||
* andere Quelle gewählt.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('press_release_index_checks', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
111
dev/2026-09-01/roadmap-anhang2-2026.md
Normal file
111
dev/2026-09-01/roadmap-anhang2-2026.md
Normal file
|
|
@ -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 |
|
||||
|
|
@ -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
|
||||
|
||||
`<meta name="robots" content="noindex, nofollow">` 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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
217
tests/Feature/CanonicalAssignmentTest.php
Normal file
217
tests/Feature/CanonicalAssignmentTest.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\AssignCanonicalPressReleases;
|
||||
use App\Console\Commands\HashPressReleaseContent;
|
||||
use App\Console\Commands\ReportDuplicatePressReleases;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseIndexCheck;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\CanonicalAssigner;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\SearchConsole\IndexStatusChecker;
|
||||
use App\Support\ContentFingerprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Doppelveröffentlichung (Anhang 2, 1c.7–1c.9): identische Texte auf
|
||||
* beiden Portalen bekommen dieselbe Prüfsumme, die zweite Fassung verweist
|
||||
* per rel=canonical auf die erste (Kategorie entscheidet), Sitemaps nennen
|
||||
* nur die Erstquelle, und die Quellenauskunft bleibt hinter E8.
|
||||
*/
|
||||
function duplicatePair(string $categorySlug = 'it', array $a = [], array $b = []): array
|
||||
{
|
||||
$category = Category::factory()->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 = '<p>Die Doppel GmbH eröffnet einen neuen Standort.</p>';
|
||||
|
||||
$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' => "<p>Die Doppel GmbH eröffnet einen\nneuen Standort.</p>", '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', '<p>Ein Text</p>'))->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('<link rel="canonical" href="https://businessportal24.test/de/press-release/doppel-standort">', 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('<link rel="canonical" href="https://businessportal24.test/de/press-release/doppel-standort">', 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('<link rel="canonical" href="https://presseecho.test/de/press-release/doppel-standort">', 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('<link rel="canonical" href="https://presseecho.test/de/press-release/beide-portale">', 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('<meta name="robots" content="noindex, nofollow" />', 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');
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue