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:
parent
951b6c47fa
commit
6b8728242f
17 changed files with 1324 additions and 30 deletions
18
.env.example
18
.env.example
|
|
@ -244,3 +244,21 @@ LEGAL_CONTACT_EMAIL=
|
|||
REPORT_EMAIL_PRESSEKONTO=
|
||||
REPORT_EMAIL_PRESSEECHO=
|
||||
REPORT_EMAIL_BUSINESSPORTAL=
|
||||
|
||||
# Crawler-Abruf-Protokoll (Roadmap 1c.0): Tag, ab dem Abrufe durch Such- und
|
||||
# KI-Dienste je Meldung erfasst werden — auf den Deploy-Tag setzen.
|
||||
CRAWLER_HITS_RECORDED_SINCE=2026-09-18
|
||||
|
||||
# Google Search Console, URL Inspection API (Roadmap 1c.4). Läuft nur mit
|
||||
# Service-Konto (JSON-Schlüssel außerhalb von public/), das in beiden
|
||||
# Properties als Nutzer eingetragen ist. Kundenansicht bleibt zu (E8).
|
||||
GSC_ENABLED=false
|
||||
GSC_SERVICE_ACCOUNT_FILE=
|
||||
GSC_PROPERTY_PRESSEECHO=sc-domain:presseecho.de
|
||||
GSC_PROPERTY_BUSINESSPORTAL24=sc-domain:businessportal24.de
|
||||
GSC_DAILY_BUDGET=300
|
||||
GSC_CUSTOMER_VISIBLE=false
|
||||
|
||||
# Transparenzseite „kostenlos veröffentlichen": Prüfdatum der Portalliste
|
||||
# (Einträge in config/transparency.php). Leer = Abschnitt erscheint nicht.
|
||||
TRANSPARENCY_FREE_PORTALS_CHECKED_AT=
|
||||
|
|
|
|||
91
app/Console/Commands/CheckIndexStatus.php
Normal file
91
app/Console/Commands/CheckIndexStatus.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Services\SearchConsole\IndexStatusChecker;
|
||||
use App\Services\SearchConsole\SearchConsoleClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Indexierungsstatus über die Search Console abfragen (Roadmap 1c.4).
|
||||
*
|
||||
* Je Portal ein Tageskontingent, nach Alter gestaffelt auf die jüngsten
|
||||
* Meldungen verteilt (config search_console.recheck_days). Jede Antwort
|
||||
* wird als Momentaufnahme gespeichert; der Lauf bewertet nichts und zeigt
|
||||
* dem Kunden nichts — das entscheidet die Verteilung (1c.5, E8).
|
||||
*/
|
||||
class CheckIndexStatus extends Command
|
||||
{
|
||||
protected $signature = 'press-releases:check-index-status
|
||||
{--portal= : Nur dieses Portal (presseecho|businessportal24)}
|
||||
{--budget= : Abfragen je Portal (Standard: config search_console.daily_budget)}
|
||||
{--dry-run : Nur auflisten, was geprüft würde}';
|
||||
|
||||
protected $description = 'Fragt den Indexierungsstatus veröffentlichter Meldungen bei der Google Search Console ab.';
|
||||
|
||||
public function handle(SearchConsoleClient $client, IndexStatusChecker $checker): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if (! $dryRun && ! $client->isConfigured()) {
|
||||
$this->warn('Search Console nicht konfiguriert (GSC_ENABLED, GSC_SERVICE_ACCOUNT_FILE) — nichts zu tun.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$budget = max(1, (int) ($this->option('budget') ?: config('search_console.daily_budget', 300)));
|
||||
$portals = $checker->portals();
|
||||
|
||||
if ($this->option('portal')) {
|
||||
$wanted = Portal::tryFrom((string) $this->option('portal'));
|
||||
$portals = array_values(array_filter($portals, fn (Portal $portal): bool => $portal === $wanted));
|
||||
}
|
||||
|
||||
if ($portals === []) {
|
||||
$this->warn('Kein Portal mit eingetragener Property.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$checked = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($portals as $portal) {
|
||||
$candidates = $checker->candidates($portal, $budget);
|
||||
$this->info(sprintf('%s: %d fällige Meldungen (Kontingent %d).', $portal->label(), $candidates->count(), $budget));
|
||||
|
||||
foreach ($candidates as $release) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(' [%d] %s (veröffentlicht %s)', $release->id, $release->slug, $release->published_at?->format('d.m.Y') ?? '-'));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$check = $checker->check($release, $portal);
|
||||
$checked++;
|
||||
$this->line(sprintf(' [%d] %s → %s', $release->id, $release->slug, $check->state->label()));
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
report($e);
|
||||
$this->error(sprintf(' [%d] %s: %s', $release->id, $release->slug, $e->getMessage()));
|
||||
|
||||
// Quote oder Auth kaputt: der Rest des Laufs würde nur
|
||||
// weitere Fehler produzieren.
|
||||
if ($failed >= 5 && $checked === 0) {
|
||||
$this->error('Abbruch nach fünf Fehlern ohne Treffer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info(sprintf('%d Prüfungen gespeichert, %d fehlgeschlagen.', $checked, $failed));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
62
app/Enums/IndexState.php
Normal file
62
app/Enums/IndexState.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* Befund einer URL-Inspektion, auf das reduziert, was in jeder Anzeige
|
||||
* unterschieden werden muss (Anhang 17.09.2026, 1c.4): „noch nicht
|
||||
* gecrawlt" und „gecrawlt, aber nicht indexiert" sind verschiedene Befunde.
|
||||
*/
|
||||
enum IndexState: string
|
||||
{
|
||||
case Indexed = 'indexed';
|
||||
case CrawledNotIndexed = 'crawled_not_indexed';
|
||||
case NotCrawled = 'not_crawled';
|
||||
case Excluded = 'excluded';
|
||||
case Unknown = 'unknown';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $indexStatus `inspectionResult.indexStatusResult` der URL Inspection API
|
||||
*/
|
||||
public static function fromIndexStatus(array $indexStatus): self
|
||||
{
|
||||
$verdict = strtoupper((string) ($indexStatus['verdict'] ?? ''));
|
||||
$coverage = mb_strtolower((string) ($indexStatus['coverageState'] ?? ''));
|
||||
$indexing = strtoupper((string) ($indexStatus['indexingState'] ?? ''));
|
||||
$robots = strtoupper((string) ($indexStatus['robotsTxtState'] ?? ''));
|
||||
$fetch = strtoupper((string) ($indexStatus['pageFetchState'] ?? ''));
|
||||
$crawled = ! empty($indexStatus['lastCrawlTime']);
|
||||
|
||||
if ($verdict === 'PASS' || (str_contains($coverage, 'indexed') && ! str_contains($coverage, 'not indexed'))) {
|
||||
return self::Indexed;
|
||||
}
|
||||
|
||||
if ($robots === 'DISALLOWED'
|
||||
|| in_array($indexing, ['BLOCKED_BY_META_TAG', 'BLOCKED_BY_HTTP_HEADER', 'BLOCKED_BY_ROBOTS_TXT'], true)
|
||||
|| ($fetch !== '' && $fetch !== 'SUCCESSFUL' && $crawled)
|
||||
|| str_contains($coverage, 'duplicate')
|
||||
|| str_contains($coverage, 'canonical')
|
||||
|| str_contains($coverage, 'redirect')
|
||||
|| str_contains($coverage, 'noindex')
|
||||
|| str_contains($coverage, 'blocked')) {
|
||||
return self::Excluded;
|
||||
}
|
||||
|
||||
if (! $crawled) {
|
||||
return $verdict === '' && $coverage === '' ? self::Unknown : self::NotCrawled;
|
||||
}
|
||||
|
||||
return self::CrawledNotIndexed;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Indexed => 'indexiert',
|
||||
self::CrawledNotIndexed => 'gecrawlt, nicht indexiert',
|
||||
self::NotCrawled => 'noch nicht gecrawlt',
|
||||
self::Excluded => 'ausgeschlossen',
|
||||
self::Unknown => 'unbekannt',
|
||||
};
|
||||
}
|
||||
}
|
||||
62
app/Models/PressReleaseIndexCheck.php
Normal file
62
app/Models/PressReleaseIndexCheck.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\IndexState;
|
||||
use App\Enums\Portal;
|
||||
use Database\Factories\PressReleaseIndexCheckFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Eine Momentaufnahme des Indexierungsstatus einer Meldung auf einem Portal
|
||||
* (Roadmap 1c.4). Append-only — jeder Prüflauf schreibt eine neue Zeile.
|
||||
*/
|
||||
class PressReleaseIndexCheck extends Model
|
||||
{
|
||||
/** @use HasFactory<PressReleaseIndexCheckFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const ?string CREATED_AT = null;
|
||||
|
||||
public const ?string UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'press_release_id',
|
||||
'portal',
|
||||
'url',
|
||||
'state',
|
||||
'verdict',
|
||||
'coverage_state',
|
||||
'indexing_state',
|
||||
'robots_txt_state',
|
||||
'page_fetch_state',
|
||||
'last_crawl_time',
|
||||
'google_canonical',
|
||||
'raw',
|
||||
'checked_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'portal' => Portal::class,
|
||||
'state' => IndexState::class,
|
||||
'last_crawl_time' => 'datetime',
|
||||
'checked_at' => 'datetime',
|
||||
'raw' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<PressRelease, $this>
|
||||
*/
|
||||
public function pressRelease(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PressRelease::class);
|
||||
}
|
||||
}
|
||||
173
app/Services/SearchConsole/IndexStatusChecker.php
Normal file
173
app/Services/SearchConsole/IndexStatusChecker.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\SearchConsole;
|
||||
|
||||
use App\Enums\IndexState;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseIndexCheck;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Prüflauf für den Indexierungsstatus (Roadmap 1c.4): wählt je Portal die
|
||||
* fälligen Meldungen nach Alter gestaffelt aus, fragt die URL Inspection API
|
||||
* ab und speichert jede Antwort als eigene Momentaufnahme.
|
||||
*
|
||||
* Das Tageskontingent geht zuerst an die jüngsten Meldungen (kurzer
|
||||
* Prüfabstand), was übrig bleibt an ältere Stufen — so bleibt der Lauf
|
||||
* weit unter der Quote von 2.000 Abfragen je Property und Tag.
|
||||
*/
|
||||
class IndexStatusChecker
|
||||
{
|
||||
public function __construct(private readonly SearchConsoleClient $client) {}
|
||||
|
||||
/**
|
||||
* Portale mit eingetragener Property.
|
||||
*
|
||||
* @return list<Portal>
|
||||
*/
|
||||
public function portals(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
[Portal::Presseecho, Portal::Businessportal24],
|
||||
fn (Portal $portal): bool => $this->client->propertyFor($portal->value) !== null,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fällige Meldungen eines Portals, jüngste zuerst, nie geprüfte vor
|
||||
* bereits geprüften — höchstens `$limit` Stück.
|
||||
*
|
||||
* @return Collection<int, PressRelease>
|
||||
*/
|
||||
public function candidates(Portal $portal, int $limit): Collection
|
||||
{
|
||||
$selected = collect();
|
||||
$bandStart = now();
|
||||
|
||||
foreach ($this->recheckBands() as $maxAgeDays => $intervalDays) {
|
||||
$remaining = $limit - $selected->count();
|
||||
|
||||
if ($remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$bandEnd = $maxAgeDays === PHP_INT_MAX ? null : now()->subDays($maxAgeDays);
|
||||
|
||||
$selected = $selected->merge(
|
||||
$this->baseQuery($portal)
|
||||
->where('published_at', '<=', $bandStart)
|
||||
->when($bandEnd !== null, fn (Builder $query) => $query->where('published_at', '>', $bandEnd))
|
||||
->where(fn (Builder $query) => $query
|
||||
->whereNull('ic.last_checked_at')
|
||||
->orWhere('ic.last_checked_at', '<', now()->subDays($intervalDays)))
|
||||
->orderByRaw('ic.last_checked_at is null desc')
|
||||
->orderByDesc('published_at')
|
||||
->limit($remaining)
|
||||
->get(['press_releases.id', 'press_releases.slug', 'press_releases.portal', 'press_releases.language', 'press_releases.published_at'])
|
||||
);
|
||||
|
||||
if ($bandEnd === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
$bandStart = $bandEnd;
|
||||
}
|
||||
|
||||
return $selected->unique('id')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt eine Meldung auf einem Portal ab und speichert den Befund.
|
||||
*/
|
||||
public function check(PressRelease $pressRelease, Portal $portal): PressReleaseIndexCheck
|
||||
{
|
||||
$url = $this->publicUrl($pressRelease, $portal);
|
||||
$property = $this->client->propertyFor($portal->value);
|
||||
|
||||
if ($url === null || $property === null) {
|
||||
throw new \RuntimeException(sprintf('Meldung %d hat auf %s keine öffentliche URL oder Property.', $pressRelease->id, $portal->value));
|
||||
}
|
||||
|
||||
$result = $this->client->inspect($url, $property, $pressRelease->language === 'en' ? 'en-US' : 'de-DE');
|
||||
$indexStatus = is_array($result['indexStatusResult'] ?? null) ? $result['indexStatusResult'] : [];
|
||||
|
||||
return PressReleaseIndexCheck::create([
|
||||
'press_release_id' => $pressRelease->id,
|
||||
'portal' => $portal,
|
||||
'url' => $url,
|
||||
'state' => IndexState::fromIndexStatus($indexStatus),
|
||||
'verdict' => Str::limit((string) ($indexStatus['verdict'] ?? ''), 30, '') ?: null,
|
||||
'coverage_state' => Str::limit((string) ($indexStatus['coverageState'] ?? ''), 160, '') ?: null,
|
||||
'indexing_state' => Str::limit((string) ($indexStatus['indexingState'] ?? ''), 60, '') ?: null,
|
||||
'robots_txt_state' => Str::limit((string) ($indexStatus['robotsTxtState'] ?? ''), 40, '') ?: null,
|
||||
'page_fetch_state' => Str::limit((string) ($indexStatus['pageFetchState'] ?? ''), 60, '') ?: null,
|
||||
'last_crawl_time' => ! empty($indexStatus['lastCrawlTime']) ? Carbon::parse((string) $indexStatus['lastCrawlTime']) : null,
|
||||
'google_canonical' => Str::limit((string) ($indexStatus['googleCanonical'] ?? ''), 500, '') ?: null,
|
||||
'raw' => $indexStatus,
|
||||
'checked_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jüngste Momentaufnahme je Portal für die Absenderansicht (E8: nur
|
||||
* ausgeliefert, wenn config search_console.customer_visible an ist).
|
||||
*
|
||||
* @return list<PressReleaseIndexCheck>
|
||||
*/
|
||||
public function latestFor(PressRelease $pressRelease): array
|
||||
{
|
||||
return PressReleaseIndexCheck::query()
|
||||
->where('press_release_id', $pressRelease->id)
|
||||
->orderByDesc('checked_at')
|
||||
->get()
|
||||
->unique(fn (PressReleaseIndexCheck $check): string => $check->portal->value)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Builder<PressRelease>
|
||||
*/
|
||||
private function baseQuery(Portal $portal): Builder
|
||||
{
|
||||
$latest = PressReleaseIndexCheck::query()
|
||||
->select('press_release_id', DB::raw('max(checked_at) as last_checked_at'))
|
||||
->where('portal', $portal->value)
|
||||
->groupBy('press_release_id');
|
||||
|
||||
return PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->visibleInArchive()
|
||||
->forPortalValues([$portal->value, Portal::Both->value])
|
||||
->leftJoinSub($latest, 'ic', 'ic.press_release_id', '=', 'press_releases.id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function recheckBands(): array
|
||||
{
|
||||
/** @var array<int, int> $bands */
|
||||
$bands = config('search_console.recheck_days', [PHP_INT_MAX => 180]);
|
||||
ksort($bands);
|
||||
|
||||
return $bands;
|
||||
}
|
||||
|
||||
private function publicUrl(PressRelease $pressRelease, Portal $portal): ?string
|
||||
{
|
||||
foreach ($pressRelease->publicPortalLinks() as $link) {
|
||||
if ($link['portal'] === $portal) {
|
||||
return $link['url'];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
133
app/Services/SearchConsole/IndexStatusReport.php
Normal file
133
app/Services/SearchConsole/IndexStatusReport.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\SearchConsole;
|
||||
|
||||
use App\Enums\IndexState;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseIndexCheck;
|
||||
use App\Scopes\PortalScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Admin-Auswertung des Indexierungsstatus (Roadmap 1c.4/1c.5): Verteilung
|
||||
* der jüngsten Befunde je Portal, getrennt nach Direkteinreichung und
|
||||
* Syndikation über die API. Erst diese Verteilung entscheidet, ob und wie
|
||||
* der Status in die Kundenansicht kommt (E8).
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
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";
|
||||
|
||||
/**
|
||||
* Anzahl der Meldungen je Portal, Herkunft 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
|
||||
*/
|
||||
public function distribution(): array
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ([Portal::Presseecho, Portal::Businessportal24] as $portal) {
|
||||
// Herkunft je Meldung erst in einer abgeleiteten Tabelle bestimmen,
|
||||
// dann darüber gruppieren — MySQL (only_full_group_by) lehnt den
|
||||
// korrelierten Ausdruck direkt im GROUP BY ab.
|
||||
$visible = DB::query()
|
||||
->fromSub(
|
||||
PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->visibleInArchive()
|
||||
->forPortalValues([$portal->value, Portal::Both->value])
|
||||
->selectRaw('press_releases.id, '.self::ORIGIN_SQL.' as origin'),
|
||||
'r'
|
||||
)
|
||||
->selectRaw('origin, count(*) as total')
|
||||
->groupBy('origin')
|
||||
->get();
|
||||
|
||||
$checked = DB::query()
|
||||
->fromSub(
|
||||
$this->latestChecks($portal)
|
||||
->join('press_releases', 'press_releases.id', '=', 'press_release_index_checks.press_release_id')
|
||||
->whereNull('press_releases.deleted_at')
|
||||
->selectRaw('press_release_index_checks.id, press_release_index_checks.state, '.self::ORIGIN_SQL.' as origin'),
|
||||
'c'
|
||||
)
|
||||
->selectRaw('origin, state, count(*) as total')
|
||||
->groupBy('origin', 'state')
|
||||
->get();
|
||||
|
||||
foreach (['direct', 'api'] as $origin) {
|
||||
$bucket = ['total' => 0, 'unchecked' => 0];
|
||||
|
||||
foreach (IndexState::cases() as $state) {
|
||||
$bucket[$state->value] = 0;
|
||||
}
|
||||
|
||||
$bucket['total'] = (int) ($visible->firstWhere('origin', $origin)->total ?? 0);
|
||||
|
||||
$checkedTotal = 0;
|
||||
|
||||
foreach ($checked->where('origin', $origin) as $row) {
|
||||
$bucket[(string) $row->state] = (int) $row->total;
|
||||
$checkedTotal += (int) $row->total;
|
||||
}
|
||||
|
||||
$bucket['unchecked'] = max(0, $bucket['total'] - $checkedTotal);
|
||||
$rows[$portal->value][$origin] = $bucket;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jüngste Prüfungen, neueste zuerst.
|
||||
*
|
||||
* @return Collection<int, PressReleaseIndexCheck>
|
||||
*/
|
||||
public function latest(int $limit = 100): Collection
|
||||
{
|
||||
return PressReleaseIndexCheck::query()
|
||||
->with(['pressRelease' => fn ($query) => $query->withoutGlobalScopes()->select(['id', 'title', 'slug', 'published_at'])])
|
||||
->orderByDesc('checked_at')
|
||||
->orderByDesc('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function lastCheckedAt(): ?Carbon
|
||||
{
|
||||
$value = PressReleaseIndexCheck::query()->max('checked_at');
|
||||
|
||||
return $value ? Carbon::parse((string) $value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeweils jüngste Prüfung je Meldung auf einem Portal.
|
||||
*
|
||||
* @return Builder<PressReleaseIndexCheck>
|
||||
*/
|
||||
private function latestChecks(Portal $portal): Builder
|
||||
{
|
||||
$latest = PressReleaseIndexCheck::query()
|
||||
->select('press_release_id', DB::raw('max(checked_at) as last_checked_at'))
|
||||
->where('portal', $portal->value)
|
||||
->groupBy('press_release_id');
|
||||
|
||||
return PressReleaseIndexCheck::query()
|
||||
->joinSub($latest, 'latest', fn ($join) => $join
|
||||
->on('latest.press_release_id', '=', 'press_release_index_checks.press_release_id')
|
||||
->on('latest.last_checked_at', '=', 'press_release_index_checks.checked_at'))
|
||||
->where('press_release_index_checks.portal', $portal->value);
|
||||
}
|
||||
}
|
||||
146
app/Services/SearchConsole/SearchConsoleClient.php
Normal file
146
app/Services/SearchConsole/SearchConsoleClient.php
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\SearchConsole;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Schmaler Client für die URL Inspection API der Google Search Console
|
||||
* (Roadmap 1c.4). Authentifiziert sich als Service-Konto über ein selbst
|
||||
* signiertes JWT (RS256) — bewusst ohne Google-SDK, weil nur ein Endpunkt
|
||||
* gebraucht wird und die Abhängigkeit sonst größer wäre als der Code.
|
||||
*/
|
||||
class SearchConsoleClient
|
||||
{
|
||||
public const string INSPECT_URL = 'https://searchconsole.googleapis.com/v1/urlInspection/index:inspect';
|
||||
|
||||
private const string SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly';
|
||||
|
||||
private const string TOKEN_CACHE_KEY = 'search-console.access-token';
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$file = (string) config('search_console.credentials');
|
||||
|
||||
return (bool) config('search_console.enabled') && $file !== '' && is_readable($file);
|
||||
}
|
||||
|
||||
public function propertyFor(string $portal): ?string
|
||||
{
|
||||
$property = config('search_console.properties.'.$portal);
|
||||
|
||||
return is_string($property) && $property !== '' ? $property : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt den Indexierungsstatus einer URL ab und liefert
|
||||
* `inspectionResult` zurück (mit `indexStatusResult`).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function inspect(string $url, string $siteUrl, string $languageCode = 'de-DE'): array
|
||||
{
|
||||
$response = Http::withToken($this->accessToken())
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->post(self::INSPECT_URL, [
|
||||
'inspectionUrl' => $url,
|
||||
'siteUrl' => $siteUrl,
|
||||
'languageCode' => $languageCode,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'URL Inspection API antwortet mit %d für %s: %s',
|
||||
$response->status(),
|
||||
$url,
|
||||
(string) ($response->json('error.message') ?? $response->body()),
|
||||
));
|
||||
}
|
||||
|
||||
$result = $response->json('inspectionResult');
|
||||
|
||||
return is_array($result) ? $result : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zugriffstoken des Service-Kontos, 50 Minuten gecacht (Gültigkeit 60).
|
||||
*/
|
||||
private function accessToken(): string
|
||||
{
|
||||
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addMinutes(50), function (): string {
|
||||
$credentials = $this->credentials();
|
||||
$now = time();
|
||||
|
||||
$assertion = $this->jwt([
|
||||
'iss' => $credentials['client_email'],
|
||||
'scope' => self::SCOPE,
|
||||
'aud' => $credentials['token_uri'],
|
||||
'iat' => $now,
|
||||
'exp' => $now + 3600,
|
||||
], $credentials['private_key']);
|
||||
|
||||
$response = Http::asForm()->timeout(20)->post($credentials['token_uri'], [
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $assertion,
|
||||
]);
|
||||
|
||||
$token = $response->json('access_token');
|
||||
|
||||
if (! $response->successful() || ! is_string($token) || $token === '') {
|
||||
throw new RuntimeException('Search Console: kein Zugriffstoken erhalten ('.$response->status().').');
|
||||
}
|
||||
|
||||
return $token;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{client_email: string, private_key: string, token_uri: string}
|
||||
*/
|
||||
private function credentials(): array
|
||||
{
|
||||
$file = (string) config('search_console.credentials');
|
||||
$json = is_readable($file) ? json_decode((string) file_get_contents($file), true) : null;
|
||||
|
||||
if (! is_array($json) || empty($json['client_email']) || empty($json['private_key'])) {
|
||||
throw new RuntimeException('Search Console: Schlüsseldatei des Service-Kontos fehlt oder ist unvollständig.');
|
||||
}
|
||||
|
||||
return [
|
||||
'client_email' => (string) $json['client_email'],
|
||||
'private_key' => (string) $json['private_key'],
|
||||
'token_uri' => (string) ($json['token_uri'] ?? 'https://oauth2.googleapis.com/token'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function jwt(array $claims, string $privateKey): string
|
||||
{
|
||||
$segments = [
|
||||
$this->base64Url((string) json_encode(['alg' => 'RS256', 'typ' => 'JWT'])),
|
||||
$this->base64Url((string) json_encode($claims)),
|
||||
];
|
||||
|
||||
$key = openssl_pkey_get_private($privateKey);
|
||||
|
||||
if ($key === false) {
|
||||
throw new RuntimeException('Search Console: privater Schlüssel des Service-Kontos ist nicht lesbar.');
|
||||
}
|
||||
|
||||
$signature = '';
|
||||
openssl_sign(implode('.', $segments), $signature, $key, OPENSSL_ALGO_SHA256);
|
||||
$segments[] = $this->base64Url($signature);
|
||||
|
||||
return implode('.', $segments);
|
||||
}
|
||||
|
||||
private function base64Url(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
50
config/search_console.php
Normal file
50
config/search_console.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Google Search Console — Indexierungsstatus (Roadmap 1c.4)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Abgleich veröffentlichter Meldungen über die URL Inspection API. Läuft
|
||||
| zunächst nur als Admin-Auswertung; die Anzeige beim Kunden ist per
|
||||
| `customer_visible` geschlossen (Entscheidung E8, 17.09.2026), bis die
|
||||
| Verteilung getrennt nach Direkteinreichung und Syndikation bekannt ist.
|
||||
|
|
||||
| Zugang über ein Service-Konto (JSON-Schlüsseldatei), das in der Search
|
||||
| Console als Nutzer der jeweiligen Property eingetragen ist. Quote: 2.000
|
||||
| Abfragen je Tag und Property — `daily_budget` bleibt bewusst weit darunter
|
||||
| und wird nach Alter gestaffelt auf die jüngsten Meldungen verteilt.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'enabled' => (bool) env('GSC_ENABLED', false),
|
||||
|
||||
// Pfad zur JSON-Schlüsseldatei des Service-Kontos (außerhalb von public/).
|
||||
'credentials' => env('GSC_SERVICE_ACCOUNT_FILE'),
|
||||
|
||||
// Property je Portal, wie in der Search Console angelegt
|
||||
// (Domain-Property „sc-domain:…" oder URL-Präfix „https://…/").
|
||||
'properties' => [
|
||||
'presseecho' => env('GSC_PROPERTY_PRESSEECHO', 'sc-domain:presseecho.de'),
|
||||
'businessportal24' => env('GSC_PROPERTY_BUSINESSPORTAL24', 'sc-domain:businessportal24.de'),
|
||||
],
|
||||
|
||||
// Abfragen je Lauf und Portal.
|
||||
'daily_budget' => (int) env('GSC_DAILY_BUDGET', 300),
|
||||
|
||||
// Wie oft eine Meldung erneut geprüft wird, gestaffelt nach Alter seit
|
||||
// Veröffentlichung: [maximales Alter in Tagen => Abstand in Tagen].
|
||||
// Die letzte Stufe gilt für alles Ältere.
|
||||
'recheck_days' => [
|
||||
14 => 3,
|
||||
90 => 14,
|
||||
365 => 45,
|
||||
PHP_INT_MAX => 180,
|
||||
],
|
||||
|
||||
// Indexierungsstatus in der Absenderansicht zeigen (E8: zunächst zu).
|
||||
'customer_visible' => (bool) env('GSC_CUSTOMER_VISIBLE', false),
|
||||
|
||||
];
|
||||
46
database/factories/PressReleaseIndexCheckFactory.php
Normal file
46
database/factories/PressReleaseIndexCheckFactory.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\IndexState;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseIndexCheck;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<PressReleaseIndexCheck>
|
||||
*/
|
||||
class PressReleaseIndexCheckFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'press_release_id' => PressRelease::factory(),
|
||||
'portal' => Portal::Presseecho,
|
||||
'url' => 'https://presseecho.test/de/press-release/'.fake()->slug(),
|
||||
'state' => IndexState::Indexed,
|
||||
'verdict' => 'PASS',
|
||||
'coverage_state' => 'Submitted and indexed',
|
||||
'indexing_state' => 'INDEXING_ALLOWED',
|
||||
'robots_txt_state' => 'ALLOWED',
|
||||
'page_fetch_state' => 'SUCCESSFUL',
|
||||
'last_crawl_time' => now()->subDay(),
|
||||
'google_canonical' => null,
|
||||
'raw' => null,
|
||||
'checked_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function withState(IndexState $state): static
|
||||
{
|
||||
return $this->state([
|
||||
'state' => $state,
|
||||
'verdict' => $state === IndexState::Indexed ? 'PASS' : 'NEUTRAL',
|
||||
'last_crawl_time' => $state === IndexState::NotCrawled ? null : now()->subDay(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Indexierungsstatus aus der Google Search Console (Roadmap 1c.4).
|
||||
*
|
||||
* Jede Antwort der URL Inspection API wird mit Zeitstempel als eigene Zeile
|
||||
* gespeichert: die API liefert eine Momentaufnahme, der Verlauf lässt sich
|
||||
* nicht nachträglich erzeugen. `state` ist der reduzierte Befund
|
||||
* (App\Enums\IndexState), die Google-Felder bleiben daneben erhalten.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('press_release_index_checks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('press_release_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('portal', 20);
|
||||
$table->string('url', 500);
|
||||
$table->string('state', 30);
|
||||
$table->string('verdict', 30)->nullable();
|
||||
$table->string('coverage_state', 160)->nullable();
|
||||
$table->string('indexing_state', 60)->nullable();
|
||||
$table->string('robots_txt_state', 40)->nullable();
|
||||
$table->string('page_fetch_state', 60)->nullable();
|
||||
$table->timestamp('last_crawl_time')->nullable();
|
||||
$table->string('google_canonical', 500)->nullable();
|
||||
$table->json('raw')->nullable();
|
||||
$table->timestamp('checked_at');
|
||||
|
||||
$table->index(['press_release_id', 'portal', 'checked_at'], 'pric_release_portal_checked_idx');
|
||||
$table->index(['portal', 'checked_at'], 'pric_portal_checked_idx');
|
||||
$table->index(['state', 'checked_at'], 'pric_state_checked_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('press_release_index_checks');
|
||||
}
|
||||
};
|
||||
|
|
@ -230,6 +230,10 @@
|
|||
:current="request()->routeIs('admin.reports.submitter-contacts')" wire:navigate>
|
||||
{{ __('Einlieferer-Bericht') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="magnifying-glass-circle" :href="route('admin.reports.index-status')"
|
||||
:current="request()->routeIs('admin.reports.index-status')" wire:navigate>
|
||||
{{ __('Indexierung') }}
|
||||
</flux:navlist.item>
|
||||
@can('settings:manage')
|
||||
<flux:navlist.item icon="heart" :href="route('admin.reports.system-health')"
|
||||
:current="request()->routeIs('admin.reports.system-health')" wire:navigate>
|
||||
|
|
|
|||
182
resources/views/livewire/admin/reports/index-status.blade.php
Normal file
182
resources/views/livewire/admin/reports/index-status.blade.php
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\IndexState;
|
||||
use App\Enums\Portal;
|
||||
use App\Services\SearchConsole\IndexStatusReport;
|
||||
use App\Services\SearchConsole\SearchConsoleClient;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Admin ▸ Reports ▸ Indexierung (Roadmap 1c.4).
|
||||
*
|
||||
* Verteilung der jüngsten Search-Console-Befunde je Portal, getrennt nach
|
||||
* Direkteinreichung und Syndikation über die API. Diese Verteilung ist die
|
||||
* Grundlage für 1c.5 (Kundenansicht ja/nein, E8) — der Kunde sieht bis
|
||||
* dahin nichts davon.
|
||||
*/
|
||||
new #[Layout('components.layouts.app'), Title('Indexierung')] class extends Component
|
||||
{
|
||||
public function downloadLatest(): StreamedResponse
|
||||
{
|
||||
$rows = app(IndexStatusReport::class)->latest(2000);
|
||||
$name = sprintf('indexierung-%s.csv', now()->format('Y-m-d'));
|
||||
|
||||
return response()->streamDownload(function () use ($rows): void {
|
||||
$handle = fopen('php://output', 'wb');
|
||||
fputcsv($handle, ['Geprüft', 'Portal', 'ID', 'Titel', 'URL', 'Befund', 'Google-Status', 'Zuletzt gecrawlt', 'Kanonisch'], ';');
|
||||
|
||||
foreach ($rows as $check) {
|
||||
fputcsv($handle, [
|
||||
$check->checked_at->format('d.m.Y H:i'),
|
||||
$check->portal->label(),
|
||||
$check->press_release_id,
|
||||
$check->pressRelease?->title ?? '',
|
||||
$check->url,
|
||||
$check->state->label(),
|
||||
$check->coverage_state ?? '',
|
||||
$check->last_crawl_time?->format('d.m.Y') ?? '',
|
||||
$check->google_canonical ?? '',
|
||||
], ';');
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
}, $name, ['Content-Type' => 'text/csv; charset=UTF-8']);
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$report = app(IndexStatusReport::class);
|
||||
|
||||
return [
|
||||
'configured' => app(SearchConsoleClient::class)->isConfigured(),
|
||||
'customerVisible' => (bool) config('search_console.customer_visible'),
|
||||
'distribution' => $report->distribution(),
|
||||
'latest' => $report->latest(100),
|
||||
'lastCheckedAt' => $report->lastCheckedAt(),
|
||||
'states' => IndexState::cases(),
|
||||
'portals' => [Portal::Presseecho, Portal::Businessportal24],
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8">
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Reports')"
|
||||
:title="__('Indexierung')"
|
||||
: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.')"
|
||||
/>
|
||||
|
||||
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<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-portal.stat-card>
|
||||
<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-portal.stat-card>
|
||||
<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-portal.stat-card>
|
||||
</section>
|
||||
|
||||
@foreach ($portals as $portal)
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ $portal->label() }}</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>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Herkunft') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Meldungen') }}</flux:table.column>
|
||||
@foreach ($states as $state)
|
||||
<flux:table.column>{{ ucfirst($state->label()) }}</flux:table.column>
|
||||
@endforeach
|
||||
<flux:table.column>{{ __('Ungeprüft') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@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
|
||||
<flux:table.row :key="$portal->value.'-'.$origin">
|
||||
<flux:table.cell>
|
||||
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $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, 0, ',', '.') }}</span>
|
||||
</flux:table.cell>
|
||||
@foreach ($states as $state)
|
||||
@php $count = (int) ($row[$state->value] ?? 0); @endphp
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ 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
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</article>
|
||||
@endforeach
|
||||
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Jüngste Prüfungen') }}</span>
|
||||
@if ($latest->isNotEmpty())
|
||||
<flux:button size="sm" variant="filled" icon="arrow-down-tray" wire:click="downloadLatest">{{ __('CSV') }}</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Geprüft') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Meldung') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Portal') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Befund') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Zuletzt gecrawlt') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@forelse ($latest as $check)
|
||||
<flux:table.row :key="$check->id">
|
||||
<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>
|
||||
</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>
|
||||
<span class="block text-[11px] text-[color:var(--color-ink-4)]">{{ __('veröffentlicht :date', ['date' => $check->pressRelease?->published_at?->format('d.m.Y') ?? '—']) }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $check->portal->label() }}</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<span @class(['badge dot', 'ok' => $check->state === IndexState::Indexed, 'warn' => in_array($check->state, [IndexState::CrawledNotIndexed, IndexState::NotCrawled], true), 'err' => $check->state === IndexState::Excluded, 'muted' => $check->state === IndexState::Unknown])>{{ $check->state->label() }}</span>
|
||||
@if ($check->coverage_state)
|
||||
<span class="block text-[11px] text-[color:var(--color-ink-4)] mt-1">{{ $check->coverage_state }}</span>
|
||||
@endif
|
||||
</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>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5">
|
||||
<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') }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
</article>
|
||||
</div>
|
||||
|
|
@ -21,6 +21,7 @@ use App\Services\PressRelease\PressReleaseCoverImage;
|
|||
use App\Services\PressRelease\PressReleaseService;
|
||||
use App\Services\PressRelease\QuotaExceededException;
|
||||
use App\Services\PressRelease\VisibilityStats;
|
||||
use App\Services\SearchConsole\IndexStatusChecker;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
|
@ -37,7 +38,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
public ?string $shareUrl = null;
|
||||
|
||||
/** Zeitraum der Verlaufsgrafik „Sichtbarkeit" in Tagen (Roadmap 1c.1). */
|
||||
/** Zeitraum der Verlaufsgrafik „Auslieferung" in Tagen (Roadmap 1c.1). */
|
||||
#[Url(as: 'verlauf', except: 30)]
|
||||
public int $viewRange = 30;
|
||||
|
||||
|
|
@ -788,15 +789,21 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$boostService = app(BoostService::class);
|
||||
$proofService = app(ProofPdfService::class);
|
||||
|
||||
// Sichtbarkeit belegen (Roadmap 1c.1): Rohzahlen und Tagesverlauf,
|
||||
// nur für Meldungen, die je online waren.
|
||||
// Auslieferung belegen (Roadmap 1c.1, Stufe 1): Rohzahlen und
|
||||
// Tagesverlauf aus eigener Hand, nur für Meldungen, die je online
|
||||
// waren. Der Indexierungsstatus (1c.4) kommt erst mit, wenn die
|
||||
// Kundenansicht freigegeben ist (E8).
|
||||
$stats = app(VisibilityStats::class);
|
||||
$wasPublic = $pr->published_at !== null || (int) $pr->hits > 0;
|
||||
$range = VisibilityStats::ranges()->contains($this->viewRange) ? $this->viewRange : 30;
|
||||
$indexChecks = $wasPublic && config('search_console.customer_visible')
|
||||
? app(IndexStatusChecker::class)->latestFor($pr)
|
||||
: [];
|
||||
|
||||
return [
|
||||
'pr' => $pr,
|
||||
'visibility' => $wasPublic ? $stats->releaseSummary($pr) : null,
|
||||
'indexChecks' => $indexChecks,
|
||||
'helpPageUrl' => rtrim((string) config('domains.domain_portal_url'), '/').'/de/gute-pressemitteilung',
|
||||
'dailyViews' => $wasPublic ? $stats->dailyViews($pr, $range) : [],
|
||||
'viewRanges' => VisibilityStats::ranges(),
|
||||
|
|
@ -1496,7 +1503,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== SICHTBARKEIT (Roadmap 1c.1) ============== --}}
|
||||
{{-- ============== AUSLIEFERUNG (Roadmap 1c.1, Stufe 1) ==============
|
||||
Nur Daten aus eigener Hand; nichts, was von Dritten abhängt.
|
||||
Kein Link auf eine site:-Suche (Anhang 17.09.2026). --}}
|
||||
@if ($visibility !== null)
|
||||
@php
|
||||
$measured = array_values(array_filter($dailyViews, fn ($day) => $day['views'] !== null));
|
||||
|
|
@ -1508,9 +1517,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$gap = $barCount > 45 ? 1 : 2;
|
||||
$barWidth = max(1, ($chartWidth - ($barCount - 1) * $gap) / max(1, $barCount));
|
||||
@endphp
|
||||
<article class="panel" id="sichtbarkeit">
|
||||
<article class="panel" id="auslieferung">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Sichtbarkeit') }}</span>
|
||||
<span class="section-eyebrow">{{ __('Auslieferung') }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
@foreach ($viewRanges as $days)
|
||||
<button type="button" wire:click="$set('viewRange', {{ $days }})"
|
||||
|
|
@ -1577,24 +1586,62 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Auffindbarkeit') }}</div>
|
||||
<div class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1">
|
||||
@if ($visibility['in_sitemap_since'])
|
||||
{{ __('In der Sitemap für Suchmaschinen seit :date.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y')]) }}
|
||||
<span class="text-[color:var(--color-ink-4)]">{{ __('Ob und wann Google die Seite aufnimmt, entscheidet Google — der Link zeigt den aktuellen Stand.') }}</span>
|
||||
@else
|
||||
{{ __('Derzeit nicht öffentlich — nicht in der Sitemap.') }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sitemap für Suchmaschinen') }}</div>
|
||||
<div class="text-[12.5px] text-[color:var(--color-ink-2)] mt-1">
|
||||
@if ($visibility['in_sitemap_since'])
|
||||
{{ __('Gelistet seit :date — Veröffentlichung und Sitemap-Eintrag fallen zusammen.', ['date' => $visibility['in_sitemap_since']->format('d.m.Y')]) }}
|
||||
@else
|
||||
{{ __('Derzeit nicht gelistet — die Meldung ist nicht öffentlich.') }}
|
||||
@endif
|
||||
</div>
|
||||
@if ($visibility['search_url'])
|
||||
<flux:button size="sm" variant="filled" icon="magnifying-glass" href="{{ $visibility['search_url'] }}" target="_blank" rel="noopener">
|
||||
{{ __('Bei Google prüfen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Abrufe durch Such- und KI-Dienste (Roadmap 1c.6). Ein
|
||||
Abruf ist kein Zitat — die Anzeige belegt nur, dass der
|
||||
Crawler des Dienstes die Seite geholt hat. --}}
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Abrufe durch Such- und KI-Dienste') }}</div>
|
||||
@if ($visibility['crawlers'] !== [])
|
||||
<ul class="mt-1 m-0 p-0 list-none divide-y divide-[color:var(--color-bg-rule)]">
|
||||
@foreach ($visibility['crawlers'] as $crawler)
|
||||
<li class="flex flex-wrap items-baseline justify-between gap-x-3 py-1 text-[12.5px]">
|
||||
<span class="text-[color:var(--color-ink)]">{{ $crawler['label'] }}
|
||||
<span class="text-[11px] text-[color:var(--color-ink-4)]">{{ $crawler['kind'] === 'search' ? __('Suche') : __('KI') }}</span>
|
||||
</span>
|
||||
<span class="tabular-nums text-[color:var(--color-ink-2)]">{{ __('zuletzt :date', ['date' => $crawler['last_at']->format('d.m.Y')]) }} · {{ trans_choice(':n Abruf|:n Abrufe', $crawler['fetches'], ['n' => number_format($crawler['fetches'], 0, ',', '.')]) }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-2 mb-0 text-[11px] 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.') }}</p>
|
||||
@else
|
||||
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Noch kein Abruf protokolliert.') }}
|
||||
@if ($visibility['crawlers_since'] && $visibility['crawlers_since']->isFuture())
|
||||
{{ __('Das Protokoll beginnt am :date.', ['date' => $visibility['crawlers_since']->format('d.m.Y')]) }}
|
||||
@elseif ($visibility['crawlers_since'])
|
||||
{{ __('Erfasst seit :date; frühere Abrufe lassen sich nicht nachtragen.', ['date' => $visibility['crawlers_since']->format('d.m.Y')]) }}
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Indexierungsstatus (Roadmap 1c.4) — Kundenansicht per
|
||||
config search_console.customer_visible (E8), sonst leer. --}}
|
||||
@if ($indexChecks !== [])
|
||||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Google-Index') }}</div>
|
||||
<ul class="mt-1 m-0 p-0 list-none space-y-1 text-[12.5px]">
|
||||
@foreach ($indexChecks as $check)
|
||||
<li class="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<span class="text-[color:var(--color-ink)]">{{ $check->portal->label() }}: {{ $check->state->label() }}</span>
|
||||
<span class="text-[11px] tabular-nums text-[color:var(--color-ink-4)]">{{ __('Stand :date', ['date' => $check->checked_at->format('d.m.Y')]) }}@if ($check->last_crawl_time) · {{ __('zuletzt gecrawlt :date', ['date' => $check->last_crawl_time->format('d.m.Y')]) }}@endif</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Momentaufnahme aus der Google Search Console. Ob und wann Google eine Seite aufnimmt, entscheidet Google.') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -127,6 +127,10 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
|
|||
// Meldungen je API-Einlieferer (Kontakterreichbarkeit).
|
||||
Volt::route('admin/reports/einlieferer', 'admin.reports.submitter-contacts')->name('admin.reports.submitter-contacts');
|
||||
|
||||
// Indexierungsstatus aus der Search Console (Roadmap 1c.4): Verteilung
|
||||
// nach Direkteinreichung/Syndikation — Grundlage für die Kundenansicht.
|
||||
Volt::route('admin/reports/indexierung', 'admin.reports.index-status')->name('admin.reports.index-status');
|
||||
|
||||
Route::middleware('can:settings:manage')->group(function (): void {
|
||||
Volt::route('admin/reports/system-health', 'admin.reports.system-health')->name('admin.reports.system-health');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\AggregatePressReleaseViews;
|
||||
use App\Console\Commands\CheckIndexStatus;
|
||||
use App\Console\Commands\CheckMailDomains;
|
||||
use App\Console\Commands\FetchMailBounces;
|
||||
use App\Console\Commands\GenerateManualInvoices;
|
||||
|
|
@ -113,6 +114,14 @@ Schedule::command(PurgePressReleaseEvents::class)
|
|||
->runInBackground()
|
||||
->onFailure($alertOnFailure('press-releases:purge-events'));
|
||||
|
||||
// Indexierungsstatus aus der Search Console (Roadmap 1c.4), täglich 05:10.
|
||||
// Ohne GSC_ENABLED/Schlüsseldatei beendet sich der Befehl sofort.
|
||||
Schedule::command(CheckIndexStatus::class)
|
||||
->dailyAt('05:10')
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->onFailure($alertOnFailure('press-releases:check-index-status'));
|
||||
|
||||
// Konversions-Ereignisse auf den Aufbewahrungszeitraum begrenzen (täglich 03:25).
|
||||
// Frist zusammen mit der Tabelle festgelegt (395 Tage), siehe config/conversion.php.
|
||||
Schedule::command(PurgeConversionEvents::class)
|
||||
|
|
|
|||
190
tests/Feature/IndexStatusCheckTest.php
Normal file
190
tests/Feature/IndexStatusCheckTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue