オンクラスエンハンサーにコミュニティ通知機能を追加した

ツール

はじめに

「コミュニティに投稿があったのに気づかなかった…」

公式の通知APIが整備されていないWebサービスでも、Chrome拡張を使えばリアルタイム通知を実装できます。この記事では、Vue.js(Vuetify)製のWebサービス「オンクラス」を題材に、MutationObserverとfetchプロキシを組み合わせた通知機能の設計と実装を解説します。

以前、オンクラス管理画面のカテゴリ自動整理感想の一括エクスポートを自動化するChrome拡張「オンクラスエンハンサー」を公開しました。今回のv1.1.0でコミュニティ通知機能を追加しています。

この記事は以下のような方を想定しています。
– Chrome拡張の基本は分かるが、通知機能の実装経験がない
– 公式APIがないサービスで通知を実装したい
– Vue.js/ReactなどのSPAサイトでDOM監視の設計に悩んでいる

追加した機能

コミュニティ通知

コミュニティでメンションや新着投稿があると、ブラウザ通知でお知らせします。

  • 拡張機能アイコンに未読数をバッジ表示
  • メンション通知のON/OFF
  • 未読投稿通知のON/OFF(メンション以外も通知するか)
  • コミュニティページを開くと自動で既読に

オプションページ

機能が増えてきたので、設定画面を独立させました。

  • カテゴリ自動移動
  • コミュニティ通知
  • 未読投稿も通知
  • 感想エクスポート

それぞれON/OFFを切り替えられます。

設計の選択肢と判断

通知機能を実装するにあたり、3つのアプローチを検討しました。

選択肢1: APIポーリング

バックグラウンドで定期的にAPIを叩いて新着を確認する方法です。一般的なアプローチですが、オンクラスには公式の通知APIが公開されていません。既存のAPIエンドポイントを推測して叩くことは可能ですが、仕様変更に弱く安定しません。

選択肢2: WebSocket監視

オンクラスがWebSocketで通知を配信していれば、それを監視する方法もあります。しかし調査した結果、通知にWebSocketは使われていませんでした。

選択肢3: DOM監視 + fetchプロキシ(採用)

オンクラスのサイドバーにはVuetifyのv-badgeコンポーネントでメンション数が表示されています。この既に画面上にある情報を監視するアプローチを採用しました。

  • ページが表示されていればリアルタイムに検知できる
  • サイトの内部APIに依存しない
  • UIの構造が変わらない限り安定して動作する

トレードオフとして「ページを開いたままにする必要がある」制約はありますが、オンクラスを日常的に使うユーザーにとっては許容範囲と考えました。

技術的なポイント

サイドバーのバッジを監視する

オンクラスはVue.jsで作られていて、サイドバーにVuetifyの v-badge コンポーネントでメンション数が表示されます。

これをContent Scriptから直接監視するのは難しいため、Page Scriptを注入してDOMを監視しています。Content ScriptはページのDOMにアクセスできる一方、Vue.jsの仮想DOMとは同期タイミングにずれが生じます。Page Scriptならページと同じコンテキストで動作するため、DOMの変化を確実にキャッチできます。

// MutationObserverでバッジの変化を監視
const observer = new MutationObserver(() => {
  checkMentionBadge();
});
observer.observe(document.body, { childList: true, subtree: true });

subtree: trueを指定することで、サイドバー内のどの階層でバッジが変化しても検知できます。document.body全体を監視するとパフォーマンスが気になるところですが、実測した範囲では問題ありませんでした。将来的にはサイドバーのルート要素に限定することも検討しています。

バッジの状態は2種類あります。

  • 数字がある → メンション数
  • 空のバッジ → 未読投稿あり(ドット表示)
const badgeText = badge.textContent.trim();
if (badgeText === '') {
  // 未読ドット
  notifyUnreadDot(true);
} else {
  // メンション数
  const count = parseInt(badgeText, 10);
  notifyMentionCount(count);
}

この2種類を区別することで、メンション通知と未読投稿通知を独立して制御できるようにしています。

認証情報の自動取得

将来的にAPIを直接叩く拡張も視野に入れて、認証情報を自動取得する仕組みも実装しました。オンクラスはdevise_token_authを使っており、レスポンスヘッダーに認証トークンが含まれます。fetchをプロキシし、レスポンスヘッダーから自動取得しています。

