rssd-fs 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rssd_fs-0.1.0/LICENSE +21 -0
- rssd_fs-0.1.0/PKG-INFO +248 -0
- rssd_fs-0.1.0/README.md +218 -0
- rssd_fs-0.1.0/pyproject.toml +67 -0
- rssd_fs-0.1.0/pyproject.toml.orig +64 -0
- rssd_fs-0.1.0/src/rssd/__init__.py +0 -0
- rssd_fs-0.1.0/src/rssd/__main__.py +5 -0
- rssd_fs-0.1.0/src/rssd/cli.py +278 -0
- rssd_fs-0.1.0/src/rssd/config.py +151 -0
- rssd_fs-0.1.0/src/rssd/daemon.py +168 -0
- rssd_fs-0.1.0/src/rssd/diff.py +125 -0
- rssd_fs-0.1.0/src/rssd/events.py +253 -0
- rssd_fs-0.1.0/src/rssd/fetch.py +212 -0
- rssd_fs-0.1.0/src/rssd/fixtures.py +109 -0
- rssd_fs-0.1.0/src/rssd/fulltext.py +86 -0
- rssd_fs-0.1.0/src/rssd/identity.py +148 -0
- rssd_fs-0.1.0/src/rssd/models.py +229 -0
- rssd_fs-0.1.0/src/rssd/parse.py +213 -0
- rssd_fs-0.1.0/src/rssd/pipeline.py +96 -0
- rssd_fs-0.1.0/src/rssd/reader.py +488 -0
- rssd_fs-0.1.0/src/rssd/readercli.py +728 -0
- rssd_fs-0.1.0/src/rssd/render.py +213 -0
- rssd_fs-0.1.0/src/rssd/schedule.py +85 -0
- rssd_fs-0.1.0/src/rssd/scheduler.py +484 -0
- rssd_fs-0.1.0/src/rssd/semantic.py +483 -0
- rssd_fs-0.1.0/src/rssd/state.py +62 -0
- rssd_fs-0.1.0/src/rssd/store.py +292 -0
- rssd_fs-0.1.0/src/rssd/subscriptions.py +190 -0
- rssd_fs-0.1.0/src/rssd/timeutil.py +143 -0
- rssd_fs-0.1.0/src/rssd/tui/__init__.py +0 -0
- rssd_fs-0.1.0/src/rssd/tui/app.py +810 -0
- rssd_fs-0.1.0/src/rssd/userconf.py +153 -0
- rssd_fs-0.1.0/src/rssd/watcher.py +128 -0
rssd_fs-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Allie Coleman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
rssd_fs-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rssd-fs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A file-based RSS daemon: the filesystem is the API
|
|
5
|
+
Keywords: rss,atom,feed,daemon,filesystem,tui
|
|
6
|
+
Author: Allie Coleman
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Dist: feedparser>=6.0.14
|
|
17
|
+
Requires-Dist: httpx>=0.28.1
|
|
18
|
+
Requires-Dist: lxml>=6.1.3
|
|
19
|
+
Requires-Dist: watchfiles>=1.2.0
|
|
20
|
+
Requires-Dist: trafilatura>=2.2.0 ; extra == 'fulltext'
|
|
21
|
+
Requires-Dist: textual>=8.2.8 ; extra == 'tui'
|
|
22
|
+
Requires-Python: >=3.14
|
|
23
|
+
Project-URL: Homepage, https://github.com/alliecatowo/rssd
|
|
24
|
+
Project-URL: Documentation, https://alliecatowo.github.io/rssd/
|
|
25
|
+
Project-URL: Source, https://github.com/alliecatowo/rssd
|
|
26
|
+
Project-URL: Issues, https://github.com/alliecatowo/rssd/issues
|
|
27
|
+
Provides-Extra: fulltext
|
|
28
|
+
Provides-Extra: tui
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# rssd
|
|
32
|
+
|
|
33
|
+
A file-based RSS daemon. It watches a folder of XML subscription files, polls
|
|
34
|
+
those feeds, and materialises every entry as an individual XML file in a
|
|
35
|
+
per-feed folder.
|
|
36
|
+
|
|
37
|
+
**The filesystem is the API.** There is no database, no socket to connect to and
|
|
38
|
+
no client library. Any program that can read a directory is a client — `grep`,
|
|
39
|
+
`find`, `fswatch`, a shell script, your editor. The bundled TUI is deliberately
|
|
40
|
+
built the same way: it reads the output tree and the event log like anyone else
|
|
41
|
+
would, with no privileged channel back to the daemon.
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
demo/
|
|
45
|
+
├── feeds.d/ you write these
|
|
46
|
+
│ └── rust-blog.xml
|
|
47
|
+
├── store/ the daemon writes these
|
|
48
|
+
│ └── rust-blog/
|
|
49
|
+
│ ├── feed.xml
|
|
50
|
+
│ ├── status.xml
|
|
51
|
+
│ └── entries/
|
|
52
|
+
│ ├── 20260907T000000Z-9f2c1a3b-crates-io-update.xml → newest
|
|
53
|
+
│ ├── 20260907T000000Z-9f2c1a3b-crates-io-update.r1.xml
|
|
54
|
+
│ └── 20260907T000000Z-9f2c1a3b-crates-io-update.r2.xml
|
|
55
|
+
└── var/
|
|
56
|
+
└── events.jsonl tail -F this
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
mise install
|
|
63
|
+
uv sync --extra tui
|
|
64
|
+
|
|
65
|
+
uv run rssd init demo/
|
|
66
|
+
uv run rssd daemon --root demo --poll-now # terminal 1
|
|
67
|
+
uv run rss tui --root demo # terminal 2
|
|
68
|
+
tail -F demo/var/events.jsonl | jq -c # terminal 3
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Drop a new `.xml` file into `demo/feeds.d/` and the daemon picks it up without a
|
|
72
|
+
restart. Delete one and it stops polling — but never deletes what it already
|
|
73
|
+
harvested.
|
|
74
|
+
|
|
75
|
+
### Offline
|
|
76
|
+
|
|
77
|
+
Every fixture is committed, so the whole thing runs with no network at all:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv run rssd init demo/ --fixture-mode
|
|
81
|
+
uv run rssd serve-fixtures --dir demo/fixtures # terminal 0
|
|
82
|
+
uv run rssd daemon --root demo --poll-now
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## What an entry looks like
|
|
86
|
+
|
|
87
|
+
Feed HTML is mapped onto a small fixed vocabulary rather than passed through, so
|
|
88
|
+
every entry has the same shape no matter how the publisher writes their markup.
|
|
89
|
+
Wrapper `div`s, `class` attributes and publisher CSS hooks are gone; relative
|
|
90
|
+
URLs are resolved; `javascript:` and `data:` URLs are dropped.
|
|
91
|
+
|
|
92
|
+
```xml
|
|
93
|
+
<entry xmlns="https://rssd.dev/entry/1"
|
|
94
|
+
id="sha256:9f2c1a3b…" revision="1" first-seen="2026-09-15T21:14:02Z">
|
|
95
|
+
<source>
|
|
96
|
+
<link>https://blog.rust-lang.org/2026/07/13/crates-io-development-update/</link>
|
|
97
|
+
<feed name="rust-blog" title="Rust Blog">https://blog.rust-lang.org/feed.xml</feed>
|
|
98
|
+
<guid basis="atom-id">https://blog.rust-lang.org/2026/07/13/…</guid>
|
|
99
|
+
</source>
|
|
100
|
+
<title>crates.io: development update</title>
|
|
101
|
+
<published origin="feed">2026-07-13T00:00:00Z</published>
|
|
102
|
+
<content origin="feed:content" hash="sha256:bc167659…">
|
|
103
|
+
<paragraph>Another six months have passed since our
|
|
104
|
+
<link href="https://blog.rust-lang.org/2026/01/21/…">last update</link>.</paragraph>
|
|
105
|
+
<heading level="2">Source Code Viewer</heading>
|
|
106
|
+
<list><item>Browse published crate versions</item></list>
|
|
107
|
+
</content>
|
|
108
|
+
</entry>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`origin` tells you where the body came from: `feed:content`, `feed:summary`,
|
|
112
|
+
`fulltext:trafilatura`, or `none`. rssd never fabricates content — a feed that
|
|
113
|
+
ships only a title and a link produces an entry that says so.
|
|
114
|
+
|
|
115
|
+
## Three invariants
|
|
116
|
+
|
|
117
|
+
**Reads are sacred.** Every file is written to a temporary name in the same
|
|
118
|
+
directory, fsynced, then `rename(2)`d into place. A reader sees the old file or
|
|
119
|
+
the new one, never a mix, and never a half-written one. A `SIGKILL` mid-write can
|
|
120
|
+
only leave a `.rssd-tmp-*` orphan, which is swept on the next start. Every
|
|
121
|
+
document is re-parsed with a strict XML parser *before* the rename, so a
|
|
122
|
+
malformed entry can never reach the tree.
|
|
123
|
+
|
|
124
|
+
**The tree is the truth; `var/` is a disposable cache.** Each entry file embeds
|
|
125
|
+
its own ID and content hash, so `rm -rf var/` is fully recoverable — the daemon
|
|
126
|
+
rebuilds what it knows by scanning the tree, and writes nothing.
|
|
127
|
+
|
|
128
|
+
**An entry file is written only when its content changes.** No heartbeat, no
|
|
129
|
+
`last-seen` field. If nothing changed upstream, nothing on disk is touched, so
|
|
130
|
+
`mtime` means exactly what a reader expects it to mean.
|
|
131
|
+
|
|
132
|
+
## Revisions
|
|
133
|
+
|
|
134
|
+
Entry files are never mutated. When a publisher edits a post, the new version
|
|
135
|
+
lands beside the old one as `.r2.xml` and a stable symlink is repointed:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
uv run rssd demo-mutate --root demo # forces a revision on demand
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Follow the symlink for "current", or list `.rN` for the full history. Because
|
|
142
|
+
the change is detected by hashing the *rendered semantic content* rather than
|
|
143
|
+
the raw HTML, a reordered attribute or a rotating ad token can't manufacture a
|
|
144
|
+
revision. Three further guards — a daily per-entry cap, rotating-GUID detection,
|
|
145
|
+
and a flat cap on new entries per poll — keep a misbehaving feed from filling
|
|
146
|
+
the disk.
|
|
147
|
+
|
|
148
|
+
## Events
|
|
149
|
+
|
|
150
|
+
Everything the daemon does is appended to `var/events.jsonl`, one JSON object
|
|
151
|
+
per line, with a sequence number that is monotonic across restarts:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{"v":1,"seq":42,"ts":"2026-09-15T21:14:02.140Z","event":"entry.new","feed":"rust-blog","data":{"id":"sha256:9f2c…","path":"store/rust-blog/entries/2026…xml","title":"crates.io: development update"}}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Events are emitted *after* the corresponding file is on disk, so **if you see
|
|
158
|
+
`entry.new`, the file exists and is complete**. The converse isn't promised — a
|
|
159
|
+
crash between the rename and the append loses the event — so consumers reconcile
|
|
160
|
+
by scanning the tree when they see `daemon.started`.
|
|
161
|
+
|
|
162
|
+
Use `tail -F`, not `tail -f`: the log rotates, and `-f` follows the inode and
|
|
163
|
+
goes quietly dead.
|
|
164
|
+
|
|
165
|
+
## Politeness
|
|
166
|
+
|
|
167
|
+
Conditional GET via `ETag`/`Last-Modified`; `Cache-Control: max-age` raises the
|
|
168
|
+
poll interval; `Retry-After` on 429/503 is honoured and isn't counted as a
|
|
169
|
+
failure. Feeds that send no validators at all are compared by body hash and
|
|
170
|
+
polled less often, since there's no cheap way to ask. Failures back off
|
|
171
|
+
exponentially with jitter, per feed, and one broken feed never affects another.
|
|
172
|
+
|
|
173
|
+
Fetching article pages is **opt-in per subscription** and off by default — the
|
|
174
|
+
normal footprint is one request per feed, never one per entry.
|
|
175
|
+
|
|
176
|
+
## Commands
|
|
177
|
+
|
|
178
|
+
Two binaries, on purpose. `rssd` writes the tree; `rss` only reads it.
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
rssd init <root> scaffold an instance [--fixture-mode]
|
|
182
|
+
rssd daemon --root <root> run it [--poll-now]
|
|
183
|
+
rssd once --root <root> one poll pass, then exit
|
|
184
|
+
rssd poll <feed> --root <r> force-poll one feed
|
|
185
|
+
rssd validate --root <root> check every subscription parses
|
|
186
|
+
rssd prune --retired --yes delete retired folders (never automatic)
|
|
187
|
+
rssd serve-fixtures local HTTP server over the fixtures
|
|
188
|
+
rssd demo-mutate --root <r> force a revision on demand
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
rss feeds what's subscribed, and is it healthy
|
|
193
|
+
rss ls [feed] entries, newest first
|
|
194
|
+
rss show <ref> read an entry as wrapped text
|
|
195
|
+
rss link <ref> print just the URL
|
|
196
|
+
rss open <ref> open it in a browser
|
|
197
|
+
rss links <ref> every link in an entry
|
|
198
|
+
rss search <query> [feed] match titles and body text
|
|
199
|
+
rss revisions <ref> every stored version
|
|
200
|
+
rss diff <ref> what changed when it was edited
|
|
201
|
+
rss info <ref> id, dates, origin, hash, path
|
|
202
|
+
rss config effective settings and their source
|
|
203
|
+
rss tui the terminal reader
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Refer to an entry by id prefix, position, feed, or path — all four work:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
rss show 9f2c1a3b # unique id prefix
|
|
210
|
+
rss show rust-blog:3 # 3rd newest in a feed
|
|
211
|
+
rss show rust-blog # newest in a feed
|
|
212
|
+
rss show demo/store/rust-blog/entries/2026….xml
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
An ambiguous prefix lists the candidates rather than guessing.
|
|
216
|
+
|
|
217
|
+
## The TUI
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
rss tui --root demo
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Modal, like vim. Three panes — feeds, entries, reader — a status line, and no
|
|
224
|
+
chrome. `hjkl` to move, `Enter` to read, `o` to open in a browser, `y` to yank
|
|
225
|
+
the URL, `/` to search, `:` for ex commands (`:e rust-blog`, `:set width=100`,
|
|
226
|
+
`:links`, `:open 3`, `:q`).
|
|
227
|
+
|
|
228
|
+
`:set` changes the session only, exactly like vim; persistent preferences go in
|
|
229
|
+
`rss.toml`.
|
|
230
|
+
|
|
231
|
+
The daemon doesn't need to be running — the TUI reads the filesystem, so it
|
|
232
|
+
works fine against a static tree or a directory you rsynced from elsewhere.
|
|
233
|
+
|
|
234
|
+
## Development
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
uv sync --extra tui
|
|
238
|
+
uv run pytest # 322 tests, no network required
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The pure modules — `semantic`, `identity`, `parse`, `render`, `schedule`,
|
|
242
|
+
`diff`, `timeutil`, `subscriptions` — hold everything that can actually be
|
|
243
|
+
*wrong*, and are tested against committed byte-exact captures of six real feeds
|
|
244
|
+
chosen to cover the awkward cases: rich Atom with `xml:base`, RSS with
|
|
245
|
+
`content:encoded`, CRLF line endings, an image-only body, and one feed that
|
|
246
|
+
sends no cache validators at all.
|
|
247
|
+
|
|
248
|
+
`SPEC.md` is the full specification.
|
rssd_fs-0.1.0/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# rssd
|
|
2
|
+
|
|
3
|
+
A file-based RSS daemon. It watches a folder of XML subscription files, polls
|
|
4
|
+
those feeds, and materialises every entry as an individual XML file in a
|
|
5
|
+
per-feed folder.
|
|
6
|
+
|
|
7
|
+
**The filesystem is the API.** There is no database, no socket to connect to and
|
|
8
|
+
no client library. Any program that can read a directory is a client — `grep`,
|
|
9
|
+
`find`, `fswatch`, a shell script, your editor. The bundled TUI is deliberately
|
|
10
|
+
built the same way: it reads the output tree and the event log like anyone else
|
|
11
|
+
would, with no privileged channel back to the daemon.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
demo/
|
|
15
|
+
├── feeds.d/ you write these
|
|
16
|
+
│ └── rust-blog.xml
|
|
17
|
+
├── store/ the daemon writes these
|
|
18
|
+
│ └── rust-blog/
|
|
19
|
+
│ ├── feed.xml
|
|
20
|
+
│ ├── status.xml
|
|
21
|
+
│ └── entries/
|
|
22
|
+
│ ├── 20260907T000000Z-9f2c1a3b-crates-io-update.xml → newest
|
|
23
|
+
│ ├── 20260907T000000Z-9f2c1a3b-crates-io-update.r1.xml
|
|
24
|
+
│ └── 20260907T000000Z-9f2c1a3b-crates-io-update.r2.xml
|
|
25
|
+
└── var/
|
|
26
|
+
└── events.jsonl tail -F this
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
mise install
|
|
33
|
+
uv sync --extra tui
|
|
34
|
+
|
|
35
|
+
uv run rssd init demo/
|
|
36
|
+
uv run rssd daemon --root demo --poll-now # terminal 1
|
|
37
|
+
uv run rss tui --root demo # terminal 2
|
|
38
|
+
tail -F demo/var/events.jsonl | jq -c # terminal 3
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Drop a new `.xml` file into `demo/feeds.d/` and the daemon picks it up without a
|
|
42
|
+
restart. Delete one and it stops polling — but never deletes what it already
|
|
43
|
+
harvested.
|
|
44
|
+
|
|
45
|
+
### Offline
|
|
46
|
+
|
|
47
|
+
Every fixture is committed, so the whole thing runs with no network at all:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv run rssd init demo/ --fixture-mode
|
|
51
|
+
uv run rssd serve-fixtures --dir demo/fixtures # terminal 0
|
|
52
|
+
uv run rssd daemon --root demo --poll-now
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## What an entry looks like
|
|
56
|
+
|
|
57
|
+
Feed HTML is mapped onto a small fixed vocabulary rather than passed through, so
|
|
58
|
+
every entry has the same shape no matter how the publisher writes their markup.
|
|
59
|
+
Wrapper `div`s, `class` attributes and publisher CSS hooks are gone; relative
|
|
60
|
+
URLs are resolved; `javascript:` and `data:` URLs are dropped.
|
|
61
|
+
|
|
62
|
+
```xml
|
|
63
|
+
<entry xmlns="https://rssd.dev/entry/1"
|
|
64
|
+
id="sha256:9f2c1a3b…" revision="1" first-seen="2026-09-15T21:14:02Z">
|
|
65
|
+
<source>
|
|
66
|
+
<link>https://blog.rust-lang.org/2026/07/13/crates-io-development-update/</link>
|
|
67
|
+
<feed name="rust-blog" title="Rust Blog">https://blog.rust-lang.org/feed.xml</feed>
|
|
68
|
+
<guid basis="atom-id">https://blog.rust-lang.org/2026/07/13/…</guid>
|
|
69
|
+
</source>
|
|
70
|
+
<title>crates.io: development update</title>
|
|
71
|
+
<published origin="feed">2026-07-13T00:00:00Z</published>
|
|
72
|
+
<content origin="feed:content" hash="sha256:bc167659…">
|
|
73
|
+
<paragraph>Another six months have passed since our
|
|
74
|
+
<link href="https://blog.rust-lang.org/2026/01/21/…">last update</link>.</paragraph>
|
|
75
|
+
<heading level="2">Source Code Viewer</heading>
|
|
76
|
+
<list><item>Browse published crate versions</item></list>
|
|
77
|
+
</content>
|
|
78
|
+
</entry>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`origin` tells you where the body came from: `feed:content`, `feed:summary`,
|
|
82
|
+
`fulltext:trafilatura`, or `none`. rssd never fabricates content — a feed that
|
|
83
|
+
ships only a title and a link produces an entry that says so.
|
|
84
|
+
|
|
85
|
+
## Three invariants
|
|
86
|
+
|
|
87
|
+
**Reads are sacred.** Every file is written to a temporary name in the same
|
|
88
|
+
directory, fsynced, then `rename(2)`d into place. A reader sees the old file or
|
|
89
|
+
the new one, never a mix, and never a half-written one. A `SIGKILL` mid-write can
|
|
90
|
+
only leave a `.rssd-tmp-*` orphan, which is swept on the next start. Every
|
|
91
|
+
document is re-parsed with a strict XML parser *before* the rename, so a
|
|
92
|
+
malformed entry can never reach the tree.
|
|
93
|
+
|
|
94
|
+
**The tree is the truth; `var/` is a disposable cache.** Each entry file embeds
|
|
95
|
+
its own ID and content hash, so `rm -rf var/` is fully recoverable — the daemon
|
|
96
|
+
rebuilds what it knows by scanning the tree, and writes nothing.
|
|
97
|
+
|
|
98
|
+
**An entry file is written only when its content changes.** No heartbeat, no
|
|
99
|
+
`last-seen` field. If nothing changed upstream, nothing on disk is touched, so
|
|
100
|
+
`mtime` means exactly what a reader expects it to mean.
|
|
101
|
+
|
|
102
|
+
## Revisions
|
|
103
|
+
|
|
104
|
+
Entry files are never mutated. When a publisher edits a post, the new version
|
|
105
|
+
lands beside the old one as `.r2.xml` and a stable symlink is repointed:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv run rssd demo-mutate --root demo # forces a revision on demand
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Follow the symlink for "current", or list `.rN` for the full history. Because
|
|
112
|
+
the change is detected by hashing the *rendered semantic content* rather than
|
|
113
|
+
the raw HTML, a reordered attribute or a rotating ad token can't manufacture a
|
|
114
|
+
revision. Three further guards — a daily per-entry cap, rotating-GUID detection,
|
|
115
|
+
and a flat cap on new entries per poll — keep a misbehaving feed from filling
|
|
116
|
+
the disk.
|
|
117
|
+
|
|
118
|
+
## Events
|
|
119
|
+
|
|
120
|
+
Everything the daemon does is appended to `var/events.jsonl`, one JSON object
|
|
121
|
+
per line, with a sequence number that is monotonic across restarts:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{"v":1,"seq":42,"ts":"2026-09-15T21:14:02.140Z","event":"entry.new","feed":"rust-blog","data":{"id":"sha256:9f2c…","path":"store/rust-blog/entries/2026…xml","title":"crates.io: development update"}}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Events are emitted *after* the corresponding file is on disk, so **if you see
|
|
128
|
+
`entry.new`, the file exists and is complete**. The converse isn't promised — a
|
|
129
|
+
crash between the rename and the append loses the event — so consumers reconcile
|
|
130
|
+
by scanning the tree when they see `daemon.started`.
|
|
131
|
+
|
|
132
|
+
Use `tail -F`, not `tail -f`: the log rotates, and `-f` follows the inode and
|
|
133
|
+
goes quietly dead.
|
|
134
|
+
|
|
135
|
+
## Politeness
|
|
136
|
+
|
|
137
|
+
Conditional GET via `ETag`/`Last-Modified`; `Cache-Control: max-age` raises the
|
|
138
|
+
poll interval; `Retry-After` on 429/503 is honoured and isn't counted as a
|
|
139
|
+
failure. Feeds that send no validators at all are compared by body hash and
|
|
140
|
+
polled less often, since there's no cheap way to ask. Failures back off
|
|
141
|
+
exponentially with jitter, per feed, and one broken feed never affects another.
|
|
142
|
+
|
|
143
|
+
Fetching article pages is **opt-in per subscription** and off by default — the
|
|
144
|
+
normal footprint is one request per feed, never one per entry.
|
|
145
|
+
|
|
146
|
+
## Commands
|
|
147
|
+
|
|
148
|
+
Two binaries, on purpose. `rssd` writes the tree; `rss` only reads it.
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
rssd init <root> scaffold an instance [--fixture-mode]
|
|
152
|
+
rssd daemon --root <root> run it [--poll-now]
|
|
153
|
+
rssd once --root <root> one poll pass, then exit
|
|
154
|
+
rssd poll <feed> --root <r> force-poll one feed
|
|
155
|
+
rssd validate --root <root> check every subscription parses
|
|
156
|
+
rssd prune --retired --yes delete retired folders (never automatic)
|
|
157
|
+
rssd serve-fixtures local HTTP server over the fixtures
|
|
158
|
+
rssd demo-mutate --root <r> force a revision on demand
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
rss feeds what's subscribed, and is it healthy
|
|
163
|
+
rss ls [feed] entries, newest first
|
|
164
|
+
rss show <ref> read an entry as wrapped text
|
|
165
|
+
rss link <ref> print just the URL
|
|
166
|
+
rss open <ref> open it in a browser
|
|
167
|
+
rss links <ref> every link in an entry
|
|
168
|
+
rss search <query> [feed] match titles and body text
|
|
169
|
+
rss revisions <ref> every stored version
|
|
170
|
+
rss diff <ref> what changed when it was edited
|
|
171
|
+
rss info <ref> id, dates, origin, hash, path
|
|
172
|
+
rss config effective settings and their source
|
|
173
|
+
rss tui the terminal reader
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Refer to an entry by id prefix, position, feed, or path — all four work:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
rss show 9f2c1a3b # unique id prefix
|
|
180
|
+
rss show rust-blog:3 # 3rd newest in a feed
|
|
181
|
+
rss show rust-blog # newest in a feed
|
|
182
|
+
rss show demo/store/rust-blog/entries/2026….xml
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
An ambiguous prefix lists the candidates rather than guessing.
|
|
186
|
+
|
|
187
|
+
## The TUI
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
rss tui --root demo
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Modal, like vim. Three panes — feeds, entries, reader — a status line, and no
|
|
194
|
+
chrome. `hjkl` to move, `Enter` to read, `o` to open in a browser, `y` to yank
|
|
195
|
+
the URL, `/` to search, `:` for ex commands (`:e rust-blog`, `:set width=100`,
|
|
196
|
+
`:links`, `:open 3`, `:q`).
|
|
197
|
+
|
|
198
|
+
`:set` changes the session only, exactly like vim; persistent preferences go in
|
|
199
|
+
`rss.toml`.
|
|
200
|
+
|
|
201
|
+
The daemon doesn't need to be running — the TUI reads the filesystem, so it
|
|
202
|
+
works fine against a static tree or a directory you rsynced from elsewhere.
|
|
203
|
+
|
|
204
|
+
## Development
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
uv sync --extra tui
|
|
208
|
+
uv run pytest # 322 tests, no network required
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The pure modules — `semantic`, `identity`, `parse`, `render`, `schedule`,
|
|
212
|
+
`diff`, `timeutil`, `subscriptions` — hold everything that can actually be
|
|
213
|
+
*wrong*, and are tested against committed byte-exact captures of six real feeds
|
|
214
|
+
chosen to cover the awkward cases: rich Atom with `xml:base`, RSS with
|
|
215
|
+
`content:encoded`, CRLF line endings, an image-only body, and one feed that
|
|
216
|
+
sends no cache validators at all.
|
|
217
|
+
|
|
218
|
+
`SPEC.md` is the full specification.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rssd-fs"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A file-based RSS daemon: the filesystem is the API"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
keywords = [
|
|
8
|
+
"rss",
|
|
9
|
+
"atom",
|
|
10
|
+
"feed",
|
|
11
|
+
"daemon",
|
|
12
|
+
"filesystem",
|
|
13
|
+
"tui",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
|
|
22
|
+
"Topic :: Utilities",
|
|
23
|
+
]
|
|
24
|
+
readme = "README.md"
|
|
25
|
+
requires-python = ">=3.14"
|
|
26
|
+
dependencies = [
|
|
27
|
+
"feedparser>=6.0.14",
|
|
28
|
+
"httpx>=0.28.1",
|
|
29
|
+
"lxml>=6.1.3",
|
|
30
|
+
"watchfiles>=1.2.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "Allie Coleman"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/alliecatowo/rssd"
|
|
38
|
+
Documentation = "https://alliecatowo.github.io/rssd/"
|
|
39
|
+
Source = "https://github.com/alliecatowo/rssd"
|
|
40
|
+
Issues = "https://github.com/alliecatowo/rssd/issues"
|
|
41
|
+
|
|
42
|
+
[project.optional-dependencies]
|
|
43
|
+
tui = ["textual>=8.2.8"]
|
|
44
|
+
fulltext = ["trafilatura>=2.2.0"]
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
rssd = "rssd.cli:main"
|
|
48
|
+
rss = "rssd.readercli:main"
|
|
49
|
+
|
|
50
|
+
[dependency-groups]
|
|
51
|
+
dev = [
|
|
52
|
+
"pytest>=9.1.1",
|
|
53
|
+
"pytest-asyncio>=1.4.0",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[build-system]
|
|
57
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
58
|
+
build-backend = "uv_build"
|
|
59
|
+
|
|
60
|
+
[tool.uv.build-backend]
|
|
61
|
+
module-root = "src"
|
|
62
|
+
module-name = "rssd"
|
|
63
|
+
|
|
64
|
+
[tool.pytest.ini_options]
|
|
65
|
+
testpaths = ["tests"]
|
|
66
|
+
asyncio_mode = "auto"
|
|
67
|
+
filterwarnings = ["ignore:To avoid breaking existing software:DeprecationWarning"]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rssd-fs"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A file-based RSS daemon: the filesystem is the API"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
authors = [{ name = "Allie Coleman" }]
|
|
8
|
+
keywords = ["rss", "atom", "feed", "daemon", "filesystem", "tui"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Environment :: Console",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3.14",
|
|
15
|
+
"Topic :: Internet :: WWW/HTTP :: Indexing/Search",
|
|
16
|
+
"Topic :: Utilities",
|
|
17
|
+
]
|
|
18
|
+
readme = "README.md"
|
|
19
|
+
requires-python = ">=3.14"
|
|
20
|
+
dependencies = [
|
|
21
|
+
"feedparser>=6.0.14",
|
|
22
|
+
"httpx>=0.28.1",
|
|
23
|
+
"lxml>=6.1.3",
|
|
24
|
+
"watchfiles>=1.2.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/alliecatowo/rssd"
|
|
29
|
+
Documentation = "https://alliecatowo.github.io/rssd/"
|
|
30
|
+
Source = "https://github.com/alliecatowo/rssd"
|
|
31
|
+
Issues = "https://github.com/alliecatowo/rssd/issues"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
tui = ["textual>=8.2.8"]
|
|
35
|
+
fulltext = ["trafilatura>=2.2.0"]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
# Two binaries on purpose: `rssd` writes the tree, `rss` only reads it.
|
|
39
|
+
rssd = "rssd.cli:main"
|
|
40
|
+
rss = "rssd.readercli:main"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=9.1.1",
|
|
45
|
+
"pytest-asyncio>=1.4.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
50
|
+
build-backend = "uv_build"
|
|
51
|
+
|
|
52
|
+
[tool.uv.build-backend]
|
|
53
|
+
module-root = "src"
|
|
54
|
+
module-name = "rssd"
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = ["tests"]
|
|
58
|
+
asyncio_mode = "auto"
|
|
59
|
+
filterwarnings = [
|
|
60
|
+
# feedparser warns once per entry that it is falling back from
|
|
61
|
+
# updated_parsed to published_parsed. We handle both explicitly in
|
|
62
|
+
# parse.py, and 500 copies of the notice drowns out real output.
|
|
63
|
+
"ignore:To avoid breaking existing software:DeprecationWarning",
|
|
64
|
+
]
|
|
File without changes
|