- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
9.3 KiB
PHP
217 lines
9.3 KiB
PHP
<?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. Ein Feed ist Art + Schlüssel + Portal + Sprache,
|
|
// wie in topFeeds() und fetchesFor(): der Portalfeed von presseecho
|
|
// und der von businessportal24 sind zwei Feeds mit zwei Abonnentenzahlen.
|
|
$rows = FeedFetch::query()
|
|
->where('created_at', '>=', now()->subDays($days))
|
|
->selectRaw('reader, feed, feed_key, portal, language, count(*) as fetches, max(subscribers) as subscribers, max(created_at) as last_at')
|
|
->groupBy('reader', 'feed', 'feed_key', 'portal', 'language')
|
|
->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
|
|
{
|
|
// Gruppiert und begrenzt in SQL (Umsetzungsaudit 29.09.2026, OPT-1).
|
|
// Reader ohne Kennung zählen wie bisher als ein eigener Reader.
|
|
$rows = FeedFetch::query()
|
|
->where('created_at', '>=', now()->subDays($days))
|
|
->selectRaw("feed, feed_key, portal, language, count(*) as fetches, count(distinct coalesce(reader, '')) as readers, max(subscribers) as subscribers, max(created_at) as last_at")
|
|
->groupBy('feed', 'feed_key', 'portal', 'language')
|
|
->orderByDesc('fetches')
|
|
->orderByDesc('last_at')
|
|
->limit($limit)
|
|
->toBase()
|
|
->get();
|
|
|
|
$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 [];
|
|
}
|
|
|
|
$key = static fn (string $feed, ?string $feedKey, string $portal, string $language): string => implode(':', [$feed, (string) $feedKey, $portal, $language]);
|
|
|
|
// Eine Abfrage für alle Feeds statt einer je Feed (OPT-1).
|
|
$found = FeedFetch::query()
|
|
->where('created_at', '>=', now()->subDays($days))
|
|
->where(function ($query) use ($feeds): void {
|
|
foreach ($feeds as $feed) {
|
|
$query->orWhere(fn ($match) => $match
|
|
->where('feed', $feed['feed'])
|
|
->when($feed['feed_key'] === null, fn ($q) => $q->whereNull('feed_key'), fn ($q) => $q->where('feed_key', $feed['feed_key']))
|
|
->where('portal', $feed['portal'])
|
|
->where('language', $feed['language']));
|
|
}
|
|
})
|
|
->selectRaw('feed, feed_key, portal, language, count(*) as fetches')
|
|
->groupBy('feed', 'feed_key', 'portal', 'language')
|
|
->toBase()
|
|
->get()
|
|
->mapWithKeys(fn (object $row): array => [
|
|
$key((string) $row->feed, $row->feed_key === null ? null : (string) $row->feed_key, (string) $row->portal, (string) $row->language) => (int) $row->fetches,
|
|
]);
|
|
|
|
$counts = [];
|
|
|
|
foreach ($feeds as $feed) {
|
|
$counts[$key($feed['feed'], $feed['feed_key'], $feed['portal'], $feed['language'])] = (int) ($found[$key($feed['feed'], $feed['feed_key'], $feed['portal'], $feed['language'])] ?? 0);
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|