お疲れ様です!IT業界で働くアライグマです!
個人開発している開発関連ニュースのキュレーションサービス「DevPick」に、媒体ごとのソース購読・ミュート設定を追加しました。DevPickにはその場で表示媒体を絞るフィルターがありましたが、「このプロフィールでは今後も表示しない」という継続的な好みまでは表せていませんでした。今回は、その設定をどこに持たせ、複数の配信経路へどう反映したかを書きます。
一時的な絞り込みでは「次も見たくない」を表せなかった
もともとのDevPickには、記事一覧で表示する媒体を一時的に絞り込む機能がありました。特定の媒体の記事だけを確認したいときには便利ですが、画面を開くたびに適用する検索条件です。ある媒体を今後も表示しないという意思とは、役割が違います。
一時的な絞り込みは、「今回はこの媒体を見る」という操作です。今回追加したミュートは、「このプロフィールの候補には、この媒体を最初から含めない」という設定です。両方を同じフィルターとして扱うと、保存した好みを一時操作で復活させられるのか、通知にも適用するのかといった境界が曖昧になります。
そこで、処理の優先順位を明確にしました。まずプロフィールに保存されたミュート設定で記事の母集団を狭め、その後で画面上の一時フィルターを適用します。ミュート済みの媒体を一時フィルターで指定しても復活させません。一時フィルターは、ミュート後に残った記事をさらに絞るだけです。
この順序にしたことで、「継続的な好み」と「今だけの操作」を別の概念として扱えるようになりました。見た目はどちらも媒体を選ぶ操作ですが、効き続ける期間と優先順位は同じではありません。
IT女子 アラ美設定の持ち主をプロフィールにした
ミュート設定はブラウザの一時状態ではなく、DevPickのプロフィールに紐づく設定としてデータベースへ保存しました。これにより、再ログインしたときや別の端末から同じプロフィールを使ったときも、選んだ媒体が引き継がれます。
ここで「ユーザーの好み」と大きく括らず、プロフィールを設定の境界にしたことが今回の中心的な判断です。DevPickのフィードは、プロフィールの興味や習熟度をもとに記事を選びます。読みたくない媒体も同じプロフィールに結び付ければ、「誰のフィードを作っているのか」と「どの媒体を除外するのか」が同じ単位で決まります。
更新用の入口も、プロフィール全体の更新APIへ混ぜず、ソース設定専用のAPIに分けました。通知設定にも専用の入口があるため、どちらか一方を保存したときに、もう一方の値を既定値へ戻してしまう事故を避けられます。ソース設定を変えても、記事の興味度を表すスコアキャッシュは削除しません。今回変えるのはスコアの計算方法ではなく、スコアリングへ渡す前の候補だからです。
設定の置き場所を決めるときは、画面の部品ではなく、その設定によって振る舞いが変わる対象を見る必要がありました。ソース選択のUIが設定パネルにあることより、フィードを作るプロフィールに属することのほうが設計上は重要です。



Web・Chrome拡張・ダイジェストで同じ母集団を使う
設定を保存できても、使う場所によって結果が違えば「ミュート」とは呼べません。今回の対象には、ブラウザで見るWebフィード、Chrome拡張から見る未読フィード、定時に届くダイジェスト通知がありました。
WebとChrome拡張は、同じ認証済みフィードAPIを使っています。その共通経路で、データベースから記事を取得するときにミュート済みの媒体を除外しました。Chrome拡張用に別の除外処理を持たせなかったので、Webだけ直して拡張機能からは見えてしまう、という実装のずれを作らずに済みます。
ダイジェスト通知は別の経路で候補を組み立てるため、こちらにも同じプロフィール設定を適用しました。候補を抽出するのは送信直前です。そのため、配信ジョブが作られた後に媒体をミュートしても、実際に送る記事からは除外されます。
もう1つ注意したのがページ送りです。DevPickのフィードは、並び順を固定したスナップショットを使っています。以前のフィードのページ送りを作り直した話で導入した仕組みですが、最初のページを開いた後に設定を変える可能性があります。そのため、スナップショットがすでにあってもミュート設定を再適用し、2ページ目以降から対象媒体が戻らないことまでテストしました。
結果として、設定値を保存する機能よりも、「記事候補を作るすべての入口で同じルールが効くこと」のほうが大きな実装になりました。個人設定は、入力画面を作った時点ではまだ半分しか完成していません。設定を読む経路を洗い出して、初めて一貫した機能になります。



