Closed8

【TypeScript】Plasmo で Bluesky を読み上げるブラウザ拡張を作ってみた

NanaoNanao

はじめに

Plasmo というフレームワークを使って Bluesky を読み上げるブラウザ拡張を作りました。
この記事を応用すれば様々なサイトに読み上げ機能を追加できます。
ソースコード全体は僕の Github リポジトリにあります。

https://github.com/7oh2020/bsky-speak

インスピレーション

Firesky という Bluesky のリアルタイム検索サイトがあります。
世界中の投稿が次々と流れてきて面白いのですがこういったリアルタイム表示はスクリーンリーダーでは読み上げされないので困っていました。
なので定期的に投稿を読み上げてくれるブラウザ拡張を作りました。

要件

  • Firesky にアクセスしたら自動的に投稿を読み上げる。
  • UI から読み上げ速度、ピッチ、音量を変更できる。
  • ショートカットキーでミュート状態のオン/オフを切り替えできる。
  • タブがアクティブな時だけ読み上げる。タブが非アクティブになったら停止する。

注意事項

元サイトに負荷をかけないように注意します。

  • ブラウザで取得済みの DOM を読み上げる。
  • ページの再ロードは行わない。

Plasmo とは

https://docs.plasmo.com/

Plasmo はブラウザ拡張を作るためのフレームワークです。
React と TypeScript で宣言的にブラウザ拡張を開発できます。
ライブリロードによりファイルの変更がすぐに反映されるのも便利です。

NanaoNanao

準備

まずは Plasmo のプロジェクトをセットアップします。

プロジェクトの作成

Plasmo は pnpm を推奨しているので pnpm を使用します。
以下のコマンドを実行すると対話形式でプロジェクトを作成できます。
--with-srcはコードを src ディレクトリに生成するオプションです。
コマンド実行後にプロジェクト名、プロジェクトの説明、作者名の順に入力します。

pnpm create plasmo --with-src

プロジェクトの作成が完了すると package.json に先ほどの入力項目が反映されています。
Plasmo はこの package.json の情報を拡張機能に使用します。例えば displayName は拡張機能の表示名になります。

また src ディレクトリ配下に各種ファイルが生成されています。Plasmo はこれらのファイル名を認識して自動的に拡張機能に取り込みます。
例えば popup.tsx は拡張機能のアイコンをクリックした時に表示される UI です。
options.tsx と newtab.tsx は今回不要なので削除しておきます。

追加のインストール

今回はストレージ機能とメッセージング機能も利用するため以下のコマンドでパッケージを追加します。
それぞれの機能については後述します。

pnpm install @plasmohq/messaging
pnpm install @plasmohq/storage

開発サーバーの起動

以下のコマンドを実行すると開発サーバーが起動します。
開発サーバーの起動中はライブリロードによりファイルの変更が即反映されます。

pnpm dev

ブラウザへの追加

開発サーバーを起動すると build ディレクトリ配下に「chrome-mv3-dev」のようなディレクトリが作成されているはずです。
このディレクトリをブラウザの拡張機能ページから追加します。

例えば Chrome ならchrome://extensions/にアクセスします。
「デベロッパーモード」をオンにしてから「パッケージ化されていない拡張機能を読み込む」をクリックし先程の「chrome-mv3-dev」ディレクトリを選択します。

また、拡張機能アイコンをブラウザ上部のメニューに固定しておくとポップアップへ素早くアクセスできるため便利です。

権限の設定

今回は読み上げ機能を利用するため package.json の permissions に tts を追加します。
必要な権限は API ドキュメントを参照します。例えば TTS ならchrome.ttsに記載があります。
デフォルトでは tabs 権限が記述されているはずです。その下に tts 権限を追加します。

package.json
{
  "manifest": {"permissions": [
      "tabs",
      "tts"
    ]
  }
}
NanaoNanao

読み上げ機能の追加

まずは対象ページへアクセスした時に投稿が読み上げされるところまでを作成します。
パラメータはとりあえず固定値にします。

メッセージハンドラの追加

