Compare commits

...
Sign in to create a new pull request.

22 commits

Author SHA1 Message Date
0e8dba9d6d
chore: fix discord webhook call to include APP_VERSION 2026-05-04 11:48:29 -04:00
e5a5f4f7d2
readme: fix URL 2026-04-25 22:48:54 -04:00
a34cc1ac8e
readme: add demo screenshot 2026-04-25 22:47:37 -04:00
4128e7808c
readme: fix small error 2026-04-25 22:40:17 -04:00
9c81fb0c81
readme: update readme with info about recent changes 2026-04-25 22:37:28 -04:00
85d13b9309
bump version to 0.3rc1 2026-04-24 16:02:56 -04:00
cdd7a2569f
log: change error printing to logging 2026-04-24 15:53:02 -04:00
63300e6012
logging: update logging levels and added new messages 2026-04-24 10:51:59 -04:00
98d1a3ba45
args: implemented argparse for config file, log, and dry run 2026-04-24 10:31:07 -04:00
5a3e3333b3
hash: added FIFO for hashes, should reduce duplicates 2026-04-24 10:26:26 -04:00
d412c1a378
hash: updated hash to use URL. also added DRY_RUN option 2026-04-24 10:23:18 -04:00
02aa1aa11b
hash: remove time from hash, now title only 2026-04-13 15:26:35 -04:00
c385b3266c
async: complete rewrite as async code 2026-04-12 14:37:39 -04:00
A.M. Rowsell
1abda8d6e4
Small comment updates 2025-06-20 14:14:47 -04:00
A.M. Rowsell
1787d4da99
fix: bail install if on a non-systemd machine
Instead suggest using cron. At some point I can probably write
up some simple instructions for a basic cron setup.
2025-04-25 23:59:24 -04:00
A.M. Rowsell
b243bc7bb4
dev: Added more prompts to install script 2025-04-22 17:29:45 -04:00
A.M. Rowsell
9d2530ab02
fix: corrected errors in install.sh
Also improved the script to actually use the script location
in the discorss.service file... yeah I should have done that
from the start, d'oh!
2025-04-22 04:26:49 -04:00
A.M. Rowsell
b0f08c405b
fix: install.sh works properly now (with colour!) 2025-04-22 03:17:16 -04:00
A.M. Rowsell
e4539b5733
chore: rename function, move some init code
Also switching logging back to ERROR from DEBUG. The solution
to the lockups for now is to just use systemd timer timeouts.
2025-04-22 02:11:01 -04:00
A.M. Rowsell
2e18ede6a8
docs/fix: Updated README.md and install.sh 2025-04-21 19:29:56 -04:00
A.M. Rowsell
2b4e4216f4
release: bump version number to 0.2 2025-04-20 16:10:08 -04:00
A.M. Rowsell
087a6339c8
class refactor: Squashed commit of the following:
commit 597f383724
Author: A.M. Rowsell <amrowsell@frozenelectronics.ca>
Date:   Sun Apr 20 16:04:00 2025 -0400

    fix: typo in log setup, error when replacing w/self

commit 810cbd6f3d
Author: A.M. Rowsell <amrowsell@frozenelectronics.ca>
Date:   Sun Apr 20 13:51:41 2025 -0400

    refactor: transformed into class-based app
2025-04-20 16:06:16 -04:00
4 changed files with 550 additions and 187 deletions

177
.gitignore vendored
View file

@ -4,4 +4,179 @@ log/
*.bak
bin/
lib/
*.cfg
*.cfg# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python

102
README.md
View file