書き込みは厳しく、読み出しではフィードを止めない
ミュートする媒体のIDは、プロフィールの1つの列へカンマ区切りで保存しています。たとえば、ASCII.jpとHacker Newsをミュートした状態は、内部では次のような形です。
ascii,hacker_news
この形式は小さく扱えますが、カンマを含むIDを受け付けると、保存した瞬間に別の2項目へ分裂します。そこで書き込み時は、英数字・アンダースコア・ハイフンだけで構成された1〜50文字のIDに限定しました。保存件数には100件の上限を設け、重複を除いて並べ替えます。空の一覧は空文字ではなく、ミュートなしを表す NULL として保存します。
一方、読み出し時は意図的に寛容にしました。空の要素や余分な空白が残っていても、それだけを除いて処理を続けます。現在のソース一覧に存在しないIDが保存されていても、エラーにはしません。媒体の提供を終了した後も過去の設定がプロフィールに残る可能性があり、その1件のためにフィードとダイジェスト全体を止めるほうが困るからです。
書き込み時に不正なデータを入れないことと、読み出し時に過去のデータでサービスを止めないことは、両立できます。どちらも同じ厳しさにそろえるのではなく、境界の役割に合わせました。新しく受け取る値は厳しく検証し、すでに保存されている値は利用者が設定画面へ戻れるように読み進めます。
保存形式の解釈は専用のモジュールへ集約しました。フィード、ダイジェスト、APIレスポンスがそれぞれ独自にカンマ区切りを解釈すると、同じ設定なのに重複除去や空値の扱いがずれるためです。単純な文字列でも、意味を決める場所は1つにしたほうが安全でした。



全ソースをオフにした空画面にも理由が必要だった
すべての媒体をオフにすると、フィードの記事は0件になります。データとしては正しい結果ですが、画面が従来の「記事を取得しています」という表示のままだと、利用者には処理待ちなのか、設定によって空なのかが分かりません。
そこで、全ソースをオフにした場合は通常の空状態と分け、設定を見直す案内を表示するようにしました。利用者が自分で作った空状態には、その原因と戻り道を示す必要があります。記事がまだ収集されていない場合や、分析を待っている場合と同じメッセージでは、次に取るべき行動が正反対になるからです。
設定を保存した直後の動きもそろえました。画面上の一時的な媒体フィルターに、今ミュートした媒体が残っていた場合は、その選択から外してフィードを取り直します。保存済みの設定では除外されているのに、画面の選択だけが古いまま残る状態を避けるためです。
この対応で、設定機能はデータベースへの保存だけでは完結しないと改めて分かりました。結果が0件になる正常系、保存直後の画面状態、元へ戻す導線まで含めて、利用者にとっての設定です。バックエンドが正しい0件を返していても、その理由を画面が説明できなければ、機能が壊れたように見えてしまいます。



まとめ
DevPickのソース購読・ミュート設定は、画面上のフィルターではなく、プロフィールが持つ継続的な設定として実装しました。一時的な絞り込みより先に適用し、Webフィード・Chrome拡張・ダイジェスト通知の候補から同じ媒体を除外します。
実装で大切だったのは、設定値をどの列へ保存するかだけではありませんでした。どの単位の振る舞いを変えるのか、設定を読む入口がいくつあるのか、過去の値をどこまで許容するのか、0件になった理由を画面で説明できるかまでが1つにつながっています。
今の私の結論は、個人設定の置き場所を決めるときは、UIの配置ではなく「誰の、どの結果を変える設定か」から考えることです。設定の持ち主と結果を作る境界がそろっていれば、表示経路が増えても同じルールを通しやすくなります。











