I develop a command line tool that I can use to manage my RSS/Atom feeds.
I want to encourage myself to read more diligently and part of that involves summarizing what I read. The problem with using a tablet, as I have been, is that it requires a huge context switch to start writing notes. The barrier to write is so high that I often just don’t do it.
My new strategy is to do my reading closer to where I do my writing. Since I write on the computer, I should read on the computer. On my tablet, I had a feed reader, but I don’t have one yet on the computer.
I looked through a selection this morning and they all look so bloated. In fact, feed reader is far beyond what I need. I’ve already set up Firefox with all the creature comforts and privacy settings that I want. Why would I give that up to use some chromium-based bloatware?
Instead, I really just want something to track what I should read and what I have read. A feed umm… manager?
I like the one-day build model used on Adam Savage’s Tested channel. For one thing, it gives you room to experiment without getting too tied to the final product. But more specifically, it forces you to turn an idea into a plan into action in a short period of time. That’s the kind of pressure that makes diamonds. I wouldn’t want to work like that every day, but I’m hoping that it helps me develop more effective planning strategies.
These are the features of a feed reader that I rely on:
That’s not a lot. It’s mostly just very simple querying. With no GUI compoenent, it looks like something I could knock out pretty quickly. Even better, as a CLI app, it would integrate well with my usual workflow which is split between Firefox and VSCodium.
I start to envision the workflow that I want in shell pseudocode:
`feed <filters> | xdg-open`
No. Not feed
. Let’s go with nom
. “Nom” is
the cartoonish sound of eating. nom
keys slightly better
than feed
and surely the feed-reading namespace is more
saturated with “feed” than “nom”.
Let’s make room for two more tweaks. First, an aspect of CLI app
design. Running nom
with no subcommands should display
help. So we need a subcommand to show urls. Second, it turns out that
xdg-open
operates on a single file or URL, not a list. With
that, my envisioned workflow to open entries is:
nom show <filters> | xargs -n1 xdg-open
That’s our north star.
To map out a design, I used markdown with markmap to visualize it.
You can see the whole plan here and the markdown file used to create it here.
Summarizing the design plan:
nom feed
.nom entry
.grep
to filter
rows and cut
to filter columns. (Eventually built-in
filters would be nice.)It’s really nice how far convention takes us with this design. It wasn’t immediately obvious to separate “feed actions” from “entry actions”, but otherwise many options seemed pretty straightforward when you subscribe a UNIXy design.
Remember, our north start is
nom show <filters> | xargs -n1 xdg-open
. We can
inject cut
and grep
in their to take the place
of <filters>
for the time being. With that in mind,
we need to get everything in place for the very basics of
nom show
to work. (I should mention that
nom show
is an alias for nom entry show
, but
since I expect that to be the most common nom
command, we
might as well make it easy to use.)
Let’s work backward from our demo to see what work we need to do.
nom show
prints a list of (unread) entries.Okay, that enumerates all the dependencies of nom show
.
Now, let’s build out the details by starting from the beginning and
filling in each step until nom show
prints, at
minimum, the URLs that we want to open. Here, I try to be
precise and cut out extraneous work.
~/.local/share/nom/feedlist/default
.~/.cache/nom/feeds/<filename>.xml
.~/.local/share/nom/entrylist/default/entries.csv
.
nom feed update
reads default feedlist; (re)populates
feed cache; builds the entry list.nom entry show
print the list of entries.I think that will get all the way there. There are a few details that are worth explaining. They might be based on faulty assumptions, so it’s worth recording here so we can reassess those assumptions later.
~/.local/share/nom/feedlist
directory will have a single, but that leaves us room in the future to
add another file and a --feedlist
option. I’m okay with
this kind of “future-proofing” because it doesn’t add any complexity to
what I’m trying to do today..cache
because it doesn’t matter
if you throw them out or not. You can always refetch them. On the other
hand, your feed list defines your feed. If you throw that out,
you throw out information that is hard to recreate. Similarly, if you
throw out you entry lists, you’ll lose track of any other information
stored alongside them (such as which entries you’ve read and which you
haven’t.)Create the directory:
mkdir -p ~/.local/share/nom/feedlist"
In an editor, add the following to
~/.local/share/nom/feedlist/default
:
https://simonwillison.net/atom/everything/ https://jvns.ca/atom.xml
Maybe someday, I’ll add support for a name or something, but line-delimited URLs is good enough for now.
I guess at this point, I need to pick a language and do stuff. I’m most familiar with Python and it seems to fit okay. Since I’m treating this as a one-day build, I should probably use what’s familiar and has a lot of libraries, so Python it is.
See the appendix below for the code. This section walks you through the use case. (I need to start doing the GIF recordings, but that’s for another time.)
Okay, so we have this feed list:
cat ~/.local/share/nom/feedlist/default
https://simonwillison.net/atom/everything/ https://jvns.ca/atom.xml
Let’s use that feed list, which is hardcoded as the default, to download those feeds locally:
/home/kyle/projects/utils/nom/.venv/bin/python /home/kyle/projects/utils/nom/src/main.py feed update
Note that all that mess up until main.py
is how I call
it from my development environment and would be replace with
nom
when deployed.
Let’s check that the feeds have been downloaded:
ls -l ~/.cache/nom/feeds/
total 472
-rw-r--r-- 1 kyle kyle 330339 Jan 4 21:05 jvns.ca-atom.xml -rw-r--r-- 1 kyle kyle 150604 Jan 4 21:05 simonwillison.net-atom-everything-
So now, this command:
/home/kyle/projects/utils/nom/.venv/bin/python /home/kyle/projects/utils/nom/src/main.py entry show | cut -d '|' -f 2 | head -n 2
Produces this output:
https://simonwillison.net/2025/Jan/4/weeknotes/#atom-everything https://simonwillison.net/2025/Jan/4/i-live-my-life-a-quarter-century-at-a-time/#atom-everything
When I pipe it into xargs -n1 xdg-open
, FireFox opens
with two tabs that load those URLs. Sweet!
I’m pleased with how far along this got in a day. I spent most of the day working out the command line use cases, which will help if/when I build out the feature set.
Keep in mind, this is a hacky one-day build (including this writeup). It’s not pretty, but it wouldn’t be too bad to clean up.
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse, quote, unquote
import os
import requests
import feedparser
=Path.home() / ".cache" / "nom" / "feeds"
FEED_CACHE=Path.home() / ".local" / "share" / "nom" / "feedlist" / "default"
FEED_LIST
class NomError(Exception):
pass
def url2filename(url: str)->str:
= urlparse(url)
p = ''.join([p.netloc, p.path])
stringified return quote(stringified, safe='',).replace('%2F','-')
def filename2url(url: str)->str:
= urlparse(url.replace('-','%2f'))
p return "https://" + unquote(''.join([p.netloc, p.path]))
# TODO: This should probably use Pydantic.
@dataclass
class Entry:
str
title: str
url: str # TODO: Make this datetime when I add filters
updated: # summary: str # TODO: Add this when you feel like stripping HTML
# TODO: What if there's a pipe in one of the fields?
def write_line(self, delimiter: str ='|'):
return delimiter.join([self.title, self.url, self.updated])
class Feed:
def __init__(self, url: str):
= feedparser.parse(url)
d self.d = d
self.name = d.feed.title
self.url = url # how is this different from d.feed.link?
self.entries : list[Entry] = [
Entry(
e.title,
e.link,
e.updatedfor e in d.entries]
)
# TODO: Fix this with command line option
def to_stdout(self, file: Optional[Path]=None):
for entry in self.entries:
if entry:
print(entry.write_line())
class FeedList:
def __init__(self, file: Path):
with open(file, 'r') as f:
= f.read().splitlines()
urls
self.name = file.name
self.urls = urls
def fetch_feeds(self, save_dir: Path=FEED_CACHE):
if not os.path.exists(save_dir):
os.makedirs(save_dir)
for url in self.urls:
= url2filename(url)
filename = save_dir / filename
path with open(path, 'w') as f:
# TODO: URL Error Handling
= requests.get(url)
r
f.write(r.text)print(f"{path} updated")
# TODO: Need to append feeds to one another (and save to entrylist)
# TODO: Flesh out CLI.
if __name__ == "__main__":
from argparse import ArgumentParser
= ArgumentParser(description="Nom Script")
parser = parser.add_subparsers(dest='command', help='Sub-command help')
subparsers
# Entry subcommand
= subparsers.add_parser('entry', help='Entry related commands')
entry_parser = entry_parser.add_subparsers(dest='entry_command', help='Entry sub-command help')
entry_subparsers = entry_subparsers.add_parser('show', help='Show entries')
entry_show_parser
# Feed subcommand
= subparsers.add_parser('feed', help='Feed related commands')
feed_parser = feed_parser.add_subparsers(dest='feed_command', help='Feed sub-command help')
feed_subparsers = feed_subparsers.add_parser('update', help='Update feed')
feed_update_parser
# Parse Args
= parser.parse_args()
args
# Direct Logic
=FeedList(FEED_LIST)
feedlistif args.command == "entry" and args.entry_command == "show":
for url in feedlist.urls:
=Feed(str(FEED_CACHE / url2filename(url)))
feed
feed.to_stdout()elif args.command == "feed" and args.feed_command == "update":
feedlist.fetch_feeds()else:
raise NomError("That option is not yet supported.")