Chromeの拡張機能を作ってみる備忘録。

# あらすじ
ニコニコ動画でコマ送りがしたい！
ということで、(おそらく)比較的簡単に作ることのできる、Chromeの拡張機能を作ってみました。
で、備忘録も兼ねて開発の大体の流れと要点をまとめてみました。

# 注意
筆者はHTML&CSS&JavaScript初心者です。正確でない箇所や他の方法を使った方が良い箇所等あると思いますが、予めご了承ください。そのような箇所があった場合はコメントしていただければ幸いです。

丁寧な説明をしているサイトのリンクを記事の最後に載せてあるので、詳しく知りたい人はそちらも参考にしてください。

# 1. フォルダ を作る
拡張機能に関するファイルは一つのフォルダにまとめてぶち込みます。
便宜上、このフォルダを「拡張機能フォルダ」と呼ぶことにします。

```:folder
NicoVideo_FlameAdvance
├─ images
│   ├─ FlameAdvance_Disabled.svg
│   ├─ FlameAdvance_Enabled.svg
│   ⁝
│   └─ icon128.png
│
├─ manifest.json
├─ content.js
⁝
└─ options.css
```

# 2. manifest.json を作る
Chromeが拡張機能を認識するためのファイルです。
拡張機能フォルダの直下に入れておきます。

```json-doc:manifest.json
{
    ...
    "name": "NicoVideo_FlameAdvance",
    "version": "1.0.0",
    "description": "ニコニコ動画で、動画の再生中に...",
    "manifest_version": 2,
    "icons": {
        "16": "images/icon16.png",
        "48": "images/icon48.png",
        "128": "images/icon128.png"
    },
    ...
}
```
　
この時点でひとまず書いておけば良さげなフィールドの説明。

+ 最低限必要なフィールド

|フィールド名|説明|
|:-:|:-:|
|`name`|拡張機能の名前|
|`version`|拡張機能のバージョン|
|`manifest_version`|マニフェストファイルの形式(現在は 2 のみ)|

+ 書くことが推奨されているフィールド

