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:
Kevin Adametz 2026-09-21 09:00:09 +00:00
parent ff75159b8f
commit fc700677ff
18 changed files with 794 additions and 13 deletions

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