A Reader Mode Proxy for the Slow Web

Matthias Endler

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

原文由 Matthias Endler 發布,訂閱此部落格

以淺色與深色模式顯示文章的閱讀器。
以淺色與深色模式顯示文章的閱讀器。

tl;dr:我做了一個服務,能把任何文章轉成好讀、適合列印的版本。它有點像 Firefox/Safari 的閱讀檢視,但在舊版瀏覽器上也能用、可以分享,而且特別講究優美的排版。歡迎看看原始碼

網路曾經是個很有趣的地方。

現在呢?唉。追蹤器、廣告、臃腫的內容、全版面彈出視窗、自動播放影片……這一切都讓人覺得筋疲力盡

我只想泡杯好茶,靜靜地讀一篇不受打擾的長文,看著貓在窗台上酣睡,窗外輕輕飄著雪。

慢網路

我非常喜歡慢網路運動,也喜歡把一件事做到好的小網站

要無干擾地閱讀長文,我會用 Firefox 裡的閱讀檢視,雖然它不是每次都有效、也不算最漂亮,但我還是很喜歡。

其他瀏覽器也有閱讀模式,但有些——像是 Chrome——把它藏在實驗功能旗標後面。還有些瀏覽器,像我電子書閱讀器上的那個,根本沒有閱讀模式,這讓我在主要用來閱讀的裝置上,只能忍受又差又慢的瀏覽體驗。

所以我做了一個以優美排版為核心、能在所有瀏覽器上運作的閱讀模式即服務。它非常陽春,但我會用它在舊裝置上讀文章;在頻寬不足的地區或旅途中,它也能讓內容更容易取得。

動手打造

前陣子我看到一篇關於circumflex,一款 Hacker News 終端機客戶端的貼文。這個工具在呈現網頁內容上做得相當不錯,讓我想到能不能把它改造成代理伺服器。

Go 語言的清理程式碼在這裡

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 工具,輸入一個網址就能輸出乾淨、易讀的 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.'

這代表我在一個 runtime 裡又啟動了另一個 runtime。

檢查 readability crate 的原始碼後,我發現它建立了一個 reqwest::blocking::Client 來抓取網址。請求完成後,client 被釋放,進而導致 runtime 被關閉。

我改用 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 部落格的風格,因為它總讓我想到在讀一本排版精美的書。

字型方面,我選了自己最喜歡的兩款

兩款字型皆採用 SIL Open Font License 1.1 授權。

你甚至可以在終端機裡使用 readable

lynx https://readable.shuttleapp.rs/https://en.wikipedia.org/wiki/Alan_Turing

限制與注意事項

這個代理伺服器還遠遠不夠完美。它只是我花幾個小時為自己做的東西。

  • 它不一定每次都會產生有效的 HTML。
  • 不會執行 JavaScript,所以有些網站無法正常運作。有些人可能會說這是特色,不是臭蟲。 😉
  • 對於有複雜付費牆或機器人偵測的網站也是如此。解法是用像 ScrapingBeeBrowserless 這類無頭瀏覽器,但我不想為專案增加那樣的複雜度。
  • readability 函式庫在格式化文件時有很大的自由度,有時會產生奇怪的結果。舉例來說,它很喜歡把程式碼區塊弄得亂七八糟。

致謝

我不是第一個打造 readability 代理服務的人。做研究時我發現了 readable-proxy,不過這個專案似乎已經沒再維護了。儘管如此,看到也有其他人有同樣需求,感覺還是挺好的。

感謝 Ruud van Asseldonk 將他的 部落格開源。🙏 他的文章與文件一直是我很重要的靈感來源。

結語

我在舊款 Kobo 電子書閱讀器上的瀏覽器,使用 readability 代理服務的畫面。
我在舊款 Kobo 電子書閱讀器上的瀏覽器,使用 readability 代理服務的畫面。

最受歡迎的瀏覽器可能會封殺廣告阻擋工具的時代,一個能讓你不受廣告與追蹤干擾來閱讀文章的小服務就顯得格外實用。我不是說你要把所有流量都經由它傳送,但在下雨天、配上一杯熱飲與一篇好文章時,工具箱裡有這樣一個小工具是很不錯的。☕

歡迎自行部署一個 readable 執行個體,或直接使用我架設的版本。原始碼已放在 GitHub 上。也許你們有人願意一起幫忙維護。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言