diff --git a/.env.example b/.env.example index cd97d29d..99e3b80f 100644 --- a/.env.example +++ b/.env.example @@ -244,3 +244,21 @@ LEGAL_CONTACT_EMAIL= REPORT_EMAIL_PRESSEKONTO= REPORT_EMAIL_PRESSEECHO= REPORT_EMAIL_BUSINESSPORTAL= + +# Crawler-Abruf-Protokoll (Roadmap 1c.0): Tag, ab dem Abrufe durch Such- und +# KI-Dienste je Meldung erfasst werden — auf den Deploy-Tag setzen. +CRAWLER_HITS_RECORDED_SINCE=2026-09-18 + +# Google Search Console, URL Inspection API (Roadmap 1c.4). Läuft nur mit +# Service-Konto (JSON-Schlüssel außerhalb von public/), das in beiden +# Properties als Nutzer eingetragen ist. Kundenansicht bleibt zu (E8). +GSC_ENABLED=false +GSC_SERVICE_ACCOUNT_FILE= +GSC_PROPERTY_PRESSEECHO=sc-domain:presseecho.de +GSC_PROPERTY_BUSINESSPORTAL24=sc-domain:businessportal24.de +GSC_DAILY_BUDGET=300 +GSC_CUSTOMER_VISIBLE=false + +# Transparenzseite „kostenlos veröffentlichen": Prüfdatum der Portalliste +# (Einträge in config/transparency.php). Leer = Abschnitt erscheint nicht. +TRANSPARENCY_FREE_PORTALS_CHECKED_AT= diff --git a/app/Console/Commands/CheckIndexStatus.php b/app/Console/Commands/CheckIndexStatus.php new file mode 100644 index 00000000..426243dc --- /dev/null +++ b/app/Console/Commands/CheckIndexStatus.php @@ -0,0 +1,91 @@ +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; + + foreach ($portals as $portal) { + $candidates = $checker->candidates($portal, $budget); + $this->info(sprintf('%s: %d fällige Meldungen (Kontingent %d).', $portal->label(), $candidates->count(), $budget)); + + 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; + } + + try { + $check = $checker->check($release, $portal); + $checked++; + $this->line(sprintf(' [%d] %s → %s', $release->id, $release->slug, $check->state->label())); + } catch (\Throwable $e) { + $failed++; + report($e); + $this->error(sprintf(' [%d] %s: %s', $release->id, $release->slug, $e->getMessage())); + + // Quote oder Auth kaputt: der Rest des Laufs würde nur + // weitere Fehler produzieren. + if ($failed >= 5 && $checked === 0) { + $this->error('Abbruch nach fünf Fehlern ohne Treffer.'); + + return self::FAILURE; + } + } + } + } + + if (! $dryRun) { + $this->info(sprintf('%d Prüfungen gespeichert, %d fehlgeschlagen.', $checked, $failed)); + } + + return self::SUCCESS; + } +} diff --git a/app/Enums/IndexState.php b/app/Enums/IndexState.php new file mode 100644 index 00000000..f2fc9acb --- /dev/null +++ b/app/Enums/IndexState.php @@ -0,0 +1,62 @@ + $indexStatus `inspectionResult.indexStatusResult` der URL Inspection API + */ + public static function fromIndexStatus(array $indexStatus): self + { + $verdict = strtoupper((string) ($indexStatus['verdict'] ?? '')); + $coverage = mb_strtolower((string) ($indexStatus['coverageState'] ?? '')); + $indexing = strtoupper((string) ($indexStatus['indexingState'] ?? '')); + $robots = strtoupper((string) ($indexStatus['robotsTxtState'] ?? '')); + $fetch = strtoupper((string) ($indexStatus['pageFetchState'] ?? '')); + $crawled = ! empty($indexStatus['lastCrawlTime']); + + if ($verdict === 'PASS' || (str_contains($coverage, 'indexed') && ! str_contains($coverage, 'not indexed'))) { + return self::Indexed; + } + + if ($robots === 'DISALLOWED' + || in_array($indexing, ['BLOCKED_BY_META_TAG', 'BLOCKED_BY_HTTP_HEADER', 'BLOCKED_BY_ROBOTS_TXT'], true) + || ($fetch !== '' && $fetch !== 'SUCCESSFUL' && $crawled) + || str_contains($coverage, 'duplicate') + || str_contains($coverage, 'canonical') + || str_contains($coverage, 'redirect') + || str_contains($coverage, 'noindex') + || str_contains($coverage, 'blocked')) { + return self::Excluded; + } + + if (! $crawled) { + return $verdict === '' && $coverage === '' ? self::Unknown : self::NotCrawled; + } + + return self::CrawledNotIndexed; + } + + public function label(): string + { + return match ($this) { + self::Indexed => 'indexiert', + self::CrawledNotIndexed => 'gecrawlt, nicht indexiert', + self::NotCrawled => 'noch nicht gecrawlt', + self::Excluded => 'ausgeschlossen', + self::Unknown => 'unbekannt', + }; + } +} diff --git a/app/Models/PressReleaseIndexCheck.php b/app/Models/PressReleaseIndexCheck.php new file mode 100644 index 00000000..589a9c6e --- /dev/null +++ b/app/Models/PressReleaseIndexCheck.php @@ -0,0 +1,62 @@ + */ + use HasFactory; + + public const ?string CREATED_AT = null; + + public const ?string UPDATED_AT = null; + + protected $fillable = [ + 'press_release_id', + 'portal', + 'url', + 'state', + 'verdict', + 'coverage_state', + 'indexing_state', + 'robots_txt_state', + 'page_fetch_state', + 'last_crawl_time', + 'google_canonical', + 'raw', + 'checked_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'portal' => Portal::class, + 'state' => IndexState::class, + 'last_crawl_time' => 'datetime', + 'checked_at' => 'datetime', + 'raw' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function pressRelease(): BelongsTo + { + return $this->belongsTo(PressRelease::class); + } +} diff --git a/app/Services/SearchConsole/IndexStatusChecker.php b/app/Services/SearchConsole/IndexStatusChecker.php new file mode 100644 index 00000000..6a6dee49 --- /dev/null +++ b/app/Services/SearchConsole/IndexStatusChecker.php @@ -0,0 +1,173 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + private function recheckBands(): array + { + /** @var array $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; + } +} diff --git a/app/Services/SearchConsole/IndexStatusReport.php b/app/Services/SearchConsole/IndexStatusReport.php new file mode 100644 index 00000000..b231ca7a --- /dev/null +++ b/app/Services/SearchConsole/IndexStatusReport.php @@ -0,0 +1,133 @@ +>> 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 + */ + 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 + */ + 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); + } +} diff --git a/app/Services/SearchConsole/SearchConsoleClient.php b/app/Services/SearchConsole/SearchConsoleClient.php new file mode 100644 index 00000000..9786be04 --- /dev/null +++ b/app/Services/SearchConsole/SearchConsoleClient.php @@ -0,0 +1,146 @@ + + */ + 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 $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), '+/', '-_'), '='); + } +} diff --git a/config/search_console.php b/config/search_console.php new file mode 100644 index 00000000..0ee981db --- /dev/null +++ b/config/search_console.php @@ -0,0 +1,50 @@ + (bool) env('GSC_ENABLED', false), + + // Pfad zur JSON-Schlüsseldatei des Service-Kontos (außerhalb von public/). + 'credentials' => env('GSC_SERVICE_ACCOUNT_FILE'), + + // Property je Portal, wie in der Search Console angelegt + // (Domain-Property „sc-domain:…" oder URL-Präfix „https://…/"). + 'properties' => [ + 'presseecho' => env('GSC_PROPERTY_PRESSEECHO', 'sc-domain:presseecho.de'), + 'businessportal24' => env('GSC_PROPERTY_BUSINESSPORTAL24', 'sc-domain:businessportal24.de'), + ], + + // Abfragen je Lauf und Portal. + 'daily_budget' => (int) env('GSC_DAILY_BUDGET', 300), + + // Wie oft eine Meldung erneut geprüft wird, gestaffelt nach Alter seit + // Veröffentlichung: [maximales Alter in Tagen => Abstand in Tagen]. + // Die letzte Stufe gilt für alles Ältere. + 'recheck_days' => [ + 14 => 3, + 90 => 14, + 365 => 45, + PHP_INT_MAX => 180, + ], + + // Indexierungsstatus in der Absenderansicht zeigen (E8: zunächst zu). + 'customer_visible' => (bool) env('GSC_CUSTOMER_VISIBLE', false), + +]; diff --git a/database/factories/PressReleaseIndexCheckFactory.php b/database/factories/PressReleaseIndexCheckFactory.php new file mode 100644 index 00000000..8026179f --- /dev/null +++ b/database/factories/PressReleaseIndexCheckFactory.php @@ -0,0 +1,46 @@ + + */ +class PressReleaseIndexCheckFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'press_release_id' => PressRelease::factory(), + 'portal' => Portal::Presseecho, + 'url' => 'https://presseecho.test/de/press-release/'.fake()->slug(), + 'state' => IndexState::Indexed, + 'verdict' => 'PASS', + 'coverage_state' => 'Submitted and indexed', + 'indexing_state' => 'INDEXING_ALLOWED', + 'robots_txt_state' => 'ALLOWED', + 'page_fetch_state' => 'SUCCESSFUL', + 'last_crawl_time' => now()->subDay(), + 'google_canonical' => null, + 'raw' => null, + 'checked_at' => now(), + ]; + } + + public function withState(IndexState $state): static + { + return $this->state([ + 'state' => $state, + 'verdict' => $state === IndexState::Indexed ? 'PASS' : 'NEUTRAL', + 'last_crawl_time' => $state === IndexState::NotCrawled ? null : now()->subDay(), + ]); + } +} diff --git a/database/migrations/2026_09_18_090100_create_press_release_index_checks_table.php b/database/migrations/2026_09_18_090100_create_press_release_index_checks_table.php new file mode 100644 index 00000000..bbb43ffd --- /dev/null +++ b/database/migrations/2026_09_18_090100_create_press_release_index_checks_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('press_release_id')->constrained()->cascadeOnDelete(); + $table->string('portal', 20); + $table->string('url', 500); + $table->string('state', 30); + $table->string('verdict', 30)->nullable(); + $table->string('coverage_state', 160)->nullable(); + $table->string('indexing_state', 60)->nullable(); + $table->string('robots_txt_state', 40)->nullable(); + $table->string('page_fetch_state', 60)->nullable(); + $table->timestamp('last_crawl_time')->nullable(); + $table->string('google_canonical', 500)->nullable(); + $table->json('raw')->nullable(); + $table->timestamp('checked_at'); + + $table->index(['press_release_id', 'portal', 'checked_at'], 'pric_release_portal_checked_idx'); + $table->index(['portal', 'checked_at'], 'pric_portal_checked_idx'); + $table->index(['state', 'checked_at'], 'pric_state_checked_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('press_release_index_checks'); + } +}; diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 0b3f76df..81e19d2a 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -230,6 +230,10 @@ :current="request()->routeIs('admin.reports.submitter-contacts')" wire:navigate> {{ __('Einlieferer-Bericht') }} + + {{ __('Indexierung') }} + @can('settings:manage') diff --git a/resources/views/livewire/admin/reports/index-status.blade.php b/resources/views/livewire/admin/reports/index-status.blade.php new file mode 100644 index 00000000..e7dec8a4 --- /dev/null +++ b/resources/views/livewire/admin/reports/index-status.blade.php @@ -0,0 +1,182 @@ +latest(2000); + $name = sprintf('indexierung-%s.csv', now()->format('Y-m-d')); + + return response()->streamDownload(function () use ($rows): void { + $handle = fopen('php://output', 'wb'); + fputcsv($handle, ['Geprüft', 'Portal', 'ID', 'Titel', 'URL', 'Befund', 'Google-Status', 'Zuletzt gecrawlt', 'Kanonisch'], ';'); + + foreach ($rows as $check) { + fputcsv($handle, [ + $check->checked_at->format('d.m.Y H:i'), + $check->portal->label(), + $check->press_release_id, + $check->pressRelease?->title ?? '', + $check->url, + $check->state->label(), + $check->coverage_state ?? '', + $check->last_crawl_time?->format('d.m.Y') ?? '', + $check->google_canonical ?? '', + ], ';'); + } + + fclose($handle); + }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + public function with(): array + { + $report = app(IndexStatusReport::class); + + return [ + 'configured' => app(SearchConsoleClient::class)->isConfigured(), + 'customerVisible' => (bool) config('search_console.customer_visible'), + 'distribution' => $report->distribution(), + 'latest' => $report->latest(100), + 'lastCheckedAt' => $report->lastCheckedAt(), + 'states' => IndexState::cases(), + 'portals' => [Portal::Presseecho, Portal::Businessportal24], + ]; + } +}; ?> + +
+ + +
+ + {{ $configured ? __('täglicher Lauf 05:10 Uhr') : __('GSC_ENABLED und Schlüsseldatei setzen') }} + + + {{ __('Momentaufnahmen, jede Antwort eine Zeile') }} + + + {{ __('Entscheidung E8 — config search_console.customer_visible') }} + +
+ + @foreach ($portals as $portal) +
+
+ {{ $portal->label() }} + {{ __('im Archiv sichtbare Meldungen, Stand der jeweils jüngsten Prüfung') }} +
+ + + {{ __('Herkunft') }} + {{ __('Meldungen') }} + @foreach ($states as $state) + {{ ucfirst($state->label()) }} + @endforeach + {{ __('Ungeprüft') }} + + + @foreach (['direct' => __('Direkt eingereicht'), 'api' => __('Syndikation (API)')] as $origin => $label) + @php + $row = $distribution[$portal->value][$origin] ?? []; + $checkedTotal = max(0, ($row['total'] ?? 0) - ($row['unchecked'] ?? 0)); + @endphp + + + {{ $label }} + + + {{ number_format($row['total'] ?? 0, 0, ',', '.') }} + + @foreach ($states as $state) + @php $count = (int) ($row[$state->value] ?? 0); @endphp + + {{ number_format($count, 0, ',', '.') }} + @if ($checkedTotal > 0) + {{ number_format($count / $checkedTotal * 100, 1, ',', '.') }} % + @endif + + @endforeach + + {{ number_format($row['unchecked'] ?? 0, 0, ',', '.') }} + + + @endforeach + + +
+ @endforeach + +
+
+ {{ __('Jüngste Prüfungen') }} + @if ($latest->isNotEmpty()) + {{ __('CSV') }} + @endif +
+ + + {{ __('Geprüft') }} + {{ __('Meldung') }} + {{ __('Portal') }} + {{ __('Befund') }} + {{ __('Zuletzt gecrawlt') }} + + + @forelse ($latest as $check) + + + {{ $check->checked_at->format('d.m.Y H:i') }} + + + {{ $check->pressRelease?->title ?? '#'.$check->press_release_id }} + {{ __('veröffentlicht :date', ['date' => $check->pressRelease?->published_at?->format('d.m.Y') ?? '—']) }} + + + {{ $check->portal->label() }} + + + $check->state === IndexState::Indexed, 'warn' => in_array($check->state, [IndexState::CrawledNotIndexed, IndexState::NotCrawled], true), 'err' => $check->state === IndexState::Excluded, 'muted' => $check->state === IndexState::Unknown])>{{ $check->state->label() }} + @if ($check->coverage_state) + {{ $check->coverage_state }} + @endif + + + {{ $check->last_crawl_time?->format('d.m.Y') ?? '—' }} + + + @empty + + +
+ {{ __('Noch keine Prüfung gespeichert. Erster Lauf: php artisan press-releases:check-index-status') }} +
+
+
+ @endforelse +
+
+
+
diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index 9bd7342d..0aa1d84a 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -21,6 +21,7 @@ use App\Services\PressRelease\PressReleaseCoverImage; use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; use App\Services\PressRelease\VisibilityStats; +use App\Services\SearchConsole\IndexStatusChecker; use Flux\Flux; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; @@ -37,7 +38,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends public ?string $shareUrl = null; - /** Zeitraum der Verlaufsgrafik „Sichtbarkeit" in Tagen (Roadmap 1c.1). */ + /** Zeitraum der Verlaufsgrafik „Auslieferung" in Tagen (Roadmap 1c.1). */ #[Url(as: 'verlauf', except: 30)] public int $viewRange = 30; @@ -788,15 +789,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $boostService = app(BoostService::class); $proofService = app(ProofPdfService::class); - // Sichtbarkeit belegen (Roadmap 1c.1): Rohzahlen und Tagesverlauf, - // nur für Meldungen, die je online waren. + // Auslieferung belegen (Roadmap 1c.1, Stufe 1): Rohzahlen und + // Tagesverlauf aus eigener Hand, nur für Meldungen, die je online + // waren. Der Indexierungsstatus (1c.4) kommt erst mit, wenn die + // Kundenansicht freigegeben ist (E8). $stats = app(VisibilityStats::class); $wasPublic = $pr->published_at !== null || (int) $pr->hits > 0; $range = VisibilityStats::ranges()->contains($this->viewRange) ? $this->viewRange : 30; + $indexChecks = $wasPublic && config('search_console.customer_visible') + ? app(IndexStatusChecker::class)->latestFor($pr) + : []; return [ 'pr' => $pr, 'visibility' => $wasPublic ? $stats->releaseSummary($pr) : null, + 'indexChecks' => $indexChecks, 'helpPageUrl' => rtrim((string) config('domains.domain_portal_url'), '/').'/de/gute-pressemitteilung', 'dailyViews' => $wasPublic ? $stats->dailyViews($pr, $range) : [], 'viewRanges' => VisibilityStats::ranges(), @@ -1496,7 +1503,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends - {{-- ============== SICHTBARKEIT (Roadmap 1c.1) ============== --}} + {{-- ============== AUSLIEFERUNG (Roadmap 1c.1, Stufe 1) ============== + Nur Daten aus eigener Hand; nichts, was von Dritten abhängt. + Kein Link auf eine site:-Suche (Anhang 17.09.2026). --}} @if ($visibility !== null) @php $measured = array_values(array_filter($dailyViews, fn ($day) => $day['views'] !== null)); @@ -1508,9 +1517,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends $gap = $barCount > 45 ? 1 : 2; $barWidth = max(1, ($chartWidth - ($barCount - 1) * $gap) / max(1, $barCount)); @endphp -
+
- {{ __('Sichtbarkeit') }} + {{ __('Auslieferung') }}
@foreach ($viewRanges as $days)
@endif diff --git a/routes/admin.php b/routes/admin.php index e2817b67..cddad543 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -127,6 +127,10 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens // Meldungen je API-Einlieferer (Kontakterreichbarkeit). Volt::route('admin/reports/einlieferer', 'admin.reports.submitter-contacts')->name('admin.reports.submitter-contacts'); + // Indexierungsstatus aus der Search Console (Roadmap 1c.4): Verteilung + // nach Direkteinreichung/Syndikation — Grundlage für die Kundenansicht. + Volt::route('admin/reports/indexierung', 'admin.reports.index-status')->name('admin.reports.index-status'); + Route::middleware('can:settings:manage')->group(function (): void { Volt::route('admin/reports/system-health', 'admin.reports.system-health')->name('admin.reports.system-health'); }); diff --git a/routes/console.php b/routes/console.php index d6bb9606..980f771e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ runInBackground() ->onFailure($alertOnFailure('press-releases:purge-events')); +// Indexierungsstatus aus der Search Console (Roadmap 1c.4), täglich 05:10. +// Ohne GSC_ENABLED/Schlüsseldatei beendet sich der Befehl sofort. +Schedule::command(CheckIndexStatus::class) + ->dailyAt('05:10') + ->withoutOverlapping() + ->runInBackground() + ->onFailure($alertOnFailure('press-releases:check-index-status')); + // Konversions-Ereignisse auf den Aufbewahrungszeitraum begrenzen (täglich 03:25). // Frist zusammen mit der Tabelle festgelegt (395 Tage), siehe config/conversion.php. Schedule::command(PurgeConversionEvents::class) diff --git a/tests/Feature/IndexStatusCheckTest.php b/tests/Feature/IndexStatusCheckTest.php new file mode 100644 index 00000000..9aff2c0d --- /dev/null +++ b/tests/Feature/IndexStatusCheckTest.php @@ -0,0 +1,190 @@ + 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $pem); + + $file = tempnam(sys_get_temp_dir(), 'gsc'); + file_put_contents($file, json_encode([ + 'client_email' => 'indexer@example.iam.gserviceaccount.com', + 'private_key' => $pem, + 'token_uri' => 'https://oauth2.googleapis.com/token', + ])); + + config()->set('search_console.enabled', true); + config()->set('search_console.credentials', $file); + + return $file; +} + +function publishedOn(Portal $portal, int $daysAgo, array $attributes = []): PressRelease +{ + $company = Company::factory()->create(['portal' => $portal]); + + return PressRelease::factory() + ->published() + ->forPortal($portal) + ->for($company) + ->create(array_merge(['published_at' => now()->subDays($daysAgo)], $attributes)); +} + +test('the index state distinguishes not crawled from crawled but not indexed', function (array $indexStatus, IndexState $expected) { + expect(IndexState::fromIndexStatus($indexStatus))->toBe($expected); +})->with([ + 'indexed' => [['verdict' => 'PASS', 'coverageState' => 'Submitted and indexed', 'lastCrawlTime' => '2026-09-10T08:00:00Z'], IndexState::Indexed], + 'crawled, not indexed' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Crawled - currently not indexed', 'lastCrawlTime' => '2026-09-10T08:00:00Z', 'pageFetchState' => 'SUCCESSFUL'], IndexState::CrawledNotIndexed], + 'discovered, not crawled' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Discovered - currently not indexed'], IndexState::NotCrawled], + 'unknown to google' => [['verdict' => 'NEUTRAL', 'coverageState' => 'URL is unknown to Google'], IndexState::NotCrawled], + 'duplicate canonical' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Duplicate, Google chose different canonical than user', 'lastCrawlTime' => '2026-09-10T08:00:00Z'], IndexState::Excluded], + 'blocked by robots' => [['verdict' => 'FAIL', 'coverageState' => 'Blocked by robots.txt', 'robotsTxtState' => 'DISALLOWED'], IndexState::Excluded], + 'empty' => [[], IndexState::Unknown], +]); + +test('candidates are picked by age band, never-checked first, within the budget', function () { + /** @var TestCase $this */ + config()->set('search_console.recheck_days', [14 => 3, 90 => 14, PHP_INT_MAX => 180]); + + $fresh = publishedOn(Portal::Presseecho, 2); + $freshChecked = publishedOn(Portal::Presseecho, 5); + PressReleaseIndexCheck::factory()->create(['press_release_id' => $freshChecked->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]); + $freshDue = publishedOn(Portal::Presseecho, 6); + PressReleaseIndexCheck::factory()->create(['press_release_id' => $freshDue->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDays(4)]); + $middle = publishedOn(Portal::Presseecho, 40); + $old = publishedOn(Portal::Presseecho, 400); + $otherPortal = publishedOn(Portal::Businessportal24, 1); + $both = publishedOn(Portal::Both, 3); + + $ids = app(IndexStatusChecker::class)->candidates(Portal::Presseecho, 10)->pluck('id')->all(); + + expect($ids)->toContain($fresh->id, $freshDue->id, $middle->id, $old->id, $both->id) + ->and($ids)->not->toContain($freshChecked->id, $otherPortal->id) + // Jüngste zuerst, innerhalb der Stufe nie geprüfte vor geprüften. + ->and(array_slice($ids, 0, 3))->toBe([$fresh->id, $both->id, $freshDue->id]); + + expect(app(IndexStatusChecker::class)->candidates(Portal::Presseecho, 2)->pluck('id')->all())->toBe([$fresh->id, $both->id]); +}); + +test('the command inspects due releases via the API and stores each answer as a snapshot', function () { + /** @var TestCase $this */ + $file = fakeServiceAccount(); + config()->set('search_console.properties.presseecho', 'sc-domain:presseecho.test'); + config()->set('search_console.properties.businessportal24', null); + + $release = publishedOn(Portal::Presseecho, 2, ['slug' => 'index-pruefung-pm']); + + Http::fake([ + 'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123', 'expires_in' => 3600]), + SearchConsoleClient::INSPECT_URL => Http::response(['inspectionResult' => [ + 'inspectionResultLink' => 'https://search.google.com/search-console/inspect?resource_id=sc-domain:presseecho.test', + 'indexStatusResult' => [ + 'verdict' => 'NEUTRAL', + 'coverageState' => 'Crawled - currently not indexed', + 'robotsTxtState' => 'ALLOWED', + 'indexingState' => 'INDEXING_ALLOWED', + 'lastCrawlTime' => '2026-09-15T06:30:00Z', + 'pageFetchState' => 'SUCCESSFUL', + 'googleCanonical' => 'https://presseecho.test/de/press-release/index-pruefung-pm', + ], + ]]), + ]); + + $this->artisan(CheckIndexStatus::class, ['--budget' => 5]) + ->expectsOutputToContain('1 Prüfungen gespeichert, 0 fehlgeschlagen.') + ->assertSuccessful(); + + Http::assertSent(fn ($request) => $request->url() === SearchConsoleClient::INSPECT_URL + && $request['inspectionUrl'] === 'https://presseecho.test/de/press-release/index-pruefung-pm' + && $request['siteUrl'] === 'sc-domain:presseecho.test' + && $request->hasHeader('Authorization', 'Bearer token-123')); + + $check = PressReleaseIndexCheck::query()->sole(); + + expect($check->press_release_id)->toBe($release->id) + ->and($check->portal)->toBe(Portal::Presseecho) + ->and($check->state)->toBe(IndexState::CrawledNotIndexed) + ->and($check->coverage_state)->toBe('Crawled - currently not indexed') + ->and($check->last_crawl_time?->toDateString())->toBe('2026-09-15') + ->and($check->raw['verdict'])->toBe('NEUTRAL'); + + // Zweiter Lauf: die Meldung ist gerade geprüft, also nicht fällig — keine weitere Abfrage. + $this->artisan(CheckIndexStatus::class, ['--budget' => 5])->assertSuccessful(); + expect(PressReleaseIndexCheck::query()->count())->toBe(1); + + @unlink($file); +}); + +test('the command does nothing without configuration', function () { + /** @var TestCase $this */ + config()->set('search_console.enabled', false); + publishedOn(Portal::Presseecho, 2); + + Http::fake(); + + $this->artisan(CheckIndexStatus::class) + ->expectsOutputToContain('nicht konfiguriert') + ->assertSuccessful(); + + Http::assertNothingSent(); + expect(PressReleaseIndexCheck::query()->count())->toBe(0); +}); + +test('the admin report splits the distribution by origin and lists the latest checks', function () { + /** @var TestCase $this */ + test()->seed(RolesAndPermissionsSeeder::class); + + $direct = publishedOn(Portal::Presseecho, 10, ['title' => 'Direkt eingereichte Meldung']); + $api = publishedOn(Portal::Presseecho, 12, ['title' => 'Syndizierte Meldung']); + PressReleaseStatusLog::query()->create(['press_release_id' => $api->id, 'from_status' => 'draft', 'to_status' => 'review', 'source' => 'api']); + $unchecked = publishedOn(Portal::Presseecho, 20); + + // Ältere Momentaufnahme zählt nicht — nur die jüngste je Meldung. + PressReleaseIndexCheck::factory()->withState(IndexState::NotCrawled)->create(['press_release_id' => $direct->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDays(5)]); + PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $direct->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]); + PressReleaseIndexCheck::factory()->withState(IndexState::CrawledNotIndexed)->create(['press_release_id' => $api->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]); + + $distribution = app(IndexStatusReport::class)->distribution(); + + expect($distribution['presseecho']['direct']['total'])->toBe(2) + ->and($distribution['presseecho']['direct']['indexed'])->toBe(1) + ->and($distribution['presseecho']['direct']['not_crawled'])->toBe(0) + ->and($distribution['presseecho']['direct']['unchecked'])->toBe(1) + ->and($distribution['presseecho']['api']['total'])->toBe(1) + ->and($distribution['presseecho']['api']['crawled_not_indexed'])->toBe(1) + ->and($distribution['businessportal24']['direct']['total'])->toBe(0); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $this->actingAs($admin) + ->get(route('admin.reports.index-status')) + ->assertSuccessful() + ->assertSeeText('Indexierung') + ->assertSeeText('Direkt eingereicht') + ->assertSeeText('Syndikation (API)') + ->assertSeeText('Direkt eingereichte Meldung') + ->assertSeeText('Syndizierte Meldung') + ->assertSeeText('gecrawlt, nicht indexiert') + ->assertSeeText('Kundenansicht'); +}); diff --git a/tests/Feature/VisibilityStatsTest.php b/tests/Feature/VisibilityStatsTest.php index ae7d4d52..618ebb9a 100644 --- a/tests/Feature/VisibilityStatsTest.php +++ b/tests/Feature/VisibilityStatsTest.php @@ -1,10 +1,13 @@ dailyViews($release, 400))->toHaveCount(5); }); -test('the release summary reports sitemap presence and a search link only for public releases', function () { +test('the release summary reports sitemap presence only for public releases and lists crawler fetches', function () { /** @var TestCase $this */ [, $company] = claimant(); $public = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDay(), 'hits' => 9]); @@ -111,23 +114,27 @@ test('the release summary reports sitemap presence and a search link only for pu PressReleaseEvent::query()->create(['press_release_id' => $public->id, 'type' => PressReleaseEventType::Share, 'channel' => 'linkedin']); viewsOn($public, 2, daysAgo: 1); + PressReleaseCrawlerHit::factory()->service('google', 'Mozilla/5.0 (compatible; Googlebot/2.1)')->count(2)->create(['press_release_id' => $public->id]); + PressReleaseCrawlerHit::factory()->count(1)->create(['press_release_id' => $public->id]); + $stats = app(VisibilityStats::class); $summary = $stats->releaseSummary($public); expect($summary['hits'])->toBe(9) ->and($summary['shares_total'])->toBe(1) ->and($summary['in_sitemap_since']->isSameDay($public->published_at))->toBeTrue() - ->and($summary['search_url'])->toContain('site%3A') - ->and($summary['search_url'])->toContain($public->slug) + ->and($summary)->not->toHaveKey('search_url') + ->and(collect($summary['crawlers'])->pluck('fetches', 'service')->sortKeys()->all())->toBe(['google' => 2, 'openai' => 1]) + ->and(collect($summary['crawlers'])->firstWhere('service', 'google')['label'])->toBe('Google Suche') ->and($summary['first_view_at'])->not->toBeNull(); $draftSummary = $stats->releaseSummary($draft); expect($draftSummary['in_sitemap_since'])->toBeNull() - ->and($draftSummary['search_url'])->toBeNull(); + ->and($draftSummary['crawlers'])->toBe([]); }); -test('the release page shows the visibility panel with daily bars and the search link', function () { +test('the release page shows the delivery panel with daily bars, sitemap date and crawler fetches, but no search link', function () { /** @var TestCase $this */ [$customer, $company] = claimant(); $partner = User::factory()->create(); @@ -135,24 +142,49 @@ test('the release page shows the visibility panel with daily bars and the search 'company_id' => $company->id, 'user_id' => $partner->id, 'hits' => 777, 'published_at' => now()->subDays(5), ]); viewsOn($release, 6, daysAgo: 2); + PressReleaseCrawlerHit::factory()->service('anthropic', 'ClaudeBot/1.0')->create(['press_release_id' => $release->id]); LivewireVolt::test('customer.press-releases.show', ['id' => $release->id]) - ->assertSeeText('Sichtbarkeit') + ->assertSeeText('Auslieferung') ->assertSeeText('777') ->assertSeeText('Aufrufe 30 Tage') - ->assertSeeText('Bei Google prüfen') - ->assertSeeText('In der Sitemap für Suchmaschinen seit '.now()->subDays(5)->format('d.m.Y')) + ->assertDontSeeText('Bei Google prüfen') + ->assertDontSee('google.com/search', false) + ->assertSeeText('Gelistet seit '.now()->subDays(5)->format('d.m.Y')) + ->assertSeeText('Anthropic / Claude') + ->assertSeeText('1 Abruf') + ->assertSeeText('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat') + // Indexierungsstatus bleibt zu (E8), solange die Kundenansicht nicht freigegeben ist. + ->assertDontSeeText('Google-Index') ->assertSee('set('viewRange', 90) ->assertSeeText('Aufrufe 90 Tage'); }); +test('the index status appears in the delivery panel only once the customer view is released', function () { + /** @var TestCase $this */ + [, $company] = claimant(); + $release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDays(5)]); + PressReleaseIndexCheck::factory()->withState(IndexState::CrawledNotIndexed)->create(['press_release_id' => $release->id, 'portal' => Portal::Businessportal24, 'checked_at' => now()->subDay()]); + PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $release->id, 'portal' => Portal::Businessportal24, 'checked_at' => now()->subDays(9)]); + + config()->set('search_console.customer_visible', false); + LivewireVolt::test('customer.press-releases.show', ['id' => $release->id]) + ->assertDontSeeText('Google-Index'); + + config()->set('search_console.customer_visible', true); + LivewireVolt::test('customer.press-releases.show', ['id' => $release->id]) + ->assertSeeText('Google-Index') + ->assertSeeText('Businessportal24: gecrawlt, nicht indexiert') + ->assertDontSeeText('Businessportal24: indexiert'); +}); + test('a draft shows no visibility panel', function () { /** @var TestCase $this */ [$customer, $company] = claimant(); $draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $customer->id, 'status' => 'draft', 'hits' => 0, 'published_at' => null]); LivewireVolt::test('customer.press-releases.show', ['id' => $draft->id]) - ->assertDontSeeText('Bei Google prüfen') + ->assertDontSeeText('Auslieferung') ->assertSeeText('noch nicht veröffentlicht'); });