From 7d62243fe610291953a1c8c277302abc34a39a0c Mon Sep 17 00:00:00 2001 From: Seth Date: Thu, 12 Mar 2026 04:05:30 +0000 Subject: [PATCH] Add hourly news briefing system with continuous marquee scroll - Fetches 12 articles hourly from FreshRSS - Generates 4-6 sentence summaries using Ollama - Creates sequential sethpc.xyz short links (pi0, pi1, etc.) via yourls - Continuous vertical marquee scrolling at reading speed - No wait between rescrolls - immediately loops to next pass - New briefing generated every hour at :00 - Systemd service and timer for auto-start and hourly scheduling --- piNail2/pinail-briefing.service | 19 +++ piNail2/pinail-briefing.timer | 11 ++ piNail2/tty_briefing.py | 265 ++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 piNail2/pinail-briefing.service create mode 100644 piNail2/pinail-briefing.timer create mode 100644 piNail2/tty_briefing.py diff --git a/piNail2/pinail-briefing.service b/piNail2/pinail-briefing.service new file mode 100644 index 0000000..eb3db39 --- /dev/null +++ b/piNail2/pinail-briefing.service @@ -0,0 +1,19 @@ +[Unit] +Description=piNail Hourly News Briefing Display +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/home/pi/piNail2 +ExecStart=/usr/bin/python3 /home/pi/piNail2/tty_briefing.py +Restart=always +RestartSec=10 +StandardOutput=tty +StandardError=file:/tmp/briefing.log +TTYPath=/dev/tty1 +SyslogIdentifier=pinail-briefing + +[Install] +WantedBy=multi-user.target diff --git a/piNail2/pinail-briefing.timer b/piNail2/pinail-briefing.timer new file mode 100644 index 0000000..e463db3 --- /dev/null +++ b/piNail2/pinail-briefing.timer @@ -0,0 +1,11 @@ +[Unit] +Description=piNail Hourly News Briefing Timer +Requires=pinail-briefing.service + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/piNail2/tty_briefing.py b/piNail2/tty_briefing.py new file mode 100644 index 0000000..1fcfa22 --- /dev/null +++ b/piNail2/tty_briefing.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +""" +Enhanced hourly news briefing with long summaries, yourls links, and continuous marquee scroll. +Fetches from FreshRSS, summarizes with Ollama, generates short URLs, rescrolls continuously. +""" +import os +import sys +import json +import time +import requests +import re +import schedule +import textwrap +from datetime import datetime + +# Debug logging +DEBUG_LOG = "/tmp/briefing_debug.log" +def debug_log(msg): + with open(DEBUG_LOG, "a") as f: + f.write("[{}] {}\n".format(datetime.now().strftime('%H:%M:%S'), msg)) + f.flush() + +# Load config from local copy +CONFIG_PATH = "/home/pi/piNail2/briefing_config.json" +with open(CONFIG_PATH, "r") as f: + config = json.load(f) + +FRESHRSS_URL = config["freshrss_url"] +FRESHRSS_USER = config["freshrss_user"] +FRESHRSS_TOKEN = config["freshrss_api_key"] +OLLAMA_URL = config["ollama_url"] +OLLAMA_MODEL = config.get("ollama_model", "mistral") +YOURLS_URL = config.get("yourls_url", "http://192.168.0.152:8080/yourls-api.php") +YOURLS_USER = config.get("yourls_user", "admin") +YOURLS_PASS = config.get("yourls_pass", "REDACTED_PASSWORD") + +def fetch_news(count=12): + """Fetch top articles from FreshRSS.""" + login_url = "{}/api/greader.php/accounts/ClientLogin".format(FRESHRSS_URL) + login_data = {"Email": FRESHRSS_USER, "Passwd": FRESHRSS_TOKEN} + try: + login_resp = requests.post(login_url, data=login_data, timeout=15) + if login_resp.status_code != 200: + return [] + auth_token = "" + for line in login_resp.text.splitlines(): + if line.startswith("Auth="): + auth_token = line.split("=", 1)[1] + break + if not auth_token: + return [] + except Exception as e: + print("[!] Login exception: {}".format(e), file=sys.stderr) + return [] + + api_url = "{}/api/greader.php/reader/api/0/stream/contents/user/-/state/com.google/reading-list".format(FRESHRSS_URL) + params = {"n": count + 5, "xt": "user/-/state/com.google/read", "output": "json"} + headers = {"Authorization": "GoogleLogin auth={}".format(auth_token)} + try: + response = requests.get(api_url, params=params, headers=headers, timeout=15) + if response.status_code != 200: + return [] + data = response.json() + items = [] + for item in data.get("items", [])[:count]: + title = item.get("title", "") + summary = item.get("summary", {}).get("content", "") or item.get("content", {}).get("content", "") + source = item.get("origin", {}).get("title", "Unknown Source") + link = item.get("alternate", [{}])[0].get("href", "") if item.get("alternate") else "" + summary_clean = re.sub('<[^<]+?>', '', summary).strip() + items.append({ + "title": title, + "summary": summary_clean, + "source": source, + "link": link + }) + return items + except Exception as e: + print("[!] Exception fetching news: {}".format(e), file=sys.stderr) + return [] + +def summarize_article(text): + """Summarize article with Ollama (longer, 4-6 sentences).""" + if not text: + return "" + payload = { + "model": OLLAMA_MODEL, + "prompt": "Provide a detailed summary in 4-6 sentences: {}".format(text[:1000]), + "stream": False, + "options": {"num_ctx": 2048, "temperature": 0.3} + } + try: + response = requests.post("{}/api/generate".format(OLLAMA_URL), json=payload, timeout=60) + return response.json().get("response", "").strip() + except: + return text[:400] + "..." + +def create_yourls_link(original_url, index): + """Generate short URL using yourls with pi____ format.""" + if not original_url: + debug_log("No URL provided") + return "" + try: + # Create sequential alias: pi0, pi1, pi2, etc. + alias = "pi{}".format(index) + debug_log("Creating yourls link with alias: {}".format(alias)) + + payload = { + "action": "shorturl", + "url": original_url, + "keyword": alias, + "username": YOURLS_USER, + "password": YOURLS_PASS, + "format": "json" + } + response = requests.post(YOURLS_URL, data=payload, timeout=10) + data = response.json() + + # Check if we got a shorturl (success or URL already exists) + if data.get("shorturl"): + short_link = data.get("shorturl", "") + debug_log("Created link {}: {}".format(alias, short_link)) + return short_link + + # Try random alias if keyword alias is taken + if "already exists" in data.get("message", "").lower() or "keyword already exists" in data.get("message", "").lower(): + debug_log("Alias {} taken, trying random".format(alias)) + payload_random = { + "action": "shorturl", + "url": original_url, + "username": YOURLS_USER, + "password": YOURLS_PASS, + "format": "json" + } + response = requests.post(YOURLS_URL, data=payload_random, timeout=10) + data = response.json() + if data.get("shorturl"): + short_link = data.get("shorturl", "") + debug_log("Created fallback link: {}".format(short_link)) + return short_link + + debug_log("yourls failed: {}".format(data.get("message", "unknown error"))) + return "" + except Exception as e: + debug_log("yourls exception: {}".format(e)) + return "" + +def render_marquee(articles, tty): + """Render articles as continuous vertical marquee.""" + # Build full content once + full_content = "" + for i, article in enumerate(articles, 1): + full_content += "\n{}\n".format("="*70) + full_content += "[{}/{}] {}\n".format(i, len(articles), article['source']) + full_content += "{}\n\n".format("─" * 70) + full_content += " {}\n".format(article['title']) + full_content += " {}\n\n".format("─" * 70) + + summary = article.get('ai_summary', article['summary'][:300]) + wrapped = textwrap.fill(summary, width=66, initial_indent=" ", subsequent_indent=" ") + full_content += "{}\n".format(wrapped) + + short_link = article.get('short_link', article.get('link', '')) + if short_link: + full_content += "\n Link: {}\n".format(short_link) + + full_content += "\n" + + full_content += "\n{}\n".format("="*70) + full_content += " --- END OF BRIEFING, RESCROLLING IN 30 SECONDS ---\n" + full_content += "{}\n".format("="*70) + + return full_content + +def scroll_content(tty, content): + """Scroll content at reading speed, continuous loop without waiting.""" + char_delay = 0.025 # 25ms per char = ~40 chars/sec + + while True: + for char in content: + tty.write(char) + tty.flush() + if char == '\n': + time.sleep(0.1) + else: + time.sleep(char_delay) + # Loop back immediately without pause + +def hourly_briefing(): + """Fetch, summarize, and display briefing. Continuously scrolls until next hourly refresh.""" + try: + tty = open('/dev/tty1', 'w') + except Exception: + tty = sys.stdout + + print("[*] Fetching briefing at {}".format(datetime.now().strftime('%Y-%m-%d %H:%M:%S')), file=sys.stderr) + + articles = fetch_news(count=12) + if not articles: + tty.write(" [!] No articles fetched\n") + tty.flush() + print("[!] No articles fetched", file=sys.stderr) + if tty is not sys.stdout: + tty.close() + return + + # Summarize and generate yourls links + debug_log("Summarizing {} articles...".format(len(articles))) + for i, article in enumerate(articles): + debug_log("Article {}/{}: summarizing...".format(i+1, len(articles))) + article['ai_summary'] = summarize_article(article['summary']) + debug_log("Article {}/{}: creating link...".format(i+1, len(articles))) + article['short_link'] = create_yourls_link(article['link'], i) + debug_log("Article {}/{}: done. Link={}".format(i+1, len(articles), article.get('short_link', ''))) + + # Render and display header + timestamp = datetime.now().strftime("%A, %B %d, %Y | %I:%M %p") + tty.write("\033[2J\033[H\033[?25l") # Clear and hide cursor + tty.write("\n{}\n".format("="*70)) + tty.write(" PINAIL NEWS BRIEFING - {}\n".format(timestamp)) + tty.write(" {} stories\n".format(len(articles))) + tty.write("{}\n\n".format("="*70)) + tty.flush() + time.sleep(1) + + # Build full marquee content + full_content = render_marquee(articles, tty) + + # Continuously scroll content until next scheduled run + print("[*] Starting continuous scroll", file=sys.stderr) + try: + scroll_content(tty, full_content) + except KeyboardInterrupt: + pass + finally: + tty.write('\033[?25h') # Restore cursor + tty.flush() + if tty is not sys.stdout: + tty.close() + +def main(): + """Schedule hourly briefing.""" + schedule.every().hour.at(":00").do(hourly_briefing) + + print("[*] piNail News Briefing scheduled for every hour", file=sys.stderr) + next_run = datetime.now().replace(minute=0, second=0, microsecond=0) + print("[*] Next run at {}".format(next_run.strftime('%H:%M')), file=sys.stderr) + + # Run first briefing immediately + hourly_briefing() + + # Keep scheduler alive (will interrupt scroll_content when next hour arrives) + while True: + schedule.run_pending() + time.sleep(1) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n[*] Briefing scheduler stopped", file=sys.stderr) + sys.exit(0) + except Exception as e: + print("[!] Fatal error: {}".format(e), file=sys.stderr) + raise