presseportale/app/Console/Commands/ReportDuplicatePressReleases.php
Kevin Adametz ee267c622e 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>
2026-09-17 16:09:16 +00:00

73 lines
3.7 KiB
PHP

<?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;
}
}