新着記事を毎日 AI に要約させてから届ける Web アプリを作った
自分用にフィード URL と独自のプロンプト (AI への指示) を登録しておくと毎朝、新着記事の有無を確認し、新着があれば AI が記事を要約して一覧表示できる Web アプリを作りました。
私のユーザーとしての感想としては既存のフィードリーダーに比べると、機能的な面に関しては記事の概要をさっと把握しやすく若干便利 (釣りタイトルやジャンルが把握できないタイトルの記事でとくに便利)、UI に関しては気持ちよく使えるのでいい感じ、といった感じです。自分なりのプロンプトを指定できるのでいじりたくなると思いきや、とくにカスタマイズしたくならないです。フィードの URL を直接入力しないといけないのでそのあたりはかなり不便です。AI は英語の記事も問題なく読んでくれます。
誰でも使える作りになっていますが、実質的には私の個人用の想定なのでスケール (とくに AI 使用料金) に関する考慮が不十分なところがあります。
そのため宣伝するようなことはしていません。この記事は技術的な話がしたくて書いています。
仕組み
この Web アプリは Google Cloud 上に構築されています。毎朝バッチ処理が実行され全ユーザーの登録されたフィード URL にアクセスし、新着を判定します。新着記事があればその記事の全文を Playwright を使って取得し OpenAI Chat Completions API を使ってユーザーのプロンプトと合わせて入力し要約させています。サイトやデータは Firebase の Hosting, Authentication, Firestore を用いています。Remix (SPA モード) も使っています。既読管理機能もあります。
Cloud Run Jobs でフィードのチェック、Playwright で取得、OpenAI で要約、Remix で表示といった感じです。
技術的な構成
- dispatcher: poller をユーザーごとに起動するためのバッチ処理
- Cloud Run Jobs
- Cloud Scheduler
- poller: 特定ユーザーの全新着記事をチェック & AI で要約を作るバッチ処理
- Cloud Run Jobs
- Playwright
- OpenAI Chat Completions API
- OpenAI Moderation API
- web: Web クライアントアプリ
- Firebase Hosting
- Firebase Authentication
- React
- Remix (SPA Mode)
- TanStack Query
- Jotai
共通して言語は TypeScript, データベースは Firestore が使われています。
こだわりポイント
正直フィードと AI の組み合わせなんか誰でも思いつくし実装も簡単なのでプロダクトとしては面白みがないんですが、技術的にはこだわったポイントがいくつかあります。
カードめくり
動画を見てもらえればわかりますが、スクロールをすると重なったカードの一番上をスライドさせているような表示になっているかと思います。
1 コンテンツを全画面で表示・スワイプしてどんどん消費していく UI (TikTok で有名?) は一度 Web で実装してみたいと思っており、今回自分用に主に iPhone でのみ使えれば良かったのでいい機会と思って実装してみました。
タッチ操作に追従したり JavaScript ベースで作ったりしているわけではなく、CSS のみで実現しています。Scroll Snap と Sticky Position を組み合わせて作っています。ややトリッキーな使い方だと思うので少し頭を捻る必要がありました。ただ iOS Safari には問題がありそう でワークアラウンドを仕込んでおり、それも実用上問題がないとは言えないのでやりたかったことをできたかというと不完全ではあります。
基本的には Web でこういう UI が作れることを確かめられたのは良かったです。
既読管理機能もあるため、スクロール位置 = カードをどこまでめくったかによってコンテンツの既読状態を更新しています。これはシンプルにスクロール位置の監視によって実現されています。
中間状態を表示しない
Web がスマホアプリほどの「安定感のあるアプリ」に感じられない理由は Web は画像データの読み込み中や API 読み込み中の仮表示などの中間状態の表示によるものだと私は考えています。画面のすべての要素を読み込み終わってはじめて画面全体をパッと表示することで高級で安定感のある感じを与えることができると思います。もちろんなるべく早く&多少壊れていても本質的なコンテンツだけでも提供するという Web の発想自体は素晴らしい側面も多いのですが。
今回は記事のサムネイル画像 (実態は OGP 画像) を画面いっぱいに表示しています。この画像が最初真っ白で表示 → その後パッと表示されるという体験ではなく、スプラッシュスクリーン (の裏で読み込み) → 完全な画面という形にしたいと思いました。
そこで JavaScript で HTMLImageElement
と load
イベントを用いて読み込み完了がされるのを待つようにしました。幸い React は Suspense を用いることで自然に組み込めるので実現も容易でした。
もちろんタイムアウトも組み込んでいます。
昔からやってみたいと思っていたものを晴れて実現できました。
なお、最初は TanStack Router を使っていたのですが初期レンダリング中に発生した Suspense をハイドレーション中として上手く扱えないみたいで表示が空になってしまっていたので、それをうまくできる Remix に乗り換えました。
サムネイル画像のアスペクト比に合わせたレイアウト
さまざまなサイトのサムネイル画像を扱うので画像のアスペクト比を仮定することはできません。一方で全体のレイアウトは固定すべきだと考えました。カードめくりの UI なのでタイトルや本文などを次々見ていくときに視線を動かさなくて済むようにできる・したいと思ったからです。
横長のサムネイル画像は縦長の画面 (iPhone を想定しています) の中央部分に表示、縦長のサムネイル画像は画面全体を使って表示するようにしようと考えました。つまりアスペクト比によって切り替える必要がありました。
そこで実際の画像データのサイズから判定するようにしました。HTMLImageElement
の naturalWidth
, naturalHeight
を用いています。
有害な入力を与えるユーザーの自動停止
ユーザーの任意の入力を OpenAI の API にリクエストを投げている仕組み上、ユーザーが有害な内容のプロンプトや記事を入力しそれを私のシステムが OpenAI に与えてしまうリスクがあります。これを私のシステムが適切に処理しなければ私のシステムやアカウント自体が OpenAI から BAN される可能性があります。
これを防ぐため Chat Completions API にリクエストを投げる前に Moderation API を使ってチェックするようにしました。ここで有害だと判定されると自動的にユーザーを凍結しユーザーに対しては警告通知を送るような仕組みを作りました。
まあこのシステム自体、私の自分用のものなのでこの仕組みが実行されることは一度もないと思います…。
その他のこだわり
その他のこだわりを列挙します。適切な構成の文章にするのが面倒なのでリスト形式です。
-
クライアント側
- カードめくり UI
- 画面全体のバウンススクロールは無効化する
- スプラッシュスクリーン
- 最初のコンテンツの読み込みや Firebase SDK の初期化待ちなどをスプラッシュスクリーンで隠す
- サムネイル画像はタイムアウト付き。タイムアウトした場合は画像は通常の
img
要素と同じ扱いで読み込む
- サムネイル画像の表示の仕方を工夫
- サムネイル領域 (縦 1/3) が 16:9 以上に横長にならないように全体の左右を制限し中央寄せ
- サムネイルが横幅いっぱいを使うようにフィットさせる
- サムネイルが 1:1 より縦長の場合は、全体を使って画像を表示するように
- 可変するウィンドウ高さ (Safari の引っ込むアドレスバーなど) に対応: 短い高さに合わせるとサムネイル高さが足りなくて「七部丈」になるので長い高さに合わせつつ、メインコンテンツは短い高さ以内に収まるように
- CSS ですりガラスエフェクト
- Safe Area 対応
- 前後数件以外は読み込みとレンダリングを遅延させる (ネットワーク負荷と CPU 負荷の軽減)
- 仮想スクロールと同様の技術
- 当初は TanStack Virtual を使っていたが、Snap Scroll + Sticky Position と併用するとうまく動かなかったので独自実装
- 登録直後の誘導メッセージの整備
- 読み切った画面に新着チェックボタンをつける
- なければリロードボタンを非表示にする
- 翌日にその画面を見直したときに再チェックするように Windows Focus Refetching を有効にする
- アイコン (Favicon に使用) は AI で生成
- Firebase の API Key は公開して良いもののはずだが、それを埋め込んだソースコードを GitHub にプッシュしたら Google Cloud と GitHub の両方から自動検知&警告メールが飛んできてびびった。大丈夫なんだよね……?
- カードめくり UI
-
サーバー側
- OpenAI API に与えられる分量の記事全文の取得
- AI の賢さを信じてなるべく HTML をそのまま与える
- HTML がセマンティックであると仮定して「本文」っぽい部分 (
<main>
とか) のみ与える - セマンティックなタグが見つからなければ一番長いテキストを持つ要素を見つけてきて (← 本文だろうという仮定) 特定の長さを超えないもっとも遠い先祖のテキストを取得する
- HTML は非常に長くなりがちなので、最長トークン数を超えそうならテキストにフォールバックする
- テキストでも文字数が多すぎれば先頭のみ切り出す
- ここは頑張らなくても Reader API とかでも良さそう
- 要約に失敗してもその記事のみエラー状態にして全体としては続行するように
- 未読が 100 件を超えないように生成数を制限する
- 通知機能を作り、「溜めると生成が止まる」という警告を出す
- サムネイル画像として OGP イメージを使用する
- フィード (RSS, Atom) には画像を指定する機能もあるが、ほぼ使われていないのでフォールバックとして OGP 画像を HTML のパース結果から取得し使用する
- Playwright インスタンスはなるべく使い回す
- OpenAI API のレートリミット対策
- 公式のクライアント (npm) の実装を見る限り、
429
に対してRetry-After
ヘッダーを見ているような感じだったが、Moderation API での429
でエラーになることが多い。API サーバー側がRetry-After
を返していないかも
- 公式のクライアント (npm) の実装を見る限り、
- ユーザーごとにバッチ処理を Cloud Run Jobs タスクに分ける
- ユーザーごとに 1 回のバッチ処理のタイムアウトを設けるため
- poller, dispatcher に分け、dispatcher がキック。
CLOUD_RUN_TASK_INDEX
を用いてユーザーごとに分岐させる
- OpenAI API に与えられる分量の記事全文の取得
-
API サーバーのない作り
- Remix SPA モードでビルド + Firebase Hosting
- Firestore の活用
- Firestore はクライアント開発者が「いい感じに」と願って手を出すには辛い制限が多い
Discussion