🙌

ChatGPT & YouTube Summary拡張機能をどのように再構築したか

2024/11/28に公開1


この記事は、「How We Rebuilt the ChatGPT & YouTube Summary Extensions」を翻訳し、公開するものです。

著者: Koki Nagai

はじめに

Glaspでは、いくつかのサービスをChrome拡張機能として提供しています。
私たちの「Glasp Web Highlighter」は、AIの助けを借りてウェブページやPDFからコンテンツをハイライトして保存し、簡単に共有できるようにします。また、「ChatGPT & YouTube Summary by Glasp」はYouTube動画を要約するツールであり、「YouTube Summary with ChatGPT & Claude」は、YouTube動画に加えてウェブページやPDFも要約する機能を提供しています。

最近、私たちは「ChatGPT & YouTube Summary」の実装をVanillaJSからViteを使用したReact/TypeScriptに移行しました。

この記事では、その再実装の理由と、実装プロセスで採用した技術的な選択について紹介します。

「ChatGPT & YouTube Summary」がどのようなものかは、以下のYouTube動画をご覧いただくと理解できます。

Chrome拡張機能の仕組み

Chrome拡張機能とは何か、そしてその仕組みについて説明します。Chrome拡張機能は、Chromeで表示されるウェブページの動作や外観を変更する技術です。これらは、HTML、CSS、JavaScriptのような静的ファイルを使用して構築されます。

たとえば、ウェブフロントエンド開発で最もよく知られているChrome拡張機能の1つに「React Developer Tools」があります。この拡張機能はReactを使用しているページを検出し、コンポーネントツリーの状態やレンダリングのハイライトを視覚的に検査するのを支援します。これにより、ページ上でReactがどのように機能しているかをより簡単に理解できます。

「ChatGPT & YouTube Summary」拡張機能では、YouTube動画視聴ページの右上隅に以下の画像のようなパネルが挿入されます。このパネルは、現在視聴している動画の文字起こしと要約を提供します。


https://www.youtube.com/watch?v=E8MG-aauMeU
ChatGPT & YouTube Summary


Chrome拡張機能の構築に必要な概念と技術がいくつか存在します。

manifest.json

Chrome拡張機能を構築する際に唯一必須となるファイルが「manifest.json」です。このファイルでは、ページに挿入するスクリプトのパスを定義し、権限を設定し、拡張機能の名前、バージョン、アイコンなどの詳細を構成します。このファイルはプロジェクトのルートに配置し、「manifest.json」という名前で保存する必要があります。

バックグラウンドスクリプト

バックグラウンドスクリプトは、タブの開閉や後述するコンテントスクリプトからのメッセージ受信などのイベントを監視し、それらのイベントに応じてアクションを実行します。

例えば、「ChatGPT & YouTube Summary」拡張機能では、ユーザーが拡張機能をインストールした際にオンボーディングページへリダイレクトする処理をバックグラウンドスクリプトで実装しています。

Manifestバージョン3以降、バックグラウンドスクリプトにはService Workerが使用されます。Service Workerはイベント駆動型であり、特定のイベントが発生した場合のみロードされるため、常時実行されることはありません。そのため、バックグラウンドスクリプトはDOMを直接参照することができません。

コンテントスクリプト

コンテントスクリプトでは、ユーザーが閲覧しているページ上でJavaScriptを実行し、DOMを操作したり要素を挿入したりすることができます。先述のように、「ChatGPT & YouTube Summary」拡張機能では、YouTubeページの右上隅にパネルを挿入するためにコンテントスクリプトが使用されています。このパネルでのユーザーの操作に基づき、GlaspのAPIにリクエストを送信してデータを取得し、動画コンテンツを要約するスクリプトを実行します。

オプションページ

オプションページでは、独立した静的なウェブページを表示することができます。「ChatGPT & YouTube Summary」拡張機能では、ユーザーがダークモード、AIモデル、プロンプトなどの設定をカスタマイズすることができ、拡張機能を自身の好みに合わせてパーソナライズすることができます。


Option page of ChatGPT & YouTube Summary

ポップアップ

ポップアップとは、Chromeのツールバーにある拡張機能のアイコンをクリックすると表示される要素です。拡張機能に関連するさまざまな要素を表示し、ユーザーに情報やコントロールへの迅速なアクセスを提供します。

再構築を決定した理由

