admin: Bericht „Indexierung & Abrufe" — Rolle Erstquelle/Kopie, Quellenbefund, Crawler-Abrufe; Schlüsselpfad ab Projektwurzel
Der Bericht meldete auf Live „nicht konfiguriert", während der Lauf längst Daten holte: der relative Pfad der Schlüsseldatei war im Cron (Projektwurzel) lesbar, im Web-Request (public/) nicht. Relative Pfade werden jetzt ab base_path() aufgelöst. Die Verteilung trennt zusätzlich nach Rolle in der kanonischen Zuordnung (1c.8): bei einer Kopie mit Verweis ist „ausgeschlossen" der erwartete Befund. Neu: Quellenbefund über Erstquellen (welche Adresse Google führt, fremde Hosts), Abrufe durch Such- und KI-Dienste je Dienst, Tag und Meldung mit CSV. Herkunftsmarker als Join statt korreliertem EXISTS, Aggregate 15 Minuten gecacht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c421c0377
commit
c26bdff681
8 changed files with 609 additions and 87 deletions
112
app/Services/PressRelease/CrawlerHitReport.php
Normal file
112
app/Services/PressRelease/CrawlerHitReport.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease;
|
||||||
|
|
||||||
|
use App\Models\PressReleaseCrawlerHit;
|
||||||
|
use App\Support\CrawlerIdentifier;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-Auswertung des Crawler-Abruf-Protokolls (Roadmap 1c.0): welche
|
||||||
|
* Such- und KI-Dienste in welchem Umfang Meldungen geholt haben. Zählt
|
||||||
|
* Abrufe, nicht Zitate — das Protokoll weiß nicht, was ein Dienst mit der
|
||||||
|
* Seite gemacht hat.
|
||||||
|
*/
|
||||||
|
class CrawlerHitReport
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Je Dienst: Abrufe und betroffene Meldungen im Zeitraum, Abrufe seit
|
||||||
|
* Beginn des Protokolls, letzter Abruf. Sortiert nach Abrufen im Zeitraum.
|
||||||
|
*
|
||||||
|
* @return list<array{service: string, label: string, kind: string, fetches: int, releases: int, fetches_total: int, last_at: Carbon}>
|
||||||
|
*/
|
||||||
|
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<array{date: Carbon, search: int, ai: int}>
|
||||||
|
*/
|
||||||
|
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<array{press_release_id: int, title: string, fetches: int, services: int}>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ use App\Scopes\PortalScope;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,73 +24,148 @@ class IndexStatusReport
|
||||||
/**
|
/**
|
||||||
* Ausdruck, der eine Meldung der Herkunft „api" oder „direct" zuordnet:
|
* Ausdruck, der eine Meldung der Herkunft „api" oder „direct" zuordnet:
|
||||||
* Marker ist ein Status-Log-Eintrag mit Quelle „api" (wie beim
|
* 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
|
* Rolle einer Meldung in der kanonischen Zuordnung (1c.8): Erstquelle
|
||||||
* jüngsten Prüfung. Meldungen ohne Prüfung erscheinen unter `unchecked`.
|
* 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<string> */
|
||||||
|
public const array ORIGINS = ['direct', 'api'];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
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<string, array<string, array<string, int>>> portal => origin => state|unchecked|total => count
|
* @return array<string, array<string, array<string, array<string, int>>>> portal => origin => role => state|unchecked|total => count
|
||||||
*/
|
*/
|
||||||
public function distribution(): array
|
public function distribution(): array
|
||||||
|
{
|
||||||
|
return Cache::remember('index-status.distribution', self::CACHE_SECONDS, fn (): array => $this->computeDistribution());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<string, array<string, array<string, int>>>>
|
||||||
|
*/
|
||||||
|
private function computeDistribution(): array
|
||||||
{
|
{
|
||||||
$rows = [];
|
$rows = [];
|
||||||
|
|
||||||
foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) {
|
foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) {
|
||||||
// Herkunft je Meldung erst in einer abgeleiteten Tabelle bestimmen,
|
// Herkunft und Rolle je Meldung erst in einer abgeleiteten Tabelle
|
||||||
// dann darüber gruppieren — MySQL (only_full_group_by) lehnt den
|
// bestimmen, dann darüber gruppieren — MySQL (only_full_group_by)
|
||||||
// korrelierten Ausdruck direkt im GROUP BY ab.
|
// lehnt den korrelierten Ausdruck direkt im GROUP BY ab.
|
||||||
$visible = DB::query()
|
$visible = DB::query()
|
||||||
->fromSub(
|
->fromSub(
|
||||||
PressRelease::query()
|
$this->apiJoin(PressRelease::query()
|
||||||
->withoutGlobalScope(PortalScope::class)
|
->withoutGlobalScope(PortalScope::class)
|
||||||
->visibleInArchive()
|
->visibleInArchive()
|
||||||
->forPortalValues([$portal->value, Portal::Both->value])
|
->forPortalValues([$portal->value, Portal::Both->value]))
|
||||||
->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin'),
|
->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'),
|
||||||
'r'
|
'r'
|
||||||
)
|
)
|
||||||
->selectRaw('origin, count(*) as total')
|
->selectRaw('origin, role, count(*) as total')
|
||||||
->groupBy('origin')
|
->groupBy('origin', 'role')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$checked = DB::query()
|
$checked = DB::query()
|
||||||
->fromSub(
|
->fromSub(
|
||||||
$this->latestChecks($portal)
|
$this->apiJoin($this->latestChecks($portal)
|
||||||
->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id')
|
->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id')
|
||||||
->whereNull('press_releases.deleted_at')
|
->whereNull('press_releases.deleted_at'))
|
||||||
->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin'),
|
->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin, '.self::ROLE_SQL.' as role'),
|
||||||
'c'
|
'c'
|
||||||
)
|
)
|
||||||
->selectRaw('origin, state, count(*) as total')
|
->selectRaw('origin, role, state, count(*) as total')
|
||||||
->groupBy('origin', 'state')
|
->groupBy('origin', 'role', 'state')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach (['direct', 'api'] as $origin) {
|
foreach (self::ORIGINS as $origin) {
|
||||||
$bucket = ['total' => 0, 'unchecked' => 0];
|
foreach (self::ROLES as $role) {
|
||||||
|
$bucket = ['total' => 0, 'unchecked' => 0];
|
||||||
|
|
||||||
foreach (IndexState::cases() as $state) {
|
foreach (IndexState::cases() as $state) {
|
||||||
$bucket[$state->value] = 0;
|
$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;
|
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<string, array{self: int, own_other: int, foreign: int, unknown: int, total: int, foreign_hosts: array<string, int>}>
|
||||||
|
*/
|
||||||
|
public function sourceSummary(IndexStatusChecker $checker): array
|
||||||
|
{
|
||||||
|
return Cache::remember('index-status.sources', self::CACHE_SECONDS, fn (): array => $this->computeSourceSummary($checker));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{self: int, own_other: int, foreign: int, unknown: int, total: int, foreign_hosts: array<string, int>}>
|
||||||
|
*/
|
||||||
|
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.
|
* Jüngste Prüfungen, neueste zuerst.
|
||||||
*
|
*
|
||||||
|
|
@ -98,7 +174,7 @@ class IndexStatusReport
|
||||||
public function latest(int $limit = 100): Collection
|
public function latest(int $limit = 100): Collection
|
||||||
{
|
{
|
||||||
return PressReleaseIndexCheck::query()
|
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('checked_at')
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
|
|
@ -112,6 +188,25 @@ class IndexStatusReport
|
||||||
return $value ? Carbon::parse((string) $value) : null;
|
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<TModel> $query
|
||||||
|
* @return Builder<TModel>
|
||||||
|
*/
|
||||||
|
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.
|
* Jeweils jüngste Prüfung je Meldung auf einem Portal.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,26 @@ class SearchConsoleClient
|
||||||
|
|
||||||
public function isConfigured(): bool
|
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
|
public function propertyFor(string $portal): ?string
|
||||||
|
|
@ -102,8 +119,8 @@ class SearchConsoleClient
|
||||||
*/
|
*/
|
||||||
private function credentials(): array
|
private function credentials(): array
|
||||||
{
|
{
|
||||||
$file = (string) config('search_console.credentials');
|
$file = $this->credentialsPath();
|
||||||
$json = is_readable($file) ? json_decode((string) file_get_contents($file), true) : null;
|
$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'])) {
|
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.');
|
throw new RuntimeException('Search Console: Schlüsseldatei des Service-Kontos fehlt oder ist unvollständig.');
|
||||||
|
|
|
||||||
|
|
@ -2095,3 +2095,30 @@ Muster" fehlt; gehört zum Rechnungs-Paket).
|
||||||
die Sitemap-Caches (1 h TTL). E9 ist ohne `.env`-Eintrag aktiv — davor
|
die Sitemap-Caches (1 h TTL). E9 ist ohne `.env`-Eintrag aktiv — davor
|
||||||
müssen `press-releases:hash-content` und `press-releases:assign-canonicals`
|
müssen `press-releases:hash-content` und `press-releases:assign-canonicals`
|
||||||
gelaufen sein, sonst passiert schlicht nichts.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@
|
||||||
</flux:navlist.item>
|
</flux:navlist.item>
|
||||||
<flux:navlist.item icon="magnifying-glass-circle" :href="route('admin.reports.index-status')"
|
<flux:navlist.item icon="magnifying-glass-circle" :href="route('admin.reports.index-status')"
|
||||||
:current="request()->routeIs('admin.reports.index-status')" wire:navigate>
|
:current="request()->routeIs('admin.reports.index-status')" wire:navigate>
|
||||||
{{ __('Indexierung') }}
|
{{ __('Indexierung & Abrufe') }}
|
||||||
</flux:navlist.item>
|
</flux:navlist.item>
|
||||||
@can('settings:manage')
|
@can('settings:manage')
|
||||||
<flux:navlist.item icon="heart" :href="route('admin.reports.system-health')"
|
<flux:navlist.item icon="heart" :href="route('admin.reports.system-health')"
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,43 @@
|
||||||
|
|
||||||
use App\Enums\IndexState;
|
use App\Enums\IndexState;
|
||||||
use App\Enums\Portal;
|
use App\Enums\Portal;
|
||||||
|
use App\Services\PressRelease\CrawlerHitReport;
|
||||||
|
use App\Services\SearchConsole\IndexStatusChecker;
|
||||||
use App\Services\SearchConsole\IndexStatusReport;
|
use App\Services\SearchConsole\IndexStatusReport;
|
||||||
use App\Services\SearchConsole\SearchConsoleClient;
|
use App\Services\SearchConsole\SearchConsoleClient;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Attributes\Title;
|
use Livewire\Attributes\Title;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin ▸ Reports ▸ Indexierung (Roadmap 1c.4).
|
* Admin ▸ Reports ▸ Indexierung und Abrufe (Roadmap 1c.0, 1c.4, 1c.9).
|
||||||
*
|
*
|
||||||
* Verteilung der jüngsten Search-Console-Befunde je Portal, getrennt nach
|
* Drei Fragen, drei Quellen:
|
||||||
* Direkteinreichung und Syndikation über die API. Diese Verteilung ist die
|
* - Ist die Meldung im Google-Index? Search Console, jüngster Befund je
|
||||||
* Grundlage für 1c.5 (Kundenansicht ja/nein, E8) — der Kunde sieht bis
|
* Meldung, getrennt nach Herkunft (direkt/API) und Rolle (Erstquelle/
|
||||||
* dahin nichts davon.
|
* Kopie mit rel=canonical) — die Verteilung ist die Grundlage für 1c.5.
|
||||||
|
* - Welche Adresse führt Google als Quelle? Quellenbefund über
|
||||||
|
* googleCanonical, nur für Erstquellen.
|
||||||
|
* - Welche Such- und KI-Dienste holen die Seiten? Crawler-Abruf-Protokoll.
|
||||||
|
*
|
||||||
|
* Der Kunde sieht davon nur, was GSC_CUSTOMER_VISIBLE freigibt (E8).
|
||||||
*/
|
*/
|
||||||
new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Component
|
new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class extends Component
|
||||||
{
|
{
|
||||||
|
#[Url]
|
||||||
|
public int $days = 30;
|
||||||
|
|
||||||
public function downloadLatest(): StreamedResponse
|
public function downloadLatest(): StreamedResponse
|
||||||
{
|
{
|
||||||
$rows = app(IndexStatusReport::class)->latest(2000);
|
$rows = app(IndexStatusReport::class)->latest(2000);
|
||||||
|
$checker = app(IndexStatusChecker::class);
|
||||||
$name = sprintf('indexierung-%s.csv', now()->format('Y-m-d'));
|
$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');
|
$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) {
|
foreach ($rows as $check) {
|
||||||
fputcsv($handle, [
|
fputcsv($handle, [
|
||||||
|
|
@ -38,6 +50,7 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp
|
||||||
$check->state->label(),
|
$check->state->label(),
|
||||||
$check->coverage_state ?? '',
|
$check->coverage_state ?? '',
|
||||||
$check->last_crawl_time?->format('d.m.Y') ?? '',
|
$check->last_crawl_time?->format('d.m.Y') ?? '',
|
||||||
|
$this->sourceLabel($checker->sourceVerdict($check)['kind']),
|
||||||
$check->google_canonical ?? '',
|
$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']);
|
}, $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
|
public function with(): array
|
||||||
{
|
{
|
||||||
$report = app(IndexStatusReport::class);
|
$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 [
|
return [
|
||||||
'configured' => app(SearchConsoleClient::class)->isConfigured(),
|
'configured' => app(SearchConsoleClient::class)->isConfigured(),
|
||||||
'customerVisible' => (bool) config('search_console.customer_visible'),
|
'customerVisible' => (bool) config('search_console.customer_visible'),
|
||||||
|
'canonicalActive' => (bool) config('canonical.enabled'),
|
||||||
'distribution' => $report->distribution(),
|
'distribution' => $report->distribution(),
|
||||||
|
'sources' => $report->sourceSummary($checker),
|
||||||
'latest' => $report->latest(100),
|
'latest' => $report->latest(100),
|
||||||
'lastCheckedAt' => $report->lastCheckedAt(),
|
'lastCheckedAt' => $report->lastCheckedAt(),
|
||||||
'states' => IndexState::cases(),
|
'states' => IndexState::cases(),
|
||||||
'portals' => [Portal::Presseecho, Portal::Businessportal24],
|
'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
|
||||||
<x-portal.page-header
|
<x-portal.page-header
|
||||||
:badge="__('Admin Backend')"
|
:badge="__('Admin Backend')"
|
||||||
:eyebrow="__('Administration · Reports')"
|
:eyebrow="__('Administration · Reports')"
|
||||||
:title="__('Indexierung')"
|
:title="__('Indexierung und Abrufe')"
|
||||||
:description="__('Stand der jüngsten Search-Console-Prüfung je Meldung, getrennt nach Direkteinreichung und Syndikation über die API. „Noch nicht gecrawlt“ und „gecrawlt, nicht indexiert“ sind verschiedene Befunde. Die Herkunft ist nur für Meldungen aus dem neuen System belegbar — der Altbestand ohne Status-Log zählt als direkt. Der Kunde sieht diese Daten erst, wenn die Anzeige freigegeben ist.')"
|
:description="__('Stand der jüngsten Search-Console-Prüfung je Meldung, getrennt nach Herkunft (direkt oder API) und Rolle (Erstquelle oder Kopie mit Verweis). „Noch nicht gecrawlt“ und „gecrawlt, nicht indexiert“ sind verschiedene Befunde; bei einer Kopie ist „ausgeschlossen“ der erwartete Befund. Die Herkunft ist nur für Meldungen aus dem neuen System belegbar — der Altbestand ohne Status-Log zählt als direkt. Darunter: welche Adresse Google als Quelle führt und welche Such- und KI-Dienste die Seiten holen.')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<section class="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
<x-portal.stat-card :variant="$configured ? 'primary' : 'warn'" :label="__('Search Console')" :value="$configured ? __('verbunden') : __('nicht konfiguriert')">
|
<x-portal.stat-card :variant="$configured ? 'primary' : 'warn'" :label="__('Search Console')" :value="$configured ? __('verbunden') : __('nicht konfiguriert')">
|
||||||
<x-slot:meta>{{ $configured ? __('täglicher Lauf 05:10 Uhr') : __('GSC_ENABLED und Schlüsseldatei setzen') }}</x-slot:meta>
|
<x-slot:meta>{{ $configured ? __('täglicher Lauf 05:10 Uhr') : __('GSC_ENABLED und Schlüsseldatei setzen') }}</x-slot:meta>
|
||||||
</x-portal.stat-card>
|
</x-portal.stat-card>
|
||||||
<x-portal.stat-card variant="muted" :label="__('Letzte Prüfung')" :value="$lastCheckedAt?->format('d.m.Y H:i') ?? '—'">
|
<x-portal.stat-card variant="muted" :label="__('Letzte Prüfung')" :value="$lastCheckedAt?->format('d.m.Y H:i') ?? '—'">
|
||||||
<x-slot:meta>{{ __('Momentaufnahmen, jede Antwort eine Zeile') }}</x-slot:meta>
|
<x-slot:meta>{{ __('Momentaufnahmen, jede Antwort eine Zeile') }}</x-slot:meta>
|
||||||
</x-portal.stat-card>
|
</x-portal.stat-card>
|
||||||
|
<x-portal.stat-card :variant="$canonicalActive ? 'primary' : 'muted'" :label="__('Kanonische Zuordnung')" :value="$canonicalActive ? __('aktiv') : __('aus')">
|
||||||
|
<x-slot:meta>{{ __('Kopien verweisen auf die Erstquelle (1c.8, E9)') }}</x-slot:meta>
|
||||||
|
</x-portal.stat-card>
|
||||||
<x-portal.stat-card :variant="$customerVisible ? 'warn' : 'muted'" :label="__('Kundenansicht')" :value="$customerVisible ? __('offen') : __('geschlossen')">
|
<x-portal.stat-card :variant="$customerVisible ? 'warn' : 'muted'" :label="__('Kundenansicht')" :value="$customerVisible ? __('offen') : __('geschlossen')">
|
||||||
<x-slot:meta>{{ __('Entscheidung E8 — config search_console.customer_visible') }}</x-slot:meta>
|
<x-slot:meta>{{ __('Entscheidung E8 — GSC_CUSTOMER_VISIBLE') }}</x-slot:meta>
|
||||||
</x-portal.stat-card>
|
</x-portal.stat-card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@foreach ($portals as $portal)
|
@foreach ($portals as $portal)
|
||||||
<article class="panel overflow-hidden">
|
<article class="panel overflow-hidden">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ $portal->label() }}</span>
|
<span class="section-eyebrow">{{ $portal->label() }} · {{ __('Google-Index') }}</span>
|
||||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('im Archiv sichtbare Meldungen, Stand der jeweils jüngsten Prüfung') }}</span>
|
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('im Archiv sichtbare Meldungen, Stand der jeweils jüngsten Prüfung') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<flux:table>
|
<flux:table>
|
||||||
<flux:table.columns>
|
<flux:table.columns>
|
||||||
<flux:table.column>{{ __('Herkunft') }}</flux:table.column>
|
<flux:table.column>{{ __('Herkunft · Rolle') }}</flux:table.column>
|
||||||
<flux:table.column>{{ __('Meldungen') }}</flux:table.column>
|
<flux:table.column>{{ __('Meldungen') }}</flux:table.column>
|
||||||
@foreach ($states as $state)
|
@foreach ($states as $state)
|
||||||
<flux:table.column>{{ ucfirst($state->label()) }}</flux:table.column>
|
<flux:table.column>{{ ucfirst($state->label()) }}</flux:table.column>
|
||||||
|
|
@ -98,37 +152,193 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp
|
||||||
<flux:table.column>{{ __('Ungeprüft') }}</flux:table.column>
|
<flux:table.column>{{ __('Ungeprüft') }}</flux:table.column>
|
||||||
</flux:table.columns>
|
</flux:table.columns>
|
||||||
<flux:table.rows>
|
<flux:table.rows>
|
||||||
@foreach (['direct' => __('Direkt eingereicht'), 'api' => __('Syndikation (API)')] as $origin => $label)
|
@foreach (['direct' => __('Direkt eingereicht'), 'api' => __('Syndikation (API)')] as $origin => $originLabel)
|
||||||
@php
|
@foreach (['source' => __('Erstquelle'), 'copy' => __('Kopie mit Verweis')] as $role => $roleLabel)
|
||||||
$row = $distribution[$portal->value][$origin] ?? [];
|
@php
|
||||||
$checkedTotal = max(0, ($row['total'] ?? 0) - ($row['unchecked'] ?? 0));
|
$row = $distribution[$portal->value][$origin][$role] ?? [];
|
||||||
@endphp
|
$checkedTotal = max(0, ($row['total'] ?? 0) - ($row['unchecked'] ?? 0));
|
||||||
<flux:table.row :key="$portal->value.'-'.$origin">
|
@endphp
|
||||||
<flux:table.cell>
|
@continue(($row['total'] ?? 0) === 0 && $role === 'copy')
|
||||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $label }}</span>
|
<flux:table.row :key="$portal->value.'-'.$origin.'-'.$role">
|
||||||
</flux:table.cell>
|
|
||||||
<flux:table.cell>
|
|
||||||
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($row['total'] ?? 0, 0, ',', '.') }}</span>
|
|
||||||
</flux:table.cell>
|
|
||||||
@foreach ($states as $state)
|
|
||||||
@php $count = (int) ($row[$state->value] ?? 0); @endphp
|
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($count, 0, ',', '.') }}</span>
|
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $originLabel }}</span>
|
||||||
@if ($checkedTotal > 0)
|
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ $roleLabel }}</span>
|
||||||
<span class="block text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ number_format($count / $checkedTotal * 100, 1, ',', '.') }} %</span>
|
|
||||||
@endif
|
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
@endforeach
|
<flux:table.cell>
|
||||||
<flux:table.cell>
|
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($row['total'] ?? 0, 0, ',', '.') }}</span>
|
||||||
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-3)]">{{ number_format($row['unchecked'] ?? 0, 0, ',', '.') }}</span>
|
</flux:table.cell>
|
||||||
</flux:table.cell>
|
@foreach ($states as $state)
|
||||||
</flux:table.row>
|
@php
|
||||||
|
$count = (int) ($row[$state->value] ?? 0);
|
||||||
|
$expected = $role === 'copy' ? $state === IndexState::Excluded : $state === IndexState::Indexed;
|
||||||
|
@endphp
|
||||||
|
<flux:table.cell>
|
||||||
|
<span @class(['text-[12.5px] tabular-nums', 'font-semibold text-[color:var(--color-ink)]' => $expected && $count > 0, 'text-[color:var(--color-ink-2)]' => ! ($expected && $count > 0)])>{{ number_format($count, 0, ',', '.') }}</span>
|
||||||
|
@if ($checkedTotal > 0)
|
||||||
|
<span class="block text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ number_format($count / $checkedTotal * 100, 1, ',', '.') }} %</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
@endforeach
|
||||||
|
<flux:table.cell>
|
||||||
|
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-3)]">{{ number_format($row['unchecked'] ?? 0, 0, ',', '.') }}</span>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforeach
|
||||||
@endforeach
|
@endforeach
|
||||||
</flux:table.rows>
|
</flux:table.rows>
|
||||||
</flux:table>
|
</flux:table>
|
||||||
|
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-4)]">
|
||||||
|
{{ __('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.') }}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
|
{{-- Quellenbefund (1c.9): welche Adresse Google als maßgeblich führt.
|
||||||
|
Nur Erstquellen — bei Kopien ist die Abweichung gewollt. --}}
|
||||||
|
<article class="panel overflow-hidden">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="section-eyebrow">{{ __('Quellenbefund') }}</span>
|
||||||
|
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">{{ __('Erstquellen mit Prüfung: welche Adresse Google als maßgeblich führt') }}</span>
|
||||||
|
</div>
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Geprüft') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Diese Adresse') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Eigene Fassung, anderes Portal') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Fremdes Portal') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Keine Angabe') }}</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
<flux:table.rows>
|
||||||
|
@foreach ($portals as $portal)
|
||||||
|
@php $row = $sources[$portal->value]; @endphp
|
||||||
|
<flux:table.row :key="'src-'.$portal->value">
|
||||||
|
<flux:table.cell><span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $portal->label() }}</span></flux:table.cell>
|
||||||
|
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($row['total'], 0, ',', '.') }}</span></flux:table.cell>
|
||||||
|
@foreach (['self', 'own_other', 'foreign', 'unknown'] as $kind)
|
||||||
|
<flux:table.cell>
|
||||||
|
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($row[$kind], 0, ',', '.') }}</span>
|
||||||
|
@if ($row['total'] > 0)
|
||||||
|
<span class="block text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ number_format($row[$kind] / $row['total'] * 100, 1, ',', '.') }} %</span>
|
||||||
|
@endif
|
||||||
|
@if ($kind === 'foreign' && $row['foreign_hosts'] !== [])
|
||||||
|
<span class="block text-[11px] text-[color:var(--color-ink-4)] mt-1">{{ collect($row['foreign_hosts'])->map(fn ($n, $host) => $host.' '.$n)->implode(' · ') }}</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
|
@endforeach
|
||||||
|
</flux:table.row>
|
||||||
|
@endforeach
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-4)]">
|
||||||
|
{{ __('„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.') }}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{{-- Crawler-Abruf-Protokoll (1c.0): Abrufe, keine Zitate. --}}
|
||||||
|
<article class="panel overflow-hidden">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="section-eyebrow">{{ __('Abrufe durch Such- und KI-Dienste') }}</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
@foreach ([7, 30, 90] as $range)
|
||||||
|
<button type="button" wire:click="$set('days', {{ $range }})"
|
||||||
|
@class([
|
||||||
|
'px-2 py-0.5 rounded-[4px] text-[11px] font-semibold transition-colors',
|
||||||
|
'bg-[color:var(--color-hub)] text-white' => $days === $range,
|
||||||
|
'text-[color:var(--color-ink-3)] hover:bg-[color:var(--color-bg-elev)]' => $days !== $range,
|
||||||
|
])>{{ __(':days Tage', ['days' => $range]) }}</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@if ($crawlerServices !== [])
|
||||||
|
<flux:button size="sm" variant="filled" icon="arrow-down-tray" wire:click="downloadCrawlers">{{ __('CSV') }}</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$dailyPeak = max(1, (int) collect($crawlerDaily)->max(fn ($d) => $d['search'] + $d['ai']));
|
||||||
|
@endphp
|
||||||
|
<div class="px-5 pt-4 pb-2">
|
||||||
|
<div class="flex items-baseline justify-between mb-2">
|
||||||
|
<span class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Abrufe je Tag, letzte 14 Tage') }}</span>
|
||||||
|
<span class="text-[11px] text-[color:var(--color-ink-4)]">
|
||||||
|
<span class="inline-block size-2 rounded-[2px] bg-[color:var(--color-hub)] align-middle"></span> {{ __('KI') }}
|
||||||
|
<span class="inline-block size-2 rounded-[2px] bg-[color:var(--color-ink-4)] align-middle ml-2"></span> {{ __('Suche') }}
|
||||||
|
· {{ __('Spitze :n', ['n' => number_format($dailyPeak, 0, ',', '.')]) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-1 h-16">
|
||||||
|
@foreach ($crawlerDaily as $day)
|
||||||
|
@php $total = $day['search'] + $day['ai']; @endphp
|
||||||
|
<div class="flex-1 flex flex-col justify-end h-full" title="{{ $day['date']->format('d.m.Y') }}: {{ $day['ai'] }} KI · {{ $day['search'] }} Suche">
|
||||||
|
<div class="bg-[color:var(--color-hub)] rounded-t-[2px]" style="height: {{ $total > 0 ? max(2, round($day['ai'] / $dailyPeak * 100)) : 0 }}%"></div>
|
||||||
|
<div class="bg-[color:var(--color-ink-4)]" style="height: {{ $total > 0 ? max($day['search'] > 0 ? 2 : 0, round($day['search'] / $dailyPeak * 100)) : 2 }}%"></div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mt-1 text-[10.5px] tabular-nums text-[color:var(--color-ink-4)]">
|
||||||
|
<span>{{ $crawlerDaily[0]['date']->format('d.m.') }}</span>
|
||||||
|
<span>{{ __('heute') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:table>
|
||||||
|
<flux:table.columns>
|
||||||
|
<flux:table.column>{{ __('Dienst') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Art') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Abrufe :days Tage', ['days' => $days]) }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Meldungen :days Tage', ['days' => $days]) }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Abrufe gesamt') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Letzter Abruf') }}</flux:table.column>
|
||||||
|
</flux:table.columns>
|
||||||
|
<flux:table.rows>
|
||||||
|
@forelse ($crawlerServices as $service)
|
||||||
|
<flux:table.row :key="'crawler-'.$service['service']">
|
||||||
|
<flux:table.cell><span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $service['label'] }}</span></flux:table.cell>
|
||||||
|
<flux:table.cell><span class="badge {{ $service['kind'] === 'search' ? 'muted' : 'hub' }}">{{ $service['kind'] === 'search' ? __('Suche') : __('KI') }}</span></flux:table.cell>
|
||||||
|
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($service['fetches'], 0, ',', '.') }}</span></flux:table.cell>
|
||||||
|
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($service['releases'], 0, ',', '.') }}</span></flux:table.cell>
|
||||||
|
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-3)]">{{ number_format($service['fetches_total'], 0, ',', '.') }}</span></flux:table.cell>
|
||||||
|
<flux:table.cell><span class="text-[12px] tabular-nums text-[color:var(--color-ink-3)]">{{ $service['last_at']->format('d.m.Y H:i') }}</span></flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@empty
|
||||||
|
<flux:table.row>
|
||||||
|
<flux:table.cell colspan="6">
|
||||||
|
<div class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||||
|
{{ __('Noch kein Abruf protokolliert.') }}
|
||||||
|
@if ($crawlersSince)
|
||||||
|
{{ __('Erfasst seit :date.', ['date' => $crawlersSince->format('d.m.Y')]) }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</flux:table.cell>
|
||||||
|
</flux:table.row>
|
||||||
|
@endforelse
|
||||||
|
</flux:table.rows>
|
||||||
|
</flux:table>
|
||||||
|
|
||||||
|
@if ($crawlerTop !== [])
|
||||||
|
<div class="border-t border-[color:var(--color-bg-rule)]">
|
||||||
|
<div class="px-5 pt-4 pb-2 text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Am häufigsten abgerufen, :days Tage', ['days' => $days]) }}</div>
|
||||||
|
<ul class="px-5 pb-4 m-0 list-none space-y-1">
|
||||||
|
@foreach ($crawlerTop as $top)
|
||||||
|
<li class="flex flex-wrap items-baseline justify-between gap-x-3 text-[12.5px]">
|
||||||
|
<a href="{{ route('admin.press-releases.show', $top['press_release_id']) }}" class="text-[color:var(--color-ink)] hover:underline truncate max-w-[70%]">{{ $top['title'] }}</a>
|
||||||
|
<span class="tabular-nums text-[color:var(--color-ink-3)]">{{ trans_choice(':n Abruf|:n Abrufe', $top['fetches'], ['n' => $top['fetches']]) }} · {{ trans_choice(':n Dienst|:n Dienste', $top['services'], ['n' => $top['services']]) }}</span>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-4)]">
|
||||||
|
{{ __('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
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="panel overflow-hidden">
|
<article class="panel overflow-hidden">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ __('Jüngste Prüfungen') }}</span>
|
<span class="section-eyebrow">{{ __('Jüngste Prüfungen') }}</span>
|
||||||
|
|
@ -142,17 +352,19 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp
|
||||||
<flux:table.column>{{ __('Meldung') }}</flux:table.column>
|
<flux:table.column>{{ __('Meldung') }}</flux:table.column>
|
||||||
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
||||||
<flux:table.column>{{ __('Befund') }}</flux:table.column>
|
<flux:table.column>{{ __('Befund') }}</flux:table.column>
|
||||||
|
<flux:table.column>{{ __('Google führt') }}</flux:table.column>
|
||||||
<flux:table.column>{{ __('Zuletzt gecrawlt') }}</flux:table.column>
|
<flux:table.column>{{ __('Zuletzt gecrawlt') }}</flux:table.column>
|
||||||
</flux:table.columns>
|
</flux:table.columns>
|
||||||
<flux:table.rows>
|
<flux:table.rows>
|
||||||
@forelse ($latest as $check)
|
@forelse ($latest as $check)
|
||||||
|
@php $source = $checker->sourceVerdict($check); @endphp
|
||||||
<flux:table.row :key="$check->id">
|
<flux:table.row :key="$check->id">
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<span class="text-[12px] tabular-nums text-[color:var(--color-ink-3)]">{{ $check->checked_at->format('d.m.Y H:i') }}</span>
|
<span class="text-[12px] tabular-nums text-[color:var(--color-ink-3)]">{{ $check->checked_at->format('d.m.Y H:i') }}</span>
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<a href="{{ route('admin.press-releases.show', $check->press_release_id) }}" class="text-[13px] font-semibold text-[color:var(--color-ink)] hover:underline">{{ $check->pressRelease?->title ?? '#'.$check->press_release_id }}</a>
|
<a href="{{ route('admin.press-releases.show', $check->press_release_id) }}" class="text-[13px] font-semibold text-[color:var(--color-ink)] hover:underline">{{ $check->pressRelease?->title ?? '#'.$check->press_release_id }}</a>
|
||||||
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ __('veröffentlicht :date', ['date' => $check->pressRelease?->published_at?->format('d.m.Y') ?? '—']) }}</span>
|
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ __('veröffentlicht :date', ['date' => $check->pressRelease?->published_at?->format('d.m.Y') ?? '—']) }}@if ($check->pressRelease?->canonical_press_release_id) · {{ __('Kopie mit Verweis') }}@endif</span>
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $check->portal->label() }}</span>
|
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $check->portal->label() }}</span>
|
||||||
|
|
@ -163,13 +375,19 @@ new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Comp
|
||||||
<span class="block text-[11px] text-[color:var(--color-ink-4)] mt-1">{{ $check->coverage_state }}</span>
|
<span class="block text-[11px] text-[color:var(--color-ink-4)] mt-1">{{ $check->coverage_state }}</span>
|
||||||
@endif
|
@endif
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
|
<flux:table.cell>
|
||||||
|
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $this->sourceLabel($source['kind']) }}</span>
|
||||||
|
@if (in_array($source['kind'], ['own_other', 'foreign'], true))
|
||||||
|
<span class="block text-[11px] text-[color:var(--color-ink-4)] break-all">{{ $source['host'] }}</span>
|
||||||
|
@endif
|
||||||
|
</flux:table.cell>
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $check->last_crawl_time?->format('d.m.Y') ?? '—' }}</span>
|
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $check->last_crawl_time?->format('d.m.Y') ?? '—' }}</span>
|
||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
</flux:table.row>
|
</flux:table.row>
|
||||||
@empty
|
@empty
|
||||||
<flux:table.row>
|
<flux:table.row>
|
||||||
<flux:table.cell colspan="5">
|
<flux:table.cell colspan="6">
|
||||||
<div class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
|
<div class="px-4 py-10 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||||
{{ __('Noch keine Prüfung gespeichert. Erster Lauf: php artisan press-releases:check-index-status') }}
|
{{ __('Noch keine Prüfung gespeichert. Erster Lauf: php artisan press-releases:check-index-status') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Ä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::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()]);
|
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();
|
$distribution = app(IndexStatusReport::class)->distribution();
|
||||||
|
|
||||||
expect($distribution['presseecho']['direct']['total'])->toBe(2)
|
expect($distribution['presseecho']['direct']['source']['total'])->toBe(2)
|
||||||
->and($distribution['presseecho']['direct']['indexed'])->toBe(1)
|
->and($distribution['presseecho']['direct']['source']['indexed'])->toBe(1)
|
||||||
->and($distribution['presseecho']['direct']['not_crawled'])->toBe(0)
|
->and($distribution['presseecho']['direct']['source']['not_crawled'])->toBe(0)
|
||||||
->and($distribution['presseecho']['direct']['unchecked'])->toBe(1)
|
->and($distribution['presseecho']['direct']['source']['unchecked'])->toBe(1)
|
||||||
->and($distribution['presseecho']['api']['total'])->toBe(1)
|
->and($distribution['presseecho']['direct']['copy']['total'])->toBe(1)
|
||||||
->and($distribution['presseecho']['api']['crawled_not_indexed'])->toBe(1)
|
->and($distribution['presseecho']['direct']['copy']['excluded'])->toBe(1)
|
||||||
->and($distribution['businessportal24']['direct']['total'])->toBe(0);
|
->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 = User::factory()->create(['is_active' => true]);
|
||||||
$admin->assignRole('admin');
|
$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('Direkt eingereichte Meldung')
|
||||||
->assertSeeText('Syndizierte Meldung')
|
->assertSeeText('Syndizierte Meldung')
|
||||||
->assertSeeText('gecrawlt, nicht indexiert')
|
->assertSeeText('gecrawlt, nicht indexiert')
|
||||||
|
->assertSeeText('Kopie mit Verweis')
|
||||||
|
->assertSeeText('Quellenbefund')
|
||||||
|
->assertSeeText('Abrufe durch Such- und KI-Dienste')
|
||||||
->assertSeeText('Kundenansicht');
|
->assertSeeText('Kundenansicht');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\PressRelease;
|
use App\Models\PressRelease;
|
||||||
use App\Models\PressReleaseCrawlerHit;
|
use App\Models\PressReleaseCrawlerHit;
|
||||||
|
use App\Services\PressRelease\CrawlerHitReport;
|
||||||
use App\Support\CrawlerIdentifier;
|
use App\Support\CrawlerIdentifier;
|
||||||
use Tests\TestCase;
|
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);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue