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:
Kevin Adametz 2026-09-18 12:53:22 +00:00
parent 1c421c0377
commit c26bdff681
8 changed files with 609 additions and 87 deletions

View file

@ -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');
});

View file

@ -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);
});