|フィールド名|説明|
|:-:|:-:|
|`description`|拡張機能についての説明|
|`icon`|拡張機能のアイコン ([8. アイコンを作る](https://qiita.com/luuguas/items/c6ae765de6e631d35109#8-%E3%82%A2%E3%82%A4%E3%82%B3%E3%83%B3-%E3%82%92%E4%BD%9C%E3%82%8B) で説明)|

この後も作った機能に合わせて適宜フィールドを追加していきます。
<br>
# 3. 拡張機能をChromeで読み込む
Chromeを開いたら、( ⁝ )ボタン → その他のツール → 拡張機能 をクリック。
![toExtensionsPage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/2b178549-5e80-db12-7ff8-4d8860d58fef.png)
デベロッパーモード をオンにして、 パッケージ化されていない拡張機能を読み込む をクリック。
フォルダ選択画面が出てくるので、作った拡張機能フォルダを選択。
![toLoadExtension.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/835860c6-2665-1e38-e106-ba632162d51e.png)
成功すると読み込んだ拡張機能が表示されます。
(読み込みに失敗した場合は、manifest.json ファイルが正しく書かれているか確認してください)
![LoadedExtension.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/7f5aa00e-6385-92a2-a484-9b197fed4fc1.png)
拡張機能の内容を変更した場合は、更新ボタンを押すことで変更を適用できます。

# 4. コンテンツスクリプト を作る
いよいよメインの機能を作っていきます。
コンテンツスクリプト(Content scripts)を使うと、好きなウェブページを弄り、自由に見た目や機能などを変更できます。

### manifest.json
コンテンツスクリプトを拡張機能に紐付けるには、`content_scripts` フィールドを追加します。

```json-doc:manifest.json
{
    ...
     "content_scripts": [
        {
            "matches": [
                "https://www.nicovideo.jp/watch/*"
            ],
            "css": [
                "content.css"
            ],
            "js": [
                "content.js"
            ]
        }
    ],
   ...
}
```

|フィールド名|説明|
|:-:|:-:|
|`matches`|コンテンツスクリプトを適用したいサイトのURL (ワイルドカード使用可)|
|`css`|適用するCSSファイル (拡張機能フォルダからの相対パス)|
|`js`|コンテンツスクリプトとして紐付けるJavaScriptファイルのパス (〃)|

JavaScriptやCSSで画像等を参照するときは、`web_accessible_resources`フィールドを追加します。これをしないと読み込みエラーが起きてパニックになるので、**厳重注意**です。
こちらもワイルドカードを使用できます。

```json-doc:manifest.json
{
    ...
    "web_accessible_resources": [
        "images/*.png",
        "images/*.svg"
    ],
    ...
}
```

### content.css
URLに何やら謎の文字列が指定されていますが、これについてはcontent.jsのところでまとめて説明します。

```css:options.css
/* 余白削除 */
.SeekToHeadButton {
    margin-right: 0px !important;
}
.PlayerSkipNextButton {
    margin-left: 0px !important;
}

/* 画像を指定 */
.ExIcon-BD {
    background-image: url('chrome-extension://__MSG_@@extension_id__/images/FlameBack_Disabled.svg');
}
.ExIcon-BE {
    background-image: url('chrome-extension://__MSG_@@extension_id__/images/FlameBack_Enabled.svg');
}
.ExIcon-BH {
    background-image: url('chrome-extension://__MSG_@@extension_id__/images/FlameBack_Hover.svg');
}

/* ... */
```

### content.js
本命のJavaScriptファイルを作ります。

```js:content.js
//拡張機能が作動していることの確認ログ
console.log('NicoVideo_FlameAdvance: Extension is running.');

var videoNodeChecker = setInterval(function () {
    if (document.getElementById('MainVideoPlayer') != null) {
        console.log('MainVideoNode was added.');
        clearInterval(videoNodeChecker);

        var playController = document.getElementsByClassName('ControllerContainer-centerArea')[0];
        var btnSeekBack = document.getElementsByClassName('PlayerSeekBackwardButton')[0];
        var btnSkipNext = document.getElementsByClassName('PlayerSkipNextButton')[0];

        //コマ戻しのボタンを追加
        var btnFBack = document.createElement('button');
        btnFBack.type = 'button';
        btnFBack.className = 'ActionButton ControllerButton PlayerFlameBackButton ExButton-B';
        btnFBack.disabled = false;

        var icoFBack_D = document.createElement('div');
        icoFBack_D.className = 'ExIcon-BD';
        var icoFBack_E = document.createElement('div');
        icoFBack_E.className = 'ExIcon-BE';
        var icoFBack_H = document.createElement('div');
        icoFBack_H.className = 'ExIcon-BH';

        btnFBack.appendChild(icoFBack_D);
        btnFBack.appendChild(icoFBack_E);
        btnFBack.appendChild(icoFBack_H);
        playController.insertBefore(btnFBack, btnSeekBack);

        // ...
    }
}, 100);
```

拡張機能で使う画像等のパスを入手したいとき、普通に相対パスだけ指定しても`https://...(拡張機能適用先のURL)/image/FlameBack.png`のようになってしまい上手くいきません。そこで、**`chrome.extension.getURL()`**を使うと、関数が `"chrome-extension://...(拡張機能のID)/image/FlameBack.png"`と変換してくれます。
あるいは、**`"chrome-extension://__MSG_@@extension_id__/image/FlameBack.png"`**のように指定してもOKです。CSSで画像指定をするときは、`getURL()`が使えないようなので、こちらの方法で指定すれば良いでしょう。

ページ上のフォーム要素のイベントを発生させるには、`dispatchEvent()`を使う方法もあるようですが、何故か上手く作動してくれなかったので、単に`click()`で済ませました。
<br>
紆余曲折ありましたが、うまくボタンを追加できました。
![AddedContents.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/062a75a5-38a0-4c54-2fec-69859724eb54.png)
<br>
# 5. オプション画面 を作る
コマ送りの秒数をユーザーが調節できるように、設定画面も作ることにしました。

### manifest.json
設定画面を拡張機能に紐付けるには、`options_ui`フィールドを追加します。

```json-doc:manifest.json
{
    ...
    "options_ui": {
        "page": "options.html",
        "open_in_tab": false
    },
    ...
}
```

|フィールド名|説明|
|:-:|:-:|
|`page`|設定画面のHTMLのパス|
|`open_in_tab`|設定画面を新しいタブで開くかどうか (true｜false)|
<br>
`open_in_tab`による画面の違いはこんな感じ。

 `"open_in_tab": true` のとき
![open_in_tab=true.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/8e2da5ce-fc08-c6e4-e56b-630b54f36589.png)
`"open_in_tab": false` のとき
![open_in_tab=false.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/3eca6de7-0dfe-947a-8147-1cac7edaeb88.png)


### options.html
普通のページを作るときと同じように作ります(説明放棄)。

### options.css
普通のページを作るときと同じように作ります(説明放棄)。

```css:options.css
body {
    font-family: Roboto, sans-serif;
    width: 450px;
    height: 350px;
}

/* ... */
```

manifest.json で`"open_in_tab": false` を設定したときは、設定画面の大きさを`body`タグの`width`・`height`プロパティで設定することができます。

### options.js
普通のページを作るときと同じように作r...

と言いたいところですが、設定画面には設定を表示して保存するというとっても大事な機能があります。
これについては [7. chrome.storage を使った設定の読み込みと保存](https://qiita.com/luuguas/items/c6ae765de6e631d35109#7-chromestorage-%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F%E8%A8%AD%E5%AE%9A%E3%81%AE%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%A8%E4%BF%9D%E5%AD%98) で説明します。

# 6. ポップアップ画面(ブラウザアクション) を作る
右上の拡張機能のアイコンをクリックしたときに表示される、ポップアップ画面を作ります。
今回は特に設定画面と区別することも無いと思うので、設定画面をそのまま流用します。

### manifest.json
`browser_action`フィールドを追加します。

```json-doc:manifest.json
{
    ...
    "browser_action": {
        "default_popup": "options.html",
        "default_icon": {
            "16": "images/icon16.png",
            "24": "images/icon24.png",
            "32": "images/icon32.png"
        }
    },
    ...
}
```

|フィールド名|説明|
|:-:|:-:|
|`default_popup`|ポップアップ画面のHTMLのパス|
|`default_icon`|右上に表示するアイコン([8. アイコンを作る](https://qiita.com/luuguas/items/c6ae765de6e631d35109#8-%E3%82%A2%E3%82%A4%E3%82%B3%E3%83%B3-%E3%82%92%E4%BD%9C%E3%82%8B) で説明)|

<br>
仕上がりはこんな感じ。
![Popup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/a9eec6cd-cc12-4933-0849-5e8d9710e627.png)

# 7. chrome.storage を使った設定の読み込みと保存
chrome.storage API では、データの保管ができます。localStorageと似ていますが、オブジェクト(連想配列)をそのまま保存出来たり、Content Scriptからバックグラウンドページを介さず直接データを保存出来たりと色々便利です。
chrome.storageにはsyncとlocalの2種類があり、syncはユーザーが同期を有効にしている場合、同期した他のChromeブラウザでも保存したデータを利用できます。localはlocalStorageと同じくローカルに保存されます。また、データは拡張機能ごとに保管されており、拡張機能を削除するとデータも一緒に削除されます。
<br>
まず、chrome.storage API を有効にするために、`manifest.json`に`permissions`フィールドを追加します。

```json-doc:manifest.json
{
    ...
    "permissions": [
        "storage"
    ],
    ...
}
```

<br>
chrome.storageを使ったデータの保存/読み込みは次のように行います。syncを使う場合は、localの部分をsyncに書き換えるだけです。

```js
/* keyには保存するデータを示すキー名を、
   valueには保存するオブジェクトや文字列を入力 */

//データを保存する
chrome.storage.local.set({key: value}, function() { 
    //保存完了後の処理...
});

//データを読み込む
chrome.storage.local.get(['key'], function(result) {
    //読み込んだデータ(result)を利用した処理...
});
```

保存したデータは、開発者ツールのApplicationで確認することが**出来ません**。
Consoleで次のコマンドを入力すると、保存したデータを一括で確認できます。

```js
chrome.storage.local.get(null, data => console.log(data));
```

<br>
そして、ここで厄介になってくるのが`get()`関数の扱い方です。
#### ➀ キー名の指定方法

```js
chrome.storage.local.get([key], function(result) {...});
chrome.storage.local.get(['key'], function(result) {...});
```

`set()`ではキー名をシングルクオーテーション('~')で囲っても囲わなくてもよいのですが、`get()`ではキー名をシングルクオーテーションで囲わない場合、keyを**変数**とみなし、**keyに格納されている文字列**をキー名とするので注意してください。なお、ブラケット([~])は複数のキー名を指定する際に使うものなので(つまり配列)、キー名が１つだけのときは付けても付けなくても良いです。

#### ➁ 読み込んだデータの有効範囲
試しに`get()`の返り値を表示してみましょう。(既にデータが保存されているものとします。)

```js
var ret = chrome.storage.local.get(['key'], () => {});
console.log(ret); //結果: undefined
```

undefined...? 

そうです、`get()`は読み込んだデータを関数の返り値として返してはくれないのです。何故なら`get()`は**非同期関数**だからです。読み込んだデータは、`get()`の第2引数であるコールバック関数に第1引数として渡されています。そこで次のような悪あがきをしてみますが、やはり失敗に終わります。

```js
var value;
chrome.storage.local.get(['key'], function(result) {
    value = result; //valueに読み込んだデータを代入しているつもり
});
console.log(value); //結果: undefined
```

非同期関数である`get()`直後の`console.log(value);`が、コールバック関数が呼び出される前に実行されてしまい、`undefined`と表示されます。

したがって、`get()`関数を使う場合は、

- コールバック関数内で、読み込んだデータを使った処理を完結させる
- コールバック関数で読み込んだデータを一旦退避し、データが格納されるのを待ってから参照する

等の方法を使うことになります。

#### ➂読み込んだデータの参照方法
コールバック関数に渡された引数(result)の内容を確認してみます。

```js
chrome.storage.local.get(['key'], function(result) {
    console.log(result); //結果: ▼{key: {...}}
                         //       ▶key: {xx: 10, yy: 20, ...}
                         //       ▶__proto__: Object

    console.log(result.xx);     //結果: undefined
    console.log(result.key.xx); //結果: 10
});
```

どうなっているかというと、resultオブジェクトの中に**keyオブジェクトが要素として入っており**、その中に個々の設定を示す要素(xx, yy, ...)が入っています。これは、`get()`の第1引数に複数のキー名を指定したとき、読み込むキーも複数になり、それらを1つのオブジェクトにまとめて入れてから渡すようにしているためです。そのため、`result.xx`のようにresultから直接要素を参照しようとするとundefinedとなってしまいます。**`result.key.xx`**のように、間にキー名を挟んで参照するようにしてください。
<br>
`get()`の注意点はこのくらいでしょうか。扱い方が大体わかったところで、設定画面とコンテンツスクリプトにchrome.storageを組み込みましょう。今回はコールバック関数内で処理を完結させる方法にしました。

```js:options.js
    // ...

    //設定の読み込み
    chrome.storage.local.get('NVFA_Setting', function (res) {
        res = res.NVFA_Setting;
        if (res === undefined) {
            //データがない場合はデフォルトの設定を読み込む
            changeStorageData(50, 50, true, true);
            SetDisplaySetting(50, 50, true, true);
        }
        else {
            changeStorageData(res.back_span, res.advance_span, res.click_video, res.show_timelog);
            SetDisplaySetting(res.back_span, res.advance_span, res.click_video, res.show_timelog);
        }
    });

    //保存ボタンの機能設定
    var btnSave = document.querySelector('.save button');
    btnSave.addEventListener('click', function () {
        //画面情報を読み取り
        var [back_span, advance_span, click_video, show_timelog] = GetDisplaySetting();
        //設定をオブジェクトにまとめる
        var setting = {
            back_span: back_span,
            advance_span: advance_span,
            click_video: click_video,
            show_timelog: show_timelog
        };
        //設定を保存
        chrome.storage.local.set({ NVFA_Setting: setting }, () => { });
        changeStorageData(back_span, advance_span, click_video, show_timelog);
        ToggleSavable();
        var dispSaved = document.getElementsByClassName('saved')[0];
        dispSaved.hidden = false;
    });

// ...
```

```js:content.js
        // ...

        //コマ送りボタンの機能設定
        btnFAdvance.addEventListener('click', function () {
            chrome.storage.local.get('NVFA_Setting', function (res) {
                res = res.NVFA_Setting;
                var videoPlayer = document.getElementById('MainVideoPlayer').firstChild;
                var advanceSecond = 1e-3 * res.advance_span;
                videoPlayer.currentTime = videoPlayer.currentTime + advanceSecond;
                ShowVideoTime(videoPlayer, 2, 'VideoTime: ');
            });
        });

        // ...
```

# 8. アイコン を作る
サイズが16x16, 24x24, 36x36, 48x48, 128x128サイズのアイコンをそれぞれ作ります。もちろん作らないサイズがあったり、他のサイズのアイコンを作ったりしても構いません。
公式ドキュメントを見ると、拡張機能のアイコンには16x16, 48x48, 128x128サイズのものを、ポップアップ画面のアイコンには16x16, 24x24, 36x36サイズのものを使えば良いようです。
完成したら、前述の通りmanifest.jsonにフィールドを追加して、アイコンを拡張機能に適用させます。

アイコンはいつ作ってもよいのですが、筆者は一通り機能を作ってから、機能に合ったアイコンを作ることにしました。
![Icons.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/a88a4443-da61-de50-e925-d5fb4eb3c40a.png)

#9. 完成
できました！
![AddedStorage.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/729491/d4844de0-700b-f275-737c-27488b452213.png)
画像の表示やストレージの操作等で色々と手こずり、かなり時間がかかってしまいましたが、ようやく完成できました。既存のサイトに自分の欲しい機能を自由に加えてより便利に使うことができるので、拡張機能の開発はとても楽しいと思います。皆さんも是非やってみてください。

<br>
# 参考記事・サイト
### 総合
Chrome拡張の開発方法まとめ　その1：概念編
https://qiita.com/k7a/items/26d7a22233ecdf48fed8

### manifest.json について
Manifest file format(公式ドキュメント)
https://developer.chrome.com/docs/extensions/mv2/manifest/
Chrome 拡張機能のマニフェストファイルの書き方
https://qiita.com/mdstoy/items/9866544e37987337dc79

### chrome.storage について
chrome.storage(公式ドキュメント)
https://developer.chrome.com/docs/extensions/reference/storage/
Chromeエクステンションを作ろう：ストレージ編
https://qiita.com/dhun/items/cf18e43cb0376fcff302
Chrome拡張機能のchrome.storageに保存したデータの取得、削除方法メモ
https://qiita.com/mnao305/items/5d0a8de99fb8ae9fe25e

### メッセージパッシングについて(補足)
Chromeブラウザの拡張機能を作ってみたい初心者向けに開発方法を紹介！【サンプルあり】
https://qiita.com/guru_taka/items/37a90766f4f845e963e5
Chrome Extension の作り方 (最終話: メッセージパッシング)
https://qiita.com/sakaimo/items/5e41d6b2ad8d7ee04b12