diff --git a/README.md b/README.md index cd3ee2f..fc94477 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,24 @@ Pass `discord_webhook_url` to `/api/feed` and the app will send a message to Dis --- + +## Self-Hosted Terminal Reader + +If you want to host your own simple feed reader directly in your terminal, use: + +```bash +./scripts/run_terminal_reader.sh "https://www.youtube.com/feeds/videos.xml?channel_id=UCXuqSBlHAE6Xw-yeJA0Tunw" +``` + +Optional flags: + +```bash +./scripts/run_terminal_reader.sh "" --interval 60 --limit 15 +``` + +- `--interval`: refresh frequency in seconds. +- `--limit`: max entries shown per refresh. + ## CLI RSS Reader (Python) If you are running locally and want to read recent items directly in the terminal: diff --git a/api/index.html b/api/index.html index 2d53dc1..9ac4108 100644 --- a/api/index.html +++ b/api/index.html @@ -67,6 +67,14 @@ font-size: 12px; background: #444; } + .open-reader-btn { + margin-top: 6px; + margin-left: 8px; + width: auto; + padding: 7px 10px; + font-size: 12px; + background: #0066cc; + } .feed-label { color: #aaa; font-size: 12px; margin-bottom: 4px; } .atom-output { margin-top: 15px; @@ -172,6 +180,12 @@

YouTube RSS Scanner

