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>
This commit is contained in:
parent
951b6c47fa
commit
6b8728242f
17 changed files with 1324 additions and 30 deletions
173
app/Services/SearchConsole/IndexStatusChecker.php
Normal file
173
app/Services/SearchConsole/IndexStatusChecker.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
133
app/Services/SearchConsole/IndexStatusReport.php
Normal file
133
app/Services/SearchConsole/IndexStatusReport.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?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;
|
||||
|
||||
/**
|
||||
* Admin-Auswertung des Indexierungsstatus (Roadmap 1c.4/1c.5): Verteilung
|
||||
* der jüngsten Befunde je Portal, getrennt nach Direkteinreichung und
|
||||
* Syndikation über die API. Erst diese Verteilung entscheidet, ob und wie
|
||||
* der Status in die Kundenansicht kommt (E8).
|
||||
*/
|
||||
class IndexStatusReport
|
||||
{
|
||||
/**
|
||||
* Ausdruck, der eine Meldung der Herkunft „api" oder „direct" zuordnet:
|
||||
* Marker ist ein Status-Log-Eintrag mit Quelle „api" (wie beim
|
||||
* Mail-Versand — der Kontotyp ist nicht verlässlich).
|
||||
*/
|
||||
private const string ORIGIN_SQL = "case when exists (select 1 from press_release_status_logs l where l.press_release_id = press_releases.id and l.source = 'api') then 'api' else 'direct' end";
|
||||
|
||||
/**
|
||||
* Anzahl der Meldungen je Portal, Herkunft und Befund — Stand der jeweils
|
||||
* jüngsten Prüfung. Meldungen ohne Prüfung erscheinen unter `unchecked`.
|
||||
*
|
||||
* @return array<string, array<string, array<string, int>>> portal => origin => state|unchecked|total => count
|
||||
*/
|
||||
public function distribution(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) {
|
||||
// Herkunft je Meldung erst in einer abgeleiteten Tabelle bestimmen,
|
||||
// dann darüber gruppieren — MySQL (only_full_group_by) lehnt den
|
||||
// korrelierten Ausdruck direkt im GROUP BY ab.
|
||||
$visible = DB::query()
|
||||
->fromSub(
|
||||
PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->visibleInArchive()
|
||||
->forPortalValues([$portal->value, Portal::Both->value])
|
||||
->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin'),
|
||||
'r'
|
||||
)
|
||||
->selectRaw('origin, count(*) as total')
|
||||
->groupBy('origin')
|
||||
->get();
|
||||
|
||||
$checked = DB::query()
|
||||
->fromSub(
|
||||
$this->latestChecks($portal)
|
||||
->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id')
|
||||
->whereNull('press_releases.deleted_at')
|
||||
->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin'),
|
||||
'c'
|
||||
)
|
||||
->selectRaw('origin, state, count(*) as total')
|
||||
->groupBy('origin', 'state')
|
||||
->get();
|
||||
|
||||
foreach (['direct', 'api'] as $origin) {
|
||||
$bucket = ['total' => 0, 'unchecked' => 0];
|
||||
|
||||
foreach (IndexState::cases() as $state) {
|
||||
$bucket[$state->value] = 0;
|
||||
}
|
||||
|
||||
$bucket['total'] = (int) ($visible->firstWhere('origin', $origin)->total ?? 0);
|
||||
|
||||
$checkedTotal = 0;
|
||||
|
||||
foreach ($checked->where('origin', $origin) as $row) {
|
||||
$bucket[(string) $row->state] = (int) $row->total;
|
||||
$checkedTotal += (int) $row->total;
|
||||
}
|
||||
|
||||
$bucket['unchecked'] = max(0, $bucket['total'] - $checkedTotal);
|
||||
$rows[$portal->value][$origin] = $bucket;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jüngste Prüfungen, neueste zuerst.
|
||||
*
|
||||
* @return Collection<int, PressReleaseIndexCheck>
|
||||
*/
|
||||
public function latest(int $limit = 100): Collection
|
||||
{
|
||||
return PressReleaseIndexCheck::query()
|
||||
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()->select(['id', 'title', 'slug', 'published_at'])])
|
||||
->orderByDesc('checked_at')
|
||||
->orderByDesc('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function lastCheckedAt(): ?Carbon
|
||||
{
|
||||
$value = PressReleaseIndexCheck::query()->max('checked_at');
|
||||
|
||||
return $value ? Carbon::parse((string) $value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeweils jüngste Prüfung je Meldung auf einem Portal.
|
||||
*
|
||||
* @return Builder<PressReleaseIndexCheck>
|
||||
*/
|
||||
private function latestChecks(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 PressReleaseIndexCheck::query()
|
||||
->joinSub($latest, 'latest', fn ($join) => $join
|
||||
->on('latest.press_release_id', '=', 'press_release_index_checks.press_release_id')
|
||||
->on('latest.last_checked_at', '=', 'press_release_index_checks.checked_at'))
|
||||
->where('press_release_index_checks.portal', $portal->value);
|
||||
}
|
||||
}
|
||||
146
app/Services/SearchConsole/SearchConsoleClient.php
Normal file
146
app/Services/SearchConsole/SearchConsoleClient.php
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<?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 = (string) config('search_console.credentials');
|
||||
|
||||
return (bool) config('search_console.enabled') && $file !== '' && is_readable($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 = (string) config('search_console.credentials');
|
||||
$json = 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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue