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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue