A Reader Mode Proxy for the Slow Web

Matthias Endler

スローウェブのためのリーダーモードプロキシ

ライトモードとダークモードで記事を表示するリーダー
ライトモードとダークモードで記事を表示するリーダー

tl;dr: どんな記事でも読みやすく、印刷にも適した形に変換するサービスを作りました。FirefoxやSafariのリーダービューに似ていますが、古いブラウザでも動作し、共有もでき、美しいタイポグラフィにこだわっています。ソースコードもぜひご覧ください。

昔のウェブは本当に楽しい場所でした。

今はどうでしょう。トラッカー、広告、肥大化したページ、全画面ポップアップ、自動再生動画……ただただ疲れます。

温かいお茶を片手に、猫が窓辺で眠り、外では粉雪が舞うなか、長文の記事を邪魔されずに読みたいだけなのです。

スローウェブ

私はスローウェブ運動や、一つのことをうまくやる小さなサイトが大好きです。

長文をすっきりと読むために、Firefoxのリーダービューを使っています。常にうまくいくわけでも、特に美しいわけでもありませんが、気に入っています。

他のブラウザにもリーダーモードはありますが、Chromeのようにフラグの裏に隠されているものもあります。私の電子書籍リーダーのブラウザのように、リーダーモード自体を備えていないものもあり、そうなるとメインの読書用デバイスでのブラウジング体験は遅く、快適とは言えません。

そこで、どのブラウザでも動作する、美しいタイポグラフィを重視したサービスとしてのリーダーモードを作りました。とてもシンプルなものですが、古いデバイスで記事を読むのに重宝しています。帯域の細い地域や旅先でも、コンテンツをよりアクセスしやすくしてくれるかもしれません。

作ってみる

先日、Hacker Newsのターミナルクライアント、circumflexについての投稿を見かけました。このツールはウェブサイトのコンテンツ描画がとても上手で、これをプロキシサーバーに転用できないかと考えました。

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(())
}

本格的なプロキシサーバーを書く前に、まずはURLを受け取ってきれいで読みやすいHTMLファイルを出力するシンプルなCLIツールを作ってみましょう。

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に続いて2つ目のプロジェクトですが、shuttleにホストしていて、そのスムーズさにとても満足しています。🚀 アプリ名はreadableにしましょう。

cargo shuttle init --axum --name readable

これで、シンプルなhello worldルートを持つ小さなAxumアプリが作成されます。

壁その1: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.'

これは、ランタイムの中でさらにランタイムを起動してしまったということです。

readabilityクレートのソースコードを確認したところ、URLの取得にreqwest::blocking::Clientを生成して使っていることがわかりました。そのリクエストの後、クライアントがドロップされる際にランタイムがシャットダウンされてしまうのです。

reqwest::blocking::Clientの代わりにreqwest::Clientを使うことで修正しました。

// reqwest::blocking::Client
let client = reqwest::blocking::Client::new();

// reqwest::Client
let client = reqwest::Client::new();

これで記事の内容は取得できましたが、まだそれをreadabilityに渡す必要があります。幸い、Readを実装したものを受け取って抽出結果を返すextractor::extractという関数が用意されています。

ただ、reqwest::Responseは(reqwest::blocking::Responseとは違って)Readを実装していません。そのため、自分で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)?;

壁その2:ルーティング

アプリはクラッシュしなくなりましたが、それでもレスポンスが返ってきませんでした。

ルーターはこんな感じでした。

#[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氏のブログのスタイルを真似ることにしました。

フォントはお気に入りの2つを選びました。

どちらも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 により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。