Ü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>
207 lines
7.9 KiB
PHP
207 lines
7.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\SearchConsole;
|
|
|
|
use App\Enums\IndexState;
|
|
use App\Enums\Portal;
|
|
use App\Models\PressRelease;
|
|
use App\Models\PressReleaseIndexCheck;
|
|
use App\Scopes\PortalScope;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Prüflauf für den Indexierungsstatus (Roadmap 1c.4): wählt je Portal die
|
|
* fälligen Meldungen nach Alter gestaffelt aus, fragt die URL Inspection API
|
|
* ab und speichert jede Antwort als eigene Momentaufnahme.
|
|
*
|
|
* Das Tageskontingent geht zuerst an die jüngsten Meldungen (kurzer
|
|
* Prüfabstand), was übrig bleibt an ältere Stufen — so bleibt der Lauf
|
|
* weit unter der Quote von 2.000 Abfragen je Property und Tag.
|
|
*/
|
|
class IndexStatusChecker
|
|
{
|
|
public function __construct(private readonly SearchConsoleClient $client) {}
|
|
|
|
/**
|
|
* Portale mit eingetragener Property.
|
|
*
|
|
* @return list<Portal>
|
|
*/
|
|
public function portals(): array
|
|
{
|
|
return array_values(array_filter(
|
|
[Portal::Presseecho, Portal::Businessportal24],
|
|
fn (Portal $portal): bool => $this->client->propertyFor($portal->value) !== null,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Fällige Meldungen eines Portals, jüngste zuerst, nie geprüfte vor
|
|
* bereits geprüften — höchstens `$limit` Stück.
|
|
*
|
|
* @return Collection<int, PressRelease>
|
|
*/
|
|
public function candidates(Portal $portal, int $limit): Collection
|
|
{
|
|
$selected = collect();
|
|
$bandStart = now();
|
|
|
|
foreach ($this->recheckBands() as $maxAgeDays => $intervalDays) {
|
|
$remaining = $limit - $selected->count();
|
|
|
|
if ($remaining <= 0) {
|
|
break;
|
|
}
|
|
|
|
$bandEnd = $maxAgeDays === PHP_INT_MAX ? null : now()->subDays($maxAgeDays);
|
|
|
|
$selected = $selected->merge(
|
|
$this->baseQuery($portal)
|
|
->where('published_at', '<=', $bandStart)
|
|
->when($bandEnd !== null, fn (Builder $query) => $query->where('published_at', '>', $bandEnd))
|
|
->where(fn (Builder $query) => $query
|
|
->whereNull('ic.last_checked_at')
|
|
->orWhere('ic.last_checked_at', '<', now()->subDays($intervalDays)))
|
|
->orderByRaw('ic.last_checked_at is null desc')
|
|
->orderByDesc('published_at')
|
|
->limit($remaining)
|
|
->get(['press_releases.id', 'press_releases.slug', 'press_releases.portal', 'press_releases.language', 'press_releases.published_at'])
|
|
);
|
|
|
|
if ($bandEnd === null) {
|
|
break;
|
|
}
|
|
|
|
$bandStart = $bandEnd;
|
|
}
|
|
|
|
return $selected->unique('id')->values();
|
|
}
|
|
|
|
/**
|
|
* Fragt eine Meldung auf einem Portal ab und speichert den Befund.
|
|
*/
|
|
public function check(PressRelease $pressRelease, Portal $portal): PressReleaseIndexCheck
|
|
{
|
|
$url = $this->publicUrl($pressRelease, $portal);
|
|
$property = $this->client->propertyFor($portal->value);
|
|
|
|
if ($url === null || $property === null) {
|
|
throw new \RuntimeException(sprintf('Meldung %d hat auf %s keine öffentliche URL oder Property.', $pressRelease->id, $portal->value));
|
|
}
|
|
|
|
// Immer en-US: `coverageState` ist ein übersetzter Anzeigetext, und
|
|
// IndexState::fromIndexStatus() erkennt die englischen Fassungen.
|
|
$result = $this->client->inspect($url, $property, 'en-US');
|
|
$indexStatus = is_array($result['indexStatusResult'] ?? null) ? $result['indexStatusResult'] : [];
|
|
|
|
return PressReleaseIndexCheck::create([
|
|
'press_release_id' => $pressRelease->id,
|
|
'portal' => $portal,
|
|
'url' => $url,
|
|
'state' => IndexState::fromIndexStatus($indexStatus),
|
|
'verdict' => Str::limit((string) ($indexStatus['verdict'] ?? ''), 30, '') ?: null,
|
|
'coverage_state' => Str::limit((string) ($indexStatus['coverageState'] ?? ''), 160, '') ?: null,
|
|
'indexing_state' => Str::limit((string) ($indexStatus['indexingState'] ?? ''), 60, '') ?: null,
|
|
'robots_txt_state' => Str::limit((string) ($indexStatus['robotsTxtState'] ?? ''), 40, '') ?: null,
|
|
'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(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Jüngste Momentaufnahme je Portal für die Absenderansicht (E8: nur
|
|
* ausgeliefert, wenn config search_console.customer_visible an ist).
|
|
*
|
|
* @return list<PressReleaseIndexCheck>
|
|
*/
|
|
public function latestFor(PressRelease $pressRelease): array
|
|
{
|
|
return PressReleaseIndexCheck::query()
|
|
->where('press_release_id', $pressRelease->id)
|
|
->orderByDesc('checked_at')
|
|
->get()
|
|
->unique(fn (PressReleaseIndexCheck $check): string => $check->portal->value)
|
|
->values()
|
|
->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>
|
|
*/
|
|
private function baseQuery(Portal $portal): Builder
|
|
{
|
|
$latest = PressReleaseIndexCheck::query()
|
|
->select('press_release_id', DB::raw('max(checked_at) as last_checked_at'))
|
|
->where('portal', $portal->value)
|
|
->groupBy('press_release_id');
|
|
|
|
return PressRelease::query()
|
|
->withoutGlobalScope(PortalScope::class)
|
|
->visibleInArchive()
|
|
->forPortalValues([$portal->value, Portal::Both->value])
|
|
->leftJoinSub($latest, 'ic', 'ic.press_release_id', '=', 'press_releases.id');
|
|
}
|
|
|
|
/**
|
|
* @return array<int, int>
|
|
*/
|
|
private function recheckBands(): array
|
|
{
|
|
/** @var array<int, int> $bands */
|
|
$bands = config('search_console.recheck_days', [PHP_INT_MAX => 180]);
|
|
ksort($bands);
|
|
|
|
return $bands;
|
|
}
|
|
|
|
private function publicUrl(PressRelease $pressRelease, Portal $portal): ?string
|
|
{
|
|
foreach ($pressRelease->publicPortalLinks() as $link) {
|
|
if ($link['portal'] === $portal) {
|
|
return $link['url'];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|