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
ff75159b8f
commit
fc700677ff
18 changed files with 794 additions and 13 deletions
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