Chrome拡張機能を作っての社内で人気者になった話

# はじめに

この記事は、Qiita株式会社 Advent Calendar 2022 の 7日目の記事です。

業務で役立つ便利な Chrome 拡張機能を作ったら、社内で人気者になっただけでなく技術的な学びもとても多かったので、みんなも真似してみてね！という記事です。

# 背景

GitHub の便利な機能の一つとして、画像アップロード機能があります。issue や Pull Request の入力フォームに画像等のファイルをドラッグ&ドロップすると、画像がアップロードされ、 Markdown の画像埋め込みを自動で追加してくれます。ですが、この機能の注意点として、たとえプライベートリポジトリにアップロードした画像でも、URLを知っている人なら誰でも画像を見ることができてしまいます。

（↓ 詳細はこちらのGitHub公式ドキュメントに書いてあります）

https://docs.github.com/ja/get-started/writing-on-github/working-with-advanced-formatting/attaching-files

例えば、まだリリースしていない新機能のスクリーンショットや個人情報が含まれる画像等をこの機能で GitHub にアップロードした場合、何らかの理由で誤ってURLが外部に漏れてしまうと、情報漏洩が発生してしまいます。

このような理由から、Qiita株式会社ではこの GitHub の画像アップロード機能は禁止になっています。

:::note warn

現在は GitHub にアップロードした画像もプライベートリポジトリなら権限を持ったメンバーしか見れないように改善されているため、GitHub の画像アップロード機能の制限は解除されています。

https://github.blog/changelog/2023-05-09-more-secure-private-attachments/
:::



では、Qiitaのエンジニアは GitHub でどうやって画像のやりとりをしているかというと、Qiita Team の[画像アップロード機能](https://qiita-team.zendesk.com/hc/ja/articles/4639707630351-%E7%94%BB%E5%83%8F-%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E6%B7%BB%E4%BB%98%E6%A9%9F%E8%83%BD)を使用しています。

https://teams.qiita.com/

この機能でアップロードされた画像は、チームのメンバーしか見ることができないので、画像URLが漏れてしまっても問題ありません。そのため、Qiita Team にアップロードした画像の URL を GitHub の issue や Pull Request に貼り付けて安全に画像のやりとりをしています。


**ここからが本題です！!**

Qiita Team にアップロードした画像を GitHub の issue や Pull Request に **表示** するためにはどうすればいいでしょうか？
例えば、以下のように Qiita Team にアップロードした画像のURL Markdown 形式で画像を埋め込む形式にしたらどうなるでしょうか？

```markdown
![image](https://qiita-inc.qiita.com/files/xxxxxxxxxxxxxxxxxxxxxxxxxxxxx.png)
```

Markdown が変換され、以下のようなHTMLになります。

```html
<img src="https://qiita-inc.qiita.com/files/xxxxxxxxxxxxxxxxxxxxxxxxxxxxx.png">
```

ですが、これだと画像を表示することはできません...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325392/1d621f4d-e734-d187-a30b-7597d8749168.png)

GitHub 公式ドキュメントにもある通り、ログイン等が必要な場所にある画像は表示することができません。

https://docs.github.com/ja/authentication/keeping-your-account-and-data-secure/about-anonymized-urls#viewing-images-on-private-networks

そのため、これまでは仕方なく Qiita Team にアップロードした画像の URL を直接張り付け、画像を見たいときは URL を別のタブで開いて確認していました。

このような画像確認方法はとても面倒だったので、「どうにか GitHub 上で Qiita Team にアップロードした画像を表示できるようにしたい！！！」と考え、拡張機能を作ることにしました。

この時は「ページを読み込んだ時にドキュメント内の Qiita Team の画像URLがある `a` タグを探して、いい感じに変換すればいけるのでは？」と考えていましたが、意外といろいろ苦戦しました。

これからその試行錯誤の流れを紹介していきます。

# Chrome拡張機能を作る

Chrome拡張機能はかなり前に少しだけ触ったことがありましたが、今では作りかたを忘れてしまったので調べるところから始まりました。Chrome 拡張機能の実装の流れは簡単に説明すると以下のようになります。

- マニフェストファイル (`manifest.json`) を作成する
- 動かしたい JavaScript のファイルを作成し、マニフェストファイルに指定する
- Chromeでマニフェストファイル等が入ったディレクトリを読み込む
- 動く！

## マニフェストファイル (`manifest.json`) を作成する

マニフェストファイルの書き方にはバージョンが存在し、現在は `V3` を使用すればよさそうということがわかりました。

https://developer.chrome.com/docs/extensions/mv3/intro/

