Skip to content

Instantly share code, notes, and snippets.

@kimusan
Created February 11, 2026 11:54
Show Gist options
  • Select an option

  • Save kimusan/ed52e1a264274959349f538b4f458dc6 to your computer and use it in GitHub Desktop.

Select an option

Save kimusan/ed52e1a264274959349f538b4f458dc6 to your computer and use it in GitHub Desktop.
Small feed reader for the command line. Can be integrated as part of the shell init as it will only show posts that has not yet been shown.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import os
import re
import time
import urllib.request
import sys
import shutil
from typing import Optional
import feedparser
import html2text
from rich.console import Console
from rich.markdown import Markdown
from rich.prompt import Prompt
from rich.table import Table
from rich import print # Rich‑print med farver
# ----------------------------------------------------------------------
# Konfiguration
# ----------------------------------------------------------------------
cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "rss")
default_feed_file = os.path.join(os.path.expanduser("~"), ".rss_feeds.txt")
# Globale indstillinger (ændres i parse_args)
MAX_POSTS = 0 # 0 → vis alt
IGNORE_CACHE = False
COMPACT = False # <<< NEW >>> compact‑visning (kun titel + tid)
feed_file = default_feed_file
# ----------------------------------------------------------------------
# Hjælpe‑funktioner
# ----------------------------------------------------------------------
def truncate(text: str, length: int) -> str:
"""Klip tekst til «length» tegn og tilføj ellipsis, hvis den er for lang."""
return (text[:length] + "...") if len(text) > length + 3 else text
def is_valid_url(url: str) -> bool:
regex = r"(?i)\b((?:[a-z][\w-]+:(?:/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,6}(?:[/?].*)))\b"
return re.match(regex, url) is not None
def html_to_rich(text: str) -> Markdown:
"""Konverter HTML → Markdown → Rich‑Markdown."""
md = html2text.html2text(text)
return Markdown(md)
def ensure_feed_file_exists() -> None:
"""Opret feed‑filen, hvis den ikke findes."""
os.makedirs(os.path.dirname(feed_file), exist_ok=True)
if not os.path.exists(feed_file):
open(feed_file, "w", encoding="utf-8").close()
# ----------------------------------------------------------------------
# Cache‑håndtering & feed‑hentning
# ----------------------------------------------------------------------
def fetch_feed(url: str) -> feedparser.FeedParserDict:
"""Hent RSS‑feed, brug cache hvis muligt, returnér parsed data."""
cache_file = os.path.join(cache_dir, url.replace("/", "_") + ".xml")
if not IGNORE_CACHE and os.path.exists(cache_file):
with open(cache_file, "r", encoding="utf-8") as fp:
raw = fp.read()
else:
try:
with urllib.request.urlopen(url) as resp:
raw_bytes = resp.read()
except Exception as exc:
Console().print(f"[red]Kunne ikke hente {url}: {exc}[/red]")
return feedparser.FeedParserDict()
raw = raw_bytes.decode("utf-8")
os.makedirs(cache_dir, exist_ok=True)
with open(cache_file, "w", encoding="utf-8") as fp:
fp.write(raw)
return feedparser.parse(raw)
# ----------------------------------------------------------------------
# Visning af indlæg
# ----------------------------------------------------------------------
def show_new_elements(feed: feedparser.FeedParserDict, url: str) -> None:
"""Print nye indlæg. Layout afhænger af om ``COMPACT`` er sat."""
timestamp_file = os.path.join(cache_dir, url.replace("/", "_") + ".timestamp")
last_ts = float(open(timestamp_file).read()) if os.path.exists(timestamp_file) else 0.0
# ------------------------------------------------------------------
# Terminal‑bredde & dynamisk truncation
# ------------------------------------------------------------------
term_width = shutil.get_terminal_size(fallback=(80, 24)).columns # <<< NEW >>>
# Vi vil have lidt luft til kanter, så trækker 6 fra (bord‑ramme + padding)
usable_width = max(term_width - 6, 40)
# I compact‑tilstand viser vi kun to kolonner, så titellængden kan blive bredere.
title_max_len = usable_width - 20 if COMPACT else usable_width - 40
title_max_len = max(title_max_len, 20) # minimum så vi ikke får en tom titel
# ------------------------------------------------------------------
# Table‑opsætning (fuld vs compact)
# ------------------------------------------------------------------
if COMPACT:
table = Table(
title="[bold cyan]" + feed.feed.get("title", "Uden titel") + "[/bold cyan]",
expand=False,
show_lines=False,
style="bright",
show_header=False,
title_style="bright_magenta",
)
table.add_column("Tid", style="dim", no_wrap=True) # tidsstempel
table.add_column("Titel", style="bold cyan") # titel
else:
table = Table(
title="[bold magenta]" + feed.feed.get("title", "Uden titel") + "[/bold magenta]",
expand=False,
show_lines=True,
style="bright",
show_header=False,
title_style="bright_magenta",
)
table.add_column("Tid", style="dim", no_wrap=True)
table.add_column("Indhold", overflow="fold", style="")
console = Console(width=term_width, markup=True) # bruger den eksakte bredde
# Hvor mange indlæg vil vi vise?
limit = MAX_POSTS or len(feed.entries)
shown = 0
for entry in feed.entries:
if shown >= limit:
break
if "published_parsed" not in entry:
continue
entry_ts = time.mktime(entry.published_parsed)
if entry_ts > last_ts or IGNORE_CACHE:
# ------------------------------------------------------------------
# Data‑udtræk + truncation
# ------------------------------------------------------------------
raw_title = entry.get("title", "Uden titel")
title = truncate(raw_title, title_max_len)
if COMPACT:
# Compact: kun tid + titel
table.add_row(entry.get("published", "Uden dato"), f"[bold cyan]{title}[/bold cyan]")
else:
# Full: tid + rig fuld markdown‑rendering
link = entry.get("link", "")
description = entry.get("description", "")
md = f"<h1>{title}</h1>\n<i>{link}</i>\n\n{description}"
table.add_row(entry.get("published", "Uden dato"), html_to_rich(md))
shown += 1
if shown:
console.rule()
console.print(table)
console.rule()
# Opdater timestamp‑filen – så næste kørsel kun viser nye items
os.makedirs(cache_dir, exist_ok=True)
with open(timestamp_file, "w") as fp:
fp.write(str(time.time()))
# ----------------------------------------------------------------------
# Adding feeds
# ----------------------------------------------------------------------
def add_feed(url: str) -> None:
if not is_valid_url(url):
print("[red]Invalid RSS feed URL.[/red]")
sys.exit(1)
ensure_feed_file_exists()
with open(feed_file, "r", encoding="utf-8") as fp:
lines = [line.strip() for line in fp.readlines()]
if url not in lines:
lines.append(url)
with open(feed_file, "w", encoding="utf-8") as fp:
fp.write("\n".join(lines) + "\n")
Console().print("[green]RSS‑feed tilføjet.[/green]")
else:
Console().print("[yellow]RSS‑feed findes allerede i filen.[/yellow]")
def interactive_add_feed() -> None:
console = Console()
ensure_feed_file_exists()
with open(feed_file, "r", encoding="utf-8") as fp:
feeds = [line.strip() for line in fp.readlines()]
while True:
console.print("\n[bold]Eksisterende feeds:[/bold]")
for f in feeds:
console.print(f" • {f}")
answer = Prompt.ask("[bold]Vil du tilføje et nyt feed? (yes/NO)[/bold]")
if answer.strip().lower() in ("no", "", "n"):
break
new_url = Prompt.ask("[bold]Indtast URL for det nye feed:[/bold]")
if not is_valid_url(new_url):
console.print("[red]Ugyldig URL – prøv igen.[/red]")
continue
if new_url not in feeds:
feeds.append(new_url)
console.print("[green]Feed tilføjet![/green]")
else:
console.print("[yellow]Feed findes allerede.[/yellow]")
with open(feed_file, "w", encoding="utf-8") as fp:
fp.write("\n".join(feeds) + "\n")
console.print("[blue]Alle ændringer gemt.[/blue]")
# ----------------------------------------------------------------------
# Argument‑parsing
# ----------------------------------------------------------------------
def parse_args() -> None:
"""Parse kommandolinje‑argumenter og opdatér globale indstillinger."""
global MAX_POSTS, IGNORE_CACHE, COMPACT, feed_file
parser = argparse.ArgumentParser(description="RSS Feed Reader")
parser.add_argument("-f", "--file", help="Sti til fil med RSS‑feeds", metavar="RSS_FILE")
parser.add_argument("-m", "--max", type=int, default=0,
help="Maksimum antal indlæg pr. feed (0 = alle)")
parser.add_argument("-a", "--add", help="Tilføj en enkelt RSS‑feed‑URL", metavar="URL")
parser.add_argument("-i", "--interactive", action="store_true",
help="Interaktiv tilføjelse af feeds")
parser.add_argument("-n", "--nocache", action="store_true",
help="Ignorer cache‑filer")
parser.add_argument("-c", "--compact", action="store_true",
help="Compact visning: kun titel + tidsstempel i en simpel tabel") # <<< NEW >>>
args = parser.parse_args()
# Opdater globale indstillinger
MAX_POSTS = args.max
IGNORE_CACHE = args.nocache
COMPACT = args.compact # <<< NEW >>>
if args.file:
feed_file = os.path.abspath(args.file)
# En‑gang‑kommandoer
if args.add:
add_feed(args.add)
raise SystemExit(0)
if args.interactive:
interactive_add_feed()
raise SystemExit(0)
# ----------------------------------------------------------------------
# Main‑logik
# ----------------------------------------------------------------------
def main() -> None:
parse_args()
ensure_feed_file_exists()
with open(feed_file, "r", encoding="utf-8") as fp:
for line in fp:
url = line.strip()
if len(url) < 5:
continue
feed = fetch_feed(url)
if not feed.entries:
Console().print(f"[red]Ingen data fra {url}. Tjek om URL’en er en gyldig RSS‑feed.[/red]")
continue
show_new_elements(feed, url)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment