A Reader Mode Proxy for the Slow Web

Matthias Endler

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

原文は Matthias Endler により に公開されました。 このブログを購読する

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

tl;dr: 任意の記事から読みやすく、印刷にも適したバージョンを生成するサービスを作りました。FirefoxやSafariのリーダービューに似ていますが、古いブラウザでも動作し、共有可能で、美しいタイポグラフィにこだわっています。ソースコードをご覧ください。

ウェブはかつて、本当に楽しい場所でした。

今はどうでしょう?正直、微妙です。トラッカー、広告、肥大化したページ、全画面ポップアップ、自動再生動画……もう本当にうんざりします。

おいしいお茶を片手に、窓辺で眠る猫と、窓の外で静かに降る粉雪を眺めながら、邪魔されることなく長文の記事を読みたいだけなのです。

スローウェブ

私はSlow Webムーブメントや、一つのことをうまくやる小さなサイトの大ファンです。

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

他のブラウザにもリーダーモードはありますが、Chromeのようにフラグの裏に隠しているものもあります。私の電子書籍リーダーのブラウザのように、そもそもリーダーモードを搭載していないものもあり、メインの読書用デバイスでのブラウジング体験はお粗末で遅いままです。

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

作ってみる

最近、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クレートのソースコードを確認したところ、reqwest::blocking::Clientを構築してURLの取得に使っていることがわかりました。そのリクエストのあと、クライアントがドロップされる際にランタイムがシャットダウンされてしまうのです。

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ライブラリはドキュメントのフォーマットをかなり自由にいじります。時には奇妙な結果になることもあります。例えば、コードブロックをぐちゃぐちゃにしてしまうのが大好きです。

クレジット

readabilityプロキシを作ったのは私が初めてではありません。リサーチの際にreadable-proxyの存在を知りましたが、このプロジェクトは放棄されているようです。それでも、同じニーズを持つ人が他にもいたことを知れてよかったです。

自身のブログをオープンソースとして公開してくれたRuud van Asseldonk氏に感謝します。 🙏 彼の文章やドキュメントはいつも私にとって大きなインスピレーションの源です。

おわりに

古いKobo電子書籍リーダーのブラウザでreadabilityプロキシを使っている様子。
古いKobo電子書籍リーダーのブラウザでreadabilityプロキシを使っている様子。

最も人気のあるブラウザが広告ブロッカーを無効にしてしまうかもしれないこの時代に、広告やトラッキングなしで記事を読むための小さなサービスは役に立つかもしれません。すべてのトラフィックをここに流すべきだと言っているわけではありませんが、雨の日に温かい飲み物と素晴らしい記事と共に楽しむための、ツールボックスにあると嬉しい一つの道具です。 ☕

ぜひreadableのインスタンスを自分でデプロイするか、私がホストしているものを使ってみてください。ソースコードはGitHubで公開しています。どなたか一緒にメンテナンスを手伝ってくれると嬉しいです。

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

コメント