admin: Indexierungsstatus aus der Search Console, Kundenansicht vorbereitet und zu (Anhang 1: 1c.4, E8)

press-releases:check-index-status fragt täglich je Portal ein Kontingent
veröffentlichter Meldungen über die URL Inspection API ab, nach Alter
gestaffelt, nie geprüfte zuerst. Jede Antwort wird als Momentaufnahme in
press_release_index_checks gespeichert; der reduzierte Befund (IndexState)
trennt „noch nicht gecrawlt" von „gecrawlt, nicht indexiert".

Client authentifiziert sich als Service-Konto über ein selbst signiertes
JWT — kein Google-SDK. Admin ▸ Reports ▸ Indexierung zeigt die Verteilung
je Portal getrennt nach Direkteinreichung und Syndikation (Status-Log
source=api; Altbestand ohne Log zählt als direkt).

Das Kundenpanel heißt jetzt „Auslieferung", zeigt Sitemap-Datum und die
Abrufe durch Such- und KI-Dienste mit dem Satz „Ein Abruf ist kein Zitat";
der Google-Index-Block hängt an GSC_CUSTOMER_VISIBLE (false).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 14:21:51 +00:00
parent 951b6c47fa
commit 6b8728242f
17 changed files with 1324 additions and 30 deletions

View file

