web: Abruf-Protokoll der RSS-Feeds — Reader-Dienst, gemeldete Abonnenten, Admin-Bericht, Panel (Roadmap 3.1)
- feed_fetches: jeder Feed-Abruf mit Art, Schlüssel, Portal, Sprache, erkanntem Reader (config/feeds.php, FeedReaderIdentifier), vom Dienst gemeldeter Abonnentenzahl und gekürztem User-Agent — keine IP. Schreiben per defer() nach der Antwort, auch bei Cache-Treffer. - Aufbewahrung 395 Tage über press-releases:purge-events; FEED_FETCHES_RECORDED_SINCE. - Admin ▸ Reports ▸ Indexierung & Abrufe: Block „Abrufe der RSS-Feeds" (Summen je Feed-Art, je Reader-Dienst, meistabgerufene Feeds; FeedFetchReport, Verdichtung in PHP für MySQL und SQLite). - Panel „Auslieferung": Abrufe je Feed in 30 Tagen; RSS-Seite nennt die Zählung ohne IP. - Tests: FeedFetchLogTest (5), IndexStatusCheckTest erweitert. - dev/2026-09-01: Nachtrag Protokoll Teil 11, Roadmap 3.1; 0.7 gilt auch für Crawler- und Feed-Log. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
23811ad4d2
commit
44d0bb6f29
18 changed files with 794 additions and 13 deletions
211
app/Services/PressRelease/FeedFetchReport.php
Normal file
211
app/Services/PressRelease/FeedFetchReport.php
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\PressRelease;
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\FeedFetch;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Support\FeedReaderIdentifier;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Admin-Auswertung des Feed-Abruf-Protokolls (Roadmap 3.1): welche Feeds
|
||||
* wie oft geholt werden, von welchen Reader-Diensten, und welche
|
||||
* Abonnentenzahlen die Dienste selbst melden. Zählt Abrufe — Abonnenten
|
||||
* sind eine Angabe des Dienstes (Feedly, Inoreader, NewsBlur, Feedbin
|
||||
* nennen sie im User-Agent), keine eigene Zählung.
|
||||
*/
|
||||
class FeedFetchReport
|
||||
{
|
||||
/**
|
||||
* Je Reader-Dienst: Abrufe und betroffene Feeds im Zeitraum, Summe der
|
||||
* je Feed höchsten gemeldeten Abonnentenzahl, letzter Abruf.
|
||||
*
|
||||
* @return list<array{reader: string|null, label: string, fetches: int, feeds: int, subscribers: int|null, last_at: Carbon}>
|
||||
*/
|
||||
public function byReader(int $days = 30): array
|
||||
{
|
||||
// Je Reader und Feed eine Zeile (Abrufe, höchste gemeldete Abonnenten,
|
||||
// letzter Abruf); die Verdichtung je Reader passiert in PHP — das
|
||||
// bleibt auf MySQL und SQLite gleich. Abonnenten: je Feed das Maximum,
|
||||
// dann summiert — ein Dienst holt einen Feed viele Male, die Zahl darf
|
||||
// nicht mitwachsen.
|
||||
$rows = FeedFetch::query()
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->selectRaw('reader, feed, feed_key, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at')
|
||||
->groupBy('reader', 'feed', 'feed_key')
|
||||
->toBase()
|
||||
->get()
|
||||
->groupBy(fn (object $row): string => (string) $row->reader);
|
||||
|
||||
return $rows
|
||||
->map(function ($feeds, string $reader): array {
|
||||
$withSubscribers = $feeds->whereNotNull('subscribers');
|
||||
|
||||
return [
|
||||
'reader' => $reader === '' ? null : $reader,
|
||||
'label' => FeedReaderIdentifier::label($reader === '' ? null : $reader),
|
||||
'fetches' => (int) $feeds->sum('fetches'),
|
||||
'feeds' => $feeds->count(),
|
||||
'subscribers' => $withSubscribers->isEmpty() ? null : (int) $withSubscribers->sum('subscribers'),
|
||||
'last_at' => Carbon::parse((string) $feeds->max('last_at')),
|
||||
];
|
||||
})
|
||||
->sortByDesc('fetches')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Am häufigsten abgerufene Feeds im Zeitraum, mit aufgelöstem Namen
|
||||
* (Branche bzw. Firma) und der höchsten gemeldeten Abonnentenzahl.
|
||||
*
|
||||
* @return list<array{feed: string, feed_key: string|null, portal: string, language: string, label: string, fetches: int, readers: int, subscribers: int|null, last_at: Carbon}>
|
||||
*/
|
||||
public function topFeeds(int $days = 30, int $limit = 20): array
|
||||
{
|
||||
$rows = FeedFetch::query()
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->selectRaw('feed, feed_key, portal, language, reader, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at')
|
||||
->groupBy('feed', 'feed_key', 'portal', 'language', 'reader')
|
||||
->toBase()
|
||||
->get()
|
||||
->groupBy(fn (object $row): string => implode('|', [(string) $row->feed, (string) $row->feed_key, (string) $row->portal, (string) $row->language]))
|
||||
->map(fn ($readers): object => (object) [
|
||||
'feed' => (string) $readers->first()->feed,
|
||||
'feed_key' => $readers->first()->feed_key,
|
||||
'portal' => (string) $readers->first()->portal,
|
||||
'language' => (string) $readers->first()->language,
|
||||
'fetches' => (int) $readers->sum('fetches'),
|
||||
'readers' => $readers->count(),
|
||||
'subscribers' => $readers->whereNotNull('subscribers')->max('subscribers'),
|
||||
'last_at' => $readers->max('last_at'),
|
||||
])
|
||||
->sortByDesc('fetches')
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
$categoryNames = $this->categoryNames($rows->where('feed', FeedFetch::FEED_CATEGORY)->pluck('feed_key')->filter()->unique()->values()->all());
|
||||
$companyNames = $this->companyNames($rows->where('feed', FeedFetch::FEED_NEWSROOM)->pluck('feed_key')->filter()->unique()->values()->all());
|
||||
|
||||
return $rows->map(function (object $row) use ($categoryNames, $companyNames): array {
|
||||
$portal = Portal::tryFrom((string) $row->portal)?->label() ?? (string) $row->portal;
|
||||
$key = $row->feed_key === null ? null : (string) $row->feed_key;
|
||||
|
||||
$label = match ((string) $row->feed) {
|
||||
FeedFetch::FEED_CATEGORY => __('Branche').' · '.($categoryNames[$key.'|'.$row->language] ?? $key),
|
||||
FeedFetch::FEED_NEWSROOM => __('Newsroom').' · '.($companyNames[$key] ?? $key),
|
||||
default => __('Alle Meldungen'),
|
||||
};
|
||||
|
||||
return [
|
||||
'feed' => (string) $row->feed,
|
||||
'feed_key' => $key,
|
||||
'portal' => (string) $row->portal,
|
||||
'language' => (string) $row->language,
|
||||
'label' => $portal.' · '.strtoupper((string) $row->language).' · '.$label,
|
||||
'fetches' => (int) $row->fetches,
|
||||
'readers' => (int) $row->readers,
|
||||
'subscribers' => $row->subscribers === null ? null : (int) $row->subscribers,
|
||||
'last_at' => Carbon::parse((string) $row->last_at),
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Abrufe gesamt und je Feed-Art im Zeitraum.
|
||||
*
|
||||
* @return array{total: int, portal: int, category: int, newsroom: int, since_total: int}
|
||||
*/
|
||||
public function totals(int $days = 30): array
|
||||
{
|
||||
$byFeed = FeedFetch::query()
|
||||
->where('created_at', '>=', now()->subDays($days))
|
||||
->selectRaw('feed, count(*) as fetches')
|
||||
->groupBy('feed')
|
||||
->toBase()
|
||||
->pluck('fetches', 'feed');
|
||||
|
||||
return [
|
||||
'total' => (int) $byFeed->sum(),
|
||||
'portal' => (int) ($byFeed[FeedFetch::FEED_PORTAL] ?? 0),
|
||||
'category' => (int) ($byFeed[FeedFetch::FEED_CATEGORY] ?? 0),
|
||||
'newsroom' => (int) ($byFeed[FeedFetch::FEED_NEWSROOM] ?? 0),
|
||||
'since_total' => FeedFetch::query()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Abrufe je Feed für die Absenderansicht einer Meldung (30 Tage):
|
||||
* Schlüssel `feed:key:portal:language`.
|
||||
*
|
||||
* @param list<array{feed: string, feed_key: string|null, portal: string, language: string}> $feeds
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function fetchesFor(array $feeds, int $days = 30): array
|
||||
{
|
||||
if ($feeds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$since = now()->subDays($days);
|
||||
$counts = [];
|
||||
|
||||
foreach ($feeds as $feed) {
|
||||
$key = implode(':', [$feed['feed'], (string) $feed['feed_key'], $feed['portal'], $feed['language']]);
|
||||
|
||||
$counts[$key] = FeedFetch::query()
|
||||
->where('feed', $feed['feed'])
|
||||
->when($feed['feed_key'] === null, fn ($query) => $query->whereNull('feed_key'), fn ($query) => $query->where('feed_key', $feed['feed_key']))
|
||||
->where('portal', $feed['portal'])
|
||||
->where('language', $feed['language'])
|
||||
->where('created_at', '>=', $since)
|
||||
->count();
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $slugs
|
||||
* @return array<string, string> `slug|locale` => Name
|
||||
*/
|
||||
private function categoryNames(array $slugs): array
|
||||
{
|
||||
if ($slugs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Category::query()
|
||||
->join('category_translations', 'category_translations.category_id', '=', 'categories.id')
|
||||
->whereIn('category_translations.slug', $slugs)
|
||||
->toBase()
|
||||
->get(['category_translations.slug', 'category_translations.locale', 'category_translations.name'])
|
||||
->mapWithKeys(fn (object $row): array => [$row->slug.'|'.$row->locale => (string) $row->name])
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $slugs
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function companyNames(array $slugs): array
|
||||
{
|
||||
if ($slugs === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Nur der PortalScope entfällt (Feeds beider Portale im Bericht);
|
||||
// entfernte oder gelöschte Firmen bleiben mit ihrem Namen lesbar.
|
||||
return Company::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->withTrashed()
|
||||
->whereIn('slug', $slugs)
|
||||
->toBase()
|
||||
->pluck('name', 'slug')
|
||||
->map(fn ($name): string => (string) $name)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ use App\Enums\PressReleaseShareChannel;
|
|||
use App\Enums\PressReleaseStatus;
|
||||
use App\Http\Middleware\SetEdition;
|
||||
use App\Models\Company;
|
||||
use App\Models\FeedFetch;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseEvent;
|
||||
use App\Scopes\PortalScope;
|
||||
|
|
@ -135,7 +136,7 @@ class VisibilityStats
|
|||
/**
|
||||
* 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, crawlers: list<array{service: string, label: string, kind: string, fetches: int, last_at: Carbon}>, crawlers_since: Carbon|null, feeds: list<array{label: string, url: string}>}
|
||||
* @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, feeds: list<array{label: string, url: string, fetches_30d: int}>}
|
||||
*/
|
||||
public function releaseSummary(PressRelease $pressRelease): array
|
||||
{
|
||||
|
|
@ -179,7 +180,7 @@ class VisibilityStats
|
|||
* Meldungen — eine Aussage über uns, nicht über Abonnenten.
|
||||
*
|
||||
* @param list<array{portal: Portal, label: string, url: string}> $links
|
||||
* @return list<array{label: string, url: string}>
|
||||
* @return list<array{label: string, url: string, fetches_30d: int}>
|
||||
*/
|
||||
private function feedLinks(PressRelease $pressRelease, array $links): array
|
||||
{
|
||||
|
|
@ -196,11 +197,13 @@ class VisibilityStats
|
|||
|
||||
foreach ($links as $link) {
|
||||
$base = (string) parse_url($link['url'], PHP_URL_SCHEME).'://'.(string) parse_url($link['url'], PHP_URL_HOST);
|
||||
$portal = $link['portal']->value;
|
||||
|
||||
if ($categorySlug !== null) {
|
||||
$feeds[] = [
|
||||
'label' => $link['label'].' · '.$categoryName,
|
||||
'url' => $base.'/'.$edition.'/category/'.$categorySlug.'/feed',
|
||||
'feed' => FeedFetch::FEED_CATEGORY, 'feed_key' => $categorySlug, 'portal' => $portal, 'language' => $edition,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -208,16 +211,26 @@ class VisibilityStats
|
|||
$feeds[] = [
|
||||
'label' => $link['label'].' · Newsroom',
|
||||
'url' => $base.'/'.$edition.'/newsroom/'.$companySlug.'/feed',
|
||||
'feed' => FeedFetch::FEED_NEWSROOM, 'feed_key' => $companySlug, 'portal' => $portal, 'language' => $edition,
|
||||
];
|
||||
}
|
||||
|
||||
$feeds[] = [
|
||||
'label' => $link['label'],
|
||||
'url' => $base.'/'.$edition.'/feed',
|
||||
'feed' => FeedFetch::FEED_PORTAL, 'feed_key' => null, 'portal' => $portal, 'language' => $edition,
|
||||
];
|
||||
}
|
||||
|
||||
return $feeds;
|
||||
// Abrufe je Feed in den letzten 30 Tagen (Feed-Abruf-Protokoll, 3.1):
|
||||
// eine Aussage über uns — wie oft der Feed geholt wurde, nicht von wem.
|
||||
$fetches = app(FeedFetchReport::class)->fetchesFor($feeds);
|
||||
|
||||
return array_map(function (array $feed) use ($fetches): array {
|
||||
$key = implode(':', [$feed['feed'], (string) $feed['feed_key'], $feed['portal'], $feed['language']]);
|
||||
|
||||
return ['label' => $feed['label'], 'url' => $feed['url'], 'fetches_30d' => (int) ($fetches[$key] ?? 0)];
|
||||
}, $feeds);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue