presseportale/app/Services/SearchConsole/SearchConsoleClient.php
Kevin Adametz 3ea9688c9a admin: Bericht „Indexierung & Abrufe" — Rolle Erstquelle/Kopie, Quellenbefund, Crawler-Abrufe; Schlüsselpfad ab Projektwurzel
Der Bericht meldete auf Live „nicht konfiguriert", während der Lauf
längst Daten holte: der relative Pfad der Schlüsseldatei war im Cron
(Projektwurzel) lesbar, im Web-Request (public/) nicht. Relative Pfade
werden jetzt ab base_path() aufgelöst.

Die Verteilung trennt zusätzlich nach Rolle in der kanonischen Zuordnung
(1c.8): bei einer Kopie mit Verweis ist „ausgeschlossen" der erwartete
Befund. Neu: Quellenbefund über Erstquellen (welche Adresse Google führt,
fremde Hosts), Abrufe durch Such- und KI-Dienste je Dienst, Tag und
Meldung mit CSV. Herkunftsmarker als Join statt korreliertem EXISTS,
Aggregate 15 Minuten gecacht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 12:53:22 +00:00

163 lines
5.5 KiB
PHP

<?php
namespace App\Services\SearchConsole;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* 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
{
$response = Http::withToken($this->accessToken())
->acceptJson()
->timeout(20)
->post(self::INSPECT_URL, [
'inspectionUrl' => $url,
'siteUrl' => $siteUrl,
'languageCode' => $languageCode,
]);
if (! $response->successful()) {
throw new RuntimeException(sprintf(
'URL Inspection API antwortet mit %d für %s: %s',
$response->status(),
$url,
(string) ($response->json('error.message') ?? $response->body()),
));
}
$result = $response->json('inspectionResult');
return is_array($result) ? $result : [];
}
/**
* 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), '+/', '-_'), '=');
}
}