Initial commit: RSS feed dashboard aggregating YouTube, Reddit and RT
- PHP 8 + PDO SQLite cache with on-demand TTL refresh and dedup by link hash - laminas-feed + Guzzle for RSS/Atom parsing and HTTP - Bootstrap 5 UI with source and time-range filters, AJAX load-more - User cron-free deployment under Apache/php-fpm; .htaccess hardening
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class FeedService
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $feeds
|
||||
*/
|
||||
public function __construct(
|
||||
private FeedRepository $repository,
|
||||
private FeedFetcher $fetcher,
|
||||
private array $feeds,
|
||||
private int $ttlSeconds,
|
||||
private int $errorRetryTtlSeconds = 300,
|
||||
private array $throttledHosts = [],
|
||||
private int $throttledHostMax = 1,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh every feed whose cached copy is stale. Failures are recorded and
|
||||
* skipped so previously cached items remain available.
|
||||
*
|
||||
* @return array{attempted: int, succeeded: int, failed: int, new_items: int, errors: array<int, string>}
|
||||
*/
|
||||
public function refreshStale(): array
|
||||
{
|
||||
$this->repository->syncFeeds($this->feeds);
|
||||
|
||||
$report = [
|
||||
'attempted' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'new_items' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
$hostCounts = [];
|
||||
|
||||
foreach ($this->repository->staleFeeds($this->feeds, $this->ttlSeconds, $this->errorRetryTtlSeconds) as $feed) {
|
||||
$url = (string) $feed['url'];
|
||||
$host = (string) parse_url($url, PHP_URL_HOST);
|
||||
|
||||
if (in_array($host, $this->throttledHosts, true)) {
|
||||
$seen = $hostCounts[$host] ?? 0;
|
||||
if ($seen >= $this->throttledHostMax) {
|
||||
// Leave it stale so a later request picks it up.
|
||||
continue;
|
||||
}
|
||||
$hostCounts[$host] = $seen + 1;
|
||||
}
|
||||
|
||||
$report['attempted']++;
|
||||
|
||||
try {
|
||||
$result = $this->fetcher->fetch($feed);
|
||||
$report['new_items'] += $this->repository->upsertItems($result['items']);
|
||||
$this->repository->recordAttempt($url, true);
|
||||
$report['succeeded']++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->repository->recordAttempt($url, false, $e->getMessage());
|
||||
$report['failed']++;
|
||||
$report['errors'][] = ($feed['label'] ?? $url) . ': ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $report;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{items: array<int, array<string, mixed>>, page: int, page_size: int, total: int, has_more: bool}
|
||||
*/
|
||||
public function page(?string $source, int $page, int $pageSize, ?string $since = null): array
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$pageSize = max(1, $pageSize);
|
||||
$offset = ($page - 1) * $pageSize;
|
||||
|
||||
$items = $this->repository->getItems($pageSize, $offset, $source, $since);
|
||||
$total = $this->repository->countItems($source, $since);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'total' => $total,
|
||||
'has_more' => ($offset + count($items)) < $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function feedsStatus(): array
|
||||
{
|
||||
return $this->repository->feedsStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function sources(): array
|
||||
{
|
||||
$sources = [];
|
||||
foreach ($this->feeds as $feed) {
|
||||
$sources[(string) $feed['source']] = true;
|
||||
}
|
||||
|
||||
return array_keys($sources);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user