Plasmo のメッセージング機能を使用すると宣言敵かつ型安全なメッセージングが可能になります。
バックグラウンドで実行したい処理はメッセージハンドラとして作成します。そうすると Content Script や Popup から呼び出せるようになります。
メッセージハンドラはリクエストとレスポンスを持ちます。REST API のように呼び出し元のデータを受取、何らかの処理をした後にデータを返すことができます。

今回は読み上げ機能をバックグラウンドで行うためのメッセージハンドラを作成します。

  • src/background/messages/speak.tsを以下のように作成します。
  • リクエストデータは req.body で受け取れます。リクエストの型は型引数で指定できます。
  • その後受け取ったテキストを chrome.tts.speak に渡して読み上げています。
/src/background/messages/speak.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";

const handler: PlasmoMessaging.MessageHandler<string> = async (req) => {
  // テキストを読み上げる
  chrome.tts.speak(req.body, {
    volume: 0.5,
    rate: 10,
    pitch: 0,
  });
};

export default handler;

Content Script の作成

src/contents/plasmo.ts というファイルが既にあるのでそれを以下のように変更します。

  • config の matchs には対象の URL を指定します。この URL とマッチしたページにアクセスするとこの Content Script が実行されます。
  • matchs の URL にはワイルドカード「*」が使用できます。
  • window の load イベントでは setInterval により 5 秒毎に main 関数を実行しています。
  • main 関数では最新 30 件の投稿の要素を querySelectorAll でクエリしています。
  • そして取得したテキストを先程作成した speak ハンドラへ渡しています。name には messages ディレクトリ配下のファイル名しか使えないようになっています。(エラーになる場合は開発サーバーを再起動してみてください)
  • body はリクエストデータです。ハンドラのリクエストの型(この場合は string)と合わせます。
/src/contents/plasmo.ts
import { sendToBackground } from "@plasmohq/messaging";
import type { PlasmoCSConfig } from "plasmo"

export const config: PlasmoCSConfig = {
  matches: ["https://firesky.tv/*"]
}

window.addEventListener("load", () => {
  setInterval(main, 5000);
})

const main = () => {
  const elements = Array.from(document.querySelectorAll(`div[class="target"]`));
  elements
    .slice(0, 30)
    .forEach(elem => {
      if (elem instanceof HTMLDivElement) {
        // バックグラウンドのspeakハンドラを呼び出す
        sendToBackground<string>({
          name: "speak",
          body: elem.textContent,
        });
      }
    });
};

これで対象ページにアクセスすると投稿が読み上げされるようになりました。

NanaoNanao

読み上げの重複を排除する

この時点で Firesky にアクセスすると、投稿が 5 秒ごとに読み上げられます。
ただし、5 秒後に新しい投稿がなければ同じ投稿が再度読み上げられてしまいます。
この問題を解決するために、読み上げた投稿を配列に保存し、同じ投稿が再度読み上げられないように改善します。

ストレージのセットアップ

履歴リストはストレージに保存します。
ストレージに保存されたデータはブラウザの再起動後も記憶され続けます。

Plasmo のストレージはキーと値がセットで保存されます。
データの参照にはキーが必要です。
そのため以下のようにストレージのキーを宣言しておくと便利です。

/src/storage_keys.ts
export const storageKeys = {
    // 読み上げ履歴
    history: "history",
} as const;

次に background.ts にストレージの初期化処理を追加します。
以下のようにすると拡張機能のインストール時にストレージが初期化されます。

/src/background.ts
import { Storage } from "@plasmohq/storage";
import { storageKeys } from "~storage_keys";

export {};

// インストール時のイベント
chrome.runtime.onInstalled.addListener(async (_) => {
  const storage = new Storage({ area: "local" });
  await storage.set(storageKeys.history, []);
});

メッセージハンドラの作成

履歴の追加、取得、クリアを行うメッセージハンドラを作成します。

pushHistory では最新 30 件の読み上げ履歴を保存します。
新しい履歴が追加されたら古いものは削除されます。

/src/background/messages/pushHistory.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";
import { storageKeys } from "~storage_keys";

const handler: PlasmoMessaging.MessageHandler<string> = async (req) => {
    const storage = new Storage({ area: "local" });
    const ids = await storage.get<string[]>(storageKeys.history);
    await storage.set(
        storageKeys.history,
        [req.body, ...ids].slice(0, 30)
    );
};

export default handler;

getHistory はリクエストデータが不要なので型引数に Unknown を指定します。
レスポンスは res.send(value)のように渡せます。

/src/background/messages/getHistory.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";
import { storageKeys } from "~storage_keys";

const handler: PlasmoMessaging.MessageHandler<unknown, string[]> = async (
  _,
  res
) => {
  const storage = new Storage({ area: "local" });
  const ids = await storage.get<string[]>(storageKeys.history);

  res.send(ids);
};

export default handler;

clearHistory では履歴を全削除します。

/src/background/messages/clearHistory.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";
import { storageKeys } from "~storage_keys";

const handler: PlasmoMessaging.MessageHandler = async () => {
    const storage = new Storage({ area: "local" });
    await storage.set(storageKeys.history, []);
};

export default handler;

Content Script の修正

plasmo.ts を修正し以下のような処理順序にします。

  1. 履歴リストを取得する
  2. 投稿のテキストと ID を取得する
  3. 履歴リストに ID が含まれる場合はスキップする
  4. 投稿を読み上げる
  5. ID を履歴リストに追加する
/src/contents/plasmo.ts
import { sendToBackground } from "@plasmohq/messaging";
import type { PlasmoCSConfig } from "plasmo";

export const config: PlasmoCSConfig = {
  matches: ["https://firesky.tv/*"],
};

window.addEventListener("load", () => {
  // 履歴リストをクリアする
  sendToBackground({ name: "clearHistory" });

  setInterval(main, 5000);
})

const main = async () => {
  // 履歴リストを取得する
  const ids = await sendToBackground<unknown, string[]>({ name: "getHistory" });

  // 投稿を取得する
  const elements = Array.from(document.querySelectorAll(`div[class="target"]`));

  // 最新30件の投稿を処理する
  elements.slice(0, 30).forEach((elem) => {
    // div要素でない場合はスキップする
    if (!(elem instanceof HTMLDivElement)) return;

    // URLをIDとして扱う
    const id = elem.parentElement.getAttribute("href");

    // 読み上げ済みならスキップする
    if (ids.includes(id)) return;

    // テキストを読み上げる
    sendToBackground<string>({
      name: "speak",
      body: elem.textContent,
    });

    // IDを履歴リストに追加する
    sendToBackground<string>({ name: "pushHistory", body: id });
  });
};

これで読み上げの重複がなくなりました。

NanaoNanao

ショートカットでミュートのオン/オフを切り替えできるようにする

拡張機能の動作とキーボードのショートカットをバインドできます。
今回はショートカットでミュート状態のオン/オフを切替できるようにします。

コマンドの登録

まずは package.json に以下のように追記します。
これにより 2 つのショートカットが登録されます。default は拡張機能アイコンをクリックした時の動作です。
(既に使用されているキーの場合はうまく動作しないので適宜変更してください。)

  • default: Alt + Shift + 1
  • togglemute: Alt + Shift + 2
package.json
{
  "manifest": {"commands": {
      "_execute_action": {
        "suggested_key": {
          "default": "Ctrl+Shift+1"
        }
      },
      "toggleMute": {
        "suggested_key": {
          "default": "Alt+Shift+2"
        },
        "description": "ミュート状態の切り替え"
      }
    }
  }
}

ストレージのセットアップ

ミュート状態はストレージに保存します。
なので storageKeys にキーを追加します。

/src/storage_keys.ts
export const storageKeys = {// 読み上げのミュート状態
    isMuted: "isMuted",
} as const;

ストレージの初期化処理も追加します。

/src/background.ts
// インストール時のイベント
chrome.runtime.onInstalled.addListener(async (_) => {
  const storage = new Storage({ area: "local" });await storage.set(storageKeys.isMuted, false);
});