@ -11,17 +11,73 @@ requests >= 2.4.2
feedparser
```
The remaining imports should all be part of the standard Python install.
The remaining imports should all be part of the standard Python install: hashlib, logging, asyncio, pathlib, json, time, os, sys, argparse, re, types. To install the required ones, use your distro's package manager -- don't use pip unless you want to run the entire script in a virtualenv (which would probably make the systemd unit file a bit more complex, I think you'd have to add a PreExec to activate the virtualenv and a PostExec to disable it?).
<p align="center">
<img src="https://frzn.dev/~amr/images/Screenshot_224228_1.png">
</p>
## Important Notes
As it currently is written, the script uses the hash of the post title to prevent sending duplicates. However, a recent change to check for the publish time was added, only because some feeds are not in reverse chronological order (latest post at top of feed, ie, entry index 0). Because of this, we do actually need to check the publish times. This still needs some testing and things might be a bit broken because of it. If you see any issues please let me know.
By default, DiscoRSS will try and put the logs in `/var/log/discorss`. Make sure to create this directory and give the user running the script write permissions there. If you want the logs to go somewhere else, just give the path as an argument (shown below). Choose a directory that makes sense. Unfortunately, as far as I know, the XDG standards don't have an equivalent to the /var/log directory in the user directory, so I wasn't sure what the best default was. In the future, we may switch to logging using systemd and journald directly, though it is nice to have a separate file.
Logging was recently enabled. Make sure that the user running the script (especially when using systemd timers) has write access to the /var/log/discorss directory. The app will try and create the directory for you, but if your user doesn't have permissions to create directories in /var/log this will fail and this will probably crash the script as is. I will try and remember to catch that exception and exit gracefully with an error message to stdout. If you want the logs to go somewhere else, just edit the log_dir variable near the top of discorss.py. Choose a directory that makes sense. Unfortunately, as far as I know, the XDG standards don't have an equivalent to the /var/log directory in the user directory, so I wasn't sure what the best default was. In the future, we may switch to logging using systemd and journald directly, though it is nice to have a separate file.
### Script Arguments
The script has a few different arguments that make it easy to customize certain things:
* `-d / --dry-run`: Just like it says on the tin -- run the script, pull feeds, but don't post anything to Discord
* `-c / --config-file`: Give a path to an alternate location for the config file. The default is ~/.config/discorss/discorss.conf and that should be fine for the vast majority of users.
* `-l / --log-file`: Give a path to where you want the log file stored. The default is /var/log/discorss/app.log but you will have to create the /var/log/discorss directory and make it writeable by whatever user will be running the script.
## How to setup
To configure the script, create ~/.config/discorss/discorss.conf with the following structure:
### Automation
**New**: There is now `install.sh` in the repo which will automatically help you set up both the config file and the systemd unit files for the service and timer, using essentially the exact text below. It will copy them to the user systemd unit folder, `~/.config/systemd/user` and optionally enable the timer. It's a good idea to edit the configuration file at `~/.config/discorss/discorss.conf` and paste in your webhook URLs and add any other feeds you want before starting the timer, unless you can do it really quickly before the next 5 minute spot on the clock :)
Of course, if it fires with an invalid config, the script will just crash, and you'll probably just have to manually start the timer once the config is fixed, so not a big deal.
_Remember to create `/var/log/discorss` and change it to be writeable by the user running the service!_
### Manual method
To automate feed posting, create a systemd service and timer to execute the script.
Use the command `systemctl --user edit --full --force discorss.service` and then paste in something like this:
```systemd
[Unit]
Description=Discord RSS feeder
Wants=discorss.timer
[Service]
Type=oneshot
TimeoutStartSec=120
ExecStart=/path/to/discorss.py
[Install]
WantedBy=default.target
```
The TimeoutStartSec will catch any issues with the script locking up due to, e.g., DNS failures or RSS feeds being slow/unavailable. 2 minutes should be more than enough time unless you are running hundreds of feeds. Also make sure to edit the ExecStart to point to the correct location. Then we need a systemd timer to automatically fire the script. Run `systemctl --user edit --full --force discorss.timer` and then paste in this:
```systemd
[Unit]
Description=Timer for DiscoRSS
Requires=discorss.service
[Timer]
Unit=discorss.service
OnCalendar=*-*-* *:00,15,30,45:30
AccuracySec=10s
[Install]
WantedBy=timers.target
```
To change how often this fires, edit the OnCalendar parameter. The config above has it firing every 15 minutes at half past the minute. Look at the systemd timer man pages for help if you want to tweak it.
### Config file format
To configure the script, create `~/.config/discorss/discorss.conf` (or have install.sh create it for you) using JSON formatting like this:
```json
{
@ -46,43 +102,7 @@ To configure the script, create ~/.config/discorss/discorss.conf with the follow
Create a webhook for each feed (unless you want them all to show as the same webhook for whatever reason) and make sure to add it in to the config. I have it set up with a webhook for each site, each with the site's icon and name set for the webhook which makes the messages look really nice.
The offset should only be required if feeds aren't showing up. This is because feedparser, in its infinite wisdom, just ignores the timezone when converting publish dates from feeds. So most feeds end up with an epoch in UTC. The offset should be the number of seconds between your time zone and UTC. This will eventually be fixed in a future update, I just need to sit down and wrangle with feedparser and datetime some more. All fields are mandatory, if you want to have no offset for example, set it to 0. The name and siteurl are used to create the "author" field in the Discord embed.
## Automation
To automate feed posting, create a systemd service and timer to execute the script.
Use the command `systemctl --user edit --full --force discorss.service` and then paste in something like this:
```systemd
[Unit]
Description=Discord RSS feeder
Wants=discorss.timer
[Service]
Type=oneshot
ExecStart=/path/to/discorss.py
[Install]
WantedBy=default.target
```
Make sure to edit the ExecStart to point to the correct location. Then we need a systemd timer to automatically fire the script. Run `systemctl --user edit --full --force discorss.timer` and then paste in this:
```systemd
[Unit]
Description=Timer for DiscoRSS
Requires=discorss.service
[Timer]
Unit=discorss.service
OnCalendar=*-*-* *:00,15,30,45:30
AccuracySec=10s
[Install]
WantedBy=timers.target
```
To change how often this fires, edit the OnCalendar parameter. The config above has it firing every 15 minutes at half past the minute. Look at the systemd timer man pages for help if you want to tweak it.
The offset should only be required if feeds from the previous 6 hours aren't showing up when you first start the script. This is because feedparser, in its infinite wisdom, just ignores the timezone when converting publish dates from feeds. So most feeds end up with an epoch in UTC. The offset should be the number of seconds between your time zone and UTC. This will eventually be fixed in a future update, I just need to sit down and wrangle with feedparser and datetime some more. All fields are mandatory, if you want to have no offset for example, set it to 0. The name and siteurl are used to create the "author" field in the Discord embed.
## Contributing

View file

@ -14,6 +14,7 @@ import requests
import feedparser
import hashlib
import logging
import asyncio
from pathlib import Path
import json
import time
@ -21,114 +22,87 @@ import os
import sys
import argparse
import re
config_dir = os.environ.get("XDG_CONFIG_HOME")
home_dir = Path.home()
if config_dir is None:
config_file_path = str(home_dir) + "/.config/discorss/discorss.conf"
config_dir = str(home_dir) + "/.config/discorss"
else:
config_file_path = config_dir + r"/discorss/discorss.conf"
log_dir = r"/var/log/discorss"
log_file_path = r"/app.log"
# Yes, I know you "can't parse HTML with regex", but
# just watch me.
html_filter = re.compile(r"\<\/?([A-Za-z0-9 \:\.\-\/\"\=])*\>")
success_codes = [200, 201, 202, 203, 204, 205, 206]
app_config = {}
# IDEA: Consider making this into a class-based program
# This would solve a couple issues around global variables and generally
# make things a bit neater
from types import SimpleNamespace
# This function gets and formats the brief excerpt that goes in the embed
# Different feeds put summaries in different fields, so we pick the best
# one and limit it to 250 characters.
def get_description(feed, length=250, min_length=150, addons=None):
try:
temporary_string = str(feed["summary_detail"]["value"])
temporary_string = html_filter.sub("", temporary_string)
while length > min_length:
if temporary_string[length - 1 : length] == " ":
break
else:
length -= 1
except KeyError:
temporary_string = str(feed["description"])
temporary_string = html_filter.sub("", temporary_string)
while length > min_length:
if temporary_string[length - 1 : length] == " ":
break
else:
length -= 1
class Discorss:
FEED_TIMEOUT_SECONDS = 15
HASH_HISTORY_LIMIT = 10
APP_VERSION = "0.3rc1"
desc = temporary_string[:length]
if addons is not None:
desc = desc + str(addons)
return desc
def setupPaths():
global app_config
global logger
# Check for log and config files/paths, create empty directories if needed
# TODO: make this cleaner
if not Path(log_dir).exists():
print("No log file path exists. Yark! We'll try and make {}...".format(log_dir))
try:
Path(log_dir).mkdir(parents=True, exist_ok=True)
except FileExistsError:
print("The path {} already exists and is not a directory!".format(log_dir))
if not Path(config_file_path).exists():
print(
"No config file at {}! Snarf. We'll try and make {}...".format(
config_file_path, config_dir
def __init__(self, args=None):
if args is None:
args = SimpleNamespace(
dry_run=False,
config_file=None,
log_file=None,
)
)
try:
Path(config_dir).mkdir(parents=True, exist_ok=True)
except FileExistsError:
print(
"The config dir {} already exists and is not a directory! Please fix manually. Quitting!".format(
config_dir
self.DRY_RUN = args.dry_run
self.config_dir = os.environ.get("XDG_CONFIG_HOME")
home_dir = Path.home()
if self.config_dir is None:
default_config_file_path = str(home_dir) + "/.config/discorss/discorss.conf"
else:
default_config_file_path = self.config_dir + r"/discorss/discorss.conf"
self.config_file_path = args.config_file or default_config_file_path
self.config_dir = str(Path(self.config_file_path).parent)
default_log_file_path = "/var/log/discorss/app.log"
self.log_file_path = args.log_file or default_log_file_path
self.log_dir = str(Path(self.log_file_path).parent)
# Yes, I know you "can't parse HTML with regex", but
# just watch me.
self.html_filter = re.compile(r"\<\/?([A-Za-z0-9 \:\.\-\/\"\=])*\>")
self.success_codes = [200, 201, 202, 203, 204, 205, 206]
self.app_config = {}
async def _fetch_feed(self, hook):
response = await asyncio.to_thread(
requests.get,
hook["url"],
headers={
"user-agent": "DiscoRSS (https://git.frzn.dev/amr/discorss, {})".format(
self.APP_VERSION
)
)
sys.exit(255)
return
# Loading the config file
with open(config_file_path, "r") as config_file:
app_config = json.load(config_file)
# Set up logging
logger = logging.getLogger(__name__)
logging.basicConfig(
filename=str(log_dir + log_file_path),
encoding="utf-8",
level=logging.DEBUG,
datefmt="%m/%d/%Y %H:%M:%S",
format="%(asctime)s: %(levelname)s: %(message)s",
)
return
},
timeout=self.FEED_TIMEOUT_SECONDS,
)
response.raise_for_status()
return await asyncio.to_thread(feedparser.parse, response.content)
async def _post_webhook(self, hook, webhook_string, custom_header):
return await asyncio.to_thread(
requests.post,
hook["webhook"],
data=webhook_string,
headers=custom_header,
timeout=self.FEED_TIMEOUT_SECONDS,
)
def main():
os.environ["TZ"] = "America/Toronto"
time.tzset()
now = time.mktime(time.localtime())
setupPaths() # Handle the config and log paths
try:
last_check = app_config["lastupdate"]
except KeyError:
last_check = now - 21600 # first run, no lastupdate, check up to 6 hours ago
for i, hook in enumerate(app_config["feeds"]): # Feed loop start
logger.debug("Parsing feed %s...", hook["name"])
feeds = feedparser.parse(hook["url"])
latest_post = []
def _get_hash_history(self, hook):
# now we store a list of hashes 10 long
# this function checks if it's the old format and updates it if needed
existing_hashes = hook.get("lasthash", [])
if isinstance(existing_hashes, str):
return [existing_hashes]
if isinstance(existing_hashes, list):
return [
saved_hash
for saved_hash in existing_hashes
if isinstance(saved_hash, str)
]
return []
async def _process_feed(self, hook, last_check):
self.logger.debug("Parsing feed %s...", hook["name"])
feeds = await self._fetch_feed(hook)
latest_post = None
prev_best = 0
logger.debug("About to sort through entries for feed %s ...", hook["name"])
bad_time = False
self.logger.debug("About to sort through entries for feed %s ...", hook["name"])
for feed in feeds["entries"]:
try:
bad_time = False
published_time = time.mktime(feed["published_parsed"])
published_time = published_time + hook["offset"]
except KeyError:
@ -137,39 +111,36 @@ def main():
if published_time > prev_best:
latest_post = feed
prev_best = published_time
else:
continue
if latest_post is None:
self.logger.warning("Feed %s had no entries to process", hook["name"])
return None
if bad_time is True:
logger.debug(
self.logger.debug(
"Feed %s doesn't supply a published time, using updated time instead",
hook["name"],
)
# Hash the title and time of the latest post and use that to determine if it's been posted
# Hash the url of the latest post and use that to determine if it's been posted
# Yes, SHA3-512 is totally unnecessary for this purpose, but I love SHA3
logger.debug("About to hash %s ...", latest_post["title"])
self.logger.debug("About to hash %s ...", latest_post["link"])
try:
new_hash = hashlib.sha3_512(
bytes(latest_post["title"] + str(published_time), "utf-8")
bytes(latest_post["link"], "utf-8") # Removed time from hash
).hexdigest()
except TypeError:
logger.error("Title of %s isn't hashing correctly", hook["name"])
continue
try:
if hook["lasthash"] != new_hash:
app_config["feeds"][i]["lasthash"] = new_hash
else:
continue
except KeyError:
app_config["feeds"][i]["lasthash"] = new_hash
logger.info(
"Feed %s has no existing hash, likely a new feed!", hook["name"]
)
self.logger.error("URL %s isn't hashing correctly", hook["link"])
return None
if new_hash in self._get_hash_history(hook):
return None
# Generate the webhook
logger.info(
"Publishing webhook for %s. Last check was %d, now is %d",
self.logger.info(
"Publishing webhook for %s. Last check was %d, self.now is %d",
hook["name"],
last_check,
now,
self.now,
)
webhook = {
"embeds": [
@ -188,38 +159,208 @@ def main():
"fields": [
{
"name": "Excerpt from post:",
"value": get_description(latest_post),
"value": self.get_description(latest_post),
}
],
# "timestamp": str(now),
# "timestamp": str(self.now),
}
],
"attachments": [],
}
custom_header = {
"user-agent": "DiscoRSS (https://git.frzn.dev/amr/discorss, 0.2rc3)",
"user-agent": "DiscoRSS (https://git.frzn.dev/amr/discorss, {})".format(
self.APP_VERSION
),
"content-type": "application/json",
}
webhook_string = json.dumps(webhook)
logger.debug("About to run POST for %s", hook["name"])
r = requests.post(hook["webhook"], data=webhook_string, headers=custom_header)
if r.status_code not in success_codes:
logger.error(
"Error %d while trying to post %s", r.status_code, hook["name"]
)
self.logger.debug("About to run POST for %s", hook["name"])
if not self.DRY_RUN:
response = await self._post_webhook(hook, webhook_string, custom_header)
else:
logger.debug("Got %d when posting %s", r.status_code, hook["name"])
self.logger.info(
"Dry run, not actually posting to webhook, faking return code 200"
)
response = SimpleNamespace(status_code=200)
if response.status_code not in self.success_codes:
self.logger.error(
"Error %d while trying to post %s", response.status_code, hook["name"]
)
return None
# End of feed loop
self.logger.debug("Got %d when posting %s", response.status_code, hook["name"])
return new_hash
# Dump updated config back to json file
logger.debug("Dumping config back to %s", str(config_file_path))
app_config["lastupdate"] = now
with open(config_file_path, "w") as config_file:
json.dump(app_config, config_file, indent=4)
async def _process_feeds(self, last_check):
tasks = [
asyncio.create_task(
asyncio.wait_for(
self._process_feed(hook, last_check),
timeout=self.FEED_TIMEOUT_SECONDS,
)
)
for hook in self.app_config["feeds"]
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
hook = self.app_config["feeds"][i]
if isinstance(result, asyncio.TimeoutError):
self.logger.critical(
"Timed out processing feed %s after %d seconds",
hook["name"],
self.FEED_TIMEOUT_SECONDS,
)
continue
if isinstance(result, requests.RequestException):
self.logger.critical(
"Network error while processing feed %s: %s",
hook["name"],
result,
)
continue
if isinstance(result, Exception):
self.logger.error(
"Unhandled error while processing feed %s: %s",
hook["name"],
result,
)
continue
if result is None:
continue
if "lasthash" not in hook:
self.logger.debug(
"Feed %s has no existing hash, likely a new feed!", hook["name"]
)
hash_history = self._get_hash_history(hook)
hash_history.append(result)
if len(hash_history) > self.HASH_HISTORY_LIMIT:
hash_history = hash_history[-self.HASH_HISTORY_LIMIT :]
self.app_config["feeds"][i]["lasthash"] = hash_history
return
# This function gets and formats the brief excerpt that goes in the embed
# Different feeds put summaries in different fields, so we pick the best
# one and limit it to 250 characters.
def get_description(self, feed, length=250, min_length=150, addons=None):
try:
temporary_string = str(feed["summary_detail"]["value"])
temporary_string = self.html_filter.sub("", temporary_string)
while length > min_length:
if temporary_string[length - 1 : length] == " ":
break
else:
length -= 1
except KeyError:
temporary_string = str(feed["description"])
temporary_string = self.html_filter.sub("", temporary_string)
while length > min_length:
if temporary_string[length - 1 : length] == " ":
break
else:
length -= 1
desc = temporary_string[:length]
if addons is not None:
desc = desc + str(addons)
return desc
# Some of this could go in __init__
def setup(self):
os.environ["TZ"] = "America/Toronto"
time.tzset()
self.now = time.mktime(time.localtime())
# Set up logging
self.logger = logging.getLogger(__name__)
logging.basicConfig(
filename=self.log_file_path,
encoding="utf-8",
level=logging.WARNING,
datefmt="%m/%d/%Y %H:%M:%S",
format="%(asctime)s [%(threadName)s] -> %(levelname)s: %(message)s",
)
# Check for log and config files/paths, create empty directories if needed
# TODO: change output to log file, as warning/error
if not Path(self.log_dir).exists():
self.logger.warning(
"No log file path exists. Yark! We'll try and make %s...", self.log_dir
)
try:
Path(self.log_dir).mkdir(parents=True, exist_ok=True)
except FileExistsError:
self.logger.critical(
"The path {} already exists and is not a directory!".format(
self.log_dir
)
)
if not Path(self.config_file_path).exists():
self.logger.warning(
"No config file at {}! Snarf. We'll try and make {}...".format(
self.config_file_path, self.config_dir
)
)
try:
Path(self.config_dir).mkdir(parents=True, exist_ok=True)
except FileExistsError:
self.warning.critical(
"The config dir {} already exists and is not a directory! Please fix manually. Quitting!".format(
self.config_dir
)
)
sys.exit(255)
return
# Loading the config file
with open(self.config_file_path, "r") as config_file:
self.app_config = json.load(config_file)
return
def process(self):
self.setup() # Handle the config and log paths
self.logger.info("Starting DiscoRSS version {}...".format(self.APP_VERSION))
try:
last_check = self.app_config["lastupdate"]
except KeyError:
last_check = (
self.now - 21600
) # first run, no lastupdate, check up to 6 hours ago
asyncio.run(self._process_feeds(last_check))
# Dump updated config back to json file
self.logger.debug("Dumping config back to %s", str(self.config_file_path))
self.app_config["lastupdate"] = self.now
with open(self.config_file_path, "w") as config_file:
json.dump(self.app_config, config_file, indent=4)
return
# end of Discorss class
def main():
parser = argparse.ArgumentParser(
description="DiscoRSS: publish feed updates to Discord webhooks."
)
parser.add_argument(
"-d",
"--dry-run",
action="store_true",
help="Parse feeds and update state without posting to Discord.",
)
parser.add_argument(
"-c",
"--config-file",
default=None,
help="Alternate config file path. Defaults to the existing config location.",
)
parser.add_argument(
"-l",
"--log-file",
default=None,
help="Alternate log file path. Defaults to the existing log location.",
)
args = parser.parse_args()
app = Discorss(args)
app.process()
if __name__ == "__main__":

View file

@ -9,21 +9,40 @@
# use systemctl --user edit --full discorss.service or discorss.timer
# after installing them.
cat << EOF > discorss.service
printf "\e[1;34mDisco\e[1;38;5;208mRSS\e[0m Install Helper Script\n\n"
workingDir=$(pwd)
# bail if we're on a non-systemd system, suggest cron
if [[ -d /run/systemd/system ]]; then
printf "systemd detected..."
else
printf "This script and DiscoRSS in general are optimized for systemd! You can use cron as a substitute but I haven't written any documentation for it, so you're on your own for now!"
exit 127 # command not found exit code
fi
printf "Would you like the systemd service and timer files created for you? [y/n]: "
read answer
if [[ "$answer" =~ ^([yY])$ ]]; then
cat << EOF > discorss.service
# Autogenerated by install.sh
[Unit]
Description=Discord RSS feeder
Wants=discorss.timer
[Service]
Type=oneshot
ExecStart=/home/amr/workspace/python/discorss/discorss.py
TimeoutStartSec=120
ExecStart=$workingDir/discorss.py
[Install]
WantedBy=default.target
EOF
cat << EOF > discorss.timer
cat << EOF > discorss.timer
# Autogenerated by install.sh
[Unit]
Description=Timer for DiscoRSS
Requires=discorss.service
@ -38,14 +57,22 @@ WantedBy=timers.target
EOF
cp discorss.service ~/.config/systemd/user/
cp discorss.timer ~/.config/systemd/user/
printf "Making ~/.config/systemd/user in case it doesn't exist ...\n"
mkdir -p -v ~/.config/systemd/user/
printf "Copying service and timer files there ... \n"
cp discorss.service ~/.config/systemd/user/
cp discorss.timer ~/.config/systemd/user/
rm -f discorss.service
rm -f discorss.timer
printf "Reloading systemd daemon ... \n\n"
systemctl --user daemon-reload
else
printf "This script is intended to be automatically run. It's designed with systemd in mind, but you are free to use any automation tools. You can look at this script for examples of how to structure systemd user services and timers.\nOf course, you could always run it by hand, if you really want to :)\n\n"
fi
systemctl --user daemon-reload
printf "Would you like a basic example config created for you? [y/n]"
printf "Would you like a basic example config created for you? [y/n]: "
read answer1
if [ "$answer1" =~ ^[yYnN]$ ]; then
if [[ "$answer1" =~ ^([yY])$ ]]; then
mkdir -p -v ~/.config/discorss
cat << EOF > ~/.config/discorss/discorss.conf
{
@ -55,25 +82,25 @@ if [ "$answer1" =~ ^[yYnN]$ ]; then
"siteurl": "https://www.phoronix.com/",
"url": "http://www.phoronix.com/rss.php",
"webhook": "PASTE WEBHOOK URL HERE",
"offset": 0,
"offset": 0
}
],
]
}
EOF
printf "Make sure to edit ~/.config/discorss/discorss.conf and add in your custom feeds and webhook URLS! The script will just error out if you don't do this."
printf "\nMake sure to edit \e[1;34m~/.config/discorss/discorss.conf\e[0m and add in your custom feeds and webhook URLS! The script will just error out if you don't do this."
else
printf "Make sure to create a config at ~/.config/discorss/discorss.conf and follow the pattern shown in the README."
printf "\nMake sure to create a config at \e[1;34m~/.config/discorss/discorss.conf\e[0m and follow the pattern shown in the README."
fi
printf "Would you like to have the timer enabled and started now? [y/n]"
printf "\nWould you like to have the timer enabled and started now? [y/n]: "
read answer
if [ "$answer" =~ ^[yYnN]$ ]; then
if [[ "$answer" =~ ^([yY])$ ]]; then
systemctl --user enable --now discorss.timer
printf "discorss.timer enabled and started. Don't enable or start discorss.service -- the timer does this automatically."
printf "\ndiscorss.timer enabled and started. \e[1;31mDon't enable or start discorss.service\e[0m -- the timer does this automatically."
else
printf "Don't forget to run systemctl --user enable --now discorss.timer when you are ready! Don't enable or start discorss.service -- the timer does this automatically."
printf "\nDon't forget to run \e[1;32msystemctl --user enable --now discorss.timer\e[0m when you are ready! \e[1;31mDon't enable or start discorss.service\e[0m -- the timer does this automatically."
fi
printf "You should be almost ready to go! Double-check your config files, and check systemctl --user list-timers once the discorss.timer is enabled to see when it will fire next. The default is every 5 minutes."
printf "\n\nYou should be almost ready to go! Double-check your config files, and check \e[1;32msystemctl --user list-timers\e[0m once the discorss.timer is enabled to see when it will fire next. The default is every 5 minutes."
printf "Remember, if you need help or encounter any bugs, contact me via the issues tracker on the git repository where you got this from!"
printf "\nRemember, if you need help or encounter any bugs, contact me via the issues tracker on the git repository where you got this from!\n"