一旦以下のように、 Manifest V3 の設定ファイルを作成しました。

```json
{
  "manifest_version": 3,
  "name": "Display Qiita Team images",
  "version": "1.0.0",
  "description": "Display Qiita Team images in GitHub",
}
```

次に、JavaScript ファイルを作成し、マニフェストファイルにそのファイルを指定していきます。

## マニフェストファイルで動かしたい JavaScript のファイルを指定する

V3 でマニフェストファイルに JavaScript ファイルを指定する方法として、以下の3種類があります。

- `action`
    - 拡張機能のアイコンをクリックしたときに表示したい HTMLを指定。
        - このHTMLの中でJavaScriptを指定すれば、アイコンをクリックしたときに実行できる。
- `content_scripts`
    - DOMの操作等を行う場合に使う
- `background` (`service_worker`)
    - バックグラウンドで実行したい処理があるときに使う

今回はDOMの操作等 (Qiita Team の画像URLがある `a` タグを探して変換）を行うので、とりあえず `content_scripts` を指定すればよさそうです。

GitHub の issue と PR で行いたいので、そのURLを `matches` に指定し、`run_at` に `document_end` を指定してページの読み込み後に実行されるようにしました。

```json
  "content_scripts": [{
    "matches": [
      "https://github.com/increments/*/pull/*",
      "https://github.com/increments/*/issues/*"
    ],
    "run_at": "document_end",
    "js": ["content.js"]
  }]
```

一旦ちゃんと読み込まれることだけ確認したいので、`console.log('test')` だけ書いた `content.js`ファイルを作成しました。

## Chromeでディレクトリを読み込む

Chrome の拡張機能設定ページ (`chrome://extensions/`)にアクセスし、`manifest.json` と `content.js` を入れたディレクトリを読みんでみると、インストール済み拡張機能一覧に表示されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325392/e14f1ed6-11d5-790e-73cc-2dd258b17bc6.png)

読み込みが完了したので、試しに GitHub の issue のページに移動しデベロッパーツールを開いてみると `content.js`ファイルに書いたスクリプトが動作し Console に `test` と表示されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325392/0ce240ab-2578-b103-576d-5d62ff20a7f3.png)

あとは `content.js` に変換処理を書いていくだけです！

# 変換処理の実装

まずはページ内の画像リンクを探していきます。

ページ内のリンクは　`document.links` で取得できます。

https://developer.mozilla.org/ja/docs/Web/API/Document/links

その中から、Qiita Team の画像を探します。ループでひとつずつ探すだけです。

```js
const links = document.links
const qiitaLinks = []

for (var i = 0; i < links.length; i++) {
  if (links[i].href.indexOf('https://qiita-inc.qiita.com/files/') === 0) {
    qiitaLinks.push(links[i])
  }
}
```

さて、これでQiita Team の画像リンクが取得できました。

# ログインが必要な場所にある画像を表示する

ここまでは割とスムーズに進みましたが、ここから画像を表示するまでが一番苦戦しました。

変数 `qiitaLinks` には Qiita Team にアップロードした画像のURLが入っていますが、これを単に以下のように `img` タグに変換するだけでが表示できないことは冒頭で確認しました。

```html
<img src="[Qiita Teamの画像URL]">
```

ではどうやって画像を表示するかというと、データURLを使用した方法を試しました。

https://developer.mozilla.org/ja/docs/Web/HTTP/Basics_of_HTTP/Data_URLs

画像データを取得してデータURLに変換し、`img` タグにセットすれば表示できるのでは？と考えました。

画像URLに対して Fetch API でデータの取得を試してみました。

https://developer.mozilla.org/ja/docs/Web/API/fetch

試しに成功したら `ok` と出力、失敗したら `ng` と出力するために、以下を `content.js` に追加しました。

```js
  fetch(url, {
    'method': 'GET'
  })
  .then((response) => {
    console.log("ok")
  })
  .catch((error) => {
    console.log("ng")
  })
```

すると、次のエラーが出ました。

![スクリーンショット 2022-12-01 13.58.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325392/c8679d73-f056-a09b-af42-592b94939215.png)

CORSのエラーです。まあ GitHubのページ上でQiita Teamにアクセスするリクエストを送信してしているので、このエラーが出るのは当然のような気もします。

CORSを解決するためにどうすればいいか調べた結果、以下の記事によると Manifest v3では `content_scripts` ではなく `background` なら CORSを回避できるということが判明しました。

https://qiita.com/not13/items/d805d66814a0bc81dc6a

https://zenn.dev/noraworld/articles/chrome-extensions-cors