コマンドの入力を処理する

background.ts を修正してコマンドの入力イベントを追加します。
chrome.commands.onCommand ではショートカットのトリガー時に先程 package.json で定義したコマンド名を受け取れます。

/src/background.ts
import { Storage } from "@plasmohq/storage";
import { storageKeys } from "~storage_keys";// コマンド入力イベント
chrome.commands.onCommand.addListener(async (command) => {
  switch (command) {
    // ミュート状態を切り替える
    case "toggleMute": {
      const storage = new Storage({ area: "local" });
      const isMuted = await storage.get<boolean>(storageKeys.isMuted);
      storage.set(storageKeys.isMuted, !isMuted);
      break;
    }
    default: {
      throw new Error(`no command: ${command}`);
    }
  }
});

ミュート状態のチェック処理を追加する

最後に speak ハンドラ内でミュート状態をチェックします。
ミュート状態の場合は読み上げをスキップします。

/src/background/messages/speak.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";
import { storageKeys } from "~storage_keys";

const handler: PlasmoMessaging.MessageHandler<string> = async (req) => {
  const storage = new Storage({ area: "local" });
  const isMuted = await storage.get<boolean>(storageKeys.isMuted);

  // ミュート状態なら読み上げをスキップする
  if (isMuted) {
    return;
  }

  // テキストを読み上げる
  chrome.tts.speak(req.body, {
    volume: 0.5,
    rate: 10,
    pitch: 0,
  });
};

export default handler;
NanaoNanao

UI から読み上げ設定を変更できるようにする

この時点ではまだ読み上げ設定が固定値なので UI から変更できるように改善します。
拡張機能アイコンをクリックした時に表示される Popup を設定画面に変更していきます。

ストレージのセットアップ

読み上げ設定はストレージに保存します。
まずは読み上げ設定に対応する型を作成します。

/src/types/settings.ts
export type Settings = {
  volume: number;
  pitch: number;
  rate: number;
};

ストレージキーを追加します。

export const storageKeys = {// 読み上げ設定
    settings: "settings",
} as const;

ストレージの初期化処理を追加します。

/src/background.ts
chrome.runtime.onInstalled.addListener(async (_) => {
    const storage = new Storage({ area: "local" });const settings: Settings = {
      volume: 0.5,
      pitch: 1.0,
      rate: 5,
    };
    await storage.set(storageKeys.settings, settings);
});

メッセージハンドラの作成

getSettings はストレージから設定値を取得するハンドラです。

/src/background/messages/getSettings.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";
import { storageKeys } from "~storage_keys";
import type { Settings } from "~types/settings";

const handler: PlasmoMessaging.MessageHandler<unknown, Settings> = async (
    _,
    res
) => {
    const storage = new Storage({ area: "local" });
    const settings = await storage.get<Settings>(storageKeys.settings);

    res.send(settings);
};

export default handler;

putSettings はストレージに設定値を保存するハンドラです。

/src/background/messages/putSettings.ts
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { Storage } from "@plasmohq/storage";
import { storageKeys } from "~storage_keys";
import type { Settings } from "~types/settings";

const handler: PlasmoMessaging.MessageHandler<Settings> = async (req) => {
    const storage = new Storage({ area: "local" });
    const settings = await storage.get<Settings>(storageKeys.settings);
    await storage.set(
        storageKeys.settings,
        req.body,
    );
};

export default handler;

カスタムフックの作成

設定値のスライダーの値を管理する useSliders というカスタムフックを作成します。

  • 初期化時に設定値をロードしています。
  • スライダーの変更時にストレージへ保存しています。
/src/hooks/use_sliders.ts
import { sendToBackground } from "@plasmohq/messaging";
import { useCallback, useEffect, useState, type ChangeEvent } from "react";
import type { Settings } from "~types/settings";

