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
|
|
@ -251,6 +251,8 @@ 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
|
||||
# Abruf-Protokoll der RSS-Feeds (Roadmap 3.1): beim Deploy auf den Deploy-Tag setzen.
|
||||
FEED_FETCHES_RECORDED_SINCE=2026-09-21
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AppSetting;
|
||||
use App\Models\FeedFetch;
|
||||
use App\Models\PressReleaseCrawlerHit;
|
||||
use App\Models\PressReleaseEvent;
|
||||
use Illuminate\Console\Command;
|
||||
|
|
@ -111,6 +112,22 @@ class PurgePressReleaseEvents extends Command
|
|||
|
||||
$this->info(sprintf('Crawler-Abruf-Protokoll bereinigt: %d Einträge gelöscht.', $crawlerDeleted));
|
||||
|
||||
// Abruf-Protokoll der RSS-Feeds (3.1): gleiche Aufbewahrung, ebenfalls
|
||||
// nirgends aufsummiert.
|
||||
$feedDeleted = 0;
|
||||
|
||||
while (true) {
|
||||
$removed = FeedFetch::query()->where('created_at', '<', $cutoff)->limit($chunk)->delete();
|
||||
|
||||
if ($removed === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$feedDeleted += $removed;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Feed-Abruf-Protokoll bereinigt: %d Einträge gelöscht.', $feedDeleted));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\FeedFetch;
|
||||
use App\Models\PressRelease;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\Company\MergedNewsroomLookup;
|
||||
|
|
@ -20,6 +21,8 @@ use Illuminate\Http\Response;
|
|||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
use function Illuminate\Support\defer;
|
||||
|
||||
/**
|
||||
* RSS-Feeds (Roadmap 3.1): je Portal (`/{edition}/feed`), je Branche
|
||||
* (`/{edition}/category/{slug}/feed`, inklusive Unterkategorien wie die
|
||||
|
|
@ -46,7 +49,7 @@ class FeedController extends Controller
|
|||
$context = WebPortalContext::fromRequest();
|
||||
$base = rtrim($request->getSchemeAndHttpHost(), '/');
|
||||
|
||||
return $this->respond($context, ['all'], function () use ($context, $base): string {
|
||||
return $this->respond($context, [FeedFetch::FEED_PORTAL], function () use ($context, $base): string {
|
||||
$releases = $this->releases($context)->get($this->columns());
|
||||
$brand = (array) ($context->domainConfig['brand'] ?? []);
|
||||
|
||||
|
|
@ -65,7 +68,7 @@ class FeedController extends Controller
|
|||
$base = rtrim($request->getSchemeAndHttpHost(), '/');
|
||||
$language = $context->language;
|
||||
|
||||
return $this->respond($context, ['category', $slug], function () use ($context, $base, $slug, $language): string {
|
||||
return $this->respond($context, [FeedFetch::FEED_CATEGORY, $slug], function () use ($context, $base, $slug, $language): string {
|
||||
$category = Category::query()
|
||||
->whereIn('portal', $context->portalValues)
|
||||
->where('is_active', true)
|
||||
|
|
@ -132,7 +135,7 @@ class FeedController extends Controller
|
|||
abort(404);
|
||||
}
|
||||
|
||||
return $this->respond($context, ['newsroom', $slug], function () use ($context, $base, $company, $language): string {
|
||||
return $this->respond($context, [FeedFetch::FEED_NEWSROOM, $slug], function () use ($context, $base, $company, $language): string {
|
||||
$releases = $this->releases($context)->where('company_id', $company->id)->get($this->columns());
|
||||
|
||||
if ($releases->isEmpty()) {
|
||||
|
|
@ -153,7 +156,11 @@ class FeedController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $keySegments
|
||||
* Antwort aus dem Cache (oder frisch gebaut) plus Abruf-Protokoll: jeder
|
||||
* Feed-Abruf wird nach der Antwort ohne IP festgehalten (feed_fetches),
|
||||
* auch bei warmem Cache — der Abruf ist die Messgröße, nicht der Aufbau.
|
||||
*
|
||||
* @param array{0: string, 1?: string} $keySegments Feed-Art und Schlüssel (Slug)
|
||||
* @param Closure(): string $build
|
||||
*/
|
||||
private function respond(WebPortalContext $context, array $keySegments, Closure $build): Response
|
||||
|
|
@ -163,6 +170,11 @@ class FeedController extends Controller
|
|||
$build,
|
||||
);
|
||||
|
||||
[$feed, $portal, $language, $userAgent] = [$keySegments[0], $context->portal->value, $context->language, request()->userAgent()];
|
||||
$feedKey = $keySegments[1] ?? null;
|
||||
|
||||
defer(static fn () => FeedFetch::recordDeferred($feed, $feedKey, $portal, $language, $userAgent));
|
||||
|
||||
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
|
||||
}
|
||||
|
||||
|
|
|
|||
60
app/Models/FeedFetch.php
Normal file
60
app/Models/FeedFetch.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\FeedReaderIdentifier;
|
||||
use Database\Factories\FeedFetchFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Ein Abruf eines RSS-Feeds (Roadmap 3.1). Append-Log ohne IP; `reader` ist
|
||||
* der Schlüssel aus config/feeds.php, `subscribers` die vom Reader-Dienst
|
||||
* selbst gemeldete Abonnentenzahl.
|
||||
*/
|
||||
class FeedFetch extends Model
|
||||
{
|
||||
/** @use HasFactory<FeedFetchFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const ?string UPDATED_AT = null;
|
||||
|
||||
public const FEED_PORTAL = 'portal';
|
||||
|
||||
public const FEED_CATEGORY = 'category';
|
||||
|
||||
public const FEED_NEWSROOM = 'newsroom';
|
||||
|
||||
protected $fillable = [
|
||||
'feed',
|
||||
'feed_key',
|
||||
'portal',
|
||||
'language',
|
||||
'reader',
|
||||
'subscribers',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
/**
|
||||
* Schreibt einen Abruf nach der Antwort (defer) — fehlertolerant, weil
|
||||
* die Messung den Abruf begleitet und ihn nie scheitern lassen darf.
|
||||
*/
|
||||
public static function recordDeferred(string $feed, ?string $feedKey, string $portal, string $language, ?string $userAgent): void
|
||||
{
|
||||
try {
|
||||
$userAgent = trim((string) $userAgent);
|
||||
|
||||
self::query()->create([
|
||||
'feed' => $feed,
|
||||
'feed_key' => $feedKey,
|
||||
'portal' => $portal,
|
||||
'language' => $language,
|
||||
'reader' => FeedReaderIdentifier::identify($userAgent),
|
||||
'subscribers' => FeedReaderIdentifier::subscribers($userAgent),
|
||||
'user_agent' => mb_substr($userAgent, 0, 255),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
54
app/Support/FeedReaderIdentifier.php
Normal file
54
app/Support/FeedReaderIdentifier.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Ordnet den User-Agent eines Feed-Abrufs einem bekannten Reader-Dienst zu
|
||||
* (config/feeds.php) und liest die vom Dienst gemeldete Abonnentenzahl.
|
||||
* Null, wenn kein Dienst passt — der Abruf wird trotzdem gezählt, als
|
||||
* „Sonstige".
|
||||
*/
|
||||
class FeedReaderIdentifier
|
||||
{
|
||||
public static function identify(?string $userAgent): ?string
|
||||
{
|
||||
if ($userAgent === null || trim($userAgent) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array<string, array{label: string, patterns: list<string>}> $readers */
|
||||
$readers = config('feeds.readers', []);
|
||||
|
||||
foreach ($readers as $key => $reader) {
|
||||
foreach ($reader['patterns'] as $pattern) {
|
||||
if (stripos($userAgent, $pattern) !== false) {
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abonnentenzahl, wie Feedly, Inoreader, NewsBlur, Feedbin & Co. sie im
|
||||
* User-Agent nennen („12 subscribers"). Null, wenn nichts angegeben ist.
|
||||
*/
|
||||
public static function subscribers(?string $userAgent): ?int
|
||||
{
|
||||
if ($userAgent === null || preg_match('/(\d{1,7})\s+subscribers?/i', $userAgent, $match) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $match[1];
|
||||
}
|
||||
|
||||
public static function label(?string $reader): string
|
||||
{
|
||||
if ($reader === null) {
|
||||
return __('Sonstige');
|
||||
}
|
||||
|
||||
return (string) (config('feeds.readers.'.$reader.'.label') ?? $reader);
|
||||
}
|
||||
}
|
||||
46
config/feeds.php
Normal file
46
config/feeds.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| RSS-Feeds (Roadmap 3.1) — Abruf-Protokoll
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Jeder Feed-Abruf wird ohne IP protokolliert (feed_fetches). Der
|
||||
| Reader-Dienst wird am User-Agent erkannt; `patterns` werden
|
||||
| case-insensitiv als Teilzeichenkette geprüft, der erste Treffer in
|
||||
| Reihenfolge der Liste gewinnt. Unbekannte Programme zählen als
|
||||
| „Sonstige". Die Abonnentenzahl kommt, wenn ein Dienst sie im User-Agent
|
||||
| nennt („12 subscribers"), sonst bleibt sie leer — es ist eine Angabe des
|
||||
| Dienstes, keine eigene Zählung.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// Ab wann das Protokoll läuft — davor gibt es keine Daten.
|
||||
'recorded_since' => env('FEED_FETCHES_RECORDED_SINCE', '2026-09-21'),
|
||||
|
||||
'readers' => [
|
||||
'feedly' => ['label' => 'Feedly', 'patterns' => ['Feedly']],
|
||||
'inoreader' => ['label' => 'Inoreader', 'patterns' => ['Inoreader']],
|
||||
'newsblur' => ['label' => 'NewsBlur', 'patterns' => ['NewsBlur']],
|
||||
'feedbin' => ['label' => 'Feedbin', 'patterns' => ['Feedbin']],
|
||||
'theoldreader' => ['label' => 'The Old Reader', 'patterns' => ['theoldreader']],
|
||||
'bazqux' => ['label' => 'BazQux', 'patterns' => ['BazQux']],
|
||||
'netnewswire' => ['label' => 'NetNewsWire', 'patterns' => ['NetNewsWire']],
|
||||
'reeder' => ['label' => 'Reeder', 'patterns' => ['Reeder']],
|
||||
'thunderbird' => ['label' => 'Thunderbird', 'patterns' => ['Thunderbird']],
|
||||
'outlook' => ['label' => 'Outlook', 'patterns' => ['Microsoft Office', 'Outlook']],
|
||||
'miniflux' => ['label' => 'Miniflux', 'patterns' => ['Miniflux']],
|
||||
'freshrss' => ['label' => 'FreshRSS', 'patterns' => ['FreshRSS']],
|
||||
'ttrss' => ['label' => 'Tiny Tiny RSS', 'patterns' => ['Tiny Tiny RSS']],
|
||||
'nextcloud' => ['label' => 'Nextcloud News', 'patterns' => ['NextCloud-News', 'Nextcloud-News']],
|
||||
'feeder' => ['label' => 'Feeder', 'patterns' => ['Feeder']],
|
||||
'rssapp' => ['label' => 'RSS.app', 'patterns' => ['RSS.app', 'rss.app']],
|
||||
'slack' => ['label' => 'Slack', 'patterns' => ['Slackbot', 'Slack-ImgProxy']],
|
||||
'wordpress' => ['label' => 'WordPress', 'patterns' => ['WordPress']],
|
||||
'google-feedfetcher' => ['label' => 'Google FeedFetcher', 'patterns' => ['FeedFetcher-Google']],
|
||||
'browser' => ['label' => 'Browser', 'patterns' => ['Mozilla/5.0 (Windows', 'Mozilla/5.0 (Macintosh', 'Mozilla/5.0 (X11', 'Mozilla/5.0 (iPhone', 'Mozilla/5.0 (Linux; Android']],
|
||||
],
|
||||
|
||||
];
|
||||
47
database/factories/FeedFetchFactory.php
Normal file
47
database/factories/FeedFetchFactory.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\FeedFetch;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<FeedFetch>
|
||||
*/
|
||||
class FeedFetchFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'feed' => FeedFetch::FEED_PORTAL,
|
||||
'feed_key' => null,
|
||||
'portal' => 'presseecho',
|
||||
'language' => 'de',
|
||||
'reader' => 'feedly',
|
||||
'subscribers' => 3,
|
||||
'user_agent' => 'Feedly/1.0 (+http://www.feedly.com/fetcher.html; 3 subscribers; like FeedFetcher-Google)',
|
||||
];
|
||||
}
|
||||
|
||||
public function category(string $slug): static
|
||||
{
|
||||
return $this->state(['feed' => FeedFetch::FEED_CATEGORY, 'feed_key' => $slug]);
|
||||
}
|
||||
|
||||
public function newsroom(string $slug): static
|
||||
{
|
||||
return $this->state(['feed' => FeedFetch::FEED_NEWSROOM, 'feed_key' => $slug]);
|
||||
}
|
||||
|
||||
public function reader(?string $reader, ?int $subscribers = null, ?string $userAgent = null): static
|
||||
{
|
||||
return $this->state([
|
||||
'reader' => $reader,
|
||||
'subscribers' => $subscribers,
|
||||
'user_agent' => $userAgent ?? (($reader ?? 'unknown').'-fetcher/1.0'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Abruf-Protokoll der RSS-Feeds (Roadmap 3.1): jeder Abruf eines Feeds mit
|
||||
* Art (portal, category, newsroom), Schlüssel (Kategorie- bzw. Firmen-Slug),
|
||||
* Portal, Sprache, erkanntem Reader-Dienst, der vom Dienst gemeldeten
|
||||
* Abonnentenzahl und gekürztem User-Agent.
|
||||
*
|
||||
* Reines Append-Log ohne Personenbezug: keine IP-Adresse. Abonnenten sind
|
||||
* eine Angabe des Reader-Dienstes (z. B. „Feedly/1.0 (12 subscribers)"),
|
||||
* keine eigene Zählung.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('feed_fetches', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('feed', 20);
|
||||
$table->string('feed_key', 191)->nullable();
|
||||
$table->string('portal', 20);
|
||||
$table->string('language', 5);
|
||||
$table->string('reader', 40)->nullable();
|
||||
$table->unsignedInteger('subscribers')->nullable();
|
||||
$table->string('user_agent', 255);
|
||||
$table->timestamp('created_at')->nullable();
|
||||
|
||||
$table->index(['feed', 'feed_key', 'created_at'], 'ff_feed_key_created_idx');
|
||||
$table->index(['reader', 'created_at'], 'ff_reader_created_idx');
|
||||
$table->index('created_at', 'ff_created_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('feed_fetches');
|
||||
}
|
||||
};
|
||||
|
|
@ -206,7 +206,7 @@ Braucht Betreuungszeit und ist teilweise von Dritten abhängig.
|
|||
|
||||
| Nr. | Arbeitspaket | Aufwand | Abhängigkeit |
|
||||
|---|---|---|---|
|
||||
| ~~3.1~~ | RSS-Feeds je Branche — **gebaut 18.09.2026**: `/{edition}/feed` und `/{edition}/category/{slug}/feed` (RSS 2.0, jüngste 50, inkl. Unterkategorien), Autodiscovery auf Start-, Branchen- und Archivseiten, Link in der Archiv-Leiste, Block im Panel „Auslieferung", Hub leitet aufs Portal; **21.09.2026:** Feed je Newsroom (`/newsroom/{slug}/feed`), Übersichtsseite `/rss` (Was ist RSS, wie abonnieren, alle Feeds), „RSS" in der Topleiste (Protokoll Teil 11) | S | 2.3 |
|
||||
| ~~3.1~~ | RSS-Feeds je Branche — **gebaut 18.09.2026**: `/{edition}/feed` und `/{edition}/category/{slug}/feed` (RSS 2.0, jüngste 50, inkl. Unterkategorien), Autodiscovery auf Start-, Branchen- und Archivseiten, Link in der Archiv-Leiste, Block im Panel „Auslieferung", Hub leitet aufs Portal; **21.09.2026:** Feed je Newsroom (`/newsroom/{slug}/feed`), Übersichtsseite `/rss` (Was ist RSS, wie abonnieren, alle Feeds), „RSS" in der Topleiste; Abruf-Protokoll `feed_fetches` (Reader-Dienst, gemeldete Abonnenten, keine IP) mit Block im Admin-Bericht und Abrufzahl im Panel (Protokoll Teil 11) | S | 2.3 |
|
||||
| 3.2 | Opt-in E-Mail-Digest je Branche, Double-Opt-in | M | 3.1, Bounce-Suppression live |
|
||||
| 3.3 | Agenturmodell: mehrere Firmen unter einem Konto, aus den Sammeladressen aus 1a.2 abgeleitet | L | 1a.2 |
|
||||
| 3.4 | Gezielte Ansprache kleiner PR-Agenturen und freier Texter | L | 3.3 |
|
||||
|
|
|
|||
|
|
@ -2144,7 +2144,9 @@ Befunde und was daraus wurde:
|
|||
**Bleibt offen, bewusst nicht aus dem Code heraus erledigt:**
|
||||
|
||||
- 0.7: Absatz zur Ereignistabelle (Zweck, Umfang, 395 Tage, Rechtsgrundlage)
|
||||
in der Datenschutzerklärung — Rechtstext, Betreiber.
|
||||
in der Datenschutzerklärung — Rechtstext, Betreiber. Gilt seit dem
|
||||
21.09.2026 ebenso für das Crawler- und das Feed-Abruf-Protokoll (beide
|
||||
ohne IP, gleiche Aufbewahrung).
|
||||
- 0.1: Kohortenwerte „28–55 Tage" vom Live-System (Fenster 01.06.–01.09.)
|
||||
einmal erheben und ablegen: `php artisan press-releases:view-distribution --from=2026-06-01 --to=2026-09-01 --json > dev/2026-09-01/basiswert-0.1.json`.
|
||||
- Abbruchbedingungen der Blöcke 1–3 tragen weiter Platzhalter für Zeitraum
|
||||
|
|
@ -2234,3 +2236,50 @@ Firmenportal, Autodiscovery auf der Newsroom-Seite), `RssPageTest` (4),
|
|||
`StaticPagesTest`-Datensatz, `VisibilityStatsTest`. Pint, Larastan ohne
|
||||
Befund. Deploy unverändert: `view:cache`/`route:cache`.
|
||||
|
||||
### Nachtrag 21.09.2026 — Abruf-Protokoll der Feeds
|
||||
|
||||
Frage des Betreibers: lassen sich Feed-Abrufe im System messen? Ja —
|
||||
gebaut nach dem Muster des Crawler-Protokolls (1c.0).
|
||||
|
||||
- Tabelle `feed_fetches`: Feed-Art (portal/category/newsroom), Schlüssel
|
||||
(Kategorie- bzw. Firmen-Slug), Portal, Sprache, erkannter Reader-Dienst,
|
||||
vom Dienst gemeldete Abonnentenzahl, gekürzter User-Agent, Zeitpunkt.
|
||||
**Keine IP.** Reader-Erkennung über `config/feeds.php`
|
||||
(`FeedReaderIdentifier`, 20 Dienste inkl. Browser); Abonnenten per
|
||||
Muster „12 subscribers" aus dem User-Agent (Feedly, Inoreader, NewsBlur,
|
||||
Feedbin nennen sie), sonst leer. Unbekannte Programme zählen als
|
||||
„Sonstige".
|
||||
- Schreiben per `defer()` nach der Antwort in `FeedController::respond()`,
|
||||
fehlertolerant, **auch bei warmem Cache** — der Abruf ist die Messgröße.
|
||||
Stolperstein: die Swoole-Erweiterung im Container definiert ein globales
|
||||
`defer()`, Laravels Helfer muss per `use function
|
||||
Illuminate\Support\defer` importiert werden (wie in
|
||||
`ReleaseDetailPageData`), sonst „API must be called in the coroutine".
|
||||
- Aufbewahrung wie Ereignis- und Crawler-Log (395 Tage), angehängt an
|
||||
`press-releases:purge-events`. `FEED_FETCHES_RECORDED_SINCE` beim Deploy
|
||||
auf den Deploy-Tag setzen.
|
||||
- Admin ▸ Reports ▸ Indexierung & Abrufe: neuer Block „Abrufe der
|
||||
RSS-Feeds" — Summen je Feed-Art im Zeitraum, Tabelle je Reader-Dienst
|
||||
(Abrufe, Feeds, gemeldete Abonnenten = je Feed das Maximum, summiert,
|
||||
letzter Abruf), meistabgerufene Feeds mit aufgelöstem Namen
|
||||
(`FeedFetchReport`). Verdichtung in PHP, damit MySQL und SQLite gleich
|
||||
rechnen.
|
||||
- Panel „Auslieferung": je Feed die Abrufe der letzten 30 Tage; Satz
|
||||
angepasst („Gezählt werden Abrufe der Feeds … wer sie abonniert hat, ist
|
||||
uns nicht bekannt"). RSS-Seite sagt jetzt ebenfalls, dass Abrufe gezählt
|
||||
werden und keine IP gespeichert wird.
|
||||
- Was das Protokoll **nicht** kann: Abonnenten zählen (nur die von Diensten
|
||||
gemeldete Zahl; Desktop-Reader holen einzeln) und Klicks aus dem Reader
|
||||
auf die Meldung zuordnen (Reader schicken keinen Referrer; ein
|
||||
Herkunftsparameter an den Feed-Links wurde bewusst nicht gesetzt, weil
|
||||
er Adressvarianten erzeugt).
|
||||
|
||||
Tests: `FeedFetchLogTest` (5: Erkennung, Protokoll ohne IP inkl.
|
||||
Cache-Treffer, Report je Reader/Feed, Panel, Bereinigung),
|
||||
`IndexStatusCheckTest` erweitert. Pint, Larastan ohne Befund.
|
||||
|
||||
**Deploy:** `migrate` (neue Tabelle, keine Änderung an `press_releases`),
|
||||
`FEED_FETCHES_RECORDED_SINCE` setzen, `view:cache`/`route:cache`,
|
||||
`config:cache`. **Offen (0.7, Betreiber):** Datenschutzerklärung — derselbe
|
||||
Absatz wie für Ereignis- und Crawler-Log (Zweck, Umfang ohne IP, 395 Tage).
|
||||
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ return [
|
|||
'how_step3' => 'Im Reader abonnieren',
|
||||
'how_step3_text' => 'Adresse einfügen, fertig. Ab jetzt erscheinen neue Meldungen von selbst; abbestellen geht jederzeit im Reader, ohne dass wir davon erfahren.',
|
||||
'how_p1' => 'Jeder Feed führt die jüngsten :count Meldungen, neueste zuerst. Eine Meldung erscheint wenige Minuten nach ihrer Veröffentlichung; wird eine Meldung zurückgezogen, verschwindet sie auch aus dem Feed.',
|
||||
'how_p2' => 'Die Feeds sind kostenlos, brauchen kein Konto und setzen keine Cookies. Wer sie abonniert hat, wissen wir nicht – ein Reader ruft die Adresse ab wie ein Browser eine Seite.',
|
||||
'how_p2' => 'Die Feeds sind kostenlos, brauchen kein Konto und setzen keine Cookies. Wie oft ein Feed abgerufen wird und mit welchem Programm, zählen wir; wer ihn abonniert hat, wissen wir nicht – ein Reader ruft die Adresse ab wie ein Browser eine Seite, ohne IP-Speicherung auf unserer Seite.',
|
||||
|
||||
'feeds_heading' => 'Feeds auf :brand',
|
||||
'main_eyebrow' => 'Hauptfeed',
|
||||
|
|
|
|||
|
|
@ -462,7 +462,7 @@ return [
|
|||
'how_step3' => 'Subscribe in the reader',
|
||||
'how_step3_text' => 'Paste the address – done. New releases now show up by themselves; unsubscribing happens in your reader at any time, without us ever knowing.',
|
||||
'how_p1' => 'Every feed carries the latest :count releases, newest first. A release appears within minutes of publication; if a release is withdrawn, it disappears from the feed as well.',
|
||||
'how_p2' => 'The feeds are free, need no account and set no cookies. We do not know who subscribes – a reader fetches the address the way a browser fetches a page.',
|
||||
'how_p2' => 'The feeds are free, need no account and set no cookies. We count how often a feed is fetched and by which program; we do not know who subscribes – a reader fetches the address the way a browser fetches a page, and we store no IP addresses.',
|
||||
|
||||
'feeds_heading' => 'Feeds on :brand',
|
||||
'main_eyebrow' => 'Main feed',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
use App\Enums\IndexState;
|
||||
use App\Enums\Portal;
|
||||
use App\Services\PressRelease\CrawlerHitReport;
|
||||
use App\Services\PressRelease\FeedFetchReport;
|
||||
use App\Services\SearchConsole\IndexStatusChecker;
|
||||
use App\Services\SearchConsole\IndexStatusReport;
|
||||
use App\Services\SearchConsole\SearchConsoleClient;
|
||||
|
|
@ -22,6 +23,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
|
|||
* - Welche Adresse führt Google als Quelle? Quellenbefund über
|
||||
* googleCanonical, nur für Erstquellen.
|
||||
* - Welche Such- und KI-Dienste holen die Seiten? Crawler-Abruf-Protokoll.
|
||||
* - Wie oft werden die RSS-Feeds geholt, von welchen Readern? Feed-Abruf-
|
||||
* Protokoll (3.1) — Abrufe und gemeldete Abonnenten, keine eigene Zählung.
|
||||
*
|
||||
* Der Kunde sieht davon nur, was GSC_CUSTOMER_VISIBLE freigibt (E8).
|
||||
*/
|
||||
|
|
@ -91,6 +94,7 @@ new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class e
|
|||
$report = app(IndexStatusReport::class);
|
||||
$checker = app(IndexStatusChecker::class);
|
||||
$crawlers = app(CrawlerHitReport::class);
|
||||
$feedFetches = app(FeedFetchReport::class);
|
||||
$days = in_array($this->days, [7, 30, 90], true) ? $this->days : 30;
|
||||
|
||||
return [
|
||||
|
|
@ -108,6 +112,10 @@ new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class e
|
|||
'crawlerDaily' => $crawlers->daily(14),
|
||||
'crawlerTop' => $crawlers->topReleases($days),
|
||||
'crawlersSince' => config('crawlers.recorded_since') ? \Illuminate\Support\Carbon::parse((string) config('crawlers.recorded_since')) : null,
|
||||
'feedTotals' => $feedFetches->totals($days),
|
||||
'feedReaders' => $feedFetches->byReader($days),
|
||||
'feedTop' => $feedFetches->topFeeds($days),
|
||||
'feedsSince' => config('feeds.recorded_since') ? \Illuminate\Support\Carbon::parse((string) config('feeds.recorded_since')) : null,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
|
@ -339,6 +347,72 @@ new #[Layout('components.layouts.app'), Title('Indexierung und Abrufe')] class e
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Feed-Abruf-Protokoll (3.1): Abrufe der RSS-Feeds, gemeldete Abonnenten. --}}
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Abrufe der RSS-Feeds') }}</span>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)] tabular-nums">
|
||||
{{ __(':n Abrufe in :days Tagen', ['n' => number_format($feedTotals['total'], 0, ',', '.'), 'days' => $days]) }}
|
||||
· {{ __('Portal :n', ['n' => number_format($feedTotals['portal'], 0, ',', '.')]) }}
|
||||
· {{ __('Branchen :n', ['n' => number_format($feedTotals['category'], 0, ',', '.')]) }}
|
||||
· {{ __('Newsrooms :n', ['n' => number_format($feedTotals['newsroom'], 0, ',', '.')]) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Reader-Dienst') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Abrufe :days Tage', ['days' => $days]) }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Feeds') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Gemeldete Abonnenten') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Letzter Abruf') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.rows>
|
||||
@forelse ($feedReaders as $reader)
|
||||
<flux:table.row :key="'feed-reader-'.($reader['reader'] ?? 'other')">
|
||||
<flux:table.cell><span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $reader['label'] }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($reader['fetches'], 0, ',', '.') }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ number_format($reader['feeds'], 0, ',', '.') }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12.5px] tabular-nums text-[color:var(--color-ink-2)]">{{ $reader['subscribers'] === null ? '—' : number_format($reader['subscribers'], 0, ',', '.') }}</span></flux:table.cell>
|
||||
<flux:table.cell><span class="text-[12px] tabular-nums text-[color:var(--color-ink-3)]">{{ $reader['last_at']->format('d.m.Y H:i') }}</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 kein Feed-Abruf protokolliert.') }}
|
||||
@if ($feedsSince)
|
||||
{{ __('Erfasst seit :date.', ['date' => $feedsSince->format('d.m.Y')]) }}
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table.rows>
|
||||
</flux:table>
|
||||
|
||||
@if ($feedTop !== [])
|
||||
<div class="border-t border-[color:var(--color-bg-rule)]">
|
||||
<div class="px-5 pt-4 pb-2 text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Am häufigsten abgerufene Feeds, :days Tage', ['days' => $days]) }}</div>
|
||||
<ul class="px-5 pb-4 m-0 list-none space-y-1">
|
||||
@foreach ($feedTop as $top)
|
||||
<li class="flex flex-wrap items-baseline justify-between gap-x-3 text-[12.5px]">
|
||||
<span class="text-[color:var(--color-ink)] truncate max-w-[70%]">{{ $top['label'] }}</span>
|
||||
<span class="tabular-nums text-[color:var(--color-ink-3)]">{{ trans_choice(':n Abruf|:n Abrufe', $top['fetches'], ['n' => $top['fetches']]) }} · {{ trans_choice(':n Reader|:n Reader', $top['readers'], ['n' => $top['readers']]) }}@if ($top['subscribers'] !== null) · {{ __(':n Abonnenten gemeldet', ['n' => $top['subscribers']]) }}@endif</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="px-5 py-3 border-t border-[color:var(--color-bg-rule)] text-[11.5px] text-[color:var(--color-ink-4)]">
|
||||
{{ __('Gezählt werden Abrufe der Feed-Adressen; ein Reader-Dienst holt einen Feed einmal für alle seine Nutzer. „Gemeldete Abonnenten" ist die Zahl, die Dienste wie Feedly oder Inoreader selbst im User-Agent nennen — je Feed das Maximum, summiert. Erkennung über den User-Agent (config/feeds.php), keine IP-Adressen.') }}
|
||||
@if ($feedsSince)
|
||||
{{ __('Protokoll seit :date.', ['date' => $feedsSince->format('d.m.Y')]) }}
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel overflow-hidden">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Jüngste Prüfungen') }}</span>
|
||||
|
|
|
|||
|
|
@ -1604,10 +1604,13 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
@if (($visibility['feeds'] ?? []) !== [])
|
||||
<ul class="mt-1 m-0 p-0 list-none space-y-0.5 text-[12.5px]">
|
||||
@foreach ($visibility['feeds'] as $feed)
|
||||
<li><a href="{{ $feed['url'] }}" target="_blank" rel="noopener" class="text-[color:var(--color-ink-2)] underline underline-offset-2 hover:text-[color:var(--color-ink)]">{{ $feed['label'] }}</a></li>
|
||||
<li class="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<a href="{{ $feed['url'] }}" target="_blank" rel="noopener" class="text-[color:var(--color-ink-2)] underline underline-offset-2 hover:text-[color:var(--color-ink)]">{{ $feed['label'] }}</a>
|
||||
<span class="tabular-nums text-[11.5px] text-[color:var(--color-ink-3)]">{{ trans_choice(':n Abruf|:n Abrufe', $feed['fetches_30d'] ?? 0, ['n' => number_format($feed['fetches_30d'] ?? 0, 0, ',', '.')]) }} · {{ __('30 Tage') }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Die Feeds führen die jüngsten :n Meldungen — wer sie abonniert hat, ist uns nicht bekannt.', ['n' => \App\Http\Controllers\Web\FeedController::MAX_ITEMS]) }}</p>
|
||||
<p class="mt-2 mb-0 text-[11px] text-[color:var(--color-ink-4)]">{{ __('Die Feeds führen die jüngsten :n Meldungen. Gezählt werden Abrufe der Feeds (ein Reader-Dienst holt einmal für alle seine Nutzer) — wer sie abonniert hat, ist uns nicht bekannt.', ['n' => \App\Http\Controllers\Web\FeedController::MAX_ITEMS]) }}</p>
|
||||
@else
|
||||
<p class="mt-1 mb-0 text-[12.5px] text-[color:var(--color-ink-3)]">{{ __('Derzeit in keinem Feed — die Meldung ist nicht öffentlich.') }}</p>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -203,5 +203,6 @@ test('the admin report splits the distribution by origin and lists the latest ch
|
|||
->assertSeeText('Kopie mit Verweis')
|
||||
->assertSeeText('Quellenbefund')
|
||||
->assertSeeText('Abrufe durch Such- und KI-Dienste')
|
||||
->assertSeeText('Abrufe der RSS-Feeds')
|
||||
->assertSeeText('Kundenansicht');
|
||||
});
|
||||
|
|
|
|||
150
tests/Feature/Web/FeedFetchLogTest.php
Normal file
150
tests/Feature/Web/FeedFetchLogTest.php
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\AggregatePressReleaseViews;
|
||||
use App\Console\Commands\PurgePressReleaseEvents;
|
||||
use App\Enums\Portal;
|
||||
use App\Models\AppSetting;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\FeedFetch;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\PressRelease\FeedFetchReport;
|
||||
use App\Services\PressRelease\VisibilityStats;
|
||||
use App\Support\FeedReaderIdentifier;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Abruf-Protokoll der RSS-Feeds (Roadmap 3.1): jeder Feed-Abruf wird mit
|
||||
* Art, Schlüssel, Reader-Dienst und gemeldeter Abonnentenzahl gespeichert —
|
||||
* ohne IP. Abrufe zählen wir, Abonnenten meldet der Dienst.
|
||||
*/
|
||||
function feedLogCategory(): Category
|
||||
{
|
||||
$category = Category::factory()->create(['portal' => Portal::Presseecho, 'is_active' => true]);
|
||||
$category->translations()->create(['locale' => 'de', 'name' => 'Handel', 'slug' => 'handel']);
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
function feedLogRelease(Category $category, Company $company, string $slug): PressRelease
|
||||
{
|
||||
return PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)
|
||||
->create(['slug' => $slug, 'language' => 'de', 'published_at' => now()->subHour()]);
|
||||
}
|
||||
|
||||
test('the identifier maps reader user agents and reads the reported subscriber count', function (?string $userAgent, ?string $reader, ?int $subscribers) {
|
||||
expect(FeedReaderIdentifier::identify($userAgent))->toBe($reader)
|
||||
->and(FeedReaderIdentifier::subscribers($userAgent))->toBe($subscribers);
|
||||
})->with([
|
||||
['Feedly/1.0 (+http://www.feedly.com/fetcher.html; 12 subscribers; like FeedFetcher-Google)', 'feedly', 12],
|
||||
['Mozilla/5.0 (compatible; inoreader.com; 5 subscribers; feed-id=123)', 'inoreader', 5],
|
||||
['NewsBlur Feed Fetcher - 3 subscribers - https://newsblur.com', 'newsblur', 3],
|
||||
['Feedbin feed-id:99 - 1 subscriber', 'feedbin', 1],
|
||||
['Mozilla/5.0 (X11; Linux x86_64; rv:115.0) Gecko/20100101 Thunderbird/115.0', 'thunderbird', null],
|
||||
['NetNewsWire (RSS Reader; https://netnewswire.com/)', 'netnewswire', null],
|
||||
['Mozilla/5.0 (Macintosh; Intel Mac OS X) AppleWebKit/537.36 Chrome/128.0 Safari/537.36', 'browser', null],
|
||||
['curl/8.4.0', null, null],
|
||||
['', null, null],
|
||||
[null, null, null],
|
||||
]);
|
||||
|
||||
test('every feed fetch is logged with feed, key, portal, language and reader but without an IP', function () {
|
||||
/** @var TestCase $this */
|
||||
$category = feedLogCategory();
|
||||
$company = Company::factory()->presseecho()->create(['slug' => 'nordwind', 'is_active' => true]);
|
||||
feedLogRelease($category, $company, 'nordwind-neu');
|
||||
|
||||
$feedly = 'Feedly/1.0 (+http://www.feedly.com/fetcher.html; 12 subscribers; like FeedFetcher-Google)';
|
||||
|
||||
$this->withHeaders(['User-Agent' => $feedly])->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
|
||||
// Zweiter Abruf kommt aus dem Cache — und wird trotzdem gezählt.
|
||||
$this->withHeaders(['User-Agent' => $feedly])->get('https://presseecho.test/de/category/handel/feed')->assertSuccessful();
|
||||
$this->withHeaders(['User-Agent' => 'curl/8.4.0'])->get('https://presseecho.test/de/feed')->assertSuccessful();
|
||||
$this->withHeaders(['User-Agent' => 'NetNewsWire (RSS Reader; https://netnewswire.com/)'])->get('https://presseecho.test/en/newsroom/nordwind/feed')->assertNotFound();
|
||||
$this->withHeaders(['User-Agent' => 'NetNewsWire (RSS Reader; https://netnewswire.com/)'])->get('https://presseecho.test/de/newsroom/nordwind/feed')->assertSuccessful();
|
||||
|
||||
expect(FeedFetch::query()->count())->toBe(4);
|
||||
|
||||
$categoryFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_CATEGORY)->first();
|
||||
expect($categoryFetch->feed_key)->toBe('handel')
|
||||
->and($categoryFetch->portal)->toBe('presseecho')
|
||||
->and($categoryFetch->language)->toBe('de')
|
||||
->and($categoryFetch->reader)->toBe('feedly')
|
||||
->and($categoryFetch->subscribers)->toBe(12)
|
||||
->and($categoryFetch->getAttributes())->not->toHaveKey('ip');
|
||||
|
||||
$portalFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_PORTAL)->sole();
|
||||
expect($portalFetch->feed_key)->toBeNull()
|
||||
->and($portalFetch->reader)->toBeNull()
|
||||
->and($portalFetch->subscribers)->toBeNull();
|
||||
|
||||
$newsroomFetch = FeedFetch::query()->where('feed', FeedFetch::FEED_NEWSROOM)->sole();
|
||||
expect($newsroomFetch->feed_key)->toBe('nordwind')
|
||||
->and($newsroomFetch->reader)->toBe('netnewswire');
|
||||
});
|
||||
|
||||
test('the report counts fetches per reader with subscribers as the per-feed maximum, and the top feeds by name', function () {
|
||||
/** @var TestCase $this */
|
||||
feedLogCategory();
|
||||
Company::factory()->presseecho()->create(['name' => 'Nordwind Energie AG', 'slug' => 'nordwind', 'is_active' => true]);
|
||||
|
||||
FeedFetch::factory()->category('handel')->reader('feedly', 12)->count(3)->create();
|
||||
FeedFetch::factory()->category('handel')->reader('feedly', 14)->create();
|
||||
FeedFetch::factory()->newsroom('nordwind')->reader('feedly', 2)->create();
|
||||
FeedFetch::factory()->reader('inoreader', 5)->count(2)->create();
|
||||
FeedFetch::factory()->reader(null)->create();
|
||||
FeedFetch::factory()->reader('feedly', 99)->create(['created_at' => now()->subDays(40)]);
|
||||
|
||||
$report = app(FeedFetchReport::class);
|
||||
$readers = collect($report->byReader(30))->keyBy('reader');
|
||||
|
||||
expect($readers->get('feedly')['fetches'])->toBe(5)
|
||||
->and($readers->get('feedly')['feeds'])->toBe(2)
|
||||
->and($readers->get('feedly')['subscribers'])->toBe(16)
|
||||
->and($readers->get('inoreader')['fetches'])->toBe(2)
|
||||
->and($readers->get('inoreader')['subscribers'])->toBe(5)
|
||||
->and($readers->get('')['label'])->toBe('Sonstige')
|
||||
->and($readers->get('')['subscribers'])->toBeNull();
|
||||
|
||||
$top = collect($report->topFeeds(30))->keyBy(fn (array $row) => $row['feed'].':'.$row['feed_key']);
|
||||
|
||||
expect($top->get('category:handel')['label'])->toBe('Presseecho · DE · Branche · Handel')
|
||||
->and($top->get('category:handel')['fetches'])->toBe(4)
|
||||
->and($top->get('category:handel')['subscribers'])->toBe(14)
|
||||
->and($top->get('newsroom:nordwind')['label'])->toBe('Presseecho · DE · Newsroom · Nordwind Energie AG')
|
||||
->and($top->get('portal:')['fetches'])->toBe(3)
|
||||
->and($report->totals(30))->toMatchArray(['total' => 8, 'portal' => 3, 'category' => 4, 'newsroom' => 1, 'since_total' => 9]);
|
||||
});
|
||||
|
||||
test('the delivery panel shows the fetch count per feed for the last 30 days', function () {
|
||||
/** @var TestCase $this */
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$company = Company::factory()->presseecho()->create(['slug' => 'nordwind', 'is_active' => true]);
|
||||
$customer->companies()->attach($company->id, ['role' => 'member']);
|
||||
$category = feedLogCategory();
|
||||
$release = feedLogRelease($category, $company, 'nordwind-neu');
|
||||
|
||||
FeedFetch::factory()->category('handel')->count(7)->create();
|
||||
FeedFetch::factory()->newsroom('nordwind')->count(2)->create();
|
||||
FeedFetch::factory()->count(1)->create();
|
||||
FeedFetch::factory()->category('handel')->create(['portal' => 'businessportal24']);
|
||||
FeedFetch::factory()->category('handel')->create(['created_at' => now()->subDays(45)]);
|
||||
|
||||
$feeds = collect(app(VisibilityStats::class)->releaseSummary($release)['feeds'])->keyBy('url');
|
||||
|
||||
expect($feeds->get('https://presseecho.test/de/category/handel/feed')['fetches_30d'])->toBe(7)
|
||||
->and($feeds->get('https://presseecho.test/de/newsroom/nordwind/feed')['fetches_30d'])->toBe(2)
|
||||
->and($feeds->get('https://presseecho.test/de/feed')['fetches_30d'])->toBe(1);
|
||||
});
|
||||
|
||||
test('the purge command removes feed fetches older than the retention period', function () {
|
||||
/** @var TestCase $this */
|
||||
AppSetting::setValue(AggregatePressReleaseViews::WatermarkKey, '0');
|
||||
FeedFetch::factory()->create(['created_at' => now()->subDays(400)]);
|
||||
FeedFetch::factory()->create(['created_at' => now()->subDays(10)]);
|
||||
|
||||
$this->artisan(PurgePressReleaseEvents::class, ['--days' => 395])->assertSuccessful();
|
||||
|
||||
expect(FeedFetch::query()->count())->toBe(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue