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
447dc4c61e
commit
ee267c622e
28 changed files with 1231 additions and 11 deletions
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>
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue