initial commit
This commit is contained in:
commit
7d74067d88
264
rss_notifier.py
Normal file
264
rss_notifier.py
Normal file
|
@ -0,0 +1,264 @@
|
|||
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()
|
Loading…
Reference in a new issue