< the skull

RSS.EXPERT
==========
2026-10-13

I had owned rss.expert for three years without putting it to any use. Every year
the renewal came around, I paid it, and the domain stayed right where it was: no
website, just waiting for an idea that never arrived. A name like that
practically demands something involving feeds. The tricky part was figuring out
what.

For anyone who's never had reason to know: RSS is a file a website publishes
listing its latest updates. Titles, links, dates, sometimes the entire text. A
feed reader pulls those files together from different websites so you can follow
everything in one place, without an algorithm deciding the order. Atom and JSON
Feed are RSS's cousins; they do the same job with different syntax. The
technology dates back to the 2000s. Plenty of people declared it dead when
Google Reader shut down in 2013, yet plenty of blogs still publish feeds.

In September 2026, I finally sat down and did something with the domain. The
result is live at rss.expert.

A website your terminal can read, too
-------------------------------------

The idea I settled on fits in one sentence: the same address responds to both
your browser and your terminal. Open rss.expert/weird in a browser and you see a
webpage. Run this in a terminal:

curl https://rss.expert/weird [1]

and you get the same content as neatly aligned plain text, without an HTML tag
in sight:

THE WEIRD WEB

─────────────────────

Small feeds, personal sites, and the odd corners of this index.

01\. Bear Roll

bearroll.dev

https://bearroll.dev/ [2]

\[...\]

04\. linkhut

ln.ht

https://ln.ht/ [3]

This works because of an HTTP detail almost nobody pays attention to. Every
request carries a header called Accept, which tells the server what kind of
response the client prefers. A browser sends text/html. curl sends \/\, meaning
“anything will do.” The server reads that header and chooses: HTML for one, text
for the other. That's why the project doesn't have a separate API. The address
you open in your browser is the very same one you call from a script.

You can force the format in three ways: with a file extension (/latest.json,
/latest.rss, /latest.atom, /weird.txt), the ?format= parameter, or the Accept
header itself. If none of those specifies a format, the response is plain text.
Ask for a format the page doesn't support and you get a 406 error, which is
readable in the terminal, too.

For people who practically live in the terminal, the most useful option is
format=urls. It returns nothing but addresses, one per line. It exists so you
can drop rss.expert into a pipe:

curl -s "https://rss.expert/search?q=openbsd&format=urls [4]"

There's a catch on Windows. In PowerShell, curl can be an alias for
Invoke-WebRequest, which returns an object instead of plain text. Use curl.exe.

The commands
------------

Each command is a short URL, and its explanation lives at /help/name. /weird
returns the list; /help/weird explains how that list is put together. The
homepage has two lines and an example, because everything else is in /help.

Some commands are for wandering around what the service has already collected:

  * /weird shows the small, strange side of the index: personal websites, tiny
    feeds, odd little corners.
  * /one returns a single recent read, with enough substance to be worth the
    click.
  * /random grabs anything from the archive, including old stuff. /one tries to
    pick something worthwhile; /random doesn't even try.
  * /latest lists the most recently published items.
  * /trending shows words that are popping up more often.
  * /new and /dead show feeds that have just been added and those that have
    stopped responding.
  * /search?q= runs a text search; /topic/name searches by tag; /domain/site.com
    tells you what the index knows about a domain.

The others work on any URL you give them:

  * /find?url= looks for a website's feed.
  * /feed?url= returns just the feed URL, when there's an obvious one.
  * /text?url= extracts an article as plain text.
  * /title?url= returns only the title.
  * /links?url= lists the links on a page, one per line.
  * /meta?url= reads the metadata published by a page: Open Graph, JSON-LD,
    microformats, and link rel attributes.

These endpoints also accept POST requests containing HTML you've already
downloaded. In that case, the service doesn't need to fetch the page again:

curl https://example.org [5] | curl -X POST --data-binary @-
https://rss.expert/extract [6]

No model, just weights and arithmetic
-------------------------------------

The selections for /weird and /one are heuristic. There's no AI model making the
choices, and /about says so explicitly. It's a points system, with all the
weights defined at the top of one file: Ranking.php.

Rare domains, small feeds, personal-site vibes, projects hosted on Codeberg,
mentions of Gemini or Gopher (two alternative protocols beloved by the small
web), custom domains, and recently seen feeds all earn points. Big portals,
aggregators, SEO-looking titles, and domains that already appear too often in
the index lose points. The score doesn't dictate the final list: it only makes a
link more likely to be drawn, so each request returns a different handful.

/trending compares titles from the last 48 hours with those from the previous 14
days, and only shows a term if it appeared at least three times in the recent
window. The page also makes it absolutely clear that these trends describe the
rss.expert index, not the whole internet. I'd rather spell that out than have
someone mistake it for a list of what the entire world is talking about today.

Where the links come from
-------------------------

