From f95e28202d4c11ca7f9561cf558c75f26c697d65 Mon Sep 17 00:00:00 2001 From: Shivam Patel Date: Mon, 9 Feb 2026 04:50:06 -0500 Subject: [PATCH] Overhaul RSS feed widget: persistence, multi-feed management, search, bookmarks - Add rss_feeds + rss_items tables with indexes and HN default seed - Add 5-min background RSS sync loop in monitor.js with 90-day prune - New /api/rss/feeds route for feed CRUD with immediate fetch on add - Rewrite /api/rss route with search, feed filter, pagination, read/bookmark PATCH - Full NewsFeed component rewrite: feed manager, search bar, filter pills, read/unread tracking, bookmarks, favicons, auto-refresh with new items badge - Remove placeholder widget, NewsFeed now spans 4 cols / 3 rows - Add rss-parser deps to Dockerfile for standalone monitor --- Dockerfile | 4 + app/api/rss/feeds/route.ts | 91 ++++++ app/api/rss/route.ts | 85 ++++- app/page.tsx | 7 +- components/widgets/NewsFeed.tsx | 559 +++++++++++++++++++++++++++++--- lib/db.ts | 33 ++ monitor.js | 80 +++++ 7 files changed, 795 insertions(+), 64 deletions(-) create mode 100644 app/api/rss/feeds/route.ts diff --git a/Dockerfile b/Dockerfile index 9e5fd02..50d8f94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,6 +51,10 @@ RUN mkdir -p /app/data && chown nextjs:nodejs /app/data COPY --from=builder --chown=nextjs:nodejs /app/monitor.js ./ COPY --from=builder --chown=nextjs:nodejs /app/node_modules/sqlite ./node_modules/sqlite COPY --from=builder --chown=nextjs:nodejs /app/node_modules/sqlite3 ./node_modules/sqlite3 +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/rss-parser ./node_modules/rss-parser +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/xml2js ./node_modules/xml2js +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/sax ./node_modules/sax +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/xmlbuilder ./node_modules/xmlbuilder COPY --chown=nextjs:nodejs start.sh ./ RUN chmod +x start.sh diff --git a/app/api/rss/feeds/route.ts b/app/api/rss/feeds/route.ts new file mode 100644 index 0000000..05893f9 --- /dev/null +++ b/app/api/rss/feeds/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; +import Parser from 'rss-parser'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const db = await getDb(); + const feeds = await db.all(` + SELECT f.*, (SELECT COUNT(*) FROM rss_items WHERE feed_id = f.id) as item_count + FROM rss_feeds f ORDER BY f.created_at ASC + `); + return NextResponse.json(feeds); +} + +export async function POST(req: NextRequest) { + const { url, name } = await req.json(); + + if (!url || typeof url !== 'string') { + return NextResponse.json({ error: 'URL is required' }, { status: 400 }); + } + + // Validate URL format + try { + new URL(url); + } catch { + return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 }); + } + + // Validate it's actually an RSS feed + const parser = new Parser({ timeout: 5000 }); + let parsed; + try { + parsed = await parser.parseURL(url); + } catch { + return NextResponse.json({ error: 'Could not parse RSS feed at this URL' }, { status: 400 }); + } + + const feedName = name?.trim() || parsed.title || new URL(url).hostname; + + const db = await getDb(); + + try { + const result = await db.run( + 'INSERT INTO rss_feeds (name, url) VALUES (?, ?)', + feedName, url.trim() + ); + const feedId = result.lastID; + + // Immediately fetch and cache items + for (const item of parsed.items || []) { + if (!item.title || !item.link) continue; + await db.run( + `INSERT OR IGNORE INTO rss_items (feed_id, title, link, pub_date, creator, snippet) + VALUES (?, ?, ?, ?, ?, ?)`, + feedId, + item.title, + item.link, + item.pubDate || item.isoDate || null, + item.creator || item.author || null, + (item.contentSnippet || item.content || '').substring(0, 500) || null + ); + } + + await db.run( + 'UPDATE rss_feeds SET last_fetched = ? WHERE id = ?', + new Date().toISOString(), feedId + ); + + const feed = await db.get('SELECT * FROM rss_feeds WHERE id = ?', feedId); + return NextResponse.json(feed, { status: 201 }); + } catch (err: unknown) { + if (err instanceof Error && err.message.includes('UNIQUE')) { + return NextResponse.json({ error: 'Feed URL already exists' }, { status: 409 }); + } + throw err; + } +} + +export async function DELETE(req: NextRequest) { + const { id } = await req.json(); + if (!id) { + return NextResponse.json({ error: 'Feed id is required' }, { status: 400 }); + } + + const db = await getDb(); + await db.run('DELETE FROM rss_items WHERE feed_id = ?', id); + await db.run('DELETE FROM rss_feeds WHERE id = ?', id); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/rss/route.ts b/app/api/rss/route.ts index 181714b..5f28f53 100644 --- a/app/api/rss/route.ts +++ b/app/api/rss/route.ts @@ -1,23 +1,76 @@ -import { NextResponse } from 'next/server'; -import Parser from 'rss-parser'; +import { NextRequest, NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; export const dynamic = 'force-dynamic'; -export async function GET() { - const parser = new Parser(); - try { - const feed = await parser.parseURL('https://news.ycombinator.com/rss'); +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const q = searchParams.get('q'); + const feedId = searchParams.get('feed_id'); + const bookmarked = searchParams.get('bookmarked'); + const limit = parseInt(searchParams.get('limit') || '50', 10); + const offset = parseInt(searchParams.get('offset') || '0', 10); - const items = feed.items.slice(0, 10).map(item => ({ - title: item.title, - link: item.link, - pubDate: item.pubDate, - creator: item.creator || 'Unknown', - contentSnippet: item.contentSnippet, - })); + const db = await getDb(); - return NextResponse.json(items); - } catch (error) { - return NextResponse.json({ error: 'Failed to parse RSS' }, { status: 500 }); + const conditions: string[] = []; + const params: (string | number)[] = []; + + if (q) { + conditions.push('(i.title LIKE ? OR i.snippet LIKE ?)'); + params.push(`%${q}%`, `%${q}%`); } + if (feedId) { + conditions.push('i.feed_id = ?'); + params.push(parseInt(feedId, 10)); + } + if (bookmarked === '1') { + conditions.push('i.bookmarked = 1'); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const items = await db.all( + `SELECT i.*, f.name as feed_name, f.url as feed_url + FROM rss_items i + LEFT JOIN rss_feeds f ON f.id = i.feed_id + ${where} + ORDER BY i.pub_date DESC + LIMIT ? OFFSET ?`, + ...params, limit, offset + ); + + return NextResponse.json(items); +} + +export async function PATCH(req: NextRequest) { + const body = await req.json(); + const { id, read, bookmarked } = body; + + if (!id) { + return NextResponse.json({ error: 'Item id is required' }, { status: 400 }); + } + + const db = await getDb(); + const updates: string[] = []; + const params: (number)[] = []; + + if (read !== undefined) { + updates.push('read = ?'); + params.push(read ? 1 : 0); + } + if (bookmarked !== undefined) { + updates.push('bookmarked = ?'); + params.push(bookmarked ? 1 : 0); + } + + if (updates.length === 0) { + return NextResponse.json({ error: 'Nothing to update' }, { status: 400 }); + } + + params.push(id); + await db.run(`UPDATE rss_items SET ${updates.join(', ')} WHERE id = ?`, ...params); + + const item = await db.get('SELECT * FROM rss_items WHERE id = ?', id); + return NextResponse.json(item); } diff --git a/app/page.tsx b/app/page.tsx index ed75215..1112dba 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -14,13 +14,8 @@ export default function Home() { {/* Row 1 & 2 (Globe spans 2 rows) */} - {/* Row 2 */} + {/* Row 2-4: NewsFeed spans 4 cols, 3 rows */} - - {/* Future widget placeholder to fill grid if needed */} -
- System Metric Placeholder -
); } diff --git a/components/widgets/NewsFeed.tsx b/components/widgets/NewsFeed.tsx index 5ba3929..4f39ab5 100644 --- a/components/widgets/NewsFeed.tsx +++ b/components/widgets/NewsFeed.tsx @@ -1,56 +1,531 @@ 'use client'; -import { Rss, ExternalLink } from 'lucide-react'; -import { useState, useEffect } from 'react'; +import { Rss, Search, Star, Settings, Plus, Trash2, X, ExternalLink } from 'lucide-react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { formatDistanceToNow } from 'date-fns'; -export function NewsFeed() { - const [news, setNews] = useState([]); - const [loading, setLoading] = useState(true); +// --- Types --- - useEffect(() => { - async function fetchNews() { - try { - const res = await fetch('/api/rss'); - const data = await res.json(); - if (Array.isArray(data)) { - setNews(data); - } - } catch (e) { - console.error(e); - } finally { - setLoading(false); - } - } - fetchNews(); - }, []); +interface FeedItem { + id: number; + feed_id: number; + title: string; + link: string; + pub_date: string | null; + creator: string | null; + snippet: string | null; + read: number; + bookmarked: number; + feed_name: string; + feed_url: string | null; +} + +interface Feed { + id: number; + name: string; + url: string; + last_fetched: string | null; + item_count: number; +} + +// --- Helpers --- + +function feedColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + const colors = [ + 'bg-amber-500/20 text-amber-400', + 'bg-blue-500/20 text-blue-400', + 'bg-emerald-500/20 text-emerald-400', + 'bg-purple-500/20 text-purple-400', + 'bg-rose-500/20 text-rose-400', + 'bg-cyan-500/20 text-cyan-400', + 'bg-orange-500/20 text-orange-400', + 'bg-indigo-500/20 text-indigo-400', + ]; + return colors[Math.abs(hash) % colors.length]; +} + +function feedHealthDot(lastFetched: string | null): string { + if (!lastFetched) return 'bg-neutral-600'; + const diff = Date.now() - new Date(lastFetched).getTime(); + if (diff < 10 * 60 * 1000) return 'bg-emerald-500'; + if (diff < 30 * 60 * 1000) return 'bg-amber-500'; + return 'bg-red-500'; +} + +function faviconUrl(feedUrl: string): string { + try { + const domain = new URL(feedUrl).hostname; + return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; + } catch { + return ''; + } +} + +function FeedFavicon({ url, size = 14 }: { url: string; size?: number }) { + const src = faviconUrl(url); + if (!src) return null; + return ( + // eslint-disable-next-line @next/next/no-img-element + { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ); +} + +// --- Sub-components --- + +function SearchBar({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const [local, setLocal] = useState(value); + const timerRef = useRef>(); + + useEffect(() => { setLocal(value); }, [value]); + + const handleChange = (v: string) => { + setLocal(v); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => onChange(v), 300); + }; return ( -
-
- - Hacker News +
+ + handleChange(e.target.value)} + className="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-7 pr-2 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500" + /> +
+ ); +} + +function FeedFilterPills({ + feeds, + activeFeedId, + onSelect, +}: { + feeds: Feed[]; + activeFeedId: number | null; + onSelect: (id: number | null) => void; +}) { + const totalItems = feeds.reduce((s, f) => s + f.item_count, 0); + + return ( +
+ + {feeds.map((f) => ( + + ))} +
+ ); +} + +function AddFeedForm({ onAdded }: { onAdded: () => void }) { + const [url, setUrl] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSubmitting(true); + try { + const res = await fetch('/api/rss/feeds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: url.trim() }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to add feed'); + } + setUrl(''); + onAdded(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Failed to add feed'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ setUrl(e.target.value)} + required + className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500" + /> + {error &&

{error}

} +
+ +
+ ); +} + +function FeedManager({ + feeds, + onChanged, + onClose, +}: { + feeds: Feed[]; + onChanged: () => void; + onClose: () => void; +}) { + const handleDelete = async (id: number) => { + try { + await fetch('/api/rss/feeds', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + onChanged(); + } catch (e) { + console.error('Failed to delete feed:', e); + } + }; + + return ( +
+
+ Manage Feeds + +
+ +
+ {feeds.map((f) => ( +
+
+
+ + {f.name} + {f.url} +
+
+ {f.last_fetched && ( + + {formatDistanceToNow(new Date(f.last_fetched), { addSuffix: true })} + + )} + +
+
+ ))} +
+
+ ); +} + +function FeedItemRow({ + item, + onRead, + onBookmark, +}: { + item: FeedItem; + onRead: (id: number) => void; + onBookmark: (id: number, bookmarked: boolean) => void; +}) { + const handleClick = () => { + window.open(item.link, '_blank', 'noopener,noreferrer'); + if (!item.read) onRead(item.id); + }; + + return ( +
+
+
+ + {item.feed_url && } + {item.feed_name || 'Unknown'} + + {item.creator && ( + {item.creator} + )} +
+

+ {item.title} +

+ {item.snippet && ( +

{item.snippet}

+ )} +
+ {item.pub_date && ( + + {formatDistanceToNow(new Date(item.pub_date), { addSuffix: true })} + + )} +
+
+ +
+ ); +} + +// --- Main Component --- + +export function NewsFeed() { + const [items, setItems] = useState([]); + const [feeds, setFeeds] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [activeFeedId, setActiveFeedId] = useState(null); + const [showBookmarked, setShowBookmarked] = useState(false); + const [showFeedManager, setShowFeedManager] = useState(false); + const [loading, setLoading] = useState(true); + const [newCount, setNewCount] = useState(0); + const prevItemIdsRef = useRef>(new Set()); + + const fetchFeeds = useCallback(async () => { + try { + const res = await fetch('/api/rss/feeds'); + const data = await res.json(); + if (Array.isArray(data)) setFeeds(data); + } catch (e) { + console.error('Failed to fetch feeds:', e); + } + }, []); + + const fetchItems = useCallback(async (opts?: { silent?: boolean; resetFeedFilter?: boolean }) => { + try { + const params = new URLSearchParams(); + if (searchQuery) params.set('q', searchQuery); + if (!opts?.resetFeedFilter && activeFeedId !== null) params.set('feed_id', String(activeFeedId)); + if (showBookmarked) params.set('bookmarked', '1'); + params.set('limit', '50'); + + const res = await fetch(`/api/rss?${params}`); + const data = await res.json(); + if (Array.isArray(data)) { + if (opts?.silent && prevItemIdsRef.current.size > 0) { + const newIds = data.filter((i: FeedItem) => !prevItemIdsRef.current.has(i.id)); + if (newIds.length > 0) { + setNewCount((c) => c + newIds.length); + } + } + prevItemIdsRef.current = new Set(data.map((i: FeedItem) => i.id)); + setItems(data); + } + } catch (e) { + console.error('Failed to fetch items:', e); + } finally { + setLoading(false); + } + }, [searchQuery, activeFeedId, showBookmarked]); + + // Initial load + refresh + useEffect(() => { + fetchFeeds(); + fetchItems(); + }, [fetchFeeds, fetchItems]); + + // Auto-refresh every 30s + useEffect(() => { + const interval = setInterval(() => { + fetchItems({ silent: true }); + fetchFeeds(); + }, 30000); + return () => clearInterval(interval); + }, [fetchItems, fetchFeeds]); + + const handleRead = async (id: number) => { + setItems((prev) => prev.map((i) => (i.id === id ? { ...i, read: 1 } : i))); + try { + await fetch('/api/rss', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, read: true }), + }); + } catch (e) { + console.error('Failed to mark as read:', e); + } + }; + + const handleBookmark = async (id: number, bookmarked: boolean) => { + setItems((prev) => + prev.map((i) => (i.id === id ? { ...i, bookmarked: bookmarked ? 1 : 0 } : i)) + ); + try { + await fetch('/api/rss', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, bookmarked }), + }); + } catch (e) { + console.error('Failed to toggle bookmark:', e); + } + }; + + const handleFeedChanged = () => { + setActiveFeedId(null); + fetchFeeds(); + fetchItems({ resetFeedFilter: true }); + }; + + const handleDismissNew = () => { + setNewCount(0); + fetchItems(); + }; + + const hasFeeds = feeds.length > 0; + + return ( +
+ {/* Header */} +
+
+ + Feed +
+
+ + + +
-
+ {/* Feed filter pills */} + {hasFeeds && ( +
+ +
+ )} + + {/* Feed manager (collapsible) */} + {showFeedManager && ( +
+ setShowFeedManager(false)} /> +
+ )} + + {/* New items badge */} + {newCount > 0 && ( + + )} + + {/* Item list */} +
{loading ? ( - [1, 2, 3].map(i => ( -
-
-
-
- )) - ) : ( - news.map((item) => ( - -

- {item.title} -

-
- {item.pubDate ? formatDistanceToNow(new Date(item.pubDate), { addSuffix: true }) : ''} +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
-
- )) + ))} +
+ ) : !hasFeeds ? ( +
+ + No feeds yet + +
+ ) : items.length === 0 ? ( +
+ + No items found +
+ ) : ( +
+ {items.map((item) => ( + + ))} +
)}
diff --git a/lib/db.ts b/lib/db.ts index 54ddc56..ba9fd5d 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -45,7 +45,40 @@ export async function getDb() { CREATE INDEX IF NOT EXISTS idx_uptime_logs_service_timestamp ON uptime_logs(service_name, timestamp); + + CREATE TABLE IF NOT EXISTS rss_feeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + last_fetched DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS rss_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feed_id INTEGER NOT NULL, + title TEXT NOT NULL, + link TEXT NOT NULL UNIQUE, + pub_date DATETIME, + creator TEXT, + snippet TEXT, + read INTEGER DEFAULT 0, + bookmarked INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_rss_items_feed ON rss_items(feed_id); + CREATE INDEX IF NOT EXISTS idx_rss_items_pubdate ON rss_items(pub_date DESC); `); + // Seed default HN feed if rss_feeds is empty + const feedCount = await db.get('SELECT COUNT(*) as cnt FROM rss_feeds'); + if (feedCount?.cnt === 0) { + await db.run( + 'INSERT INTO rss_feeds (name, url) VALUES (?, ?)', + 'Hacker News', 'https://news.ycombinator.com/rss' + ); + } + return db; } diff --git a/monitor.js b/monitor.js index 0d41f93..ffec45f 100644 --- a/monitor.js +++ b/monitor.js @@ -1,5 +1,6 @@ const sqlite3 = require('sqlite3'); const { open } = require('sqlite'); +const RSSParser = require('rss-parser'); // Node 18+ has global fetch built-in const DEFAULT_SERVICES = [ @@ -42,6 +43,78 @@ async function seedDefaults(db) { } } +async function setupRssTables(db) { + await db.exec(` + CREATE TABLE IF NOT EXISTS rss_feeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + last_fetched DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS rss_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feed_id INTEGER NOT NULL, + title TEXT NOT NULL, + link TEXT NOT NULL UNIQUE, + pub_date DATETIME, + creator TEXT, + snippet TEXT, + read INTEGER DEFAULT 0, + bookmarked INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_rss_items_feed ON rss_items(feed_id); + CREATE INDEX IF NOT EXISTS idx_rss_items_pubdate ON rss_items(pub_date DESC); + `); + + // Seed default HN feed + const count = await db.get('SELECT COUNT(*) as cnt FROM rss_feeds'); + if (count.cnt === 0) { + await db.run( + 'INSERT INTO rss_feeds (name, url) VALUES (?, ?)', + 'Hacker News', 'https://news.ycombinator.com/rss' + ); + console.log('Seeded default RSS feed (Hacker News)'); + } +} + +async function syncRssFeeds(db) { + const parser = new RSSParser({ timeout: 5000 }); + const feeds = await db.all('SELECT * FROM rss_feeds'); + + for (const feed of feeds) { + try { + const parsed = await parser.parseURL(feed.url); + for (const item of parsed.items || []) { + if (!item.title || !item.link) continue; + await db.run( + `INSERT OR IGNORE INTO rss_items (feed_id, title, link, pub_date, creator, snippet) + VALUES (?, ?, ?, ?, ?, ?)`, + feed.id, + item.title, + item.link, + item.pubDate || item.isoDate || null, + item.creator || item.author || null, + (item.contentSnippet || item.content || '').substring(0, 500) || null + ); + } + await db.run( + 'UPDATE rss_feeds SET last_fetched = ? WHERE id = ?', + new Date().toISOString(), feed.id + ); + console.log(`RSS synced: ${feed.name} (${(parsed.items || []).length} items)`); + } catch (err) { + console.error(`RSS sync error for ${feed.name}:`, err.message); + } + } + + // Prune items older than 90 days + try { + await db.run(`DELETE FROM rss_items WHERE created_at < datetime('now', '-90 days')`); + } catch (e) { } +} + async function monitor() { console.log('Starting monitoring loop...'); @@ -66,6 +139,10 @@ async function monitor() { await seedDefaults(db); + // Setup RSS tables and do initial sync + await setupRssTables(db); + syncRssFeeds(db); // initial sync, don't await to not block uptime start + setInterval(async () => { console.log('Running checks...'); const now = new Date().toISOString(); @@ -114,6 +191,9 @@ async function monitor() { } catch (e) { } }, 60000); // Run every minute + + // RSS sync every 5 minutes + setInterval(() => syncRssFeeds(db), 5 * 60 * 1000); } monitor();