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
+1
View File
@@ -0,0 +1 @@
Require all denied
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class Database
{
public static function connect(string $path): PDO
{
$dir = dirname($path);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new \RuntimeException(sprintf('Unable to create database directory "%s".', $dir));
}
$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
$pdo->exec('PRAGMA journal_mode = WAL');
$pdo->exec('PRAGMA busy_timeout = 5000');
$pdo->exec('PRAGMA foreign_keys = ON');
self::migrate($pdo);
return $pdo;
}
private static function migrate(PDO $pdo): void
{
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
feed_label TEXT,
feed_url TEXT NOT NULL,
title TEXT NOT NULL,
link TEXT NOT NULL,
author TEXT,
published_at TEXT,
description TEXT,
thumbnail TEXT,
fetched_at TEXT NOT NULL
)
SQL);
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_items_published ON items (published_at DESC)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_items_source ON items (source, published_at DESC)');
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS feeds (
url TEXT PRIMARY KEY,
source TEXT NOT NULL,
label TEXT,
last_attempt_at TEXT,
last_success_at TEXT,
last_error TEXT
)
SQL);
}
}
+303
View File
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
namespace App;
use DOMDocument;
use DOMElement;
use DOMXPath;
use GuzzleHttp\Client;
use Laminas\Feed\Reader\Reader;
final class FeedFetcher
{
private const MAX_DESCRIPTION_LENGTH = 600;
private const MAX_ATTEMPTS = 2;
private Client $http;
/** @var array<string, float> */
private array $lastRequestPerHost = [];
private int $hostIntervalMs;
public function __construct(private array $options = [])
{
$this->hostIntervalMs = (int) ($options['host_interval_ms'] ?? 1000);
$this->http = new Client([
'timeout' => $options['request_timeout'] ?? 15,
'connect_timeout' => $options['connect_timeout'] ?? 10,
'http_errors' => false,
'allow_redirects' => true,
'headers' => [
'User-Agent' => $options['user_agent'] ?? 'RSSFeed/1.0',
'Accept' => 'application/atom+xml,application/rss+xml,application/xml,text/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.9',
],
]);
}
/**
* Fetch and normalize a single feed.
*
* @return array{title: ?string, items: array<int, array<string, mixed>>}
*/
public function fetch(array $feed): array
{
$url = (string) $feed['url'];
$source = (string) $feed['source'];
$label = (string) ($feed['label'] ?? $url);
$xml = $this->download($url);
$parsed = Reader::importString($xml);
$extras = self::extractExtras($xml);
$now = gmdate('c');
$items = [];
foreach ($parsed as $entry) {
$link = self::safeUrl((string) $entry->getLink());
if ($link === null) {
continue;
}
$title = self::cleanText((string) $entry->getTitle());
$description = (string) $entry->getDescription();
if (trim($description) === '') {
$description = (string) $entry->getContent();
}
$thumbnail = $extras[$link]['thumbnail'] ?? null;
$mediaDescription = $extras[$link]['description'] ?? null;
if (trim($description) === '' && $mediaDescription !== null) {
$description = $mediaDescription;
}
if ($thumbnail === null) {
$enclosure = $entry->getEnclosure();
if ($enclosure !== null && self::isImageEnclosure($enclosure)) {
$thumbnail = (string) $enclosure->url;
}
}
if ($source === 'youtube' && $thumbnail === null) {
$videoId = self::youtubeVideoId($link);
if ($videoId !== null) {
$thumbnail = 'https://i.ytimg.com/vi/' . $videoId . '/mqdefault.jpg';
}
}
$date = $entry->getDateCreated() ?: $entry->getDateModified();
$items[] = [
'id' => hash('sha256', $link),
'source' => $source,
'feed_label' => $label,
'feed_url' => $url,
'title' => $title !== '' ? $title : '(untitled)',
'link' => $link,
'author' => self::authorName($entry->getAuthor()),
'published_at' => $date?->setTimezone(new \DateTimeZone('UTC'))->format('c'),
'description' => self::cleanText($description, self::MAX_DESCRIPTION_LENGTH),
'thumbnail' => self::safeUrl($thumbnail),
'fetched_at' => $now,
];
}
return ['title' => (string) $parsed->getTitle(), 'items' => $items];
}
private function download(string $url): string
{
$attempt = 0;
$lastStatus = 0;
while ($attempt < self::MAX_ATTEMPTS) {
$attempt++;
$this->throttle($url);
$response = $this->http->get($url);
$lastStatus = $response->getStatusCode();
if ($lastStatus === 200) {
return (string) $response->getBody();
}
// Only retry transient server errors; client errors (incl. 429) are
// handled by the caller with a staggered cooldown.
if ($lastStatus < 500) {
break;
}
if ($attempt < self::MAX_ATTEMPTS) {
$retryAfter = (int) $response->getHeaderLine('Retry-After');
$delay = $retryAfter > 0 ? min($retryAfter, 3) : 1;
sleep($delay);
}
}
throw new \RuntimeException(sprintf('HTTP %d while fetching %s', $lastStatus, $url));
}
/**
* Space out requests to the same host to avoid provider rate limiting.
*/
private function throttle(string $url): void
{
$host = (string) parse_url($url, PHP_URL_HOST);
if ($host === '' || $this->hostIntervalMs <= 0) {
return;
}
$now = microtime(true);
if (isset($this->lastRequestPerHost[$host])) {
$elapsedMs = ($now - $this->lastRequestPerHost[$host]) * 1000;
$waitMs = $this->hostIntervalMs - $elapsedMs;
if ($waitMs > 0) {
usleep((int) ($waitMs * 1000));
}
}
$this->lastRequestPerHost[$host] = microtime(true);
}
/**
* Extract media thumbnails / descriptions from the raw XML, which the
* laminas reader does not expose (YouTube media: RSS extension).
*
* @return array<string, array{thumbnail: ?string, description: ?string}>
*/
private static function extractExtras(string $xml): array
{
$dom = new DOMDocument();
$previous = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (!$loaded) {
return [];
}
$xpath = new DOMXPath($dom);
$nodes = $xpath->query('//*[local-name()="entry" or local-name()="item"]');
if ($nodes === false) {
return [];
}
$map = [];
foreach ($nodes as $node) {
$link = self::nodeLink($xpath, $node);
if ($link === '') {
continue;
}
$thumbnail = null;
$thumbNode = $xpath->query('.//*[local-name()="thumbnail"]', $node)->item(0);
if ($thumbNode instanceof DOMElement) {
$thumbnail = $thumbNode->getAttribute('url');
}
$description = null;
$descNode = $xpath->query('.//*[local-name()="group"]/*[local-name()="description"]', $node)->item(0);
if ($descNode !== null) {
$description = $descNode->textContent;
}
$map[$link] = [
'thumbnail' => self::safeUrl($thumbnail),
'description' => $description,
];
}
return $map;
}
private static function nodeLink(DOMXPath $xpath, \DOMNode $node): string
{
$links = $xpath->query('./*[local-name()="link"]', $node);
if ($links === false) {
return '';
}
$fallback = '';
foreach ($links as $link) {
if ($link instanceof DOMElement && $link->hasAttribute('href')) {
$href = trim($link->getAttribute('href'));
if ($link->getAttribute('rel') === 'alternate') {
return $href;
}
$fallback = $fallback ?: $href;
} else {
$fallback = $fallback ?: trim($link->textContent);
}
}
return $fallback;
}
private static function authorName(array|string|null $author): ?string
{
$name = is_array($author) ? ($author['name'] ?? null) : $author;
if (!is_string($name)) {
return null;
}
$name = ltrim(self::cleanText($name), '/');
return $name === '' ? null : $name;
}
private static function youtubeVideoId(string $link): ?string
{
if (preg_match('~[?&]v=([A-Za-z0-9_-]{6,})~', $link, $m)) {
return $m[1];
}
return null;
}
private static function cleanText(string $text, int $limit = 0): string
{
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
$text = trim($text);
if ($limit > 0 && mb_strlen($text) > $limit) {
$text = mb_substr($text, 0, $limit) . '…';
}
return $text;
}
private static function isImageEnclosure(object $enclosure): bool
{
$url = isset($enclosure->url) ? (string) $enclosure->url : '';
$type = isset($enclosure->type) ? (string) $enclosure->type : '';
if ($url === '' || self::safeUrl($url) === null) {
return false;
}
if (str_starts_with($type, 'image/')) {
return true;
}
$path = strtolower((string) parse_url($url, PHP_URL_PATH));
return (bool) preg_match('/\.(jpe?g|png|gif|webp|avif)$/', $path);
}
private static function safeUrl(?string $url): ?string
{
if ($url === null) {
return null;
}
$url = trim($url);
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https'], true) ? $url : null;
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class FeedRepository
{
public function __construct(private PDO $pdo)
{
}
/**
* Keep the feeds table in sync with the configured feed list.
*
* @param array<int, array<string, mixed>> $feeds
*/
public function syncFeeds(array $feeds): void
{
$stmt = $this->pdo->prepare(<<<'SQL'
INSERT INTO feeds (url, source, label)
VALUES (:url, :source, :label)
ON CONFLICT(url) DO UPDATE SET source = excluded.source, label = excluded.label
SQL);
foreach ($feeds as $feed) {
$stmt->execute([
':url' => (string) $feed['url'],
':source' => (string) $feed['source'],
':label' => (string) ($feed['label'] ?? $feed['url']),
]);
}
}
/**
* Return the subset of feeds whose last attempt is missing or older than TTL.
* Feeds that previously failed use a shorter retry interval.
*
* @param array<int, array<string, mixed>> $feeds
* @return array<int, array<string, mixed>>
*/
public function staleFeeds(array $feeds, int $ttlSeconds, int $errorTtlSeconds): array
{
$attempts = [];
foreach ($this->pdo->query('SELECT url, last_attempt_at, last_error FROM feeds') as $row) {
$attempts[(string) $row['url']] = $row;
}
$now = time();
$stale = [];
foreach ($feeds as $feed) {
$row = $attempts[(string) $feed['url']] ?? null;
$last = $row['last_attempt_at'] ?? null;
if ($last === null) {
$stale[] = $feed;
continue;
}
$age = $now - (int) strtotime((string) $last);
$isError = ($row['last_error'] ?? null) !== null;
$threshold = $ttlSeconds;
if ($isError) {
// Stagger retries so rate-limited feeds do not all retry at once.
$threshold = $errorTtlSeconds + (crc32((string) $feed['url']) % max(1, $errorTtlSeconds));
}
if ($age >= $threshold) {
$stale[] = $feed;
}
}
return $stale;
}
/**
* Insert new items, ignoring ones already stored (deduplicated by link hash).
*
* @param array<int, array<string, mixed>> $items
*/
public function upsertItems(array $items): int
{
if ($items === []) {
return 0;
}
$stmt = $this->pdo->prepare(<<<'SQL'
INSERT OR IGNORE INTO items
(id, source, feed_label, feed_url, title, link, author, published_at, description, thumbnail, fetched_at)
VALUES
(:id, :source, :feed_label, :feed_url, :title, :link, :author, :published_at, :description, :thumbnail, :fetched_at)
SQL);
$inserted = 0;
$this->pdo->beginTransaction();
try {
foreach ($items as $item) {
$stmt->execute([
':id' => $item['id'],
':source' => $item['source'],
':feed_label' => $item['feed_label'],
':feed_url' => $item['feed_url'],
':title' => $item['title'],
':link' => $item['link'],
':author' => $item['author'],
':published_at' => $item['published_at'],
':description' => $item['description'],
':thumbnail' => $item['thumbnail'],
':fetched_at' => $item['fetched_at'],
]);
$inserted += $stmt->rowCount();
}
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
return $inserted;
}
public function recordAttempt(string $url, bool $success, ?string $error = null): void
{
$now = gmdate('c');
if ($success) {
$stmt = $this->pdo->prepare(
'UPDATE feeds SET last_attempt_at = :now, last_success_at = :now2, last_error = NULL WHERE url = :url'
);
$stmt->execute([':now' => $now, ':now2' => $now, ':url' => $url]);
return;
}
$stmt = $this->pdo->prepare(
'UPDATE feeds SET last_attempt_at = :now, last_error = :error WHERE url = :url'
);
$stmt->execute([':now' => $now, ':error' => $error, ':url' => $url]);
}
/**
* Build the shared WHERE clause for item queries.
*
* @param array<string, string> $params
*/
private function itemFilter(?string $source, ?string $since, array &$params): string
{
$where = [];
if ($source !== null) {
$where[] = 'source = :source';
$params[':source'] = $source;
}
if ($since !== null) {
$where[] = 'COALESCE(published_at, fetched_at) >= :since';
$params[':since'] = $since;
}
return $where === [] ? '' : ' WHERE ' . implode(' AND ', $where);
}
/**
* @return array<int, array<string, mixed>>
*/
public function getItems(int $limit, int $offset, ?string $source = null, ?string $since = null): array
{
$limit = max(1, $limit);
$offset = max(0, $offset);
$params = [];
$sql = 'SELECT * FROM items' . $this->itemFilter($source, $since, $params)
. ' ORDER BY COALESCE(published_at, fetched_at) DESC, id LIMIT :limit OFFSET :offset';
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, PDO::PARAM_STR);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function countItems(?string $source = null, ?string $since = null): int
{
$params = [];
$sql = 'SELECT COUNT(*) FROM items' . $this->itemFilter($source, $since, $params);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return (int) $stmt->fetchColumn();
}
/**
* @return array<int, array{url: string, label: string, source: string, last_success_at: ?string, last_error: ?string}>
*/
public function feedsStatus(): array
{
return $this->pdo->query('SELECT url, label, source, last_success_at, last_error FROM feeds ORDER BY label')->fetchAll();
}
}
+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);
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App;
final class View
{
public static function e(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
public static function sourceLabel(string $source): string
{
return match ($source) {
'youtube' => 'YouTube',
'reddit' => 'Reddit',
'rt' => 'RT',
default => ucfirst($source),
};
}
public static function sourceBadge(string $source): string
{
return match ($source) {
'youtube' => 'text-bg-danger',
'reddit' => 'text-bg-warning',
'rt' => 'text-bg-dark',
default => 'text-bg-secondary',
};
}
public static function sourceColor(string $source): string
{
return match ($source) {
'youtube' => '#ff1f4b',
'reddit' => '#ff6a00',
'rt' => '#00c389',
default => '#6a3dff',
};
}
public static function rangeLabel(string $range): string
{
return match ($range) {
'1h' => '1 hour',
'2h' => '2 hours',
'6h' => '6 hours',
'24h' => '24 hours',
'7d' => '7 days',
default => 'All time',
};
}
public static function render(string $template, array $data = []): string
{
$path = dirname(__DIR__) . '/templates/' . $template . '.php';
if (!is_file($path)) {
throw new \RuntimeException(sprintf('Template "%s" not found.', $template));
}
extract($data, EXTR_SKIP);
ob_start();
include $path;
return (string) ob_get_clean();
}
public static function timeAgo(?string $iso): string
{
if ($iso === null || $iso === '') {
return '';
}
$timestamp = strtotime($iso);
if ($timestamp === false) {
return '';
}
$diff = time() - $timestamp;
if ($diff < 60) {
return 'just now';
}
$units = [
31536000 => 'y',
2592000 => 'mo',
604800 => 'w',
86400 => 'd',
3600 => 'h',
60 => 'm',
];
foreach ($units as $seconds => $label) {
if ($diff >= $seconds) {
return floor($diff / $seconds) . $label . ' ago';
}
}
return 'just now';
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
$config = require dirname(__DIR__) . '/config/app.php';
$feeds = require dirname(__DIR__) . '/config/feeds.php';
$pdo = App\Database::connect($config['db_path']);
$repository = new App\FeedRepository($pdo);
$fetcher = new App\FeedFetcher($config);
$service = new App\FeedService(
$repository,
$fetcher,
$feeds,
(int) $config['cache_ttl'],
(int) ($config['error_retry_ttl'] ?? 300),
(array) ($config['throttled_hosts'] ?? []),
(int) ($config['throttled_host_max_per_refresh'] ?? 1)
);
return [
'config' => $config,
'feeds' => $feeds,
'pdo' => $pdo,
'repository' => $repository,
'fetcher' => $fetcher,
'service' => $service,
];