給慢網路的閱讀模式代理服務

tl;dr: 我打造了一個服務,能將任何文章轉換成閱讀舒適、適合列印的版本。它類似於 Firefox/Safari 中的 Reader View,但在舊版瀏覽器上也能運作、可分享,且專注於優美的排版。歡迎查看原始碼。
網路曾經是個很有趣的地方。
現在呢?唉。追蹤器、廣告、臃腫的內容、全螢幕彈出視窗、自動播放影片……這一切都讓人精疲力竭。
我只想泡杯好茶,不受干擾地閱讀長文,看著貓在窗台上睡覺,窗外輕雪飄落。

慢網路
我是慢網路運動的忠實支持者,也喜歡把一件事做好的小型網站。
為了無干擾地閱讀長文,我在 Firefox 上使用Reader View,雖然它不一定每次都有效,介面也不算最美,但我還是很喜歡。
其他瀏覽器也有閱讀模式,但有些瀏覽器——像是 Chrome——將它隱藏在實驗性功能旗標後面。還有些瀏覽器,像我電子書閱讀器上的瀏覽器,根本沒有閱讀模式,這讓我在主要閱讀裝置上的瀏覽體驗又差又慢。
因此,我打造了一個閱讀模式即服務,以優美的排版為核心,可在所有瀏覽器上運作。它非常陽春,但我會用它在舊裝置上閱讀文章,在頻寬較低的地區或旅行時,也能讓內容更容易取得。
動手打造
最近我看到一篇關於circumflex,一款 Hacker News 終端機用戶端的文章。這個工具在呈現網站內容方面做得相當不錯,讓我思考是否能將它改造成代理伺服器。
Golang 的清理程式碼在這裡:
func GetArticle(url string, title string, width int, indentationSymbol string) (string, error) {
articleInRawHTML, httpErr := readability.FromURL(url, 5 * time.Second)
if httpErr != nil {
return "", fmt.Errorf("could not fetch url: %w", httpErr)
}
// ...
}他們使用go-readability,也就是 Mozilla Readability 的移植版本。Rust 的對應版本是readability,使用起來相當簡單:
use readability::extractor;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let response = extractor::scrape("https://endler.dev/2022/readable")?;
println!("{}", response.content);
Ok(())
}在撰寫完整的代理伺服器之前,我們先來寫一個簡單的 CLI 工具,它能接收一個 URL 並輸出乾淨、易讀的 HTML 檔案。
use readability::extractor;
use std::fs::File;
use std::io::Write;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// read the URL from the command line
let url = std::env::args().nth(1).expect("Please provide a URL");
let response = extractor::scrape(&url)?;
let mut file = File::create("index.html")?;
file.write_all(response.content.as_bytes())?;
Ok(())
}輸出結果已經出乎意料地不錯。接著,我加入了一個簡單的 HTML 範本來包裝回應內容。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
<link rel="stylesheet" href="yue.css" />
<style type="text/css">
body {
margin: 0;
padding: 0.4em 1em 6em;
background: #fff;
}
.yue {
max-width: 650px;
margin: 0 auto;
}
</style>
</head>
<body>
<div class="yue">{{content}}</div>
</body>
</html>目前還不需要使用功能完整的範本引擎;我們只要用 str::replace 把 {{content}} 預留位置替換成實際內容就行了。 😉
代理伺服器設定
透過shuttle 設定代理伺服器超級簡單。這是我在zerocal 之後的第二個專案,zerocal 也是託管在 shuttle 上,整個過程非常順暢,讓我很滿意。 🚀 我們把這個應用程式叫做 readable:
cargo shuttle init --axum --name readable這會建立一個小型的 Axum 應用程式,內含一個簡單的 hello world 路由。
卡關一:reqwest
當我把 readability crate 整合進專案時,遇到了一個小卡關。
我和上面一樣使用了 extractor::scrape,代理伺服器在本地端也順利啟動了。然而,當我想透過代理伺服器抓取網站時,卻出現了錯誤:
thread 'tokio-runtime-worker' panicked at
'Cannot drop a runtime in a context where blocking is not allowed.
This happens when a runtime is dropped from
within an asynchronous context.'這表示我在一個執行環境中又啟動了另一個執行環境。
檢查 readability crate 的原始碼後,我發現它建立了一個 reqwest::blocking::Client 並用它來抓取 URL。請求結束後,該 client 被釋放,導致執行環境被關閉。
我改用 reqwest::Client 來取代 reqwest::blocking::Client,解決了這個問題。
// reqwest::blocking::Client
let client = reqwest::blocking::Client::new();
// reqwest::Client
let client = reqwest::Client::new();現在我已經取得文章內容,但仍需要將它傳給 readability。幸好他們提供了一個名為 extractor::extract 的函式,它接受任何實作 Read 的物件,並回傳擷取後的內容。
然而,reqwest::Response 並沒有實作 Read(與 reqwest::blocking::Response 不同)。所以我必須自己將它轉換成可 Read 的型別。
幸好,reqwest::Response 有一個 bytes 方法,會回傳 Bytes 物件。Bytes 物件有實作 Read,我就可以用它來呼叫 extractor::extract。
let body = client.get(&url).await?.text().await?;
let bytes = body.bytes().await?;
let response = extractor::extract(&mut res, &url)?;卡關二:路由
應用程式不再當掉了,但我還是沒有收到任何回應。
我的路由器長這樣:
#[shuttle_service::main]
async fn axum() -> shuttle_service::ShuttleAxum {
let router = Router::new().route("/:url", get(readable));
let sync_wrapper = SyncWrapper::new(router);
Ok(sync_wrapper)
}結果發現,當我使用 /:url 作為路由時,它無法匹配 /https://example.com 這樣的路徑,因為 : 只會匹配到第一個斜線前的單一段。
解決方法是改用 /*url,這是一種萬用字元路由,會匹配到結尾的所有區段。
排版與版面配置

我的第一個原型使用了一個名為yue.css 的 CSS 框架,因為那是我找到的第一個看起來不錯的框架。
最終版本我則模仿了Ruud van Asseldonk(魯德·范阿塞爾東克) 部落格的風格,因為它總讓我聯想到一本排版精美的書。
字型方面,我選了兩個我最喜歡的字型:
- Crimson Pro 用於內文。
- JetBrains Mono 用於程式碼。
兩者皆採用SIL Open Font License 1.1 授權。
你甚至可以在終端機中使用 readable。
lynx https://readable.shuttleapp.rs/https://en.wikipedia.org/wiki/Alan_Turing注意事項
這個代理服務還遠稱不上完美。這是我花幾個小時為個人需求打造的東西。
- 它不一定總是產生有效的 HTML。
- 不會執行 JavaScript,因此有些網站無法正常運作。有人可能會說這是特色,不是臭蟲。 😉
- 對於設有複雜付費牆或機器人偵測的網站也是如此。一個變通方法是使用像ScrapingBee 或Browserless 這類無頭瀏覽器,但我不想為專案增加這種複雜度。
readability函式庫在格式化文件時有很大的自由度,有時會產生奇怪的結果。例如,它很喜歡把程式碼區塊弄得一團亂。
致謝
我不是第一個打造 readability 代理服務的人。在做研究時,我發現了readable-proxy,但這個專案似乎已經被擱置了。不過,看到也有其他人有同樣的需求,感覺還不錯。
感謝魯德·范阿塞爾東克將他的部落格開源。 🙏 他的文章與文件一直是我重要的靈感來源。
結論

在最受歡迎的瀏覽器可能封殺廣告阻擋器的時代,一個能讓你無廣告、無追蹤閱讀文章的小服務就能派上用場。我不是說你要把所有流量都透過它來傳送,但在雨天、配上一杯熱飲和一篇好文章時,工具箱裡有這樣一個工具還是很不錯的。 ☕
歡迎自行部署 readable,或使用我託管的版本。原始碼已放在GitHub 上。或許你們之中有人願意幫我一起維護它。
隨機一篇部落格