Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,59 @@
# Streamline
a small rss client written in python

Streamline is a minimal, accessible RSS reader written in Python. It can
parse local feed files or retrieve items from a Fever API compatible
server. The output is kept simple so that it works well with assistive
technologies such as screen readers.

## Requirements

```
pip install -r requirements.txt
```

## Usage

Read a local feed file:

```
python streamline.py local path/to/feed.xml
```

Interactive browsing:

```
python streamline.py local path/to/feed.xml --interactive
```

Fetch items from a Fever API endpoint:

```
python streamline.py fever --url https://example.com/fever --api-key YOUR_KEY
```

Add `--interactive` to use hotkeys for navigation when fetching from Fever.

The client prints the title, link and a short summary of each entry. In
interactive mode you can navigate with these hotkeys:

- **n**: next entry
- **p**: previous entry
- **o**: open the entry link in the default browser
- **q**: quit the viewer

## Graphical interface

For a tree view of your feeds, run the WxPython GUI:

```
python streamline_gui.py
```

Hotkeys inside the GUI:

- **Tab**: switch between feed tree and article text
- **Ctrl+I**: import feeds from an OPML or text file
- **Ctrl+E**: export your feed list
- **Ctrl+T**: fetch and display the full article text

The GUI stores subscribed feeds in `feeds.json`.
6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
feedparser
requests
readchar

wxPython
readability-lxml
139 changes: 139 additions & 0 deletions streamline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Minimal accessible RSS client with optional Fever API support."""

import argparse
import sys

import feedparser
import requests
import webbrowser

import readchar


def print_entry(entry, count=None, total=None):
"""Print a single feed entry."""
if count is not None and total is not None:
print(f"Entry {count}/{total}")
title = entry.get('title') or 'No title'
print(f"- {title}")
link = entry.get('link')
if link:
print(f" {link}")
summary = entry.get('summary') or entry.get('content', '')
if summary:
text = summary
if len(text) > 200:
text = text[:200] + '...'
print(f" {text}")
print()


def interactive_viewer(entries):
"""Interactively browse entries using hotkeys."""
if not entries:
print("No entries found.")
return

def help_message():
print("Hotkeys: [n]ext [p]revious [o]pen link [q]uit [?] help")

index = 0
total = len(entries)
help_message()
while True:
print_entry(entries[index], index + 1, total)
key = readchar.readchar()
if key == "n":
index = (index + 1) % total
elif key == "p":
index = (index - 1) % total
elif key == "o":
link = entries[index].get("link")
if link:
webbrowser.open(link)
elif key == "q":
break
elif key in ("?", "h"):
help_message()


def print_entries(entries):
"""Print feed entries in a simple, accessible format."""
for entry in entries:
title = entry.get('title') or 'No title'
print(f"- {title}")
link = entry.get('link')
if link:
print(f" {link}")
summary = entry.get('summary') or entry.get('content', '')
if summary:
# print first 200 chars for brevity
text = summary
if len(text) > 200:
text = text[:200] + '...'
print(f" {text}")
print()


def read_local(feed_path: str, interactive: bool = False) -> None:
"""Read and display a local RSS/Atom feed."""
feed = feedparser.parse(feed_path)
title = feed.feed.get('title', 'Untitled feed')
print(title)
print('=' * len(title))
if interactive:
interactive_viewer(feed.entries)
else:
print_entries(feed.entries)


def read_fever(url: str, api_key: str, interactive: bool = False) -> None:
"""Fetch unread items from a Fever API endpoint."""
response = requests.post(url, data={'api_key': api_key, 'items': ''})
if not response.ok:
sys.stderr.write('Failed to fetch items from Fever API.\n')
return
data = response.json()
items = data.get('items', [])
entries = [
{
'title': item.get('title'),
'link': item.get('url'),
'summary': item.get('html')
}
for item in items
]
if interactive:
interactive_viewer(entries)
else:
print_entries(entries)


def main(argv=None) -> None:
parser = argparse.ArgumentParser(description='Accessible RSS client')
subparsers = parser.add_subparsers(dest='command')

parser_local = subparsers.add_parser('local', help='Read a local feed file')
parser_local.add_argument('file', help='Path to the feed XML file')
parser_local.add_argument('-i', '--interactive', action='store_true',
help='Browse entries interactively')

parser_fever = subparsers.add_parser('fever', help='Fetch items via Fever API')
parser_fever.add_argument('--url', required=True, help='Fever API endpoint')
parser_fever.add_argument('--api-key', required=True, help='Fever API key')
parser_fever.add_argument('-i', '--interactive', action='store_true',
help='Browse entries interactively')

args = parser.parse_args(argv)

if args.command == 'local':
read_local(args.file, args.interactive)
elif args.command == 'fever':
read_fever(args.url, args.api_key, args.interactive)
else:
parser.print_help()


if __name__ == '__main__':
main()
147 changes: 147 additions & 0 deletions streamline_gui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Accessible GUI RSS reader using wxPython."""

