A Reader Mode Proxy for the Slow Web

Matthias Endler

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

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

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(魯德·范阿塞爾東克) 部落格的風格,因為它總讓我聯想到一本排版精美的書。

字型方面,我選了兩個我最喜歡的字型:

兩者皆採用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,但這個專案似乎已經被擱置了。不過,看到也有其他人有同樣的需求,感覺還不錯。

感謝魯德·范阿塞爾東克將他的部落格開源。 🙏 他的文章與文件一直是我重要的靈感來源。

結論

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

最受歡迎的瀏覽器可能封殺廣告阻擋器的時代,一個能讓你無廣告、無追蹤閱讀文章的小服務就能派上用場。我不是說你要把所有流量都透過它來傳送,但在雨天、配上一杯熱飲和一篇好文章時,工具箱裡有這樣一個工具還是很不錯的。 ☕

歡迎自行部署 readable,或使用我託管的版本。原始碼已放在GitHub 上。或許你們之中有人願意幫我一起維護它。

原文由 Matthias Endler 發布

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