Twitterの文字数をカウントする拡張機能

# TL;DR
Tweetの文字数をカウントできる拡張機能を開発しました。

# 背景
弊社で運営している[Fashion Tech News](https://fashiontechnews.zozo.com/)では、記事公開のタイミングに合わせてZapierからTweetを実行しています。Tweetの一部の文章は、記事の管理も行っているmicroCMSに登録し、RSSを利用してZapierに渡しています。Tweetの文字数がオーバーした場合、Zapierでエラーが出て中断されます。そのためmicroCMSに文章を登録する際に、文字数がオーバーしないように気を付ける必要があり、従来は手動で様々な項目をTwitterのウェブアプリにコピペして文字数の確認を行っていました。

そこで、記事を管理しているページを開くと自動的にTweetの文字数を表示する拡張機能を作成しました。

Tweetの例：
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">【Fashion Tech News】<br>映画公開で人気が再燃！ 「SLAM DUNK」と「エア ジョーダン6」の蜜月関係　<br><br>映画「THE FIRST SLAM DUNK」が12/3公開！！<a href="https://twitter.com/hashtag/THEFIRSTSLAMDUNK?src=hash&amp;ref_src=twsrc%5Etfw">#THEFIRSTSLAMDUNK</a> <a href="https://twitter.com/hashtag/SLAMDUNK?src=hash&amp;ref_src=twsrc%5Etfw">#SLAMDUNK</a> <a href="https://twitter.com/hashtag/%E3%82%B9%E3%83%A9%E3%83%A0%E3%83%80%E3%83%B3%E3%82%AF?src=hash&amp;ref_src=twsrc%5Etfw">#スラムダンク</a> <a href="https://twitter.com/hashtag/%E3%82%B8%E3%83%A7%E3%83%BC%E3%83%80%E3%83%B36?src=hash&amp;ref_src=twsrc%5Etfw">#ジョーダン6</a><a href="https://t.co/BQr3tY0tGT">https://t.co/BQr3tY0tGT</a></p>&mdash; Fashion Tech News (@zt_ftn) <a href="https://twitter.com/zt_ftn/status/1598815179738841089?ref_src=twsrc%5Etfw">December 2, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# ポイント
1. SPAの画面遷移に対応する
2. 文字数のカウント方法はTwitterのルールをそのまま活用する（[全角の換算方法](https://developer.twitter.com/en/docs/counting-characters#:~:text=Chinese%20/%20Japanese%20/%20Korean%20Glyphs)、[URLなどの換算方法など](https://developer.twitter.com/en/docs/counting-characters#:~:text=types%20of%20glyphs.-,Entity%20Objects,-Tweets%20can%20contain)）
3. CMS内の複数項目をTweetに含めて、変更ごとにカウントをし直して表示する

# 手順・実装
[スタートガイド](https://developer.chrome.com/docs/extensions/mv3/getstarted/)などで紹介される、基本的なcontent scriptを利用すると、SPAの画面遷移を検知できません。そのため今回はbackground scriptを利用して画面遷移を検知し、content scriptで画面への描画を行うという方法を採用しました。

フォルダ構造は以下です。
今回Twitterの文字数カウントをそのまま活用するために、[`twitter-text`](](https://www.npmjs.com/package/twitter-text))というNPMパッケージを利用しました。そのためBrowserifyを利用し、`src/`内のファイルをBuildして`scripts/`に生成するようにしました。
```markdown
├── scripts/
├── images/
├── node_modules/
├── scripts/
├── src
│   ├── background.js
│   └── content.js
├── README.md
├── manifest.json
├── package-lock.json
└── package.json
```

manifest.jsonは以下で、background scriptの読み込みを行っています。その後許可するホストと、許可する行為を指定しています。
```json:manifest.json
{
  "manifest_version": 3,
  "name": "Tweet Counter Extension",
  "description": "Displays the number of tweet characters",
  "version": "1.0",
  "icons": {
    "16": "images/icon-16.png",
    "32": "images/icon-32.png",
    "48": "images/icon-48.png",
    "128": "images/icon-128.png"
  },
  "background": {
    "service_worker": "scripts/background.js"
  },
  "host_permissions": ["https://CMSのURL/*"],
  "permissions": ["tabs", "scripting"]
}
```


<details><summary>参考までにpackage.jsonも載せておきます。</summary><div>

```json:package.json
{
  "name": "tweet-counter-extension",
  "version": "1.0.0",
  "description": "Displays the number of tweet characters",
  "scripts": {
    "build": "browserify src/content.js -o scripts/content.js && browserify src/background.js -o scripts/background.js"
  },
  "license": "ISC",
  "devDependencies": {
    "browserify": "^17.0.0",
    "twitter-text": "^3.1.0"
  }
}
```
</div></details>


background scriptは`background.js`としました。`chrome.tabs.onUpdated`で画面遷移を検知し、Tweetの文字数を表示させたい、記事を管理するページの場合のみ、`content.js`を実行するというものです。こうすることでSPAの画面遷移でも、ページごとにcontent.jsを実行することができます。
```javascript:background.js
chrome.tabs.onUpdated.addListener(function (tabId, info, tab) {
  const urlConditions = tab.url.indexOf("https://記事を管理するページ/") !== -1
  if (info.status === 'complete' && urlConditions) {
    chrome.scripting.executeScript({
      target: { tabId: tabId },
      files: ["scripts/content.js"]
    },);
  }
});
```

実行されるcontent scriptの`content.js`は以下です。
①`twitter-text`のパッケージを読み込みます。
②Zapierで毎回追加される、`【Fashion Tech News】`の見出しやURLの一例を設定します。
③Tweetに追加される、タイトル・本文をクラス名の前方一致で取得します。（前方一致で取得する理由は、ツールなどによりBuildごとに異なる文字列などが付加されるためです。）
④Tweetの文字数を表示する場所に隣接する要素を取得すると共に、その新たに追加する要素に設定する`id`を決めます。
⑤`updateBadge`の関数では、`tweet`の変数に実際のTweetの内容を定義し文字数のカウントを行います。文字数がオーバーしている場合には警告の意味を込めて赤背景・白抜き文字で表示します。
⑥SPAの場合読み込みに少し時間がかかることがあるため、上記の`updateBadge`などの処理は目的の要素が読み込まれてから実行します。
⑦読み込まれるまで`setInteval`を活用して1秒ごとに実行を試みて、読み込まれた後に`clearInteval`で繰り返し実行をキャンセルするようにします。
⑧初期実行が終わった後は、フォームなどの変更に応じて描画し直せるように、Event Listner追加します。

```javascript:content.js
const main = () => {
  const jsLoaded = () => {
    // ①パッケージの読み込み
    const twitter = require("twitter-text")
    // ②Tweetに毎回記載されている部分を定義
    const tweetHeading = "【Fashion Tech News】"
    const exampleUrl = "https://fashiontechnews.zozo.com/" // URLs are shortened and converted to 12 characters in full-width

    // ③Tweetに追加される要素を取得
    const articleTitle = document.querySelector("[class^='タイトルのフォームのクラス名一部']")
    const tweetForm = document.querySelector("[class^='Tweet本文のフォームのクラス名の一部']")

    // ④文字数を表示する要素に隣接する要素の取得と、idの設定
    const insertingArea = document.querySelector("[class^='contentActions_buttons']")
    const badgeId = "tweet-count"

    // ⑤文字数のカウントと描画を担当する関数
    const updateBadge = () => {
      const tweet = `${tweetHeading}\n${articleTitle.value}\n\n${tweetForm.value}\n${exampleUrl}`
      const parseData = twitter.parseTweet(tweet)
      const characterCountInFullWidth = Math.ceil(parseData.weightedLength / 2)
      const isOvered = characterCountInFullWidth > 140

      const updatingBadge = document.querySelector(`#${badgeId}`)
      updatingBadge.textContent = `Tweet: ${characterCountInFullWidth}文字`
      updatingBadge.style.color = isOvered ? "#fff" : "#21213b"
      updatingBadge.style.backgroundColor = isOvered ? "#c00" : "#fff"
    }

    // ⑥目的の要素が読み込まれてから実行
    if (insertingArea != null && articleTitle != null && tweetForm != null) {
      // ⑦目的の要素が読み込まれた後は繰り返し実行をキャンセル
      clearInterval(jsInitCheckTimer)

      const badge = document.createElement("p")
      badge.id = badgeId
      badge.style.padding = "8px"
      badge.style.marginRight = "16px"
      badge.style.textAlign = "right"
      badge.style.fontWeight = "700"
      insertingArea.insertAdjacentElement("afterbegin", badge)
      updateBadge()

      // ⑧フォームにEvent Listnerを追加し変更に応じて文字数の描画し直す
      articleTitle.addEventListener("input", updateBadge)
      tweetForm.addEventListener("input", updateBadge)
    }
  }

  // Iterate until the target DOM is ready
  const jsInitCheckTimer = setInterval(jsLoaded, 1000)
}

main()

```

**文字数を表示させた様子**（赤背景の部分）
![スクリーンショット 2022-12-19 15.41.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146971/4b05e209-3cb7-1ed0-a39c-7cc53927d967.png)

# 注意点
画面遷移を検知するのがbackground.js、実際に描画するのがcontent.jsという実装を行いました。その結果、上記⑦の`clearInterval`が実行される前に画面遷移が行われると、`content.js`ではそれを検知できず、`updateBadge`が複数実行され、文字数の表示が複数になってしまう問題があります。今回は社内ツールということもあり、問題が発生するページを除外するという対応策にとどめました。

# 参考
- [Chrome Extension getting started guides](https://developer.chrome.com/docs/extensions/mv3/getstarted/)：公式のもので、デモコードもあり分かりやすかったです。
- [【Chrome拡張】SPAやSSRのWebページでページ遷移を検知する](https://mem-archive.com/2019/12/09/post-2164/)：background scriptについて詳しく解説がありました。
- [動的なページの読み込みが完了してからChrome拡張機能を実行する方法](https://qiita.com/3mc/items/c3c580ca5de4a2d3990d)