presseportale/app/Services/SearchConsole/IndexStatusChecker.php
Kevin Adametz 6c4618afed admin: Indexierungsstatus aus der Search Console, Kundenansicht vorbereitet und zu (Anhang 1: 1c.4, E8)
press-releases:check-index-status fragt täglich je Portal ein Kontingent
veröffentlichter Meldungen über die URL Inspection API ab, nach Alter
gestaffelt, nie geprüfte zuerst. Jede Antwort wird als Momentaufnahme in
press_release_index_checks gespeichert; der reduzierte Befund (IndexState)
trennt „noch nicht gecrawlt" von „gecrawlt, nicht indexiert".

Client authentifiziert sich als Service-Konto über ein selbst signiertes
JWT — kein Google-SDK. Admin ▸ Reports ▸ Indexierung zeigt die Verteilung
je Portal getrennt nach Direkteinreichung und Syndikation (Status-Log
source=api; Altbestand ohne Log zählt als direkt).

Das Kundenpanel heißt jetzt „Auslieferung", zeigt Sitemap-Datum und die
Abrufe durch Such- und KI-Dienste mit dem Satz „Ein Abruf ist kein Zitat";
der Google-Index-Block hängt an GSC_CUSTOMER_VISIBLE (false).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:21:51 +00:00

173 lines
6.3 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));
}
$result = $this->client->inspect($url, $property, $pressRelease->language === 'en' ? 'en-US' : 'de-DE');
$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,
'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();
}
/**
* @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;
}
}