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

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(鲁德·范·阿塞尔东克)的博客的风格,因为它总让我想起阅读一本排版精美的书。
字体方面,我选择了两个自己最喜欢的:
- 正文使用 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库在格式化文档方面非常随意,有时会产生奇怪的结果。比如,它特别喜欢把代码块搞得一团糟。
致谢
我并不是第一个构建可读性代理的人。在调研过程中,我发现了 readable-proxy,不过这个项目似乎已被放弃。尽管如此,看到别人也有同样的需求还是让人欣慰。
感谢 Ruud van Asseldonk 开源了他的博客。🙏 他的文字和文档一直是我的灵感源泉。
结语

在最受欢迎的浏览器可能会封杀广告拦截器的时代,一个能让你阅读文章时免受广告和跟踪困扰的小服务会派上用场。我并不是说你应该把所有流量都通过它转发,但在下雨天、捧着一杯热饮、读一篇好文章的时候,它是你工具箱里一件称手的工具。☕
欢迎部署你自己的 readable 实例,或者直接使用我托管的那个。源代码已在 GitHub 上提供。也许你们中有人愿意帮我一起维护它。
随机一篇博客