@ -0,0 +1,190 @@
<?php
use App\Console\Commands\CheckIndexStatus;
use App\Enums\IndexState;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseIndexCheck;
use App\Models\PressReleaseStatusLog;
use App\Models\User;
use App\Services\SearchConsole\IndexStatusChecker;
use App\Services\SearchConsole\IndexStatusReport;
use App\Services\SearchConsole\SearchConsoleClient;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
/**
* Indexierungsstatus aus der Search Console (Roadmap 1c.4): Befunde
* unterscheiden „noch nicht gecrawlt" von „gecrawlt, nicht indexiert",
* jede Antwort ist eine Momentaufnahme, die Auswahl folgt dem Alter, und
* der Kunde sieht nichts, solange E8 die Anzeige zu hält.
*/
function fakeServiceAccount(): string
{
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($key, $pem);
$file = tempnam(sys_get_temp_dir(), 'gsc');
file_put_contents($file, json_encode([
'client_email' => 'indexer@example.iam.gserviceaccount.com',
'private_key' => $pem,
'token_uri' => 'https://oauth2.googleapis.com/token',
]));
config()->set('search_console.enabled', true);
config()->set('search_console.credentials', $file);
return $file;
}
function publishedOn(Portal $portal, int $daysAgo, array $attributes = []): PressRelease
{
$company = Company::factory()->create(['portal' => $portal]);
return PressRelease::factory()
->published()
->forPortal($portal)
->for($company)
->create(array_merge(['published_at' => now()->subDays($daysAgo)], $attributes));
}
test('the index state distinguishes not crawled from crawled but not indexed', function (array $indexStatus, IndexState $expected) {
expect(IndexState::fromIndexStatus($indexStatus))->toBe($expected);
})->with([
'indexed' => [['verdict' => 'PASS', 'coverageState' => 'Submitted and indexed', 'lastCrawlTime' => '2026-09-10T08:00:00Z'], IndexState::Indexed],
'crawled, not indexed' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Crawled - currently not indexed', 'lastCrawlTime' => '2026-09-10T08:00:00Z', 'pageFetchState' => 'SUCCESSFUL'], IndexState::CrawledNotIndexed],
'discovered, not crawled' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Discovered - currently not indexed'], IndexState::NotCrawled],
'unknown to google' => [['verdict' => 'NEUTRAL', 'coverageState' => 'URL is unknown to Google'], IndexState::NotCrawled],
'duplicate canonical' => [['verdict' => 'NEUTRAL', 'coverageState' => 'Duplicate, Google chose different canonical than user', 'lastCrawlTime' => '2026-09-10T08:00:00Z'], IndexState::Excluded],
'blocked by robots' => [['verdict' => 'FAIL', 'coverageState' => 'Blocked by robots.txt', 'robotsTxtState' => 'DISALLOWED'], IndexState::Excluded],
'empty' => [[], IndexState::Unknown],
]);
test('candidates are picked by age band, never-checked first, within the budget', function () {
/** @var TestCase $this */
config()->set('search_console.recheck_days', [14 => 3, 90 => 14, PHP_INT_MAX => 180]);
$fresh = publishedOn(Portal::Presseecho, 2);
$freshChecked = publishedOn(Portal::Presseecho, 5);
PressReleaseIndexCheck::factory()->create(['press_release_id' => $freshChecked->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]);
$freshDue = publishedOn(Portal::Presseecho, 6);
PressReleaseIndexCheck::factory()->create(['press_release_id' => $freshDue->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDays(4)]);
$middle = publishedOn(Portal::Presseecho, 40);
$old = publishedOn(Portal::Presseecho, 400);
$otherPortal = publishedOn(Portal::Businessportal24, 1);
$both = publishedOn(Portal::Both, 3);
$ids = app(IndexStatusChecker::class)->candidates(Portal::Presseecho, 10)->pluck('id')->all();
expect($ids)->toContain($fresh->id, $freshDue->id, $middle->id, $old->id, $both->id)
->and($ids)->not->toContain($freshChecked->id, $otherPortal->id)
// Jüngste zuerst, innerhalb der Stufe nie geprüfte vor geprüften.
->and(array_slice($ids, 0, 3))->toBe([$fresh->id, $both->id, $freshDue->id]);
expect(app(IndexStatusChecker::class)->candidates(Portal::Presseecho, 2)->pluck('id')->all())->toBe([$fresh->id, $both->id]);
});
test('the command inspects due releases via the API and stores each answer as a snapshot', function () {
/** @var TestCase $this */
$file = fakeServiceAccount();
config()->set('search_console.properties.presseecho', 'sc-domain:presseecho.test');
config()->set('search_console.properties.businessportal24', null);
$release = publishedOn(Portal::Presseecho, 2, ['slug' => 'index-pruefung-pm']);
Http::fake([
'https://oauth2.googleapis.com/token' => Http::response(['access_token' => 'token-123', 'expires_in' => 3600]),
SearchConsoleClient::INSPECT_URL => Http::response(['inspectionResult' => [
'inspectionResultLink' => 'https://search.google.com/search-console/inspect?resource_id=sc-domain:presseecho.test',
'indexStatusResult' => [
'verdict' => 'NEUTRAL',
'coverageState' => 'Crawled - currently not indexed',
'robotsTxtState' => 'ALLOWED',
'indexingState' => 'INDEXING_ALLOWED',
'lastCrawlTime' => '2026-09-15T06:30:00Z',
'pageFetchState' => 'SUCCESSFUL',
'googleCanonical' => 'https://presseecho.test/de/press-release/index-pruefung-pm',
],
]]),
]);
$this->artisan(CheckIndexStatus::class, ['--budget' => 5])
->expectsOutputToContain('1 Prüfungen gespeichert, 0 fehlgeschlagen.')
->assertSuccessful();
Http::assertSent(fn ($request) => $request->url() === SearchConsoleClient::INSPECT_URL
&& $request['inspectionUrl'] === 'https://presseecho.test/de/press-release/index-pruefung-pm'
&& $request['siteUrl'] === 'sc-domain:presseecho.test'
&& $request->hasHeader('Authorization', 'Bearer token-123'));
$check = PressReleaseIndexCheck::query()->sole();
expect($check->press_release_id)->toBe($release->id)
->and($check->portal)->toBe(Portal::Presseecho)
->and($check->state)->toBe(IndexState::CrawledNotIndexed)
->and($check->coverage_state)->toBe('Crawled - currently not indexed')
->and($check->last_crawl_time?->toDateString())->toBe('2026-09-15')
->and($check->raw['verdict'])->toBe('NEUTRAL');
// Zweiter Lauf: die Meldung ist gerade geprüft, also nicht fällig — keine weitere Abfrage.
$this->artisan(CheckIndexStatus::class, ['--budget' => 5])->assertSuccessful();
expect(PressReleaseIndexCheck::query()->count())->toBe(1);
@unlink($file);
});
test('the command does nothing without configuration', function () {
/** @var TestCase $this */
config()->set('search_console.enabled', false);
publishedOn(Portal::Presseecho, 2);
Http::fake();
$this->artisan(CheckIndexStatus::class)
->expectsOutputToContain('nicht konfiguriert')
->assertSuccessful();
Http::assertNothingSent();
expect(PressReleaseIndexCheck::query()->count())->toBe(0);
});
test('the admin report splits the distribution by origin and lists the latest checks', function () {
/** @var TestCase $this */
test()->seed(RolesAndPermissionsSeeder::class);
$direct = publishedOn(Portal::Presseecho, 10, ['title' => 'Direkt eingereichte Meldung']);
$api = publishedOn(Portal::Presseecho, 12, ['title' => 'Syndizierte Meldung']);
PressReleaseStatusLog::query()->create(['press_release_id' => $api->id, 'from_status' => 'draft', 'to_status' => 'review', 'source' => 'api']);
$unchecked = publishedOn(Portal::Presseecho, 20);
// Ältere Momentaufnahme zählt nicht — nur die jüngste je Meldung.
PressReleaseIndexCheck::factory()->withState(IndexState::NotCrawled)->create(['press_release_id' => $direct->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDays(5)]);
PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $direct->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]);
PressReleaseIndexCheck::factory()->withState(IndexState::CrawledNotIndexed)->create(['press_release_id' => $api->id, 'portal' => Portal::Presseecho, 'checked_at' => now()->subDay()]);
$distribution = app(IndexStatusReport::class)->distribution();
expect($distribution['presseecho']['direct']['total'])->toBe(2)
->and($distribution['presseecho']['direct']['indexed'])->toBe(1)
->and($distribution['presseecho']['direct']['not_crawled'])->toBe(0)
->and($distribution['presseecho']['direct']['unchecked'])->toBe(1)
->and($distribution['presseecho']['api']['total'])->toBe(1)
->and($distribution['presseecho']['api']['crawled_not_indexed'])->toBe(1)
->and($distribution['businessportal24']['direct']['total'])->toBe(0);
$admin = User::factory()->create(['is_active' => true]);
$admin->assignRole('admin');
$this->actingAs($admin)
->get(route('admin.reports.index-status'))
->assertSuccessful()
->assertSeeText('Indexierung')
->assertSeeText('Direkt eingereicht')
->assertSeeText('Syndikation (API)')
->assertSeeText('Direkt eingereichte Meldung')
->assertSeeText('Syndizierte Meldung')
->assertSeeText('gecrawlt, nicht indexiert')
->assertSeeText('Kundenansicht');
});

