Created
February 11, 2026 11:54
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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