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>
192 lines
6.9 KiB
PHP
192 lines
6.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\SearchConsole;
|
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Schmaler Client für die URL Inspection API der Google Search Console
|
|
* (Roadmap 1c.4). Authentifiziert sich als Service-Konto über ein selbst
|
|
* signiertes JWT (RS256) — bewusst ohne Google-SDK, weil nur ein Endpunkt
|
|
* gebraucht wird und die Abhängigkeit sonst größer wäre als der Code.
|
|
*/
|
|
class SearchConsoleClient
|
|
{
|
|
public const string INSPECT_URL = 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect';
|
|
|
|
private const string SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly';
|
|
|
|
private const string TOKEN_CACHE_KEY = 'search-console.access-token';
|
|
|
|
public function isConfigured(): bool
|
|
{
|
|
$file = $this->credentialsPath();
|
|
|
|
return (bool) config('search_console.enabled') && $file !== null && is_readable($file);
|
|
}
|
|
|
|
/**
|
|
* Pfad der Schlüsseldatei, relative Angaben ab Projektwurzel. Ohne das
|
|
* hing die Lesbarkeit vom Arbeitsverzeichnis ab: im Cron (Projektwurzel)
|
|
* lesbar, im Web-Request (public/) nicht — der Admin meldete „nicht
|
|
* konfiguriert", während der Lauf längst Daten holte.
|
|
*/
|
|
private function credentialsPath(): ?string
|
|
{
|
|
$file = trim((string) config('search_console.credentials'));
|
|
|
|
if ($file === '') {
|
|
return null;
|
|
}
|
|
|
|
return str_starts_with($file, '/') ? $file : base_path($file);
|
|
}
|
|
|
|
public function propertyFor(string $portal): ?string
|
|
{
|
|
$property = config('search_console.properties.'.$portal);
|
|
|
|
return is_string($property) && $property !== '' ? $property : null;
|
|
}
|
|
|
|
/**
|
|
* Fragt den Indexierungsstatus einer URL ab und liefert
|
|
* `inspectionResult` zurück (mit `indexStatusResult`).
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function inspect(string $url, string $siteUrl, string $languageCode = 'de-DE'): array
|
|
{
|
|
try {
|
|
$response = Http::withToken($this->accessToken())
|
|
->acceptJson()
|
|
->timeout((int) config('search_console.timeout_seconds', 20))
|
|
->retry(
|
|
1 + (int) config('search_console.retries', 1),
|
|
(int) config('search_console.retry_sleep_ms', 3000),
|
|
fn (Throwable $e): bool => $e instanceof ConnectionException
|
|
|| ($e instanceof RequestException && self::isTransient($e->response)),
|
|
throw: false,
|
|
)
|
|
->post(self::INSPECT_URL, [
|
|
'inspectionUrl' => $url,
|
|
'siteUrl' => $siteUrl,
|
|
'languageCode' => $languageCode,
|
|
]);
|
|
} catch (ConnectionException $e) {
|
|
// Ohne Millisekundenangabe im Text: GlitchTip gruppierte die
|
|
// Zeitüberschreitungen sonst nach 20000/20001/20002 ms getrennt.
|
|
throw new SearchConsoleUnavailable('URL Inspection API nicht erreichbar (Zeitüberschreitung oder Verbindungsfehler) für '.$url, previous: $e);
|
|
}
|
|
|
|
if (! $response->successful()) {
|
|
$message = sprintf(
|
|
'URL Inspection API antwortet mit %d für %s: %s',
|
|
$response->status(),
|
|
$url,
|
|
(string) ($response->json('error.message') ?? $response->body()),
|
|
);
|
|
|
|
throw self::isTransient($response) ? new SearchConsoleUnavailable($message) : new RuntimeException($message);
|
|
}
|
|
|
|
$result = $response->json('inspectionResult');
|
|
|
|
return is_array($result) ? $result : [];
|
|
}
|
|
|
|
/**
|
|
* Vorübergehend: Google-seitiger Fehler (5xx) oder Drosselung (429).
|
|
* Alles andere (401/403/400) ist Konfiguration oder Anfrage und wird
|
|
* nicht wiederholt.
|
|
*/
|
|
private static function isTransient(Response $response): bool
|
|
{
|
|
return $response->serverError() || $response->status() === 429;
|
|
}
|
|
|
|
/**
|
|
* Zugriffstoken des Service-Kontos, 50 Minuten gecacht (Gültigkeit 60).
|
|
*/
|
|
private function accessToken(): string
|
|
{
|
|
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(50), function (): string {
|
|
$credentials = $this->credentials();
|
|
$now = time();
|
|
|
|
$assertion = $this->jwt([
|
|
'iss' => $credentials['client_email'],
|
|
'scope' => self::SCOPE,
|
|
'aud' => $credentials['token_uri'],
|
|
'iat' => $now,
|
|
'exp' => $now + 3600,
|
|
], $credentials['private_key']);
|
|
|
|
$response = Http::asForm()->timeout(20)->post($credentials['token_uri'], [
|
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
'assertion' => $assertion,
|
|
]);
|
|
|
|
$token = $response->json('access_token');
|
|
|
|
if (! $response->successful() || ! is_string($token) || $token === '') {
|
|
throw new RuntimeException('Search Console: kein Zugriffstoken erhalten ('.$response->status().').');
|
|
}
|
|
|
|
return $token;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array{client_email: string, private_key: string, token_uri: string}
|
|
*/
|
|
private function credentials(): array
|
|
{
|
|
$file = $this->credentialsPath();
|
|
$json = $file !== null && is_readable($file) ? json_decode((string) file_get_contents($file), true) : null;
|
|
|
|
if (! is_array($json) || empty($json['client_email']) || empty($json['private_key'])) {
|
|
throw new RuntimeException('Search Console: Schlüsseldatei des Service-Kontos fehlt oder ist unvollständig.');
|
|
}
|
|
|
|
return [
|
|
'client_email' => (string) $json['client_email'],
|
|
'private_key' => (string) $json['private_key'],
|
|
'token_uri' => (string) ($json['token_uri'] ?? 'https://oauth2.googleapis.com/token'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $claims
|
|
*/
|
|
private function jwt(array $claims, string $privateKey): string
|
|
{
|
|
$segments = [
|
|
$this->base64Url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])),
|
|
$this->base64Url((string) json_encode($claims)),
|
|
];
|
|
|
|
$key = openssl_pkey_get_private($privateKey);
|
|
|
|
if ($key === false) {
|
|
throw new RuntimeException('Search Console: privater Schlüssel des Service-Kontos ist nicht lesbar.');
|
|
}
|
|
|
|
$signature = '';
|
|
openssl_sign(implode('.', $segments), $signature, $key, OPENSSL_ALGO_SHA256);
|
|
$segments[] = $this->base64Url($signature);
|
|
|
|
return implode('.', $segments);
|
|
}
|
|
|
|
private function base64Url(string $value): string
|
|
{
|
|
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
|
}
|
|
}
|