RSS is still the best way to follow the internet without an algorithm deciding what you see. But it has a problem nobody talks about: most feeds are garbage. Not the content. The actual XML. Truncated posts that force you to click through. No images because the publisher strips them. Feeds with 200 entries when you only care about ones matching a keyword. Two separate blogs you wish were one feed. The protocol works fine. The feeds people publish are another story.
You can fix this. You can run a proxy that sits between the source feed and your reader, transforming it on the way through. Full-text extraction, filtering, merging, reformatting. The concept isn't new. Yahoo Pipes did it fifteen years ago before Yahoo killed it, because Yahoo kills everything. Since then the options have been scattered: a self-hosted PHP app here, a SaaS product there, a pile of brittle scripts duct-taped to cron.
I found a Rust project that does this properly, as a single binary with a config file and a web UI. I read the code, added a feature, and fixed a bug.
What Is rss-funnel?
rss-funnel is a modular RSS/Atom feed processing pipeline written in Rust by shouya. You define endpoints in a YAML config file, each with a source feed and a chain of filters. The filters do things like extract full article text, keep or discard posts by keyword, merge feeds together, run arbitrary JavaScript transformations, highlight text, or rewrite image URLs through a proxy. It ships as a single static binary with a built-in web UI for inspecting your feeds and testing filter chains.
About 145 stars. For something this complete, that's low.
The Snapshot
| Project | rss-funnel |
| Stars | ~145 at time of writing |
| Maintainer | Solo developer, actively committing |
| Code health | Clean trait-based architecture, well-tested, pedantic clippy |
| Docs | Solid README, live demo instance, config examples |
| Contributor UX | Excellent. Clear patterns, welcoming maintainer, PRs merged fast |
| Worth using | Yes, if you use RSS at all |
Under the Hood
rss-funnel is about 10,400 lines of Rust across 49 files, and the architecture is the most interesting part. The core abstraction is a Feed type that unifies RSS and Atom into a single interface. Feeds flow through filter pipelines, and each filter is a self-contained module following a consistent pattern: a config struct that deserializes from YAML, a filter struct that processes feeds, and two trait implementations connecting them.
#[async_trait::async_trait]
impl FeedFilterConfig for InjectCssConfig {
type Filter = InjectCss;
async fn build(self) -> Result<Self::Filter> { /* ... */ }
}
#[async_trait::async_trait]
impl FeedFilter for InjectCss {
async fn run(&self, _ctx: &mut FilterContext, mut feed: Feed) -> Result<Feed> { /* ... */ }
}
New filters get registered through a define_filters! macro that generates the enum variant, serde handling, and JSON schema in one line:
InjectCss => inject_css::InjectCssConfig, "Inject CSS styles into post bodies";
Adding a filter to rss-funnel means writing one file and adding one line to the macro invocation. The simplest filter in the codebase (note.rs) is 34 lines. That's the kind of architecture that makes contributions inviting.
Here's what a real pipeline looks like in the config:
source: https://blog.example.com/feed.xml
→ full_text (fetch complete article bodies)
→ keep_only (filter by keyword or field)
→ inject_css (style for your reader)
→ /feed.xml
Each arrow is a filter. Each filter is one file. The feed flows top to bottom, and the output is a new RSS endpoint you point your reader at.
The dependency choices are practical. Axum for the web server, maud for HTML templating, htmx for the inspector UI (all embedded in the binary via rust-embed). An LRU cache for HTTP responses. An embedded QuickJS runtime via rquickjs for the JavaScript filter, which is ambitious but lets users write arbitrary transformations without recompiling. The maintainer carries forked versions of ego-tree and schemars with custom patches, which is a minor maintenance burden but nothing unusual for a Rust project with specific needs.
CI runs clippy in pedantic mode with -D warnings, which tells you something about the code quality expectations. The test suite uses helper utilities for parsing configs and fetching from fixture feeds, making it straightforward to test filters in isolation.
The rough edges are minor. Some filters have thin documentation (the config schema helps, but examples would be better). The OTF (on-the-fly) filter interface, which lets you override filter parameters via URL query strings, had a parsing gap where structured configs silently failed. The web UI is functional but basic. None of this detracts from the core tool, which works well for daily use.
The Contribution
I tackled two open issues. The first was #144, a bug in the OTF filter parameter parser. The parse_single function handles URL query parameters like keep_only=foo, attempting to parse the value as a number, then falling back to a string. But filters like keep_only and discard accept structured configs with field-specific matching: {field: title, contains: foo}. When users URL-encoded that YAML mapping and passed it as a query parameter, the parser tried it as a number (failed), then treated the whole thing as a plain string (wrong). The fix was five lines: try parsing the value as YAML first, and if it's a mapping, route it directly to the config builder before falling back to the number and string paths.
The second was #164, a feature request from the maintainer for a CSS injection filter. Some RSS readers render HTML content but don't let you control the styling, so a filter that prepends a <style> block to each post's body is useful for readability tweaks. The implementation was about 90 lines including tests. Here's the entire filter, minus the test module:
The complete inject_css filter (45 lines)
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::{FeedFilter, FeedFilterConfig, FilterContext};
use crate::{error::Result, feed::Feed};
#[derive(
JsonSchema, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash,
)]
#[serde(transparent)]
/// Inject a CSS `<style>` block into the body of each post.
pub struct InjectCssConfig {
css: String,
}
#[async_trait::async_trait]
impl FeedFilterConfig for InjectCssConfig {
type Filter = InjectCss;
async fn build(self) -> Result<Self::Filter> {
Ok(InjectCss { css: self.css })
}
}
pub struct InjectCss {
css: String,
}
#[async_trait::async_trait]
impl FeedFilter for InjectCss {
async fn run(
&self,
_ctx: &mut FilterContext,
mut feed: Feed,
) -> Result<Feed> {
let style_tag = format!("<style>{}</style>", self.css);
let mut posts = feed.take_posts();
for post in &mut posts {
post.modify_bodies(|body| {
body.insert_str(0, &style_tag);
});
}
feed.set_posts(posts);
Ok(feed)
}
}
A config struct, a filter struct, two trait impls, and the actual logic is six lines in run(). That's the entire cost of adding a new capability to rss-funnel.
Getting into the codebase was fast. The filter architecture makes the "where does this go" question trivial. I had the inject_css filter working in under an hour. The OTF fix took longer to understand (tracing how URL parameters flow through decoding into YAML deserialization) but the actual change was small. The inject_css filter is in PR #172 and the OTF fix is in PR #173. I originally submitted them together, and the maintainer asked me to split them so he could evaluate each change independently. Fair request, and it took five minutes.
The Verdict
rss-funnel is for anyone who relies on RSS and has ever wished they could reshape a feed before it hits their reader. If you've written scripts to scrape full article text, filter posts by keyword, or merge feeds from related sources, this replaces all of that with a config file and a binary.
The project has a healthy trajectory. The maintainer responds to issues with detailed analysis, merges external PRs within days, and is actively adding features. Five external contributors have had PRs merged. The filter architecture means the project can grow in capability without growing in complexity, because each new filter is isolated.
What would push rss-funnel further? More filters (there's room for date-based filtering, deduplication, translation). Better documentation with cookbook-style examples for common use cases. And visibility. A tool this capable shouldn't be sitting at 145 stars.
Go Look At This
If you use RSS, try rss-funnel. There's a live demo if you want to see it before you install it. Point it at a feed you already follow and add a full_text or keep_only filter. You'll see the difference immediately.
Star the repo. If you want to contribute, the filter architecture makes it one of the most approachable Rust codebases I've read. Pick an open issue, write one file, add one line to the macro. Here's how mine went.
This is Review Bomb #2, a series where I find under-the-radar projects on GitHub, read the code, contribute something, and write it up. If you know a project that deserves more eyeballs, drop it in the comments.
Top comments (0)