use nix-shell shebang and a cronjob to get this off my back for now
This commit is contained in:
parent
7d74067d88
commit
794ed86ffe
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
## RSS Notifier
|
||||||
|
A python script which checks an RSS feed for new entries and sends
|
||||||
|
notifications to any libnotify-compatible Linux Desktop when it finds them.
|
64
rss_notifier.py
Normal file → Executable file
64
rss_notifier.py
Normal file → Executable file
|
@ -1,3 +1,5 @@
|
||||||
|
#!/usr/bin/env nix-shell
|
||||||
|
#! nix-shell -i python -p python3 libnotify python3Packages.beautifulsoup4 python3Packages.lxml python3Packages.requests
|
||||||
from requests import get
|
from requests import get
|
||||||
from sqlite3 import Connection as SQL, Cursor
|
from sqlite3 import Connection as SQL, Cursor
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
@ -6,27 +8,33 @@ from sys import argv, stderr
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import *
|
from typing import *
|
||||||
from subprocess import Popen, PIPE, run
|
from subprocess import Popen, PIPE, run
|
||||||
from os import execvp, environ
|
from os import execvp, environ, makedirs
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Feed:
|
class Feed:
|
||||||
id: int
|
id: int
|
||||||
url: str
|
url: str
|
||||||
|
title: Optional[str] = None
|
||||||
content: Optional[Soup] = None
|
content: Optional[Soup] = None
|
||||||
|
|
||||||
def fetch(self):
|
def fetch_content(self):
|
||||||
res = get(self.url)
|
res = get(self.url)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
self.content = Soup(res.text, features="xml")
|
self.content = Soup(res.text, features="xml")
|
||||||
|
if title_tag := self.content.find('title'):
|
||||||
|
self.title = title_tag.text
|
||||||
|
else:
|
||||||
|
print(f'warning: no title for feed {self.url!r}')
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def entries(self):
|
def entries(self):
|
||||||
if content := self.content:
|
if content := self.content:
|
||||||
return content.find_all('entry')
|
return content.find_all('entry')
|
||||||
else:
|
else:
|
||||||
return self.fetch().entries()
|
return self.fetch_content().entries()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_record(cls, record):
|
def from_record(cls, record):
|
||||||
|
@ -42,7 +50,7 @@ class Feed:
|
||||||
return hash(self.id)
|
return hash(self.id)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {'id': self.id, 'url': self.url}
|
return {'id': self.id, 'url': self.url, 'title': self.title}
|
||||||
|
|
||||||
def to_json(self) -> str:
|
def to_json(self) -> str:
|
||||||
return dumps(self.to_dict())
|
return dumps(self.to_dict())
|
||||||
|
@ -50,7 +58,7 @@ class Feed:
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, text: str) -> 'Feed':
|
def from_json(cls, text: str) -> 'Feed':
|
||||||
data = loads(text)
|
data = loads(text)
|
||||||
return cls(id=data['id'], url=data['url'])
|
return cls(id=data['id'], url=data['url'], title=data['title'])
|
||||||
|
|
||||||
ACTIONS = ['--action', 'open=Open link', '--action', 'read=Mark read']
|
ACTIONS = ['--action', 'open=Open link', '--action', 'read=Mark read']
|
||||||
|
|
||||||
|
@ -65,10 +73,16 @@ class FeedEntry:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def select_all(cls, db_connection) -> List['FeedEntry']:
|
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()
|
query = '''
|
||||||
|
select entry.*, feed.url, feed.title
|
||||||
|
from entries entry
|
||||||
|
join feeds feed
|
||||||
|
on feed.id = entry.feed_id;
|
||||||
|
'''
|
||||||
|
feed_entries = db_connection.cursor().execute(query).fetchall()
|
||||||
return set(
|
return set(
|
||||||
FeedEntry(id, Feed(feed_id, feed_url), upstream_id, title, link, read)
|
FeedEntry(id, Feed(feed_id, feed_url, feed_title), upstream_id, title, link, read)
|
||||||
for id, feed_id, upstream_id, title, link, read, feed_url,
|
for id, feed_id, upstream_id, title, link, read, feed_url, feed_title
|
||||||
in feed_entries
|
in feed_entries
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -83,7 +97,10 @@ class FeedEntry:
|
||||||
|
|
||||||
def to_json(self) -> str:
|
def to_json(self) -> str:
|
||||||
assert list(self.__dict__.keys()) == "id feed upstream_id title link read".split()
|
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() })
|
return dumps({
|
||||||
|
k: v.to_dict() if k == 'feed' else v
|
||||||
|
for k, v in self.__dict__.items()
|
||||||
|
})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, data) -> 'FeedEntry':
|
def from_json(cls, data) -> 'FeedEntry':
|
||||||
|
@ -118,7 +135,7 @@ class FeedEntry:
|
||||||
self.upstream_id,
|
self.upstream_id,
|
||||||
self.title,
|
self.title,
|
||||||
self.link,
|
self.link,
|
||||||
self.mark_read
|
self.read
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -133,9 +150,13 @@ class RSSNotifier:
|
||||||
|
|
||||||
def create_tables(self):
|
def create_tables(self):
|
||||||
db = self.db_connection.cursor()
|
db = self.db_connection.cursor()
|
||||||
db.execute(
|
db.execute('''
|
||||||
'create table if not exists feeds (id integer primary key autoincrement, url text)'
|
create table if not exists feeds (
|
||||||
)
|
id integer primary key autoincrement,
|
||||||
|
url text unique not null,
|
||||||
|
title text
|
||||||
|
);
|
||||||
|
''')
|
||||||
db.execute('''
|
db.execute('''
|
||||||
create table if not exists entries (
|
create table if not exists entries (
|
||||||
id integer primary key autoincrement,
|
id integer primary key autoincrement,
|
||||||
|
@ -154,9 +175,9 @@ class RSSNotifier:
|
||||||
return map(Feed, feeds)
|
return map(Feed, feeds)
|
||||||
|
|
||||||
def add_feed(self, url: str, mark_read: bool):
|
def add_feed(self, url: str, mark_read: bool):
|
||||||
feed = Feed(-1, url).fetch()
|
feed = Feed(-1, url).fetch_content()
|
||||||
cursor = self.db_connection.cursor()
|
cursor = self.db_connection.cursor()
|
||||||
cursor.execute("insert into feeds (url) values (?)", (url,))
|
cursor.execute("insert into feeds (url, title) values (?, ?)", (url, feed.title))
|
||||||
feed.id = cursor.lastrowid
|
feed.id = cursor.lastrowid
|
||||||
for entry in feed.entries():
|
for entry in feed.entries():
|
||||||
FeedEntry.from_rss(entry, feed, mark_read).insert(cursor)
|
FeedEntry.from_rss(entry, feed, mark_read).insert(cursor)
|
||||||
|
@ -166,7 +187,7 @@ class RSSNotifier:
|
||||||
def parse_args(self, args=None):
|
def parse_args(self, args=None):
|
||||||
mark_read = True
|
mark_read = True
|
||||||
if args is None:
|
if args is None:
|
||||||
args = argv
|
args = argv[1:]
|
||||||
feeds_to_add = []
|
feeds_to_add = []
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
@ -241,7 +262,7 @@ class RSSNotifier:
|
||||||
'--expire-time', '0',
|
'--expire-time', '0',
|
||||||
'--app-name', 'RSS Notifier',
|
'--app-name', 'RSS Notifier',
|
||||||
*ACTIONS,
|
*ACTIONS,
|
||||||
f'New RSS Story: {entry.title}'
|
f'New RSS Story from "{entry.feed.title}": {entry.title}'
|
||||||
],
|
],
|
||||||
stdout=PIPE,
|
stdout=PIPE,
|
||||||
stderr=PIPE
|
stderr=PIPE
|
||||||
|
@ -261,4 +282,11 @@ class RSSNotifier:
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
RSSNotifier(environ.get("RSS_NOTIFIER_DATABASE_LOCATION") or Path(environ.get("HOME")) / ".local/state/rss-notifier/db.sqlite3").parse_args()
|
db_path = environ.get("RSS_NOTIFIER_DATABASE_LOCATION")
|
||||||
|
if db_path is None:
|
||||||
|
db_path = Path(environ.get("HOME")) / ".local/state/rss-notifier/db.sqlite3"
|
||||||
|
else:
|
||||||
|
db_path = Path(db_path)
|
||||||
|
if not db_path.parent.exists():
|
||||||
|
makedirs(db_path.parent)
|
||||||
|
RSSNotifier(db_path).parse_args()
|
Loading…
Reference in a new issue