「ChatGPT & YouTube Summary」拡張機能は、OpenAIがChatGPTを公開した直後の2022年11月頃にリリースされました。この拡張機能は、ChatGPTのリリースからわずか約1週間で公開されました。その開発期間中、最優先事項は迅速なリリースでした。そのため、以前にVanillaJSを使用して開発した別の拡張機能を基盤として、「ChatGPT & YouTube Summary」を構築しました。

リリース後、ユーザー基盤は着実に成長し、「ChatGPT & YouTube Summary」の機能も拡充し続けました。しかし、スピードを最優先したアプローチを取ったため、開発においていくつかの問題が浮上し始めました。これには、技術的負債、VanillaJSを使用したためのDOM操作や状態管理の複雑さ、型安全性の欠如による予期しないバグなどが含まれます。

「ChatGPT & YouTube Summary」を引き続き成長させながら開発スピードを維持するには、さまざまな機能を追加および改良しながら、ペースを落とさず進める必要がありました。このため、再構築を決定しました。

新しい技術を選定する際の基準は以下の通りです:

  1. サービスの拡大に伴い、保守性が維持できる技術。
  2. チーム内で知識や専門性が蓄積されている技術。

また、私たちは「glasp.co」というサービスも開発・運営しています。このサービスは当初HTMLとVanillaJSで構築されていましたが、同様の課題に直面し、Next.js + TypeScriptを使用して以前に再構築しました。このプロセスを通じて、UI構築におけるコンポーネントベースの開発の利点や、TypeScriptによるバグ検出能力と可読性の向上について深く理解しました。チームはこれらの分野において多くの知識を蓄積しています。さらに、チームが2〜3名の小規模であるため、異なるサービス間での言語のコンテキストスイッチングコストを最小限に抑えることが重要でした。これらの考慮事項に基づき、「ChatGPT & YouTube Summary」をReact + TypeScriptを使用して再構築することを決定しました。

リファクタリングの実施方法

再構築には以下の技術を使用しました:

  • React
  • TypeScript
  • Vite + CRXJS Vite Plugin
  • Tailwind CSS

React + TypeScript

Reactを使用することで、VanillaJSによる命令型アプローチから、UIを構築するための宣言型アプローチに移行しました。これにより、開発者が実装されているUIを理解しやすくなりました。また、「ChatGPT & YouTube Summary」はYouTubeの文字起こしデータを利用して要約を生成するため、TypeScriptを使用してYouTubeから受け取るデータに型を付けることで、実装をより堅牢にしました。これにより、他の開発者も型定義を参照することで、YouTubeからどのデータが使用されているかを理解できるようになります。

Reactは主に以下の2つの領域で使用しています:オプションページとコンテントスクリプト。オプションページはシンプルな静的ページであるため、以下のようなHTML構造を準備し、指定されたIDを持つターゲット要素にReactをマウントするプロセスを実装できます。

<!DOCTYPE html>
<html lang="en">
  <head></head>
  <body>
    <div id="root"></div>
    <script type="module" src="./index.tsx"></script>
  </body>
</html>
import ReactDOM from "react-dom/client";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <div>Sample</div>
  </React.StrictMode>
);

コンテントスクリプトは、先述のようにターゲットのウェブページ上でJavaScriptを実行するスクリプトです。つまり、コンテントスクリプトを使用してReactコンポーネントをマウントしたい場合、オプションページと同様にターゲットウェブページ上の目的のDOM要素を特定し、そのDOM要素に対してcreateRootを実行してReactでUIを構築することができます。以下はその例です:

import ReactDOM from "react-dom/client";

const targetElement = document.getElementById("target-element");
ReactDOM.createRoot(targetElement).render(
  <React.StrictMode>
    <div>Sample</div>
  </React.StrictMode>
);

この例では、単純にReactコンポーネントをマウントしています。しかし、YouTubeはSPA(シングルページアプリケーション)であり、マウントされたReactコンポーネントはYouTubeのコンポーネントライフサイクルとは別のものです。そのため、YouTubeページが切り替わっても、同じコンポーネントが残り続け、前の要約を表示するコンポーネントが次のページでも持続してしまいます。

これを防ぐために、MutationObserverを使用してページの変更を検出し、ページが変更されるたびに新しいReactコンポーネントを作成してリマウントするようにします。以下はその実装例です:

const insertElement = () => {
  const targetElement = document.getElementById("target-element");
  ReactDOM.createRoot(targetElement).render(
    <React.StrictMode>
      <div>Sample</div>
    </React.StrictMode>
  );
};

