推しに貢ぎたいChrome拡張

## はじめに

こんにちは、[@rs_tukki](https://twitter.com/rs_tukki)です。
これは[クソアプリ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/kuso-app)の19日目の記事です。

## ある日思い立った

ぼく「今年はなんかアドベントカレンダー参加してみたいなあ」
ぼく「よしなんか今年(ぼくの中で)流行ったものでひとつ作ってみるか」
ぼく「最近コロナで外出できなくてYouTubeで推しの配信見ることが多かったし、YouTubeの拡張機能作ってみよう」
## 使い方
1.  Youtubeの生配信、もしくはプレミア公開中の動画を開く
1.  おもむろにコメントを投稿する
1.  2秒ほど待つ
1.  君の書いたコメントがスパチャみたいに見えるよ！ふしぎ！

![superchat.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308803/08932afd-59dc-5aad-d1c0-1cf5e835b35c.png)

スパチャの金額は100円～10,000円の間で自由に設定できます。
投稿するたびに金額をランダムにすることも可能です。勿論金額に応じて色が変わります。
![popup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308803/772fd757-075f-4f7c-5504-1ee2189f2498.png)
**以上です。**
コードはGithubに上げておきましたので手動でインストールしてご自由にお使いください。

https://github.com/rs-tukki/superchat-maker

https://reviews.f-tools.net/Add-On/Jisaku-Tuika.html
## 実装
技術スタックは以下の通り。

* HTML/CSS
* JavaScript(Jquery3.6.0)  

何分Chrome拡張はおろか業務以外でコードをガッツリ書くのすらほぼ初めてだったので「とりあえず動けばいいや」の精神です。
### スパチャと通常のチャットのDOMを比較してみる
今回やりたいことは、言ってしまえば「通常のチャットをスーパーチャットの形式に変換する」というだけの話です。
通常のチャットとスーパーチャットのDOMを比較してJavaScriptで変換してやればOKです。

というわけで早速二つを比較してみましょう。実際のページからそれぞれの要素を抜き出してみます。

<details><summary>通常チャット</summary><div>

```html
<yt-live-chat-text-message-renderer class="style-scope yt-live-chat-item-list-renderer" id="[コメントID]" author-type="member">
  <!--css-build:shady-->
  <yt-img-shadow id="author-photo" class="no-transition style-scope yt-live-chat-text-message-renderer" height="24" width="24" loaded="" style="background-color: transparent;">
    <!--css-build:shady-->
    <img id="img" class="style-scope yt-img-shadow" alt="" height="24" width="24" src="[ユーザアイコンURL]">
  </yt-img-shadow>
  <div id="content" class="style-scope yt-live-chat-text-message-renderer">
    <span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">[投稿時間]</span>
    <yt-live-chat-author-chip class="style-scope yt-live-chat-text-message-renderer">
      <!--css-build:shady-->
      <span id="author-name" dir="auto" class="member style-scope yt-live-chat-author-chip">[ユーザ名]
        <span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
      </span>
      <span id="chat-badges" class="style-scope yt-live-chat-author-chip">
      </span>
    </yt-live-chat-author-chip>​
    <span id="message" dir="auto" class="style-scope yt-live-chat-text-message-renderer">[コメントの中身]</span>
    <span id="deleted-state" class="style-scope yt-live-chat-text-message-renderer"></span>
    <a id="show-original" href="#" class="style-scope yt-live-chat-text-message-renderer"></a>
  </div>
  <div id="menu" class="style-scope yt-live-chat-text-message-renderer">
    <yt-icon-button id="menu-button" class="style-scope yt-live-chat-text-message-renderer" touch-feedback="">
      <!--css-build:shady-->
      <button id="button" class="style-scope yt-icon-button" aria-label="コメントの操作">
        <yt-icon icon="more_vert" class="style-scope yt-live-chat-text-message-renderer">
          <svg viewBox="0 0 24 24" preserveAspectRatio="xMidYMid meet" focusable="false" class="style-scope yt-icon" style="pointer-events: none; display: block; width: 100%; height: 100%;">
            <g class="style-scope yt-icon">
              <path d="M12,16.5c0.83,0,1.5,0.67,1.5,1.5s-0.67,1.5-1.5,1.5s-1.5-0.67-1.5-1.5S11.17,16.5,12,16.5z M10.5,12 c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5s-0.67-1.5-1.5-1.5S10.5,11.17,10.5,12z M10.5,6c0,0.83,0.67,1.5,1.5,1.5 s1.5-0.67,1.5-1.5S12.83,4.5,12,4.5S10.5,5.17,10.5,6z" class="style-scope yt-icon"></path>
            </g>
          </svg>
          <!--css-build:shady-->
        </yt-icon>
      </button>
      <yt-interaction id="interaction" class="circular style-scope yt-icon-button">
        <!--css-build:shady-->
        <div class="stroke style-scope yt-interaction"></div>
        <div class="fill style-scope yt-interaction"></div>
      </yt-interaction>
    </yt-icon-button>
  </div>
  <div id="inline-action-button-container" class="style-scope yt-live-chat-text-message-renderer" aria-hidden="true">
    <div id="inline-action-buttons" class="style-scope yt-live-chat-text-message-renderer"></div>
  </div>
</yt-live-chat-text-message-renderer>
```
</div></details>
<details><summary>スーパーチャット</summary><div>

```html
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" id="[コメントID]" allow-animations="" style="[色情報]">
<!--css-build:shady-->
<div id="card" class="style-scope yt-live-chat-paid-message-renderer">
  <div id="header" class="style-scope yt-live-chat-paid-message-renderer">
    <yt-img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-paid-message-renderer no-transition" loaded="" style="background-color: transparent;">
      <!--css-build:shady-->
      <img id="img" class="style-scope yt-img-shadow" alt="" height="40" width="40" src="[ユーザアイコンURL]">
    </yt-img-shadow>
    <dom-if restamp="" class="style-scope yt-live-chat-paid-message-renderer">
      <template is="dom-if"></template>
    </dom-if>
    <dom-if class="style-scope yt-live-chat-paid-message-renderer">
      <template is="dom-if"></template>
    </dom-if>
    <div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
      <div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
        <div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">[ユーザ名]</div>
        <div id="purchase-amount-column" class="style-scope yt-live-chat-paid-message-renderer">
          <yt-img-shadow id="currency-img" height="16" width="16" class="style-scope yt-live-chat-paid-message-renderer no-transition" hidden="">
            <!--css-build:shady-->
            <img id="img" class="style-scope yt-img-shadow" alt="" height="16" width="16">
          </yt-img-shadow>
          <div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">
            <yt-formatted-string class="style-scope yt-live-chat-paid-message-renderer">[金額]</yt-formatted-string>
          </div>
        </div>
      </div>
      <span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">[投稿時間]</span>
    </div>
    <div id="menu" class="style-scope yt-live-chat-paid-message-renderer">
      <yt-icon-button id="menu-button" class="style-scope yt-live-chat-paid-message-renderer" touch-feedback="">
        <!--css-build:shady-->
        <button id="button" class="style-scope yt-icon-button" aria-label="コメントの操作">
          <yt-icon icon="more_vert" class="style-scope yt-live-chat-paid-message-renderer">
            <svg viewBox="0 0 24 24" preserveAspectRatio="xMidYMid meet" focusable="false" class="style-scope yt-icon" style="pointer-events: none; display: block; width: 100%; height: 100%;">
              <g class="style-scope yt-icon">
                <path d="M12,16.5c0.83,0,1.5,0.67,1.5,1.5s-0.67,1.5-1.5,1.5s-1.5-0.67-1.5-1.5S11.17,16.5,12,16.5z M10.5,12 c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5s-0.67-1.5-1.5-1.5S10.5,11.17,10.5,12z M10.5,6c0,0.83,0.67,1.5,1.5,1.5 s1.5-0.67,1.5-1.5S12.83,4.5,12,4.5S10.5,5.17,10.5,6z" class="style-scope yt-icon"></path>
              </g>
            </svg>
            <!--css-build:shady-->
          </yt-icon>
        </button>
        <yt-interaction id="interaction" class="circular style-scope yt-icon-button">
          <!--css-build:shady-->
          <div class="stroke style-scope yt-interaction"></div>
          <div class="fill style-scope yt-interaction"></div>
        </yt-interaction>
      </yt-icon-button>
    </div>
  </div>
  <div id="content" class="style-scope yt-live-chat-paid-message-renderer">
    <div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">[コメントの中身]</div>
    <div id="input-container" class="style-scope yt-live-chat-paid-message-renderer">
      <dom-if class="style-scope yt-live-chat-paid-message-renderer">
        <template is="dom-if"></template>
      </dom-if>
    </div>
    <yt-formatted-string id="deleted-state" class="style-scope yt-live-chat-paid-message-renderer">
      <!--css-build:shady-->
    </yt-formatted-string>
    <div id="footer" class="style-scope yt-live-chat-paid-message-renderer"></div>
  </div>
</div>
<div id="buy-flow-button" class="style-scope yt-live-chat-paid-message-renderer" hidden=""></div>
<div id="inline-action-button-container" class="style-scope yt-live-chat-paid-message-renderer" aria-hidden="true">
  <div id="inline-action-buttons" class="style-scope yt-live-chat-paid-message-renderer"></div>
</div>
</yt-live-chat-paid-message-renderer>
```
</div></details>

……。
…………うん。

**無理だな？**
流石に天下のYouTubeを甘く見てました。この量のDOMの書き換えはちょっと現実的じゃなさそうです。

ただ、幸いにも要素自体はユーザ情報やコメント内容・投稿時間等を除いて固定らしいので
ゴリゴリの力押しではありますが固定の要素をテンプレートとして用意しておいて上書きし、チャット欄に追加してから元のコメントを消す方法でやってみようと思います。

とりあえず動くものがつくれればそれでいいです。(2回目)

```javascript:script.js
/**
 * Convert user-submitted chats into super chats.
 * @param {object} chatArea Where the video chat appears
 * @param {object} postedChat Chat posted by users
 */
const convertToSuperchat = function(chatArea, postedChat) {
  if(postedChat.length){
    chrome.storage.sync.get(['enabled','amount','randomize'], items => {
      const enabled = items.enabled == null ? true : items.enabled;
      const amount = items.amount || 500;
      const randomize = items.randomize == null ? false : items.enabled;
      if (enabled) {
        const chatAmount = getAmount(amount, randomize);
        let superchat = suprchatTemplate.replace('@Id@', $(postedChat).attr('id'))
                                .replace('@Color@', getSuperChatColor(chatAmount))
                                .replace('@Img@', $(postedChat).find('#author-photo img').attr('src'))
                                .replace('@Name@', $(postedChat).find('#author-name').text())
                                .replace('@Amount@',chatAmount.toLocaleString())
                                .replace('@Time@', $(postedChat).find('#timestamp').text())
                                .replace('@Comment@', $(postedChat).find('#message').outerHTML);
        postedChat.before($.parseHTML(superchat));
        postedChat.remove();
      }
    });
  }
}

/**
 * Get the amount of the superchat.
 * @param {number} amount Amount set by the user
 * @param {boolean} randomize Whether to randomize the amount
 * @returns Acquired amount
 */
const getAmount = function(amount, randomize) {
  if (randomize) {
    return Math.ceil(Math.random() * 100) * 100;
  } else {
    return amount;
  }
}

/**
 * Get the color of the super chat.
 * @param {number} amount Set amount
 * @returns Color information based on the amount
 */
const getSuperChatColor = function(amount) {
  if (amount < 200) {
    return colorTemplate.blue;
  } else if (amount < 500) {
    return colorTemplate.lightBlue;
  } else if (amount < 1000) {
    return colorTemplate.yellowGreen;
  } else if (amount < 2000) {
    return colorTemplate.yellow;
  } else if (amount < 5000) {
    return colorTemplate.orange;
  } else if (amount < 10000) {
    return colorTemplate.magenta;
  } else {
    return colorTemplate.red;
  }
};
```

* `enabled`(スパチャの変換を有効にするか)
* `amount`(スパチャの金額)
* `randomize`(金額をランダムで設定するか)

はあらかじめ設定してある値があればそれを利用し、なければデフォルトの値を使います。
とりあえず動けばいいので(3回目)、スパチャのテンプレートはjsファイルにベタ書きしてしまい、必要な要素は別途上書きしていきます。

ちなみにスパチャの色情報もこれはjs内に持っています。最終的にはまとめてstyle要素に格納するのですが、以下の記事が大変参考になりました。

https://zenn.dev/yutanakamizo/articles/7b62868facca4c

これでベース部分は完成です。
### チャット欄のDOMを取得したい
続いて、上記メソッドの`chatArea`および`postedChat`変数、つまりチャット欄のDOMと自分の投稿したチャットのDOMを取得していきます。

やり方としてはチャット欄の子要素の追加を監視して、追加された要素(チャット)が自分のものであればそれを取得すればOKです。
と言うわけで最初に作成したのがこのコード。

<details><summary>初期化処理(1回目)</summary><div>

```javascript:script.js
/**
 * Initialize the monitoring of the chat area.
 */
const init = function() {
    const chatArea = $('#chat-messages')[0];
    const observer = new MutationObserver(() => {
      const postedChat = null; //あとで
      convertToSuperchat(chatArea, postedChat);
    });
    observer.observe(chatArea, {
      childList: true,
      subtree: true,
    });
  }
};
init();
```
</div></details>

ただ、これだと`chatArea`要素が上手く取得できませんでした。
どうやらチャット欄はページ自体の読み込みからやや遅れて生成されるようで、初期化の段階でまだ要素が存在しなかったみたいです。

というわけで、チャット欄が生成されるまでループを繰り返すようにしたのが以下のコード。

<details><summary>初期化処理(2回目)</summary><div>

```javascript:script.js
/**
 * Initialize the monitoring of the chat area.
 */
const init = function() {
    const chatArea = $('#chat-messages')[0];
    if (!chatArea) {
      setTimeout(init, 1000);
      return;
    }
    const observer = new MutationObserver(() => {
      const postedChat = null; //あとで
      convertToSuperchat(chatArea, postedChat);
    });
    observer.observe(chatArea, {
      childList: true,
      subtree: true,
    });
  }
};
init();
```

</div></details>

しかしこれでもダメでした。いつまで経っても`chatArea`が取得できず、無限に初期化処理を繰り返してしまいます。
というわけで何か間違ってないかと再度ページのDOMを眺めていたのですが、そこでようやく気付きました。

**これチャット欄`iframe`で生成されてます。**
`iframe`だと別ページ扱いみたいなものなので、まあ取得できないのも当然と言えば当然でしょう。

なので一旦`iframe`要素を取得してから更にその中のチャット欄を探しに行くようにして……

```javascript:script.js
/**
 * Initialize the monitoring of the chat area.
 */
const init = function() {
  const chatFrame = $('#chatframe')[0];
  if (!chatFrame) {
    setTimeout(init, 1000);
    return;
  }
  const chatArea = $('#chat-messages', chatFrame.contentWindow.document)[0];
    if (!chatArea) {
      setTimeout(init, 1000);
      return;
    }
    const observer = new MutationObserver(() => {
      const postedChat = null; // あとで
      convertToSuperchat(chatArea, postedChat);
    });
    observer.observe(chatArea, {
      childList: true,
      subtree: true,
    });
};
init();
```
これでOKです。なんとかチャット欄の更新を検知できるようになりました。
### 自分の投稿かどうかを判定する

さて続いては、更新された投稿が自分のものかどうかを判定させていきます。
まあ自分のチャット自体は後から削除されるので多分判別の方法もあるかと思っていたのですが、
**これが何故か他の方の投稿と(ユーザ名等除いて)完全に一致しているという謎。**
どうしたもんかと他の方のChrome拡張のコードを色々調べてみたところ、「コメント入力欄の名前と投稿されたチャットの名前が一致しているか」を自分の投稿の判定基準にしているものを見つけたので、このやり方を真似させていただきました。

しかしまた力押しを……まあとりあえずは動けばいい(4回目)んですが。

```diff_javascript:script.js
    const observer = new MutationObserver(() => {
+     const yourName = $(chatArea).find('#input-container span#author-name').text();
-     const postedChat = null; // あとで
+     const postedChat = $(chatArea).find("yt-live-chat-text-message-renderer:contains('" + yourName +"')");
-     convertToSuperchat(chatArea, postedChat);
+     convertToSuperchat(yourName, chatArea, postedChat);
    });
```

### 何故かスパチャが白くなる

これでいよいよスパチャに変換するロジックができました。
早速、拡張機能を手動で読み込んで生放送でコメントしてみます。

![error.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308803/4e826a5a-1ab4-3dff-f31f-4291c8b44d79.png)

**真っ白。**
ユーザ名や画像、ID等は正しく変換できているのですが、どうやらYouTube本体のJavaScriptが個別に反応してしまったようで
数秒後に金額と色情報が消えてしまいました。

どうしようか滅茶苦茶悩んだのですが、DOM要素を追加した後に消えてしまうのでどうしようもなく……
とりあえず動くものが作りたかったので(5回目)、諦めてYouTubeくんが変更した後に更に上書きする方法で対処しました。

```diff_javascript:script.js
        const chatAmount = getAmount(amount, randomize);
        let superchat = suprchatTemplate.replace('@Id@', $(postedChat).attr('id'))
                                .replace('@Color@', getSuperChatColor(chatAmount))
                                .replace('@Img@', $(postedChat).find('#author-photo img').attr('src'))
                                .replace('@Name@', yourName)
-                               .replace('@Amount@',chatAmount.toLocaleString())
                                .replace('@Time@', $(postedChat).find('#timestamp').text())
                                .replace('@Comment@', $(postedChat).find('#message').outerHTML);
        postedChat.before($.parseHTML(superchat));
        postedChat.remove();
+       setTimeout(function() {
+         const convertedChat = $(chatArea).find("yt-live-chat-paid-message-renderer:contains('" + yourName +"')");
+         $(convertedChat).find('#purchase-amount yt-formatted-string').text('￥' + chatAmount.toLocaleString());
+         $(convertedChat).attr('style', getSuperChatColor(chatAmount));
+       }, 2000);
```
スパチャに変換してから更に2秒後に色と金額の情報を追加しています。
使い方で「2秒ほど待つ」と書いたのはこれのせいです。

出来としてはかなり微妙ですがやりたいことはできましたのでこれで完成とします。
あとはつよつよな方がPR上げてくれるでしょう。たぶん。
## 注意点
* 金額や色がやや遅れて表示される都合上、チャット欄の勢いが早すぎるとスパチャ感は薄れるかも知れません。
* 本来はチャット欄上部に投稿されたスパチャが並ぶ仕様ですが、そちらまでは対応できませんでした。
* Youtubeチャット関係のChrome拡張というと有名どころでは[youtube-live-chat-flow](https://github.com/fiahfy/youtube-live-chat-flow)がありますが、こちらとの併用も未検証です。
* あくまで推しに貢いだ**気分になるだけ**です。最後には虚無感しか残りません。

## 最後に
本拡張機能は推しに貢がないことを推奨するものではありません。
偉い人は言いました。**推しは推せるときに推せ。**拡張機能に甘えず、自分の生活の許す範囲できちんと貢ぎましょう。

あと私の推しの[大空スバルさん](https://www.youtube.com/channel/UCvzGlP9oQwU--Y0r9id_jnA)をよろしくお願いします！
## 参考

[Chrome拡張の作り方 (超概要)](https://qiita.com/RyBB/items/32b2a7b879f21b3edefc)
[YouTube Live スーパーチャットのカラーコードまとめ](https://zenn.dev/yutanakamizo/articles/7b62868facca4c)
[JavaScriptのMutationObserverでDOMの変化を監視する方法](https://qiita.com/munieru_jp/items/a6f1433652124a2165e4)
[iframe内のDOMを操作する方法](https://qiita.com/kawanamiyuu/items/0e95f360ad5490e2db74)
[youtube-live-chat-flow](https://github.com/fiahfy/youtube-live-chat-flow)