Am 06.10.2026 hing die URL Inspection API zeitweise: 28 Zeitüberschreitungen und 4 × 500, jede einzeln als Fehler gemeldet, der Lauf zog sich über 1¾ Stunden. Neu: SearchConsoleUnavailable für Timeout/5xx/429 (Warnung, Meldung bleibt fällig), echte Fehler (401/403/400) weiter über report(); FAILURE — und damit ein Betriebs-Alert — nur bei Abbruch oder ≥ 50 % Fehlern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
125 lines
5.3 KiB
PHP
125 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Enums\Portal;
|
|
use App\Services\SearchConsole\IndexStatusChecker;
|
|
use App\Services\SearchConsole\SearchConsoleClient;
|
|
use App\Services\SearchConsole\SearchConsoleUnavailable;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* Indexierungsstatus über die Search Console abfragen (Roadmap 1c.4).
|
|
*
|
|
* Je Portal ein Tageskontingent, nach Alter gestaffelt auf die jüngsten
|
|
* Meldungen verteilt (config search_console.recheck_days). Jede Antwort
|
|
* wird als Momentaufnahme gespeichert; der Lauf bewertet nichts und zeigt
|
|
* dem Kunden nichts — das entscheidet die Verteilung (1c.5, E8).
|
|
*/
|
|
class CheckIndexStatus extends Command
|
|
{
|
|
protected $signature = 'press-releases:check-index-status
|
|
{--portal= : Nur dieses Portal (presseecho|businessportal24)}
|
|
{--budget= : Abfragen je Portal (Standard: config search_console.daily_budget)}
|
|
{--dry-run : Nur auflisten, was geprüft würde}';
|
|
|
|
protected $description = 'Fragt den Indexierungsstatus veröffentlichter Meldungen bei der Google Search Console ab.';
|
|
|
|
public function handle(SearchConsoleClient $client, IndexStatusChecker $checker): int
|
|
{
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
|
|
if (! $dryRun && ! $client->isConfigured()) {
|
|
$this->warn('Search Console nicht konfiguriert (GSC_ENABLED, GSC_SERVICE_ACCOUNT_FILE) — nichts zu tun.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$budget = max(1, (int) ($this->option('budget') ?: config('search_console.daily_budget', 300)));
|
|
$portals = $checker->portals();
|
|
|
|
if ($this->option('portal')) {
|
|
$wanted = Portal::tryFrom((string) $this->option('portal'));
|
|
$portals = array_values(array_filter($portals, fn (Portal $portal): bool => $portal === $wanted));
|
|
}
|
|
|
|
if ($portals === []) {
|
|
$this->warn('Kein Portal mit eingetragener Property.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$checked = 0;
|
|
$failed = 0;
|
|
$aborted = false;
|
|
$deadline = now()->addMinutes((int) config('search_console.max_runtime_minutes', 40));
|
|
$maxConsecutive = max(1, (int) config('search_console.max_consecutive_failures', 5));
|
|
|
|
foreach ($portals as $portal) {
|
|
$candidates = $checker->candidates($portal, $budget);
|
|
$this->info(sprintf('%s: %d fällige Meldungen (Kontingent %d).', $portal->label(), $candidates->count(), $budget));
|
|
$consecutiveFailures = 0;
|
|
|
|
foreach ($candidates as $release) {
|
|
if ($dryRun) {
|
|
$this->line(sprintf(' [%d] %s (veröffentlicht %s)', $release->id, $release->slug, $release->published_at?->format('d.m.Y') ?? '-'));
|
|
|
|
continue;
|
|
}
|
|
|
|
// Zeitbudget unter der Overlap-Sperre: was übrig bleibt, ist
|
|
// morgen weiter fällig.
|
|
if (now()->greaterThanOrEqualTo($deadline)) {
|
|
$this->warn('Zeitbudget erreicht — restliche Meldungen beim nächsten Lauf.');
|
|
Log::warning('Search Console: Zeitbudget des Laufs erreicht.', ['checked' => $checked, 'failed' => $failed]);
|
|
|
|
break 2;
|
|
}
|
|
|
|
try {
|
|
$check = $checker->check($release, $portal);
|
|
$checked++;
|
|
$consecutiveFailures = 0;
|
|
$this->line(sprintf(' [%d] %s → %s', $release->id, $release->slug, $check->state->label()));
|
|
} catch (SearchConsoleUnavailable $e) {
|
|
// Google hängt oder hat einen eigenen Fehler: kein
|
|
// Anwendungsfehler, nur Warnung — die Meldung bleibt fällig.
|
|
$failed++;
|
|
$consecutiveFailures++;
|
|
Log::warning('Search Console vorübergehend nicht verfügbar.', ['release_id' => $release->id, 'error' => $e->getMessage()]);
|
|
$this->warn(sprintf(' [%d] %s: %s', $release->id, $release->slug, $e->getMessage()));
|
|
} catch (\Throwable $e) {
|
|
// Auth, Rechte, Quote, kaputte Antwort: echter Fehler.
|
|
$failed++;
|
|
$consecutiveFailures++;
|
|
report($e);
|
|
$this->error(sprintf(' [%d] %s: %s', $release->id, $release->slug, $e->getMessage()));
|
|
}
|
|
|
|
if ($consecutiveFailures >= $maxConsecutive) {
|
|
$aborted = true;
|
|
$this->error(sprintf('%s: Abbruch nach %d Fehlern in Folge.', $portal->label(), $consecutiveFailures));
|
|
|
|
continue 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($dryRun) {
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$attempted = $checked + $failed;
|
|
$this->info(sprintf('%d Prüfungen gespeichert, %d fehlgeschlagen.', $checked, $failed));
|
|
|
|
// Fehlschlag (→ einmaliger Betriebs-Alert über den Scheduler) nur,
|
|
// wenn ein Portal abbrechen musste oder mindestens die Hälfte
|
|
// scheiterte; einzelne Aussetzer erledigen sich am nächsten Tag.
|
|
if ($aborted || ($attempted >= 10 && $failed / $attempted >= 0.5)) {
|
|
return self::FAILURE;
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
}
|