Files
Seth 7d62243fe6 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
2026-03-12 04:05:30 +00:00

266 lines
9.8 KiB
Python

#!/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