A Reader Mode Proxy for the Slow Web

Matthias Endler

面向慢速网络的阅读模式代理

阅读模式在浅色与深色模式下展示一篇文章。
阅读模式在浅色与深色模式下展示一篇文章。

tl;dr: 我构建了一个服务,可以把任意文章转换成阅读舒适、便于打印的版本。它类似于 Firefox/Safari 中的阅读视图,但也能在旧浏览器上使用、可以分享,并且注重优美的排版。欢迎查看源代码

互联网曾经是个多么有趣的地方。

如今呢?唉。跟踪器、广告、臃肿的页面、全屏弹窗、自动播放的视频……一切都让人疲惫不堪

我只想泡一杯好茶,看着猫在窗台上打盹,窗外飘着小雪,不受干扰地读一篇长文。

慢速网络

我是慢速网络(Slow Web)运动的忠实拥护者,也喜欢那些把一件事做好的小网站

为了无干扰地阅读长文,我在 Firefox 中使用阅读视图(Reader View)。虽然它并不总是好用,也不算最美观,但我挺喜欢的。

其他浏览器也有阅读模式,但其中一些——比如 Chrome——把它藏在功能开关(feature flag)后面。还有一些浏览器,比如我的电子书阅读器上的那个,根本就没有阅读模式,这让我主要的阅读设备上的浏览体验既糟糕又缓慢。

于是,我构建了一种作为服务的阅读模式,专注于优美的排版,并可在所有浏览器上使用。它非常简单,我用它在我的旧设备上阅读文章,它也可以让低带宽地区或旅行途中的人们更容易访问内容。

构建过程

最近我看到一篇关于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 之后的第二个项目,后者也托管在 shuttle 上,整个流程的顺畅程度让我非常满意。🚀 就把这个应用命名为 readable

cargo shuttle init --axum --name readable

这会创建一个带简单 hello world 路由的小型 Axum 应用。

障碍一:reqwest

readability 库集成到项目时,我遇到了一个小障碍。

我像上面那样使用 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 库的源代码后,我发现它会构建一个 reqwest::blocking::Client 并用它来抓取 URL。请求完成后,客户端被丢弃,导致 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 库在格式化文档方面非常随意,有时会产生奇怪的结果。比如,它特别喜欢把代码块搞得一团糟。

致谢

我并不是第一个构建可读性代理的人。在调研过程中,我发现了 readable-proxy,不过这个项目似乎已被放弃。尽管如此,看到别人也有同样的需求还是让人欣慰。

感谢 Ruud van Asseldonk 开源了他的博客。🙏 他的文字和文档一直是我的灵感源泉。

结语

我的旧 Kobo 电子书阅读器上的浏览器正在使用这个可读性代理。
我的旧 Kobo 电子书阅读器上的浏览器正在使用这个可读性代理。

最受欢迎的浏览器可能会封杀广告拦截器的时代,一个能让你阅读文章时免受广告和跟踪困扰的小服务会派上用场。我并不是说你应该把所有流量都通过它转发,但在下雨天、捧着一杯热饮、读一篇好文章的时候,它是你工具箱里一件称手的工具。☕

欢迎部署你自己的 readable 实例,或者直接使用我托管的那个。源代码已在 GitHub 上提供。也许你们中有人愿意帮我一起维护它。

原文由 Matthias Endler 发布

本文章由 stealth/ox-alpha 进行翻译