import json
import os
import re
import feedparser
import requests
import wx

try:
from readability import Document
import lxml.html
except Exception:
Document = None

FEED_FILE = 'feeds.json'


def load_feeds():
if os.path.exists(FEED_FILE):
with open(FEED_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return []


def save_feeds(feeds):
with open(FEED_FILE, 'w', encoding='utf-8') as f:
json.dump(feeds, f, indent=2)


class MainFrame(wx.Frame):
def __init__(self):
super().__init__(None, title="Streamline RSS")
self.feeds = load_feeds()
self.build_ui()
self.Bind(wx.EVT_CHAR_HOOK, self.on_hotkey)

def build_ui(self):
panel = wx.Panel(self)
hbox = wx.BoxSizer(wx.HORIZONTAL)
self.tree = wx.TreeCtrl(panel)
self.text = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY)
hbox.Add(self.tree, 1, wx.EXPAND)
hbox.Add(self.text, 2, wx.EXPAND)
panel.SetSizer(hbox)
self.populate_tree()
self.tree.Bind(wx.EVT_TREE_ITEM_ACTIVATED, self.on_activate)

def populate_tree(self):
self.tree.DeleteAllItems()
root = self.tree.AddRoot("Feeds")
for url in self.feeds:
fitem = self.tree.AppendItem(root, url)
feed = feedparser.parse(url)
for entry in feed.entries:
child = self.tree.AppendItem(fitem, entry.get('title', 'No title'))
self.tree.SetItemData(child, entry)
self.tree.Expand(root)

def on_activate(self, event):
item = event.GetItem()
data = self.tree.GetItemData(item)
if isinstance(data, dict):
summary = data.get('summary') or data.get('content', '')
self.text.SetValue(summary or 'No content available.')
self.text.SetFocus()

def on_hotkey(self, event):
code = event.GetKeyCode()
if code == wx.WXK_TAB:
if self.tree.HasFocus():
self.text.SetFocus()
else:
self.tree.SetFocus()
elif code == ord('I') and event.ControlDown():
self.import_feeds()
elif code == ord('E') and event.ControlDown():
self.export_feeds()
elif code == ord('T') and event.ControlDown():
self.load_full_text()
else:
event.Skip()

def import_feeds(self):
with wx.FileDialog(self, "Import feeds", wildcard="OPML or text files (*.opml;*.txt)|*.opml;*.txt",
style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) as dlg:
if dlg.ShowModal() == wx.ID_CANCEL:
return
path = dlg.GetPath()
feeds = []
if path.endswith('.opml'):
content = open(path, encoding='utf-8').read()
feeds.extend(re.findall(r'xmlUrl="([^"]+)"', content))
else:
with open(path, encoding='utf-8') as f:
for line in f:
url = line.strip()
if url:
feeds.append(url)
for url in feeds:
if url not in self.feeds:
self.feeds.append(url)
save_feeds(self.feeds)
self.populate_tree()

def export_feeds(self):
with wx.FileDialog(self, "Export feeds", wildcard="Text files (*.txt)|*.txt",
style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) as dlg:
if dlg.ShowModal() == wx.ID_CANCEL:
return
path = dlg.GetPath()
with open(path, 'w', encoding='utf-8') as f:
for url in self.feeds:
f.write(url + '\n')

def load_full_text(self):
item = self.tree.GetSelection()
data = self.tree.GetItemData(item)
if not isinstance(data, dict):
return
link = data.get('link')
if not link:
return
try:
resp = requests.get(link, timeout=10)
text = resp.text
if Document:
doc = Document(text)
html = doc.summary()
root = lxml.html.fromstring(html)
text = root.text_content()
self.text.SetValue(text)
except Exception as exc:
self.text.SetValue(f"Failed to load article: {exc}")


class App(wx.App):
def OnInit(self):
frame = MainFrame()
frame.Show()
return True


if __name__ == '__main__':
app = App()
app.MainLoop()