from requests import get from sqlite3 import Connection as SQL, Cursor from contextlib import contextmanager from bs4 import BeautifulSoup as Soup from sys import argv, stderr from dataclasses import dataclass from typing import * from subprocess import Popen, PIPE, run from os import execvp, environ from json import dumps, loads @dataclass class Feed: id: int url: str content: Optional[Soup] = None def fetch(self): res = get(self.url) res.raise_for_status() self.content = Soup(res.text, features="xml") return self def entries(self): if content := self.content: return content.find_all('entry') else: return self.fetch().entries() @classmethod def from_record(cls, record): return cls(*record) def __eq__(self, other): return self.id == other.id def __ne__(self, other): return self.id != other.id def __hash__(self): return hash(self.id) def to_dict(self): return {'id': self.id, 'url': self.url} def to_json(self) -> str: return dumps(self.to_dict()) @classmethod def from_json(cls, text: str) -> 'Feed': data = loads(text) return cls(id=data['id'], url=data['url']) ACTIONS = ['--action', 'open=Open link', '--action', 'read=Mark read'] @dataclass class FeedEntry: id: int feed: Feed upstream_id: str title: str link: str read: bool @classmethod def select_all(cls, db_connection) -> List['FeedEntry']: feed_entries = db_connection.cursor().execute("select entry.*, feed.url from entries entry join feeds feed on feed.id = entry.feed_id;").fetchall() return set( FeedEntry(id, Feed(feed_id, feed_url), upstream_id, title, link, read) for id, feed_id, upstream_id, title, link, read, feed_url, in feed_entries ) def __eq__(self, other): return self.upstream_id == other.upstream_id def __ne__(self, other): return self.upstream_id != other.upstream_id def __hash__(self): return hash(self.upstream_id) def to_json(self) -> str: assert list(self.__dict__.keys()) == "id feed upstream_id title link read".split() return dumps({ k: v.to_dict() if k == 'feed' else v for k, v in self.__dict__.items() }) @classmethod def from_json(cls, data) -> 'FeedEntry': assert list(cls.__annotations__.keys()) == "id feed upstream_id title link read".split(), repr(list(cls.__annotations__.keys())) entry = loads(data) return cls(**{k: Feed(**entry[k]) if k == 'feed' else entry[k] for k in cls.__annotations__.keys()}) def mark_read(self, db_connection): db_connection.cursor().execute('update entries set read = true where id = ?', (self.id,)) db_connection.commit() self.read = True @classmethod def from_rss(cls, soup: Soup, feed: Feed, mark_read=False) -> 'FeedEntry': upstream_id = soup.find('id').text if el := soup.find('link'): link = el['href'] else: raise ValueError(f"no link tied to RSS feed {feed.url!r} entry {upstream_id}") title = soup.find('title').text return cls(id=None, title=title, feed=feed, upstream_id=upstream_id, link=link, read=mark_read) def insert(self, db_connection: Cursor): db_connection.execute(''' insert into entries ( feed_id, upstream_id, title, link, read ) values ( ?, ?, ?, ?, ? ); ''', ( self.feed.id, self.upstream_id, self.title, self.link, self.mark_read ) ) class RSSNotifier: def __init__(self, db_loc): self.db_loc = db_loc self.db_connection = SQL(db_loc) self.create_tables() self.processes = [] def create_tables(self): db = self.db_connection.cursor() db.execute( 'create table if not exists feeds (id integer primary key autoincrement, url text)' ) db.execute(''' create table if not exists entries ( id integer primary key autoincrement, feed_id int references feeds(id), upstream_id text unique, title text, link text, read boolean ); ''') def select_feeds(self): """Return all the feed urls mapped to their current content""" db = self.db_connection.cursor() feeds = db.execute("select * from feeds;").fetchall() return map(Feed, feeds) def add_feed(self, url: str, mark_read: bool): feed = Feed(-1, url).fetch() cursor = self.db_connection.cursor() cursor.execute("insert into feeds (url) values (?)", (url,)) feed.id = cursor.lastrowid for entry in feed.entries(): FeedEntry.from_rss(entry, feed, mark_read).insert(cursor) self.db_connection.commit() def parse_args(self, args=None): mark_read = True if args is None: args = argv feeds_to_add = [] try: while True: match arg := argv.pop(0): case "--no-mark-read": mark_read = False case "--add-feed": try: feed = argv.pop(0) except IndexError: print("must specify a feed to add", file=stderr) exit(1) feeds_to_add.append(feed) case '--make-notification': entry = FeedEntry.from_json(argv.pop(0)) self._launch_notification(entry) return case other: print(f'unrecognized argument {other!r}', file=stderr) except IndexError: # done parsing the args ... if feeds_to_add: for feed in feeds_to_add: self.add_feed(feed, mark_read) exit(0) self.check_for_new() def check_for_new(self): entries = FeedEntry.select_all(self.db_connection) feeds: Set[Feed] = set(entry.feed for entry in entries) for feed in feeds: feeds_markup = set(feed.entries()) for markup in feeds_markup: entry = FeedEntry.from_rss(markup, feed) if fe := next(fe for fe in entries if fe == entry): if not fe.read: self.notify(entry) else: entry.insert(self.db_connection.cursor()) self.db_connection.commit() self.notify(entry) exit_code = 0 while self.processes: for proc in self.processes: match proc.poll(): case None: continue case 0: ... case nonzero: print(f"proc {proc.pid} returned nonzero exit code {nonzero}", file=stderr) if stream := proc.stdout: print(f'stdout: {stream.read()}', file=stderr) if stream := proc.stderr: print(f'stdout: {stream.read()}', file=stderr) exit_code = nonzero self.processes = [p for p in self.processes if proc.pid != p.pid] exit(exit_code) def notify(self, entry: FeedEntry): self.processes.append( Popen(executable="python", args=["python", __file__, '--make-notification', entry.to_json()]) ) def _launch_notification(self, entry: FeedEntry): result = run( executable='notify-send', args=[ 'notify-send', '--expire-time', '0', '--app-name', 'RSS Notifier', *ACTIONS, f'New RSS Story: {entry.title}' ], stdout=PIPE, stderr=PIPE ) result.check_returncode() match result.stdout: case b'open\n': entry.mark_read(self.db_connection) print(f"opening {entry.link}") execvp('xdg-open', ['xdg-open', entry.link]) case b'read\n': entry.mark_read(self.db_connection) case b'': print(f'no response on stdout from notify-send. stderr read: {result.stderr!r}') case other: print(f'unrecognized response from notify-send: {other!r}\nstderr read: {result.stderr!r}', file=stderr) if __name__ == "__main__": RSSNotifier(environ.get("RSS_NOTIFIER_DATABASE_LOCATION") or Path(environ.get("HOME")) / ".local/state/rss-notifier/db.sqlite3").parse_args()