const initPrevUrl = () => {
  let prevUrl = "";
  return (url?: string) => {
    if (url === undefined) return prevUrl;
    prevUrl = url;
    return prevUrl;
  };
};

const bodyElement = document.querySelector("body") ?? document.body;

const observer = new MutationObserver((mutations) => {
  mutations.forEach(async () => {
    const prevUrl = initPrevUrl();
    if (prevUrl() !== document.location.href) {
      prevUrl(document.location.href);
      insertElement();
    }
  });
});

observer.observe(bodyElement, { childList: true, subtree: true });

この実装では、MutationObserverを使用して、YouTubeのページ遷移中にbodyの子要素の変更を検出します。URLが前回のものと異なる場合、Reactコンポーネントをリマウントします。このアプローチにより、ページ変更後に新しいデータを持つReactコンポーネントが作成され、更新されることが保証されます。

Vite + CRXJS Vite Plugin

CRXJS Vite PluginはChrome拡張機能の開発を支援するツールで、Hot Module Replacement(HMR)やmanifest.jsonのTypeScriptサポートといった機能を提供します。これにより、コンテントスクリプトやバックグラウンドスクリプトの変更がページをリロードせずに反映され、manifest.jsonの型安全な実装が可能になります。

CRXJS Vite PluginをViteに統合するのは非常に簡単で、以下のようにvite.config.tsにcrx関数を追加するだけで設定が完了します。

import react from "@vitejs/plugin-react";
import { defineManifest } from "@crxjs/vite-plugin";

export const manifest = defineManifest({
  // ...
});

export default defineConfig({
  plugins: [react(), crx({ manifest })],
  // ...
});

ディレクトリ構成

VanillaJSで記述されたコードでは、DOM操作、API通信、ロジックが1つのファイルにまとめられることが多く、責任の分離が不十分でした。そのため、主なメンテナー以外の開発者が新機能を追加する際、リグレッションのリスクを回避するのが困難でした。

今回の再構築では、以下のように機能ごとにディレクトリを分けた構造を採用しました。例えば、「オプション」や「YouTube要約」の機能をfeaturesディレクトリ内でそれぞれのディレクトリに分割し、各機能の責任範囲を明確に定義しています。

src
├── background-scripts ## background scripts
├── chrome-extension-api ## chrome api functions like storage
├── components ## shared components like button
├── configs ## configuration files like firebase
├── content-scripts ## Content scripts's entry
├── core ## domain logics like youtube summary transcript
├── features
│   ├── options
│   │   ├── hooks
│   │.  └── components
│   └── youtube-summary
├── hooks ## shared hooks
├── options ## options page's entry
├── providers ## shared react context providers
├── services ## api request functions
└── utils ## shared functions

このアプローチの利点

このアプローチでは、関心ごとによって機能を分離していますが、Chrome拡張機能の文脈では、コンテントスクリプトやオプションページ、バックグラウンドスクリプトなどの技術的関心ごとにファイルを整理する方が明確な場合があります。そのため、content-scriptsやbackground-scriptsなどのエントリーファイル用ディレクトリを分け、これらのスクリプト内でfeaturesディレクトリからReactコンポーネントをインポートする構造を採用しました。

さらに、複数の機能で共通して使用されるフック、UIコンポーネント、ロジックに関しては、src直下にhooks、components、utilsといったディレクトリを作成しました。これらは、さまざまな機能モジュールにインポートできる共通ファイルとして機能します。

Chrome拡張機能に適した構造を採用しつつ、機能ベースのディレクトリ構造を維持することで、責任範囲を明確に分離し、新機能の追加や将来的な改良を容易にできると考えています。

結論

この記事では、ReactとTypeScriptを使用して「ChatGPT & YouTube Summary」を再構築した経緯を共有しました。この再構築は、直接的にユーザー体験に影響を与えるものではありませんが、内部的な開発効率を大幅に向上させ、新しい価値をより迅速にユーザーへ届けることを可能にしました。

再構築は最終目標ではなく、サービスのさらなる成長に向けた一歩です。この基盤の上に、引き続きユーザーにさらなる価値を提供していきたいと考えています。

現在、Glaspの成長を促進するソフトウェアエンジニアを募集しています。ご興味のある方は、以下の求人情報をご覧ください。

Glasp求人ボード

Discussion