Make the reader the default route and document the toolset
The catch-all rule now sends anything that did not match a more specific rule to furst-serve, while `default` stays a plain browser. Because rules fall through when their command is missing, that arrangement needs no new mechanism: if furst-serve is not installed the reader rule is skipped and the browser still gets the URL. Firefox drops to a short list of sites that genuinely need it. Every reader page carries a browser link, so the list can stay short. Adds a README for furst-serve and rewrites the top-level one around the whole pipeline: what it is for, what each piece does, measured output sizes, and the limits — client-rendered pages, rate-limited search endpoints, DRM.
This commit is contained in:
parent
3f71d324c9
commit
b1d052fc79
171
README.md
171
README.md
@ -1,108 +1,103 @@
|
||||
# furst
|
||||
|
||||
A URL router. It sits where your default browser used to, and sends each URL to
|
||||
the cheapest tool that can actually handle it — `mpv` for video, a pager for
|
||||
PDFs, a light WebKit browser for reading, and Firefox only when nothing else
|
||||
will do.
|
||||
A toolset for browsing the modern web on hardware that cannot run a modern
|
||||
browser. Built for a Core2 Duo with 4GB of RAM and a minimal Arch install,
|
||||
where Firefox is the bottleneck.
|
||||
|
||||
Built for a Core2 Duo with 4GB of RAM, where the browser is the problem.
|
||||
|
||||
## Why
|
||||
## The idea
|
||||
|
||||
A news page is 2–5MB across 80+ requests with 1–3MB of JavaScript to parse and
|
||||
JIT. The same article extracted is ~20KB. Choosing a lighter *engine* buys
|
||||
2–3×; not loading the payload at all buys 10–100×. `furst` is the dispatcher
|
||||
that decides which of those you get, per URL.
|
||||
JIT. The same article, extracted, is ~20KB. Choosing a lighter browser engine
|
||||
buys 2–3×. **Not loading the payload at all buys considerably more.**
|
||||
|
||||
So the goal is not a faster browser. It is to keep a browser engine out of the
|
||||
loop wherever possible, and to make the cases where you still need one rare and
|
||||
deliberate.
|
||||
|
||||
```
|
||||
link click
|
||||
│
|
||||
┌────▼─────┐ video ──────────────► mpv (H.264 forced)
|
||||
│ furst │ pdf / image ────────► zathura / nsxiv
|
||||
│ router │ mailto / magnet ────► xdg-email / transmission
|
||||
└────┬─────┘ a short "heavy" list ► firefox
|
||||
│
|
||||
▼ everything else
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ furst-serve │───────►│ furst-read │ fetch → extract → render
|
||||
│ local reader │ │ library │
|
||||
└──────┬───────┘ └──────────────┘
|
||||
│ minimal HTML, no JS/CSS/fonts, links rewritten to stay inside
|
||||
▼
|
||||
a light browser (surf, luakit, …)
|
||||
```
|
||||
|
||||
## The pieces
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [`furst`](src/) | the router. Sits where the default browser used to and dispatches each URL to the cheapest tool that can handle it. Two dependencies, 541K binary. |
|
||||
| [`furst-read`](furst-read/README.md) | fetch, extract the article, render it as a self-contained document. Library and CLI. |
|
||||
| [`furst-serve`](furst-serve/README.md) | the local reader. Rewrites in-page links so browsing stays in reader mode, caches pages, and handles what extraction cannot: search, feeds, listings, comment threads. |
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
install -Dm755 target/release/furst ~/.local/bin/furst
|
||||
cargo build --release --workspace
|
||||
install -Dm755 target/release/furst target/release/furst-read \
|
||||
target/release/furst-serve -t ~/.local/bin/
|
||||
|
||||
furst --init # writes ~/.config/furst/rules.toml, probes for a light browser
|
||||
furst --install # registers furst as the system default browser
|
||||
furst --init # rules tuned for low-end hardware
|
||||
furst --install # become the system default browser
|
||||
furst-serve --init # home page links and feeds
|
||||
|
||||
sudo pacman -S mpv yt-dlp zathura zathura-pdf-mupdf nsxiv surf
|
||||
```
|
||||
|
||||
`--install` writes `~/.local/share/applications/furst.desktop` and points
|
||||
`xdg-settings` at it, so every link click in every application routes here.
|
||||
Then every link click in every application goes through the router, and most
|
||||
of them never reach a browser engine.
|
||||
|
||||
## Use
|
||||
## Measured
|
||||
|
||||
| page | raw HTML | scripts | stylesheets | through furst |
|
||||
|---|---|---|---|---|
|
||||
| wiki.archlinux.org/title/Zram | 64K | 4 | 2 | 16K |
|
||||
| en.wikipedia.org/wiki/Core_2 | 207K | 5 | 2 | 47K |
|
||||
| theverge.com | 770K | 55 | 71 | 2.4K |
|
||||
| lwn.net headlines (feed) | — | — | — | 11K, 15 entries |
|
||||
|
||||
The HTML column understates it. The script and stylesheet columns are the real
|
||||
story: those become zero, along with every webfont and tracker, which is where
|
||||
an old CPU actually spends its time. A cached revisit is ~0.4ms.
|
||||
|
||||
## Two details that matter on this hardware
|
||||
|
||||
**Video is forced to H.264.** A Core2 handles 720p `avc1` in software but
|
||||
stalls on VP9 and AV1, which is what YouTube serves by default. The format
|
||||
string in the `video` rule is doing more work than the resolution cap, and
|
||||
playing it in mpv skips the browser entirely.
|
||||
|
||||
**The reader is the catch-all, not a special case.** Rules fall through when
|
||||
their command is missing, so the last rule can be "send it to the reader" while
|
||||
`default` stays a plain browser. Firefox is left for a short list of sites that
|
||||
genuinely need it — and every reader page has a **browser** link for the rest.
|
||||
|
||||
## Limits
|
||||
|
||||
- Anything that renders client-side arrives empty. There is no JavaScript here;
|
||||
that is the point, not an oversight.
|
||||
- Free search endpoints rate-limit repeat visitors. The reader detects a
|
||||
challenge page and tells you which engine refused, rather than showing an
|
||||
empty list. See [furst-serve](furst-serve/README.md#on-search).
|
||||
- Paywalls and DRM are not defeated, only rendered plainly or handed off.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
furst <url> # match a rule and exec its command
|
||||
furst --explain <url> # show what would run, and why; run nothing
|
||||
furst --list # show the loaded rules
|
||||
cargo test --workspace # note: --workspace, the root package is just furst
|
||||
cargo clippy --workspace --all-targets
|
||||
```
|
||||
|
||||
`--explain` is the one you want when a URL goes somewhere surprising:
|
||||
|
||||
```
|
||||
$ furst --explain https://youtu.be/abc123
|
||||
scheme https
|
||||
host youtu.be
|
||||
path /abc123
|
||||
|
||||
-> [video] mpv --ytdl-format=bestvideo[vcodec^=avc1][height<=?720]+... https://youtu.be/abc123
|
||||
[default] surf https://youtu.be/abc123
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
`~/.config/furst/rules.toml`. First matching rule wins. **If its command is
|
||||
missing from `$PATH`, furst falls through to the next matching rule, and
|
||||
finally to `default`** — which is what lets you name tools you have not written
|
||||
yet and have the config stay working today.
|
||||
|
||||
```toml
|
||||
default = ["surf", "{url}"]
|
||||
|
||||
[[rule]]
|
||||
name = "video"
|
||||
hosts = ["youtube.com", "youtu.be"]
|
||||
run = ["mpv", "--ytdl-format=bestvideo[vcodec^=avc1][height<=?720]+bestaudio/best", "{url}"]
|
||||
|
||||
[[rule]]
|
||||
name = "hn"
|
||||
hosts = ["news.ycombinator.com"]
|
||||
terminal = true # wrap in $TERMINAL -e
|
||||
run = ["furst-hn", "{url}"]
|
||||
```
|
||||
|
||||
A rule matches when every criterion it *states* is satisfied; a criterion is
|
||||
satisfied by any one of its patterns. A rule that states nothing matches
|
||||
everything.
|
||||
|
||||
| Key | Matches against |
|
||||
|---|---|
|
||||
| `schemes` | `https`, `mailto`, `magnet`, … |
|
||||
| `hosts` | `example.com` = apex **and** every subdomain; `*.example.com` = same; `=example.com` = that host exactly; `*` = any |
|
||||
| `paths` | path component only, glob with `*`, case-insensitive |
|
||||
| `contains` | substring of the whole raw URL |
|
||||
|
||||
| Placeholder | |
|
||||
|---|---|
|
||||
| `{url}` `{url_enc}` | the URL, raw or percent-encoded |
|
||||
| `{host}` `{path}` `{scheme}` | parsed components |
|
||||
|
||||
If no argument mentions `{url}` or `{url_enc}`, the URL is appended last.
|
||||
|
||||
## Notes for old hardware
|
||||
|
||||
- **Force H.264 for video.** A Core2 handles 720p `avc1` in software but stalls
|
||||
on VP9/AV1, which is what YouTube serves by default. That format string is
|
||||
doing more work than the resolution cap.
|
||||
- Host matching strips userinfo with `rfind('@')`, so
|
||||
`https://bank.example@evil.example/` routes on `evil.example`.
|
||||
- `furst` `exec`s the handler rather than forking, so it leaves no process
|
||||
behind.
|
||||
|
||||
## Companion tools
|
||||
|
||||
- [`furst-read`](furst-read/README.md) — fetch a page, extract the article, and
|
||||
render it as minimal HTML with no scripts, stylesheets, or webfonts. The
|
||||
`reader` rule in the starter config points at it.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
104
furst-serve/README.md
Normal file
104
furst-serve/README.md
Normal file
@ -0,0 +1,104 @@
|
||||
# furst-serve
|
||||
|
||||
A local reader server. It fetches pages, strips them to what you came for, and
|
||||
serves them from `127.0.0.1` as small documents with no scripts, no
|
||||
stylesheets, no webfonts and no third-party requests.
|
||||
|
||||
The point is the link rewriting. Every link on a served page points back at
|
||||
`/read?u=…`, so following one keeps you in the reader instead of dropping you
|
||||
onto the live site. Combined with the disk cache, that is what makes browsing
|
||||
on a Core2 feel immediate.
|
||||
|
||||
## Use
|
||||
|
||||
```sh
|
||||
furst-serve # run in the foreground on 127.0.0.1:7714
|
||||
furst-serve --open <url> # start it if needed, then open <url> in a browser
|
||||
furst-serve --status
|
||||
furst-serve --init # write a starter home.toml
|
||||
furst-serve --clear # drop the page cache
|
||||
```
|
||||
|
||||
`--open` is what [furst](../README.md)'s catch-all rule calls. It starts a
|
||||
detached server when nothing is listening, waits for the port, then execs
|
||||
`$FURST_BROWSER`, `$BROWSER`, or the first light browser on `$PATH`.
|
||||
|
||||
## Routes
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `/` | home: pinned links and feeds, and the search box |
|
||||
| `/read?u=` | the page — article, site view, or link index |
|
||||
| `/search?q=` | search results |
|
||||
| `/feed?u=` | an RSS or Atom feed |
|
||||
| `/go?u=` | hand the original URL to the heavy browser |
|
||||
|
||||
Add `&fresh=1` to `/read` or `/feed` to bypass the cache. Every page carries
|
||||
**refresh**, **original** and **browser** links in the bar, so nothing is a
|
||||
dead end.
|
||||
|
||||
## What it does with a page
|
||||
|
||||
1. **A feed** — rendered as a list of entries, whether it was requested at
|
||||
`/feed` or just turned out to be XML.
|
||||
2. **A site with an adapter** — Hacker News gets stories with score and
|
||||
author, and comment threads with their indent preserved.
|
||||
3. **An article** — extracted by [furst-read](../furst-read/README.md).
|
||||
4. **Anything else** — if there is too little prose to be an article, the
|
||||
page's links are listed instead. A front page has no article to find, and
|
||||
saying so beats rendering an empty column.
|
||||
|
||||
Non-documents (a PDF, an image) redirect to the original rather than being fed
|
||||
to an article extractor.
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.config/furst/home.toml`:
|
||||
|
||||
```toml
|
||||
[[link]]
|
||||
name = "Arch Wiki"
|
||||
url = "https://wiki.archlinux.org/"
|
||||
|
||||
[[feed]]
|
||||
name = "LWN"
|
||||
url = "https://lwn.net/headlines/newrss"
|
||||
|
||||
[search]
|
||||
url = "https://html.duckduckgo.com/html/?q={q}"
|
||||
```
|
||||
|
||||
| Environment | |
|
||||
|---|---|
|
||||
| `FURST_PORT` | default 7714 |
|
||||
| `FURST_BROWSER` / `BROWSER` | browser for `--open` |
|
||||
| `FURST_HEAVY_BROWSER` | the `/go` target, default `firefox` |
|
||||
| `XDG_CACHE_HOME` | pages land in `furst/pages` under it |
|
||||
|
||||
## On search
|
||||
|
||||
Search is engine-agnostic on purpose. **Every free HTML endpoint eventually
|
||||
rate-limits a repeat visitor** — DuckDuckGo and searx.be both served this
|
||||
machine a challenge page while it was being written. So:
|
||||
|
||||
- Known result shapes (DuckDuckGo, SearXNG) are tried first, then heading
|
||||
links, then any link. An unknown engine that serves plain HTML still works.
|
||||
- A challenge page is detected from the body, not the status, because they
|
||||
arrive as 200 or 202 rather than an error. You get a page saying which
|
||||
engine refused and where to change it, never a silently empty result list.
|
||||
- Blocked searches are not cached.
|
||||
|
||||
If your engine rate-limits you, point `[search] url` at a SearXNG instance.
|
||||
|
||||
Engines that render results client-side cannot work here — there is no
|
||||
JavaScript, by design.
|
||||
|
||||
## Design notes
|
||||
|
||||
The HTTP layer is blocking, hand-rolled, GET only, one response per
|
||||
connection, with a fixed pool of four worker threads. No async runtime:
|
||||
predictable memory matters more than concurrency on a 4GB machine. Only the
|
||||
loopback interface is ever bound.
|
||||
|
||||
Cache entries carry a schema number, so changing the renderer drops them
|
||||
rather than serving stale markup.
|
||||
@ -105,7 +105,7 @@ pub const STARTER: &str = r##"# furst — route URLs to the cheapest tool that c
|
||||
#
|
||||
# First matching rule wins. If its command is missing from $PATH, furst falls
|
||||
# through to the next matching rule, and finally to `default`. That is what
|
||||
# lets you name tools you have not written yet.
|
||||
# lets the reader be the catch-all while a plain browser stays the safety net.
|
||||
#
|
||||
# Host patterns example.com the apex and every subdomain
|
||||
# *.example.com alias for the same
|
||||
@ -116,6 +116,7 @@ pub const STARTER: &str = r##"# furst — route URLs to the cheapest tool that c
|
||||
# If no argument mentions {url} or {url_enc}, the URL is appended.
|
||||
# terminal=true wraps the command in $TERMINAL -e, for TUI handlers.
|
||||
|
||||
# Used when no rule matches, and when every matching rule's command is missing.
|
||||
default = ["surf", "{url}"]
|
||||
|
||||
# ---------------------------------------------------------------- video ---
|
||||
@ -146,28 +147,9 @@ name = "pdf"
|
||||
paths = ["*.pdf"]
|
||||
run = ["zathura", "{url}"]
|
||||
|
||||
# --------------------------------------------------------------- reading ---
|
||||
# The 100x win. furst-read does not exist yet; until it does these fall
|
||||
# through to `default` on their own.
|
||||
[[rule]]
|
||||
name = "reader"
|
||||
hosts = [
|
||||
"wikipedia.org", "news.ycombinator.com", "lobste.rs", "medium.com",
|
||||
"*.substack.com", "stackoverflow.com", "reddit.com",
|
||||
]
|
||||
run = ["furst-read", "{url}"]
|
||||
|
||||
# ------------------------------------------------------------------ docs ---
|
||||
[[rule]]
|
||||
name = "docs"
|
||||
hosts = [
|
||||
"docs.rs", "doc.rust-lang.org", "wiki.archlinux.org", "man7.org",
|
||||
"github.io", "readthedocs.io",
|
||||
]
|
||||
run = ["surf", "{url}"]
|
||||
|
||||
# ----------------------------------------------------------------- heavy ---
|
||||
# The escape hatch. Only these are allowed to cost you 400MB.
|
||||
# The escape hatch. Only these are allowed to cost you 400MB. The reader also
|
||||
# offers a "browser" link on every page, so this list can stay short.
|
||||
[[rule]]
|
||||
name = "heavy"
|
||||
hosts = [
|
||||
@ -176,7 +158,7 @@ hosts = [
|
||||
]
|
||||
run = ["firefox", "{url}"]
|
||||
|
||||
# ----------------------------------------------------------- non-web ---
|
||||
# ---------------------------------------------------------------- non-web ---
|
||||
[[rule]]
|
||||
name = "mail"
|
||||
schemes = ["mailto"]
|
||||
@ -186,4 +168,13 @@ run = ["xdg-email", "{url}"]
|
||||
name = "torrent"
|
||||
schemes = ["magnet"]
|
||||
run = ["transmission-remote", "-a", "{url}"]
|
||||
|
||||
# ---------------------------------------------------------------- reader ---
|
||||
# Everything else. This rule states no criteria, so it matches anything that
|
||||
# got past the rules above. furst-serve starts the local reader if it is not
|
||||
# already running, then opens the page in it. If furst-serve is not installed
|
||||
# this falls through to `default` on its own.
|
||||
[[rule]]
|
||||
name = "reader"
|
||||
run = ["furst-serve", "--open", "{url}"]
|
||||
"##;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user