copyText(decodeURIComponent(encodedText)); } + function openInReader(encodedText) { + const feedUrl = decodeURIComponent(encodedText); + const feedlySubscribeUrl = 'https://feedly.com/i/subscription/feed/' + encodeURIComponent(feedUrl); + window.open(feedlySubscribeUrl, '_blank', 'noopener,noreferrer'); + } + async function getFeed() { const url = document.getElementById('channelUrl').value.trim(); const resultDiv = document.getElementById('result'); @@ -220,7 +234,8 @@

YouTube RSS Scanner

html += '
Selected RSS Feed:
'; html += ''; - html += '
'; + html += ''; + html += ''; if (data.official_feeds) { html += '
Official YouTube Feeds:
'; diff --git a/index.html b/index.html index a873bad..8d2768f 100644 --- a/index.html +++ b/index.html @@ -67,6 +67,14 @@ font-size: 12px; background: #444; } + .open-reader-btn { + margin-top: 6px; + margin-left: 8px; + width: auto; + padding: 7px 10px; + font-size: 12px; + background: #0066cc; + } .feed-label { color: #aaa; font-size: 12px; margin-bottom: 4px; } .atom-output { margin-top: 15px; @@ -167,6 +175,12 @@

YouTube RSS Scanner

} } + + function openInReader(feedUrl) { + const feedlySubscribeUrl = 'https://feedly.com/i/subscription/feed/' + encodeURIComponent(feedUrl); + window.open(feedlySubscribeUrl, '_blank', 'noopener,noreferrer'); + } + async function getFeed() { const url = document.getElementById('channelUrl').value.trim(); const resultDiv = document.getElementById('result'); @@ -259,6 +273,12 @@

YouTube RSS Scanner

copyBtn.textContent = 'Copy Selected RSS'; copyBtn.addEventListener('click', () => copyText((data.selected_feed || data.youtube_rss))); feedRow.appendChild(copyBtn); + + const openReaderBtn = document.createElement('button'); + openReaderBtn.className = 'open-reader-btn'; + openReaderBtn.textContent = 'Open in Reader'; + openReaderBtn.addEventListener('click', () => openInReader((data.selected_feed || data.youtube_rss))); + feedRow.appendChild(openReaderBtn); resultDiv.appendChild(feedRow); if (data.official_feeds) { const officialBox = document.createElement('div'); diff --git a/scripts/run_terminal_reader.sh b/scripts/run_terminal_reader.sh new file mode 100755 index 0000000..8f289b6 --- /dev/null +++ b/scripts/run_terminal_reader.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 [--interval SECONDS] [--limit N]" + exit 1 +fi + +python scripts/terminal_reader.py "$@" diff --git a/scripts/terminal_reader.py b/scripts/terminal_reader.py new file mode 100755 index 0000000..cecf32a --- /dev/null +++ b/scripts/terminal_reader.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Simple terminal RSS reader host. + +Fetches an RSS/Atom feed on an interval and prints new entries to the terminal. +""" + +from __future__ import annotations + +import argparse +import time +import urllib.request +import xml.etree.ElementTree as ET +from datetime import datetime + + +def fetch_feed(feed_url: str, timeout: int = 20) -> bytes: + req = urllib.request.Request(feed_url, headers={"User-Agent": "TerminalRSSReader/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as response: + return response.read() + + +def parse_entries(xml_bytes: bytes, limit: int) -> list[tuple[str, str, str]]: + root = ET.fromstring(xml_bytes) + + entries: list[tuple[str, str, str]] = [] + + # RSS + for item in root.findall('.//item'): + title = (item.findtext('title') or 'Untitled').strip() + link = (item.findtext('link') or '').strip() + published = (item.findtext('pubDate') or 'Unknown date').strip() + entries.append((title, link, published)) + + # Atom fallback + if not entries: + ns = {'atom': 'http://www.w3.org/2005/Atom'} + for entry in root.findall('.//atom:entry', ns): + title = (entry.findtext('atom:title', default='Untitled', namespaces=ns) or 'Untitled').strip() + link_el = entry.find('atom:link', ns) + link = (link_el.attrib.get('href', '') if link_el is not None else '').strip() + published = (entry.findtext('atom:published', default='Unknown date', namespaces=ns) or 'Unknown date').strip() + entries.append((title, link, published)) + + return entries[:limit] + + +def main() -> int: + parser = argparse.ArgumentParser(description='Host a simple RSS reader in your terminal.') + parser.add_argument('feed_url', help='RSS/Atom feed URL to watch') + parser.add_argument('--interval', type=int, default=120, help='Seconds between refreshes (default: 120)') + parser.add_argument('--limit', type=int, default=10, help='Maximum entries to show per refresh (default: 10)') + args = parser.parse_args() + + seen_links: set[str] = set() + + print(f"\n๐Ÿ“ก Terminal Reader started for: {args.feed_url}") + print(f"โฑ Refresh interval: {args.interval}s | ๐Ÿงพ Entry limit: {args.limit}") + print("Press Ctrl+C to stop.\n") + + try: + while True: + timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC') + try: + xml_bytes = fetch_feed(args.feed_url) + entries = parse_entries(xml_bytes, args.limit) + print(f"\n=== Refresh @ {timestamp} ===") + if not entries: + print('No entries found.') + for idx, (title, link, published) in enumerate(entries, start=1): + marker = '๐Ÿ†•' if link and link not in seen_links else ' ' + print(f"{idx:02d}. {marker} {title}") + print(f" Date: {published}") + if link: + print(f" Link: {link}") + if link: + seen_links.add(link) + except Exception as exc: + print(f"Error fetching/parsing feed: {exc}") + + time.sleep(args.interval) + except KeyboardInterrupt: + print("\nStopped terminal reader.") + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/templates/index.html b/templates/index.html index 18c7b15..ad230ea 100644 --- a/templates/index.html +++ b/templates/index.html @@ -67,6 +67,14 @@ font-size: 12px; background: #444; } + .open-reader-btn { + margin-top: 6px; + margin-left: 8px; + width: auto; + padding: 7px 10px; + font-size: 12px; + background: #0066cc; + } .feed-label { color: #aaa; font-size: 12px; margin-bottom: 4px; } .atom-output { margin-top: 15px; @@ -167,6 +175,19 @@

YouTube RSS Scanner

copyText(decodeURIComponent(encodedText)); } + function openInReader(encodedText) { + const feedUrl = decodeURIComponent(encodedText); + // Many desktop/mobile feed apps register the feed:// protocol. + // Convert https:// URLs to feed:// where possible to trigger RSS readers. + const feedProtocolUrl = feedUrl.startsWith('https://') + ? 'feed:' + feedUrl + : feedUrl.startsWith('http://') + ? 'feed:' + feedUrl + : feedUrl; + + window.open(feedProtocolUrl, '_blank', 'noopener,noreferrer'); + } + async function getFeed() { const url = document.getElementById('channelUrl').value.trim(); const resultDiv = document.getElementById('result'); @@ -214,7 +235,8 @@

YouTube RSS Scanner

html += '
Selected RSS Feed:
'; html += ''; - html += '
'; + html += ''; + html += '
'; if (data.official_feeds) { html += '
Official YouTube Feeds:
'; @@ -245,6 +267,12 @@

YouTube RSS Scanner

copyEncodedText(this.dataset.encoded); }); }); + + resultDiv.querySelectorAll('.open-reader-btn').forEach(button => { + button.addEventListener('click', function() { + openInReader(this.dataset.encoded); + }); + }); } } catch (e) { resultDiv.innerHTML = '
Error: ' + e.message + '
';