The starting point is the catalog at weirdnet.org, another project of mine: an
observatory of the small web with curated links. The entire catalog is included
in the repository and imported into the database during installation. When I
wrote this, it held 37,118 entries.

From there, there's automatic discovery, designed to be deliberately timid. When
a new article comes in, the service records up to five external links from it.
Later, another process visits a small batch of those pages, looks for a feed,
validates it, and only then stores it. It doesn't follow links from the pages it
visits, so discovery takes one hop and stops. A domain visited in the last 24
hours won't be revisited, and obvious social networks are excluded. The idea is
to stumble upon a neighbor now and then, not crawl the web.

The same restraint applies when fetching feeds. Every request sends ETag and
If-Modified-Since, the HTTP way of asking a website, “Has anything changed since
last time?” If the response is 304 (not modified), the service counts it as a
success and spaces out the next visit, up to 12 hours. If a feed keeps failing,
the wait doubles after every failure, capped at seven days. The bot identifies
itself as RSSExpert/1.0 with a link to /about, so anyone spotting it in their
logs can tell who it is and what it wants.

Cron runs the fetch job every ten minutes, processing no more than 20 feeds at a
time. Five minutes would be too short: 20 feeds with a ten-second timeout each
could exceed that on a bad run. A lock keeps two runs from stepping on each
other.

How I built it
--------------

The Git history tells the story without much shame: three consecutive commits
called “first commit,” all dated September 26, followed the next day by one
called “redesign 3.” Redesigns 1 and 2 are nowhere to be found.

The stack is PHP 8.3, SQLite, Nginx with PHP-FPM, and cron. No Laravel, Symfony,
Node, Redis, Elasticsearch, or Docker. PHP's standard facilities already handle
HTTP (through the curl extension), XML, HTML, JSON, and SQLite, and Composer is
there only to autoload classes. The router is a few dozen lines long and lives
in the repository itself. For a service this size, a framework would give me
more code to maintain than code to write.

SQLite stores everything: feeds, articles, fetch history, tags, discovered URLs,
and even rate-limit state, which would normally go in Redis. Search uses FTS5,
the full-text index built into SQLite, covering titles, content, summaries, and
authors. The service's entire state lives in one file, and backing it up means
copying that file using SQLite's own .backup command.

Feeds are messy, and deduplication has to account for that. An article is first
identified by its GUID, if the feed supplies a usable one (the code discards
values like null and undefined, which crop up more often than they should).
Without a GUID, it uses the normalized URL, stripped of its fragment and default
port. If neither is available, it falls back to a hash of the URL, title, and
text.

The HTML side takes a similar approach. Each page's text and HTML are generated
from the same data structure, and the template is just a shell: a
skip-navigation link, a short menu, and the footer. That way the terminal and
browser never show different information. The homepage loads no fonts, scripts,
or images.

The boring part that had to be right
------------------------------------

A service that fetches any URL a stranger submits has a well-known risk called
SSRF. Instead of requesting a public website, someone submits http://localhost
[7] or the IP address of a machine on the internal network. The server, sitting
on the inside, fetches it and returns the response.

Every incoming URL goes through a class called UrlGuard before a connection is
made. It rejects any scheme other than http or https, URLs containing usernames
and passwords, localhost, .local, .internal, and cloud metadata service
hostnames. It also rejects private, loopback, CGNAT, and multicast IP addresses,
including shortened representations that fool naive validators, such as 127.1 or
an address written as a decimal integer. If DNS returns several addresses and
even one is internal, the entire URL is rejected, regardless of whether the
others are public.

There are two further precautions. The validated IP is pinned to the connection,
so DNS can't change its answer between validation and the request, and the
service then checks which IP it actually connected to. Every redirect is checked
again, with a maximum of three. The tests cover a public website redirecting to
a private address.

XML is parsed without network access, HTML from feeds is escaped before being
displayed, and endpoints that fetch remote URLs have per-IP request-per-minute
limits. The log stores the route, status, duration, and error. It doesn't record
the URL the visitor requested.

The tests don't use the network. They run entirely against fixture files stored
in the repository, covering RSS, Atom, and JSON Feed parsers, URL normalization,
feed discovery, deduplication, search, content negotiation, and SSRF safeguards.

Where things stand today
------------------------

When I finished writing this, /status said: one feed, 37,118 articles, last
fetch: never. The only feed is the Weirdnet catalog, which gets imported rather
than fetched by cron. In other words, the bot I spent so much time teaching to
be polite hasn't knocked on anyone's door yet.

But at least now, when the domain renewal comes around, I know what I'm paying
for.

Links:
  [1] https://rss.expert/weird
  [2] https://bearroll.dev/
  [3] https://ln.ht/
  [4] https://rss.expert/search?q=openbsd&format=urls
  [5] https://example.org
  [6] https://rss.expert/extract
  [7] http://localhost


< the skull