diff --git a/app/Services/PressRelease/CrawlerHitReport.php b/app/Services/PressRelease/CrawlerHitReport.php new file mode 100644 index 00000000..8075f475 --- /dev/null +++ b/app/Services/PressRelease/CrawlerHitReport.php @@ -0,0 +1,112 @@ + + */ + public function byService(int $days = 30): array + { + $since = now()->subDays($days); + + $recent = PressReleaseCrawlerHit::query() + ->where('created_at', '>=', $since) + ->selectRaw('service, count(*) as fetches, count(distinct press_release_id) as releases') + ->groupBy('service') + ->toBase() + ->get() + ->keyBy('service'); + + return PressReleaseCrawlerHit::query() + ->selectRaw('service, count(*) as fetches_total, max(created_at) as last_at') + ->groupBy('service') + ->toBase() + ->get() + ->map(fn (object $row): array => [ + 'service' => (string) $row->service, + 'label' => CrawlerIdentifier::label((string) $row->service), + 'kind' => CrawlerIdentifier::kind((string) $row->service), + 'fetches' => (int) ($recent[$row->service]->fetches ?? 0), + 'releases' => (int) ($recent[$row->service]->releases ?? 0), + 'fetches_total' => (int) $row->fetches_total, + 'last_at' => Carbon::parse((string) $row->last_at), + ]) + ->sortByDesc(fn (array $row): array => [$row['fetches'], $row['fetches_total']]) + ->values() + ->all(); + } + + /** + * Abrufe je Tag über die letzten N Tage, lückenlos, getrennt nach Suche + * und KI (Art aus config/crawlers.php). + * + * @return list + */ + public function daily(int $days = 14): array + { + $start = now()->startOfDay()->subDays($days - 1); + + $counts = PressReleaseCrawlerHit::query() + ->where('created_at', '>=', $start) + ->selectRaw('date(created_at) as day, service, count(*) as fetches') + ->groupBy('day', 'service') + ->toBase() + ->get() + ->groupBy('day'); + + $series = []; + + for ($i = 0; $i < $days; $i++) { + $day = $start->copy()->addDays($i); + $row = ['date' => $day, 'search' => 0, 'ai' => 0]; + + foreach ($counts->get($day->toDateString(), collect()) as $entry) { + $row[CrawlerIdentifier::kind((string) $entry->service) === 'search' ? 'search' : 'ai'] += (int) $entry->fetches; + } + + $series[] = $row; + } + + return $series; + } + + /** + * Am häufigsten abgerufene Meldungen im Zeitraum. + * + * @return list + */ + public function topReleases(int $days = 30, int $limit = 15): array + { + return PressReleaseCrawlerHit::query() + ->join('press_releases', 'press_releases.id', '=', 'press_release_crawler_hits.press_release_id') + ->where('press_release_crawler_hits.created_at', '>=', now()->subDays($days)) + ->selectRaw('press_release_crawler_hits.press_release_id, press_releases.title, count(*) as fetches, count(distinct service) as services') + ->groupBy('press_release_crawler_hits.press_release_id', 'press_releases.title') + ->orderByDesc('fetches') + ->limit($limit) + ->toBase() + ->get() + ->map(fn (object $row): array => [ + 'press_release_id' => (int) $row->press_release_id, + 'title' => (string) $row->title, + 'fetches' => (int) $row->fetches, + 'services' => (int) $row->services, + ]) + ->all(); + } +} diff --git a/app/Services/SearchConsole/IndexStatusReport.php b/app/Services/SearchConsole/IndexStatusReport.php index b231ca7a..10056bba 100644 --- a/app/Services/SearchConsole/IndexStatusReport.php +++ b/app/Services/SearchConsole/IndexStatusReport.php @@ -10,6 +10,7 @@ use App\Scopes\PortalScope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; /** @@ -23,73 +24,148 @@ 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). + * Mail-Versand — der Kontotyp ist nicht verlässlich). Erwartet den + * Join aus apiJoin(): als Join ein Bruchteil der Laufzeit eines + * korrelierten EXISTS über den Bestand (0,3 s statt 2,6 s). */ - 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"; + private const string ORIGIN_SQL = "case when api_origin.press_release_id is null then 'direct' else 'api' end"; + + private const int CACHE_SECONDS = 900; /** - * Anzahl der Meldungen je Portal, Herkunft und Befund — Stand der jeweils - * jüngsten Prüfung. Meldungen ohne Prüfung erscheinen unter `unchecked`. + * Rolle einer Meldung in der kanonischen Zuordnung (1c.8): Erstquelle + * oder Kopie mit rel=canonical auf die andere Fassung. Für eine Kopie ist + * „ausgeschlossen" (Google folgt dem Verweis) der erwartete Befund. + */ + private const string ROLE_SQL = "case when press_releases.canonical_press_release_id is null then 'source' else 'copy' end"; + + /** @var list */ + public const array ORIGINS = ['direct', 'api']; + + /** @var list */ + public const array ROLES = ['source', 'copy']; + + /** + * Anzahl der Meldungen je Portal, Herkunft, Rolle und Befund — Stand der + * jeweils jüngsten Prüfung. Meldungen ohne Prüfung erscheinen unter + * `unchecked`. * - * @return array>> portal => origin => state|unchecked|total => count + * @return array>>> portal => origin => role => state|unchecked|total => count */ public function distribution(): array + { + return Cache::remember('index-status.distribution', self::CACHE_SECONDS, fn (): array => $this->computeDistribution()); + } + + /** + * @return array>>> + */ + private function computeDistribution(): 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. + // Herkunft und Rolle 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() + $this->apiJoin(PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->visibleInArchive() - ->forPortalValues([$portal->value, Portal::Both->value]) - ->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin'), + ->forPortalValues([$portal->value, Portal::Both->value])) + ->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'), 'r' ) - ->selectRaw('origin, count(*) as total') - ->groupBy('origin') + ->selectRaw('origin, role, count(*) as total') + ->groupBy('origin', 'role') ->get(); $checked = DB::query() ->fromSub( - $this->latestChecks($portal) + $this->apiJoin($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'), + ->whereNull('press_releases.deleted_at')) + ->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'), 'c' ) - ->selectRaw('origin, state, count(*) as total') - ->groupBy('origin', 'state') + ->selectRaw('origin, role, state, count(*) as total') + ->groupBy('origin', 'role', 'state') ->get(); - foreach (['direct', 'api'] as $origin) { - $bucket = ['total' => 0, 'unchecked' => 0]; + foreach (self::ORIGINS as $origin) { + foreach (self::ROLES as $role) { + $bucket = ['total' => 0, 'unchecked' => 0]; - foreach (IndexState::cases() as $state) { - $bucket[$state->value] = 0; + foreach (IndexState::cases() as $state) { + $bucket[$state->value] = 0; + } + + $bucket['total'] = (int) ($visible->first(fn (object $row): bool => $row->origin === $origin && $row->role === $role)->total ?? 0); + + $checkedTotal = 0; + + foreach ($checked->filter(fn (object $row): bool => $row->origin === $origin && $row->role === $role) as $row) { + $bucket[(string) $row->state] = (int) $row->total; + $checkedTotal += (int) $row->total; + } + + $bucket['unchecked'] = max(0, $bucket['total'] - $checkedTotal); + $rows[$portal->value][$origin][$role] = $bucket; } - - $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; } + /** + * Quellenbefund (1c.9) über die jüngsten Prüfungen je Portal: welche + * Adresse Google als maßgeblich führt — diese, die eigene Fassung auf dem + * anderen Portal, ein fremdes Portal, oder keine Angabe. Nur Erstquellen + * (1c.8) zählen: bei einer Kopie ist die Abweichung gewollt. + * + * @return array}> + */ + public function sourceSummary(IndexStatusChecker $checker): array + { + return Cache::remember('index-status.sources', self::CACHE_SECONDS, fn (): array => $this->computeSourceSummary($checker)); + } + + /** + * @return array}> + */ + private function computeSourceSummary(IndexStatusChecker $checker): array + { + $summary = []; + + foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) { + $bucket = ['self' => 0, 'own_other' => 0, 'foreign' => 0, 'unknown' => 0, 'total' => 0, 'foreign_hosts' => []]; + + $this->latestChecks($portal) + ->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id') + ->whereNull('press_releases.deleted_at') + ->whereNull('press_releases.canonical_press_release_id') + ->select(['press_release_index_checks.id', 'press_release_index_checks.url', 'press_release_index_checks.google_canonical', 'press_release_index_checks.portal']) + ->lazyById(1000, 'press_release_index_checks.id', 'id') + ->each(function (PressReleaseIndexCheck $check) use (&$bucket, $checker): void { + $verdict = $checker->sourceVerdict($check); + $bucket[$verdict['kind']]++; + $bucket['total']++; + + if ($verdict['kind'] === 'foreign' && $verdict['host'] !== null) { + $bucket['foreign_hosts'][$verdict['host']] = ($bucket['foreign_hosts'][$verdict['host']] ?? 0) + 1; + } + }); + + arsort($bucket['foreign_hosts']); + $bucket['foreign_hosts'] = array_slice($bucket['foreign_hosts'], 0, 10, true); + $summary[$portal->value] = $bucket; + } + + return $summary; + } + /** * Jüngste Prüfungen, neueste zuerst. * @@ -98,7 +174,7 @@ class IndexStatusReport public function latest(int $limit = 100): Collection { return PressReleaseIndexCheck::query() - ->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()->select(['id', 'title', 'slug', 'published_at'])]) + ->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()->select(['id', 'title', 'slug', 'published_at', 'canonical_press_release_id'])]) ->orderByDesc('checked_at') ->orderByDesc('id') ->limit($limit) @@ -112,6 +188,25 @@ class IndexStatusReport return $value ? Carbon::parse((string) $value) : null; } + /** + * Herkunftsmarker als Join: alle Meldungen, die je einen Status-Log- + * Eintrag mit Quelle „api" haben. + * + * @template TModel of \Illuminate\Database\Eloquent\Model + * + * @param Builder $query + * @return Builder + */ + private function apiJoin(Builder $query): Builder + { + $api = DB::table('press_release_status_logs') + ->select('press_release_id') + ->where('source', 'api') + ->distinct(); + + return $query->leftJoinSub($api, 'api_origin', 'api_origin.press_release_id', '=', 'press_releases.id'); + } + /** * Jeweils jüngste Prüfung je Meldung auf einem Portal. * diff --git a/app/Services/SearchConsole/SearchConsoleClient.php b/app/Services/SearchConsole/SearchConsoleClient.php index 9786be04..381f277f 100644 --- a/app/Services/SearchConsole/SearchConsoleClient.php +++ b/app/Services/SearchConsole/SearchConsoleClient.php @@ -22,9 +22,26 @@ class SearchConsoleClient public function isConfigured(): bool { - $file = (string) config('search_console.credentials'); + $file = $this->credentialsPath(); - return (bool) config('search_console.enabled') && $file !== '' && is_readable($file); + 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 @@ -102,8 +119,8 @@ class SearchConsoleClient */ private function credentials(): array { - $file = (string) config('search_console.credentials'); - $json = is_readable($file) ? json_decode((string) file_get_contents($file), true) : null; + $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.'); diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index a91695c6..b4d7ad1b 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -2095,3 +2095,30 @@ Muster" fehlt; gehört zum Rechnungs-Paket). die Sitemap-Caches (1 h TTL). E9 ist ohne `.env`-Eintrag aktiv — davor müssen `press-releases:hash-content` und `press-releases:assign-canonicals` gelaufen sein, sonst passiert schlicht nichts. + +### Nachtrag 18.09.2026 — Admin-Bericht „Indexierung & Abrufe" + +**Fehler auf Live:** Bericht meldete „nicht konfiguriert", obwohl der +Lauf Daten holte. Ursache: `GSC_SERVICE_ACCOUNT_FILE` relativ +(`config/files/…`), `is_readable()` hing am Arbeitsverzeichnis — Cron +in der Projektwurzel lesbar, Web-Request in `public/` nicht. Relative +Pfade werden jetzt ab `base_path()` aufgelöst. + +**Bericht erweitert** (`/admin/reports/indexierung`): + +- Verteilung je Portal jetzt nach Herkunft **und Rolle** (Erstquelle / + Kopie mit Verweis, 1c.8). Seit E9 aktiv ist, ist „ausgeschlossen" bei + Kopien der erwartete Befund und kein Problem; ohne die Trennung wäre + die Quote unlesbar. Erwarteter Befund je Zeile fett. +- **Quellenbefund** (1c.9) über Erstquellen: Google führt diese Adresse / + eigene Fassung auf dem anderen Portal / fremdes Portal (mit Top-Hosts) + / keine Angabe. In den jüngsten Prüfungen als Spalte „Google führt". +- **Abrufe durch Such- und KI-Dienste** (1c.0): je Dienst Abrufe und + Meldungen im Zeitraum (7/30/90 Tage), gesamt, letzter Abruf; Tagesbalken + 14 Tage getrennt Suche/KI; meistabgerufene Meldungen; CSV. + `App\Services\PressRelease\CrawlerHitReport`. +- Herkunftsmarker als Join statt korreliertem EXISTS (2,6 s → 0,3 s je + Portal), Verteilung und Quellenbefund 15 min gecacht. Seite warm 0,5 s. + +Dev-Verteilung presseecho (Bestand): direkt 8.845 Erstquellen / 2.126 +Kopien, API 1.308 / 308. diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 81e19d2a..02d05ef7 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -232,7 +232,7 @@ - {{ __('Indexierung') }} + {{ __('Indexierung & Abrufe') }} @can('settings:manage') latest(2000); + $checker = app(IndexStatusChecker::class); $name = sprintf('indexierung-%s.csv', now()->format('Y-m-d')); - return response()->streamDownload(function () use ($rows): void { + return response()->streamDownload(function () use ($rows, $checker): void { $handle = fopen('php://output', 'wb'); - fputcsv($handle, ['Geprüft', 'Portal', 'ID', 'Titel', 'URL', 'Befund', 'Google-Status', 'Zuletzt gecrawlt', 'Kanonisch'], ';'); + fputcsv($handle, ['Geprüft', 'Portal', 'ID', 'Titel', 'URL', 'Befund', 'Google-Status', 'Zuletzt gecrawlt', 'Google führt', 'Kanonisch (Google)'], ';'); foreach ($rows as $check) { fputcsv($handle, [ @@ -38,6 +50,7 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp $check->state->label(), $check->coverage_state ?? '', $check->last_crawl_time?->format('d.m.Y') ?? '', + $this->sourceLabel($checker->sourceVerdict($check)['kind']), $check->google_canonical ?? '', ], ';'); } @@ -46,18 +59,56 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); } + public function downloadCrawlers(): StreamedResponse + { + $rows = app(CrawlerHitReport::class)->byService($this->days); + $name = sprintf('crawler-abrufe-%s.csv', now()->format('Y-m-d')); + + return response()->streamDownload(function () use ($rows): void { + $handle = fopen('php://output', 'wb'); + fputcsv($handle, ['Dienst', 'Art', sprintf('Abrufe %d Tage', $this->days), sprintf('Meldungen %d Tage', $this->days), 'Abrufe gesamt', 'Letzter Abruf'], ';'); + + foreach ($rows as $row) { + fputcsv($handle, [$row['label'], $row['kind'] === 'search' ? 'Suche' : 'KI', $row['fetches'], $row['releases'], $row['fetches_total'], $row['last_at']->format('d.m.Y H:i')], ';'); + } + + fclose($handle); + }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + public function sourceLabel(string $kind): string + { + return match ($kind) { + 'self' => __('diese Adresse'), + 'own_other' => __('eigene Fassung, anderes Portal'), + 'foreign' => __('fremdes Portal'), + default => __('keine Angabe'), + }; + } + public function with(): array { $report = app(IndexStatusReport::class); + $checker = app(IndexStatusChecker::class); + $crawlers = app(CrawlerHitReport::class); + $days = in_array($this->days, [7, 30, 90], true) ? $this->days : 30; return [ 'configured' => app(SearchConsoleClient::class)->isConfigured(), 'customerVisible' => (bool) config('search_console.customer_visible'), + 'canonicalActive' => (bool) config('canonical.enabled'), 'distribution' => $report->distribution(), + 'sources' => $report->sourceSummary($checker), 'latest' => $report->latest(100), 'lastCheckedAt' => $report->lastCheckedAt(), 'states' => IndexState::cases(), 'portals' => [Portal::Presseecho, Portal::Businessportal24], + 'checker' => $checker, + 'crawlerServices' => $crawlers->byService($days), + 'crawlerDaily' => $crawlers->daily(14), + 'crawlerTop' => $crawlers->topReleases($days), + 'crawlersSince' => config('crawlers.recorded_since') ? \Illuminate\Support\Carbon::parse((string) config('crawlers.recorded_since')) : null, + 'days' => $days, ]; } }; ?> @@ -66,31 +117,34 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp -
+
{{ $configured ? __('täglicher Lauf 05:10 Uhr') : __('GSC_ENABLED und Schlüsseldatei setzen') }} {{ __('Momentaufnahmen, jede Antwort eine Zeile') }} + + {{ __('Kopien verweisen auf die Erstquelle (1c.8, E9)') }} + - {{ __('Entscheidung E8 — config search_console.customer_visible') }} + {{ __('Entscheidung E8 — GSC_CUSTOMER_VISIBLE') }}
@foreach ($portals as $portal)
- {{ $portal->label() }} + {{ $portal->label() }} · {{ __('Google-Index') }} {{ __('im Archiv sichtbare Meldungen, Stand der jeweils jüngsten Prüfung') }}
- {{ __('Herkunft') }} + {{ __('Herkunft · Rolle') }} {{ __('Meldungen') }} @foreach ($states as $state) {{ ucfirst($state->label()) }} @@ -98,37 +152,193 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp {{ __('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 + @foreach (['direct' => __('Direkt eingereicht'), 'api' => __('Syndikation (API)')] as $origin => $originLabel) + @foreach (['source' => __('Erstquelle'), 'copy' => __('Kopie mit Verweis')] as $role => $roleLabel) + @php + $row = $distribution[$portal->value][$origin][$role] ?? []; + $checkedTotal = max(0, ($row['total'] ?? 0) - ($row['unchecked'] ?? 0)); + @endphp + @continue(($row['total'] ?? 0) === 0 && $role === 'copy') + - {{ number_format($count, 0, ',', '.') }} - @if ($checkedTotal > 0) - {{ number_format($count / $checkedTotal * 100, 1, ',', '.') }} % - @endif + {{ $originLabel }} + {{ $roleLabel }} - @endforeach - - {{ number_format($row['unchecked'] ?? 0, 0, ',', '.') }} - - + + {{ number_format($row['total'] ?? 0, 0, ',', '.') }} + + @foreach ($states as $state) + @php + $count = (int) ($row[$state->value] ?? 0); + $expected = $role === 'copy' ? $state === IndexState::Excluded : $state === IndexState::Indexed; + @endphp + + $expected && $count > 0, 'text-[color:var(--color-ink-2)]' => ! ($expected && $count > 0)])>{{ number_format($count, 0, ',', '.') }} + @if ($checkedTotal > 0) + {{ number_format($count / $checkedTotal * 100, 1, ',', '.') }} % + @endif + + @endforeach + + {{ number_format($row['unchecked'] ?? 0, 0, ',', '.') }} + + + @endforeach @endforeach +
+ {{ __('Prozentwerte beziehen sich auf die geprüften Meldungen der Zeile. Fett: der Befund, der für die Rolle erwartet wird — Erstquelle indexiert, Kopie ausgeschlossen zugunsten der Erstquelle.') }} +
@endforeach + {{-- Quellenbefund (1c.9): welche Adresse Google als maßgeblich führt. + Nur Erstquellen — bei Kopien ist die Abweichung gewollt. --}} +
+
+ {{ __('Quellenbefund') }} + {{ __('Erstquellen mit Prüfung: welche Adresse Google als maßgeblich führt') }} +
+ + + {{ __('Portal') }} + {{ __('Geprüft') }} + {{ __('Diese Adresse') }} + {{ __('Eigene Fassung, anderes Portal') }} + {{ __('Fremdes Portal') }} + {{ __('Keine Angabe') }} + + + @foreach ($portals as $portal) + @php $row = $sources[$portal->value]; @endphp + + {{ $portal->label() }} + {{ number_format($row['total'], 0, ',', '.') }} + @foreach (['self', 'own_other', 'foreign', 'unknown'] as $kind) + + {{ number_format($row[$kind], 0, ',', '.') }} + @if ($row['total'] > 0) + {{ number_format($row[$kind] / $row['total'] * 100, 1, ',', '.') }} % + @endif + @if ($kind === 'foreign' && $row['foreign_hosts'] !== []) + {{ collect($row['foreign_hosts'])->map(fn ($n, $host) => $host.' '.$n)->implode(' · ') }} + @endif + + @endforeach + + @endforeach + + +
+ {{ __('„Keine Angabe“ heißt: Google nennt keine kanonische Adresse — meist, weil die Seite noch nicht gecrawlt oder nicht indexiert ist. Warum Google eine bestimmte Fassung wählt, ist nicht bekannt.') }} +
+
+ + {{-- Crawler-Abruf-Protokoll (1c.0): Abrufe, keine Zitate. --}} +
+
+ {{ __('Abrufe durch Such- und KI-Dienste') }} +
+
+ @foreach ([7, 30, 90] as $range) + + @endforeach +
+ @if ($crawlerServices !== []) + {{ __('CSV') }} + @endif +
+
+ + @php + $dailyPeak = max(1, (int) collect($crawlerDaily)->max(fn ($d) => $d['search'] + $d['ai'])); + @endphp +
+
+ {{ __('Abrufe je Tag, letzte 14 Tage') }} + + {{ __('KI') }} + {{ __('Suche') }} + · {{ __('Spitze :n', ['n' => number_format($dailyPeak, 0, ',', '.')]) }} + +
+
+ @foreach ($crawlerDaily as $day) + @php $total = $day['search'] + $day['ai']; @endphp +
+
+
+
+ @endforeach +
+
+ {{ $crawlerDaily[0]['date']->format('d.m.') }} + {{ __('heute') }} +
+
+ + + + {{ __('Dienst') }} + {{ __('Art') }} + {{ __('Abrufe :days Tage', ['days' => $days]) }} + {{ __('Meldungen :days Tage', ['days' => $days]) }} + {{ __('Abrufe gesamt') }} + {{ __('Letzter Abruf') }} + + + @forelse ($crawlerServices as $service) + + {{ $service['label'] }} + {{ $service['kind'] === 'search' ? __('Suche') : __('KI') }} + {{ number_format($service['fetches'], 0, ',', '.') }} + {{ number_format($service['releases'], 0, ',', '.') }} + {{ number_format($service['fetches_total'], 0, ',', '.') }} + {{ $service['last_at']->format('d.m.Y H:i') }} + + @empty + + +
+ {{ __('Noch kein Abruf protokolliert.') }} + @if ($crawlersSince) + {{ __('Erfasst seit :date.', ['date' => $crawlersSince->format('d.m.Y')]) }} + @endif +
+
+
+ @endforelse +
+
+ + @if ($crawlerTop !== []) +
+
{{ __('Am häufigsten abgerufen, :days Tage', ['days' => $days]) }}
+
    + @foreach ($crawlerTop as $top) +
  • + {{ $top['title'] }} + {{ trans_choice(':n Abruf|:n Abrufe', $top['fetches'], ['n' => $top['fetches']]) }} · {{ trans_choice(':n Dienst|:n Dienste', $top['services'], ['n' => $top['services']]) }} +
  • + @endforeach +
+
+ @endif + +
+ {{ __('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat — nicht, dass sie dort angezeigt oder zitiert wird. Erkennung über den User-Agent (config/crawlers.php), keine IP-Adressen.') }} + @if ($crawlersSince) + {{ __('Protokoll seit :date.', ['date' => $crawlersSince->format('d.m.Y')]) }} + @endif +
+
+
{{ __('Jüngste Prüfungen') }} @@ -142,17 +352,19 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp {{ __('Meldung') }} {{ __('Portal') }} {{ __('Befund') }} + {{ __('Google führt') }} {{ __('Zuletzt gecrawlt') }} @forelse ($latest as $check) + @php $source = $checker->sourceVerdict($check); @endphp {{ $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') ?? '—']) }} + {{ __('veröffentlicht :date', ['date' => $check->pressRelease?->published_at?->format('d.m.Y') ?? '—']) }}@if ($check->pressRelease?->canonical_press_release_id) · {{ __('Kopie mit Verweis') }}@endif {{ $check->portal->label() }} @@ -163,13 +375,19 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp {{ $check->coverage_state }} @endif + + {{ $this->sourceLabel($source['kind']) }} + @if (in_array($source['kind'], ['own_other', 'foreign'], true)) + {{ $source['host'] }} + @endif + {{ $check->last_crawl_time?->format('d.m.Y') ?? '—' }} @empty - +
{{ __('Noch keine Prüfung gespeichert. Erster Lauf: php artisan press-releases:check-index-status') }}
diff --git a/tests/Feature/IndexStatusCheckTest.php b/tests/Feature/IndexStatusCheckTest.php index 9aff2c0d..e7ab1153 100644 --- a/tests/Feature/IndexStatusCheckTest.php +++ b/tests/Feature/IndexStatusCheckTest.php @@ -161,18 +161,32 @@ test('the admin report splits the distribution by origin and lists the latest ch // Ä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::Indexed)->create(['press_release_id' => $direct->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay(), 'url' => 'https://presseecho.test/de/press-release/'.$direct->slug, 'google_canonical' => 'https://presseecho.test/de/press-release/'.$direct->slug]); PressReleaseIndexCheck::factory()->withState(IndexState::CrawledNotIndexed)->create(['press_release_id' => $api->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]); + // Kopie mit Verweis (1c.8): Google folgt dem Canonical → „ausgeschlossen" ist hier erwartet. + $copy = publishedOn(Portal::Presseecho, 11, ['title' => 'Kopie der Meldung', 'canonical_press_release_id' => $direct->id]); + PressReleaseIndexCheck::factory()->withState(IndexState::Excluded)->create(['press_release_id' => $copy->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay(), 'google_canonical' => 'https://businessportal24.test/de/press-release/'.$direct->slug]); + $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); + expect($distribution['presseecho']['direct']['source']['total'])->toBe(2) + ->and($distribution['presseecho']['direct']['source']['indexed'])->toBe(1) + ->and($distribution['presseecho']['direct']['source']['not_crawled'])->toBe(0) + ->and($distribution['presseecho']['direct']['source']['unchecked'])->toBe(1) + ->and($distribution['presseecho']['direct']['copy']['total'])->toBe(1) + ->and($distribution['presseecho']['direct']['copy']['excluded'])->toBe(1) + ->and($distribution['presseecho']['api']['source']['total'])->toBe(1) + ->and($distribution['presseecho']['api']['source']['crawled_not_indexed'])->toBe(1) + ->and($distribution['businessportal24']['direct']['source']['total'])->toBe(0); + + // Quellenbefund nur über Erstquellen: die Kopie zählt nicht mit. + $sources = app(IndexStatusReport::class)->sourceSummary(app(IndexStatusChecker::class)); + + expect($sources['presseecho']['total'])->toBe(2) + ->and($sources['presseecho']['self'])->toBe(1) + ->and($sources['presseecho']['unknown'])->toBe(1) + ->and($sources['presseecho']['own_other'])->toBe(0); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); @@ -186,5 +200,8 @@ test('the admin report splits the distribution by origin and lists the latest ch ->assertSeeText('Direkt eingereichte Meldung') ->assertSeeText('Syndizierte Meldung') ->assertSeeText('gecrawlt, nicht indexiert') + ->assertSeeText('Kopie mit Verweis') + ->assertSeeText('Quellenbefund') + ->assertSeeText('Abrufe durch Such- und KI-Dienste') ->assertSeeText('Kundenansicht'); }); diff --git a/tests/Feature/Web/CrawlerHitLogTest.php b/tests/Feature/Web/CrawlerHitLogTest.php index 7eeba034..bb8ce2d4 100644 --- a/tests/Feature/Web/CrawlerHitLogTest.php +++ b/tests/Feature/Web/CrawlerHitLogTest.php @@ -8,6 +8,7 @@ use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; use App\Models\PressReleaseCrawlerHit; +use App\Services\PressRelease\CrawlerHitReport; use App\Support\CrawlerIdentifier; use Tests\TestCase; @@ -88,3 +89,38 @@ test('the purge command removes crawler hits older than the retention period', f expect(PressReleaseCrawlerHit::query()->count())->toBe(1); }); + +test('the crawler report aggregates fetches by service, by day and by release', function () { + /** @var TestCase $this */ + $release = makeCrawlerLogRelease(); + $other = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(['title' => 'Zweite Meldung', 'published_at' => now()->subDay()]); + + PressReleaseCrawlerHit::factory()->count(3)->create(['press_release_id' => $release->id]); + PressReleaseCrawlerHit::factory()->service('google', 'Googlebot/2.1')->create(['press_release_id' => $release->id]); + PressReleaseCrawlerHit::factory()->service('google', 'Googlebot/2.1')->create(['press_release_id' => $other->id]); + $old = PressReleaseCrawlerHit::factory()->service('bing', 'bingbot/2.0')->create(['press_release_id' => $other->id]); + $old->forceFill(['created_at' => now()->subDays(40)])->saveQuietly(); + + $report = app(CrawlerHitReport::class); + $byService = collect($report->byService(30))->keyBy('service'); + + expect($byService->keys()->all())->toBe(['openai', 'google', 'bing']) + ->and($byService['openai']['fetches'])->toBe(3) + ->and($byService['openai']['releases'])->toBe(1) + ->and($byService['google']['fetches'])->toBe(2) + ->and($byService['google']['releases'])->toBe(2) + ->and($byService['google']['kind'])->toBe('search') + ->and($byService['bing']['fetches'])->toBe(0) + ->and($byService['bing']['fetches_total'])->toBe(1); + + $daily = $report->daily(14); + expect($daily)->toHaveCount(14) + ->and($daily[13]['ai'])->toBe(3) + ->and($daily[13]['search'])->toBe(2) + ->and($daily[0]['ai'] + $daily[0]['search'])->toBe(0); + + $top = $report->topReleases(30); + expect($top[0]['press_release_id'])->toBe($release->id) + ->and($top[0]['fetches'])->toBe(4) + ->and($top[0]['services'])->toBe(2); +});