const originalFetch = window.fetch;
window.fetch = async function(input, init) {
  const response = await originalFetch.apply(this, arguments);

  if (url.includes('api.the-online-class.com')) {
    const accessToken = response.headers.get('access-token');
    const client = response.headers.get('client');
    const uid = response.headers.get('uid');
    // 保存して後で使う
  }
  return response;
};

fetchのプロキシはPage Scriptとして注入します。Content Scriptからはwindow.fetchを上書きできないためです。取得した認証情報はwindow.postMessageでContent Scriptに渡し、chrome.storageへ保存しています。

Service Workerでバッジを復元

Manifest V3ではService Workerが使われますが、一定時間(通常30秒〜5分程度)操作がないと停止します。停止するとバッジの表示状態も失われるため、起動時にchrome.storageから復元する処理が必要でした。

// Service Worker起動時にバッジを復元
(async () => {
  const settings = await chrome.storage.sync.get({
    communityNotifications: { unreadCount: 0 }
  });
  const count = settings.communityNotifications?.unreadCount || 0;
  if (count > 0) {
    await chrome.action.setBadgeText({ text: String(count) });
  }
})();

chrome.storage.syncを使うことで、複数デバイス間でも設定が同期されます。

設定変更時のバッジクリア

「未読投稿も通知」をOFFにしたとき、既についているバッジの消えない問題がありました。chrome.storage.onChangedで設定変更を監視して対応しています。

chrome.storage.onChanged.addListener(async (changes, namespace) => {
  if (changes.communityNotifications) {
    const newValue = changes.communityNotifications.newValue;
    const oldValue = changes.communityNotifications.oldValue;

    // notifyUnreadPosts がOFFになった場合、バッジをクリア
    if (oldValue?.notifyUnreadPosts && !newValue?.notifyUnreadPosts) {
      if (newValue?.unreadCount === 0) {
        await chrome.action.setBadgeText({ text: '' });
      }
    }
  }
});

ポイントはoldValuenewValueの両方を比較している点です。現在値だけを見ると、初期化時や他の設定変更時にも意図しないバッジクリアが発生します。

ハマったポイントと対処法

MutationObserverのコールバックが大量に発火する

Vue.jsのリアクティブシステムは、データ変更のたびにDOMを更新します。subtree: trueで監視していると、バッジとは関係ないDOM変更でもコールバックが呼ばれます。

対処として、コールバック内でバッジ要素の存在チェックを最初に行い、早期リターンすることで不要な処理を防いでいます。

Content ScriptとPage Scriptの通信

Page Scriptで取得した情報をChrome拡張のAPIへ渡すには、window.postMessageを経由します。このとき、他のスクリプトからのメッセージと区別するために、メッセージに独自のプレフィックスをつけています。

Service Workerの停止タイミングが読めない

Manifest V3のService Workerは、Chromeのバージョンやシステムの状態によって停止タイミングが異なります。「動いていたのに急にバッジが消えた」という現象が開発中に何度か発生し、起動時の復元処理を追加するきっかけになりました。

制限事項と今後の改善

現状、通知を受け取るにはオンクラスのページを開いたままにしておく必要があります。

バックグラウンドでAPIをポーリングする仕組みも実装していますが、APIエンドポイントが推測ベースのため、ページ上のDOM監視をメインにしています。

公式の通知APIが公開されれば、ページを閉じた状態でもバックグラウンドで通知を受け取れるようになります。オンクラスさん、ぜひご検討いただけると嬉しいです。

おわりに

公式APIがないサービスでも、DOM監視とfetchプロキシを組み合わせることで実用的な通知機能を実装できました。利用者からは「コミュニティの投稿を見逃さなくなった」という声をいただいています。

この実装パターンはオンクラスに限らず、通知機能が弱いSPAサイト全般に応用できます。同じ課題を持つ方の参考になれば幸いです。

インストール

この拡張機能は無料で公開しています。オンクラスをお使いの方はぜひ試してみてください。

Chrome Web Storeでインストール

インストール後、オンクラスの管理画面を開くと自動で動作します。設定は拡張機能のオプションページから変更できます。

  • GitHub — https://github.com/atani/onclass-enhancer

フィードバックやPRも歓迎です。

関連記事

Chrome拡張の開発記事をほかにも書いています。

タイトルとURLをコピーしました