web: Crawler-Abruf-Protokoll und Panel „Auslieferung" ohne Google-Link (Anhang 1: 1c.0, 1c.1, 1c.6)

Abrufe öffentlicher Meldungen durch bekannte Such- und KI-Dienste werden
je Meldung mit Dienst, gekürztem User-Agent und Zeitpunkt protokolliert —
keine IP. Dienste und Muster stehen in config/crawlers.php. Aufzeichnung
wie der Aufruf-Zähler per defer() und fehlertolerant; Bereinigung hängt
an press-releases:purge-events.

VisibilityStats liefert die Abrufe je Dienst (letzter Abruf, Anzahl) und
verliert den site:-Suchlink (Stufe 1 verspricht nichts, was von Dritten
abhängt).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-17 14:21:35 +00:00
parent 6446e19727
commit 7088d092d8
10 changed files with 440 additions and 7 deletions

View file

@ -8,6 +8,7 @@ use App\Enums\PressReleaseStatus;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseEvent;
use App\Support\CrawlerIdentifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@ -25,9 +26,14 @@ use Illuminate\Support\Collection;
* Tageswert älter als die Aufbewahrung existiert nicht mehr — die
* Verlaufsgrafik darf ihn nicht als „0 Aufrufe" ausgeben.
*
* Der Indexierungsstatus ist in dieser Stufe bewusst ohne Fremddienst
* (Entscheidung 16.09.2026, Variante B): „in der Sitemap seit" plus ein
* Link auf die Suche. Die Search-Console-Anbindung kommt als eigenes Paket.
* Stufe 1 der Absenderansicht (Anhang 17.09.2026) verspricht nichts, was
* von Dritten abhängt: Überschrift „Auslieferung", Daten aus eigener Hand —
* veröffentlicht am, in der Sitemap seit, Aufrufe im Verlauf. Kein Link auf
* eine `site:`-Suche (unvollständig, ohne Einordnung). Der Indexierungs-
* status aus der Search Console (1c.4) läuft zunächst nur als Admin-
* Auswertung; die Kundenanzeige hängt an config search_console.customer_visible
* (E8). Abrufe durch Such- und KI-Dienste (1c.6) kommen aus dem
* Crawler-Abruf-Protokoll (1c.0) — ein Abruf ist kein Zitat.
*/
class VisibilityStats
{
@ -124,9 +130,9 @@ class VisibilityStats
}
/**
* Einzelwerte einer Meldung für das Panel „Sichtbarkeit".
* Einzelwerte einer Meldung für das Panel „Auslieferung".
*
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, search_url: string|null}
* @return array{hits: int, first_view_at: Carbon|null, last_view_at: Carbon|null, shares: array<string, int>, shares_total: int, in_sitemap_since: Carbon|null, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null}
*/
public function releaseSummary(PressRelease $pressRelease): array
{
@ -147,7 +153,6 @@ class VisibilityStats
->sortDesc();
$links = $pressRelease->isVisibleInArchive() ? $pressRelease->publicPortalLinks() : [];
$firstUrl = $links[0]['url'] ?? null;
return [
'hits' => (int) $pressRelease->hits,
@ -159,10 +164,44 @@ class VisibilityStats
// Moment der Veröffentlichung. Das ist eine Aussage über uns, nicht
// über Google.
'in_sitemap_since' => $links !== [] && $pressRelease->published_at !== null ? Carbon::instance($pressRelease->published_at) : null,
'search_url' => $firstUrl !== null ? 'https://www.google.com/search?q='.rawurlencode('site:'.preg_replace('#^https?://#', '', $firstUrl)) : null,
'crawlers' => $this->crawlerFetches($pressRelease),
'crawlers_since' => $this->crawlerHitsSince(),
];
}
/**
* Abrufe durch Such- und KI-Dienste je Meldung (Roadmap 1c.6), zuletzt
* abgerufener Dienst zuerst. Belegt nur den Abruf durch den Crawler des
* Dienstes — nicht, dass die Meldung dort zitiert oder angezeigt wird.
*
* @return list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>
*/
public function crawlerFetches(PressRelease $pressRelease): array
{
return $pressRelease->crawlerHits()
->selectRaw('service, count(*) as fetches, max(created_at) as last_at')
->groupBy('service')
->toBase()
->get()
->map(fn (object $row): array => [
'service' => (string) $row->service,
'label' => CrawlerIdentifier::label((string) $row->service),
'kind' => CrawlerIdentifier::kind((string) $row->service),
'fetches' => (int) $row->fetches,
'last_at' => Carbon::parse((string) $row->last_at),
])
->sortByDesc(fn (array $row): int => $row['last_at']->getTimestamp())
->values()
->all();
}
private function crawlerHitsSince(): ?Carbon
{
$since = config('crawlers.recorded_since');
return $since ? Carbon::parse((string) $since) : null;
}
/**
* Ab wann es für diese Meldung überhaupt Tageswerte geben kann: der
* spätere von Veröffentlichung und Beginn der Ereignisaufzeichnung.

View file

@ -5,6 +5,7 @@ namespace App\Services\Web;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Support\CrawlerIdentifier;
use App\Support\WebPortalContext;
use Illuminate\Database\Eloquent\Builder;
@ -86,6 +87,15 @@ class ReleaseDetailPageData
$releaseId = $release->getKey();
defer(static fn () => PressRelease::recordDeferredView($releaseId));
// Crawler-Abruf-Protokoll (Roadmap 1c.0): nur bekannte Such- und
// KI-Dienste, erkannt am User-Agent, ohne IP.
$userAgent = (string) request()->userAgent();
$crawler = CrawlerIdentifier::identify($userAgent);
if ($crawler !== null) {
defer(static fn () => PressRelease::recordDeferredCrawlerHit($releaseId, $crawler, $userAgent));
}
// Die Antwort zeigt den aktuellen Aufruf bereits an; die atomaren
// Schreibzugriffe erfolgen nach dem Senden der Response.
$release->hits = ($release->hits ?? 0) + 1;