presseportale/app/Services/PressRelease/FeedFetchReport.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- 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>
2026-10-02 13:09:00 +00:00

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();
}
}