View file

@ -1,10 +1,13 @@
<?php
use App\Enums\IndexState;
use App\Enums\Portal;
use App\Enums\PressReleaseEventType;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseCrawlerHit;
use App\Models\PressReleaseEvent;
use App\Models\PressReleaseIndexCheck;
use App\Models\User;
use App\Services\PressRelease\VisibilityStats;
use Livewire\Volt\Volt as LivewireVolt;
@ -102,7 +105,7 @@ test('the daily series is gapless, marks days before measurement as unmeasured a
expect(app(VisibilityStats::class)->dailyViews($release, 400))->toHaveCount(5);
});
test('the release summary reports sitemap presence and a search link only for public releases', function () {
test('the release summary reports sitemap presence only for public releases and lists crawler fetches', function () {
/** @var TestCase $this */
[, $company] = claimant();
$public = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDay(), 'hits' => 9]);
@ -111,23 +114,27 @@ test('the release summary reports sitemap presence and a search link only for pu
PressReleaseEvent::query()->create(['press_release_id' => $public->id, 'type' => PressReleaseEventType::Share, 'channel' => 'linkedin']);
viewsOn($public, 2, daysAgo: 1);
PressReleaseCrawlerHit::factory()->service('google', 'Mozilla/5.0 (compatible; Googlebot/2.1)')->count(2)->create(['press_release_id' => $public->id]);
PressReleaseCrawlerHit::factory()->count(1)->create(['press_release_id' => $public->id]);
$stats = app(VisibilityStats::class);
$summary = $stats->releaseSummary($public);
expect($summary['hits'])->toBe(9)
->and($summary['shares_total'])->toBe(1)
->and($summary['in_sitemap_since']->isSameDay($public->published_at))->toBeTrue()
->and($summary['search_url'])->toContain('site%3A')
->and($summary['search_url'])->toContain($public->slug)
->and($summary)->not->toHaveKey('search_url')
->and(collect($summary['crawlers'])->pluck('fetches', 'service')->sortKeys()->all())->toBe(['google' => 2, 'openai' => 1])
->and(collect($summary['crawlers'])->firstWhere('service', 'google')['label'])->toBe('Google Suche')
->and($summary['first_view_at'])->not->toBeNull();
$draftSummary = $stats->releaseSummary($draft);
expect($draftSummary['in_sitemap_since'])->toBeNull()
->and($draftSummary['search_url'])->toBeNull();
->and($draftSummary['crawlers'])->toBe([]);
});
test('the release page shows the visibility panel with daily bars and the search link', function () {
test('the release page shows the delivery panel with daily bars, sitemap date and crawler fetches, but no search link', function () {
/** @var TestCase $this */
[$customer, $company] = claimant();
$partner = User::factory()->create();
@ -135,24 +142,49 @@ test('the release page shows the visibility panel with daily bars and the search
'company_id' => $company->id, 'user_id' => $partner->id, 'hits' => 777, 'published_at' => now()->subDays(5),
]);
viewsOn($release, 6, daysAgo: 2);
PressReleaseCrawlerHit::factory()->service('anthropic', 'ClaudeBot/1.0')->create(['press_release_id' => $release->id]);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertSeeText('Sichtbarkeit')
->assertSeeText('Auslieferung')
->assertSeeText('777')
->assertSeeText('Aufrufe 30 Tage')
->assertSeeText('Bei Google prüfen')
->assertSeeText('In der Sitemap für Suchmaschinen seit '.now()->subDays(5)->format('d.m.Y'))
->assertDontSeeText('Bei Google prüfen')
->assertDontSee('google.com/search', false)
->assertSeeText('Gelistet seit '.now()->subDays(5)->format('d.m.Y'))
->assertSeeText('Anthropic / Claude')
->assertSeeText('1 Abruf')
->assertSeeText('Ein Abruf belegt, dass der Crawler des Dienstes die Seite geholt hat')
// Indexierungsstatus bleibt zu (E8), solange die Kundenansicht nicht freigegeben ist.
->assertDontSeeText('Google-Index')
->assertSee('<svg', false)
->set('viewRange', 90)
->assertSeeText('Aufrufe 90 Tage');
});
test('the index status appears in the delivery panel only once the customer view is released', function () {
/** @var TestCase $this */
[, $company] = claimant();
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'published_at' => now()->subDays(5)]);
PressReleaseIndexCheck::factory()->withState(IndexState::CrawledNotIndexed)->create(['press_release_id' => $release->id, 'portal' => Portal::Businessportal24, 'checked_at' => now()->subDay()]);
PressReleaseIndexCheck::factory()->withState(IndexState::Indexed)->create(['press_release_id' => $release->id, 'portal' => Portal::Businessportal24, 'checked_at' => now()->subDays(9)]);
config()->set('search_console.customer_visible', false);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertDontSeeText('Google-Index');
config()->set('search_console.customer_visible', true);
LivewireVolt::test('customer.press-releases.show', ['id' => $release->id])
->assertSeeText('Google-Index')
->assertSeeText('Businessportal24: gecrawlt, nicht indexiert')
->assertDontSeeText('Businessportal24: indexiert');
});
test('a draft shows no visibility panel', function () {
/** @var TestCase $this */
[$customer, $company] = claimant();
$draft = PressRelease::factory()->forPortal(Portal::Businessportal24)->create(['company_id' => $company->id, 'user_id' => $customer->id, 'status' => 'draft', 'hits' => 0, 'published_at' => null]);
LivewireVolt::test('customer.press-releases.show', ['id' => $draft->id])
->assertDontSeeText('Bei Google prüfen')
->assertDontSeeText('Auslieferung')
->assertSeeText('noch nicht veröffentlicht');
});