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:
Harwinder Sekhon
2026-10-05 17:07:48 -04:00
commit ad4dc6b984
21 changed files with 2732 additions and 0 deletions
+114
View File
@@ -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);
}
}