以上を踏まえ、以下の流れで実装すればGitHub上でQiita Teamの画像が表示できそうです。

1. `content_scripts` でURL取得し `background` に送信する
2. `background` で取得し、データURLを `content_scripts` に返す
3. `content_scripts` に帰ってきたデータURLを img にセット

それぞれ実装していきます。

## 1. `content_scripts` でURL取得し `background` に送信する

`background` で画像データを取得するために、`content_scripts` で取得したURLを `background` に渡す必要があります。
`chrome.runtime` の以下メソッドを使うと`content_scripts` から `background` にメッセージを送ることができます。

- `chrome.runtime.sendMessage`
    - メッセージを送信し、帰ってきた結果に対して処理を行う
- `chrome.runtime.onMessage`
    - メッセージを受信し、処理を行う

https://developer.chrome.com/docs/extensions/reference/runtime/

`content.js` に送信部分を追加します。

```js:content.js
  chrome.runtime.sendMessage(
    { url: url }
  )
```

`background.js` ファイルを作成し、マニフェストファイル (`manifest.json`) に追加します。

```js:background.js
chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
  // ここに処理を書いていく
  return true
})
```

```json:manifest.json
  "background": {
    "service_worker": "background.js"
  }
```


## 2. `background` で取得し、データURLを `content_scripts` に返す

以下のように試しに `fetch` を追記して、`fetch` が成功するか確認してみました。

```js:background.js
chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
  fetch(message.url, {
    'method': 'GET'
  })
  .then((response) => {
    console.log("ok")
  })
  .catch((error) => {
    console.log("ng")
  })

  return true
})
```

今度は問題なく `ok` が表示されました。

あとは `response` を データURLに変換するだけです。

`fetch` によって帰ってくる `response` はそれをどんな値として扱うかによって使用するメソッドが異なります。

https://developer.mozilla.org/ja/docs/Web/API/Response#%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89

今回は画像データなので、`response.blob()` で `Blob` を作成します。

https://developer.mozilla.org/ja/docs/Web/API/Blob

あとは、`Blob` からデータURLを作成するメソッド `URL.createObjectURL()` を使えばデータURLになるかと思いました。　

https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL

が、ドキュメントをよく読んでみると、以下のように書いてありました。

> メモ: この機能はメモリリークを生み出す可能性があるため、サービスワーカー内で利用することはできません。

`background.js` をマニフェストファイルに追加したときのコードを覚えていますか？以下のようについかしました。

```json:manifest.json
  "background": {
    "service_worker": "background.js"
  }
```

そうです！ `background.js` はサービスワーカーなので `URL.createObjectURL()` が使えません！！

終わりだ...と一瞬あきらめかけましたが、他の方法が見つかりました。それは `FileReader` を使った方法です。

https://developer.mozilla.org/ja/docs/Web/API/FileReader

`FileReader` には `FileReader.readAsDataURL()` というメソッドがあり、これは `Blob` を受け取ります。
そして、`FileReader.result` でデータURLを取得することができます。

最終的に、以下のように `FileReader` で `Blob` から データURLに変換し、 `sendResponse` で再び `content_script` に返しました。

```js
  let reader = new FileReader()
  reader.onload = function() {
    sendResponse(reader.result)
  }

  fetch(message.url, {
      'method': 'GET'
  })
  .then((response) => {
    if (response && response.ok) {
      return response.blob()
    }
  })
  .then((blob) => {
    reader.readAsDataURL(blob)
    return true
  })
```

## 3. `content_scripts` に帰ってきたデータURLを img にセット

`background.js`で画像データを取得し、データURLに変換できたので、あとは `content_scripts` で `img` を作成するだけです。

`sendMessage` の第2引数に `background.js` から帰ってきたデータURLを `img` タグに変換する処理を書いてあげれば完成です。

```js
  chrome.runtime.sendMessage(
    { url: url },
    (response) => {
      const e = new Image()
      e.src = response
      linkElement.appendChild(e)
    }
  )
```

試しに実行してみたところ、無事に画像を表示することができました。

![スクリーンショット 2022-12-05 18.39.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325392/1c1ddf4d-026e-46d5-841c-5e4417a325fc.png)

## 完成！

というわけで、完成した拡張機能を早速社内に公開したところ、Slackでたくさんの絵文字リアクションをもらったり、日報で「控えめに言って最高！」や「めちゃめちゃ便利」など多くの反響があり、社内で人気者になることができました。


# まとめ

- Chrome拡張機能を作るのは楽しい！
- Chrome拡張機能を作ると様々なことを学べる！！
- Chrome拡張機能を作ると人気者になれる！！！