export const useSliders = () => {
  const [volume, setVolume] = useState<number>(1.0);
  const [pitch, setPitch] = useState<number>(1.0);
  const [rate, setRate] = useState<number>(1.0);

  const handleChangeVolume = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      setVolume(parseFloat(event.target.value));
    },
    []
  );

  const handleChangePitch = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      setPitch(parseFloat(event.target.value));
    },
    []
  );

  const handleChangeRate = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      setRate(parseInt(event.target.value));
    },
    []
  );

  // 設定値をロードする
  useEffect(() => {
    const loadSettings = async () => {
      const settings = await sendToBackground<unknown, Settings>({
        name: "getSettings",
      });
      setVolume(settings.volume);
      setPitch(settings.pitch);
      setRate(settings.rate);
    };
    loadSettings();
  }, []);

  // 変更時に設定値を保存する
  useEffect(() => {
    sendToBackground<Settings>({
      name: "putSettings",
      body: { volume, pitch, rate },
    });
  }, [volume, pitch, rate]);

  return {
    data: {
      volume,
      pitch,
      rate,
      handleChangeVolume,
      handleChangePitch,
      handleChangeRate,
    },
  };
};

いよいよ popup.tsx を編集していきます。
TSX 形式なので React の知識そのままで UI を作成できます。

先ほど作成した useSliders を呼び出しています。
スライダーの範囲は chrome.tts のドキュメントを参考にしています。

/src/popup.tsx
import { useSliders } from "~hooks/use_sliders";

function Popup() {
  const { data } = useSliders();

  return (
    <div>
      <h1>Settings</h1>
      <div style={{ padding: "20 10" }}>
        <label htmlFor="volume">Volume</label>
        <input
          type="range"
          id="volume"
          min="0"
          max="1"
          step="0.1"
          value={data.volume}
          onChange={data.handleChangeVolume}
        />
      </div>
      <div style={{ padding: "20 10" }}>
        <label htmlFor="pitch">Pitch</label>
        <input
          type="range"
          id="pitch"
          min="0"
          max="2"
          step="0.1"
          value={data.pitch}
          onChange={data.handleChangePitch}
        />
      </div>
      <div style={{ padding: "20 10" }}>
        <label htmlFor="rate">Rate</label>
        <input
          type="range"
          id="rate"
          min="1"
          max="10"
          step="1"
          value={data.rate}
          onChange={data.handleChangeRate}
        />
      </div>
    </div>
  );
}

export default Popup;

開発サーバーを起動してブラウザの拡張機能アイコンをクリックすると Popup が表示されるはずです。
onChange で変更を検知しているため、読み上げ中でも設定の変更がすぐに反映されます。

NanaoNanao

タブのアクティブ状態で開始/停止を切り替える

この時点ではインターバルを適切に処理していないためタブを切り替えても読み上げが継続されてしまいます。
そのためタブが非アクティブになった時はインターバルを停止し、アクティブになった時は再開するよう修正します。

Content Script の修正

バックグラウンドでタブの変更を検知してもいいのですが、今回のケースでは visibilitychange イベントを使うのが一番楽そうです。
plasmo.ts を以下のように修正します。

/src/contents/plasmo.ts
// タイマーのハンドルID
let timerId = null;

const startInterval = () => {
  timerId = setInterval(main, 3000);
};

const stopInterval = () => {
  if (timerId != null) {
    clearInterval(timerId);
  }
};

// アクティブタブの更新イベント
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") {
    // アクティブになった時
    startInterval();
  } else {
    // 非アクティブになった時
    stopInterval();
  }
});

window.addEventListener("load", () => {
  // 履歴リストをクリアする
  sendToBackground({ name: "clearHistory" });

  startInterval();
});

これでタブがアクティブの時だけ読み上げされるようになりました。

NanaoNanao

まとめ

  • TypeScript ベースで開発できるので開発効率が良い
  • react の知識で UI を作成できるのは嬉しい。
  • Plasmo のメッセージングは責務が分離できて見通しが良い

ここまで読んでいただきありがとうございます。
今回作成した拡張機能のソースコード全体は以下の Github リポジトリで公開しています。

https://github.com/7oh2020/bsky-speak

このスクラップは1ヶ月前にクローズされました