仮想通貨の値段変化でサンバ音楽を流す拡張機能をリリースするまで

#なぜ作ることにしたのか
ビットコインの価格が上昇すればサンバを流し、下落すれば悲壮感漂う音楽を流したかった。
また、価格変化を合成音声に「プラス２００円なり」とか読み上げさせて、価格変化を楽しみたい。
そんな気まぐれからChrome拡張機能を実装することにしました。

作成過程から審査までまとめました。興味があればご覧ください。
ソースコードのみ見たい方はGitHubのリポジトリをご覧ください。
https://github.com/kantasv/bitcoin-samba-ticker/tree/master

#使用技術
JavaScript, HTML, CSS
bitFlyer lightning API:ビットコインの価格取得するため
SpeechSynthesis API:合成音声で価格変化で例えば「＋２００円なり」などと再生するため

以下がまとめた図です。
![スクリーンショット 2020-12-03 22.11.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614389/5ed45ddd-7677-bfcd-85a6-9910d720fe0a.png)



#完成作品 Bitcoin Samba 価格監視ツール
以下が拡張機能アイコンクリック時に現れる設定画面のスクリーンショットです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614389/0941c05f-8f28-9be4-2b63-d8cdf7dfa684.png" width="500px">

審査用に提出したデモ動画（英語）
https://youtu.be/zMeAqRwjJWQ
>ビットコインの価格上昇でサンバ、下落で悲しい音楽を流し、価格変化を音声で読み上げます。5秒ごとに価格更新。ON/OFF切り替え可能。

>ビットコインの数字ばかりのチャートを追うのが退屈と感じたことはありませんか？ビットコインの価格上昇でサンバ、下落で悲しい音楽を流し、価格変化を音声で読み上げます。5秒ごとに価格は更新され、ON/OFF切り替え可能です。 ... 略 ...

https://chrome.google.com/webstore/detail/bitcoin-samba-%E4%BE%A1%E6%A0%BC%E7%9B%A3%E8%A6%96%E3%83%84%E3%83%BC%E3%83%AB/ljjeeiafelmogjhklanhkbkodcmgdchn?hl=ja&authuser=0


#作成過程

## 1.ロイヤリティフリー音源の探索
以下のサイトでChrome拡張機能で流す音源を探しました。
・サンバ風の音楽
https://dova-s.jp/bgm/play11477.html
・悲壮感漂う音楽
https://dova-s.jp/bgm/play11949.html
なお、Chrome拡張機能上にもクレジットの方を記述しました。
（作者様、素晴らしい音源をありがとうございます！）
## 2.manifest.jsonの設定
Chrome拡張機能ではアプリの情報や、権限などをmanifest.jsonに記述します。
特に名前や権限は審査の際に非常に重要です。

## 権限設定

**不必要な権限は審査を長引かせたり、リジェクトされる要因となるので、最小限の権限を与えました。**
今回の場合、bitFlyer Lightning APIを利用するために、api.bitflyer.jpの以下のAPIのアドレスの権限を与えました。
ただ、このように1箇所だけ指定しても、拡張機能のインストール時には「api.bitflyer.jp/*へのアクセス権限」と表示が出ます。

```json:manifest.json(一部抜き出し)
    "permissions": [
        "https://api.bitflyer.jp/v1/ticker?product_code=BTC_JPY"
    ],
```
なお、こうしたxmlHttpRequestなどを利用するための権限を与えた理由は、後のストア審査に明確な根拠を述べる必要があります。後のストア審査の「権限が必要な理由」のセクションでは、英語で以下のような説明をしました。

>This extension fetches BTC last traded price via bitFlyer lightning API  That is why the permisson for "https://api.bitflyer.jp/v1/ticker?product_code=BTC_JPY" is necessarry for the extension. For the API documentation, see https://lightning.bitflyer.com/docs?lang=en . The english version of document has .com domain instead of .jp domain, but it is up to developers which to use. In my case, I used .jp domain for the API url.

なお、審査フォームは英語である必要はありませんが、英語である方が審査がスムーズに進むかもしれないと思ったためこうしました。

##3.実際の拡張機能本体の作成
全てを説明することはありませんが、重要だと思った部分について述べていきます。

**background.js**
　バックグラウンドで動くスクリプト
　サーバーとの通信を行い、価格を定期的に比較し、特定の条件の時に音楽を再生する
　popup.jsと通信する。

**popup.html/popup.js**
　拡張機能をクリックした時に開くウィンドウのインターフェースの設定と、background.jsとの通信
　価格の表示と、音声通知の切り替えをするボタンを配置。
　popup.jsと通信する
　
## background.js
background.jsはbackground scriptsと呼ばれるスクリプトの一種で、バックグラウンドで動きます。
Chrome拡張機能では、background scriptsの利用は、メモリの常時消費を避けるために望ましくはありません。
ただ、今回の価格監視ツールの場合、常にバックグラウンドで処理をしなければいけないので、利用しました。

```json:manifest.json(一部抜き出し)
    "background": {
        "scripts": [
            "background.js"
        ],
        "persistent": true
    },
```
上のようにbackground.jsをbackgound->scriptsに記述することで、background.jsがbackground scriptであると認識されます。


## バックグラウンド動作を確実に保証するために

そして、上の"persistent": trueは非常に重要です。これをtrueにすることで、確実にバックグラウンドで処理が行われることが保証されます。もしfalseにすると、ちょくちょくバックグラウンドの動作が停止させられます。どのタイミングで停止するのかは定かではありませんが、メモリの使用状況や稼働時間などに応じて自動で停止させられるのでしょう。この予期せぬ停止を防ぐ意味でも、"persistent": trueをしっかり記述しましょう。

```json:manifest.json(一部抜き出し)
"persistent": true
```
## speechSynthesis APIによる音声の合成をするには
さて、speechSynthesis APIを使えば、以下にして簡潔に文字から音声を合成できます。
ブラウザにより対応状況は異なるものの、今回はChrome拡張機能の作成なので確実に動作が期待できます。
ブラウザだけで音声合成が可能であることをご存知ない方も多いのではないでしょうか？

```js:background.js(一部抜き出し)
// Reads BTC price changes based on its arguments.
const readPrice = (changeType, absPriceDiff) => {
    let synthes = new SpeechSynthesisUtterance(`${changeType}${absPriceDiff}円なり`)
    synthes.volume = 4
    synthes.onend = () => { if (changeType == 'プラス') { sound_samba.play() } else { sound_down.play() } }
    speechSynthesis.speak(
        synthes
    );
}
```
##拡張機能アイコンのテキストや色を変えるには
また、Chrome拡張機能アイコンのテキストや色の変更は以下にして行いました。

```js:background.js(一部抜き出し)
// Sets icon bage to 'OFF' by default
chrome.browserAction.setBadgeText({ text: 'OFF' })
chrome.browserAction.setBadgeBackgroundColor({ color: 'black' });
```
こうした変更により、以下の通りに自在にアイコンとテキストを変更できます。
![スクリーンショット 2020-12-05 10.58.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614389/53d6dcc6-57d0-22f9-bb66-00e91616fd23.png)


## スクリプト間のデータのやりとりを実現するには
また、価格変化をbackground.jsからpopup.jsに送信するには以下のようにします。

```js:background.js(一部抜き出し)
            // Sends BTC price to popup.html.
            chrome.runtime.sendMessage({
                msg: "ltp-updated",
                data: {
                    ltp: data.ltp,
                    priceChange: currentPrice - previousPrice
                }
            })
```

一方で、popup.js側での受け取り体勢は以下にして整えます。

```js:popup.js(一部抜き出し)
// Listens the latest ltp from background.js
chrome.runtime.onMessage.addListener(
    function (request) {
        if (request.msg === "ltp-updated") {
            updateLTPOnPopup(request.data.ltp)
        }
    }
);

```

popup.jsからbackground.jsへの通信も同様にして行えます。
この操作は拡張機能の開発の上で非常に重要です。なぜなら、Chrome拡張機能はscriptの種類ごとに権限が異なったりするため、スクリプトを機能ごとに分割する必要があるからです。

## 外部のAPIを拡張機能から利用するには
また、外部へのxmlHttpRequestは以下にして行えます。
普通のWEB開発と同様に行えるのは非常に便利です。
ただ、CORSポリシーや、manifest.jsonへの権限設定などに注意しましょう。
それらを適切に確認しないと、思うような動作をしない可能性があります。

```js:background.js(一部抜き出し)
// Fetches the latest ltp from bitFlyer Lightning API.

let request = new XMLHttpRequest();
request.open('GET', 'https://api.bitflyer.jp/v1/ticker?product_code=BTC_JPY', true);
request.responseType = 'json';
request.onload = function () {
    let data = this.response;
    updateLTPOnPopup(data.ltp)
}
request.send();

```

## background.jsソースコード
以下にbackground.jsのソースを示します。飛ばして次のセクションに行ってもらっても結構です。


```js:background.js

'use strict';
let currentPrice = null
let previousPrice = null
// Stores an absolute value of the price changes (previous -> current)
let absPriceDiff = 0

// Loads audio files
const sound_samba = new Audio('./audio/samba.mp3');
/*
Credit
  Author: Takashi Kobayashi/小林 卓史/コバヤシ タカシ）
  Author nickname：こばっと／Kobat
  Sound profile: https://dova-s.jp/bgm/play11477.html
  Autor's profile: https://www.kobat-music.com/
*/
const sound_down = new Audio('./audio/non-samba.mp3');

/*
Credit
  Author: Notzan ACT
  Sound profile: https://dova-s.jp/bgm/play11949.html
  Autor's profile: https://dova-s.jp/_contents/author/profile362.html
*/

// Reads BTC price changes based on its arguments.
const readPrice = (changeType, absPriceDiff) => {
    let synthes = new SpeechSynthesisUtterance(`${changeType}${absPriceDiff}円なり`)
    synthes.volume = 4
    synthes.onend = () => { if (changeType == 'プラス') { sound_samba.play() } else { sound_down.play() } }
    speechSynthesis.speak(
        synthes
    );
}

// Sets sound notification OFF by default
let isSoundNotificationOn = false

// Sets icon bage to 'OFF' by default
chrome.browserAction.setBadgeText({ text: 'OFF' })
chrome.browserAction.setBadgeBackgroundColor({ color: 'black' });

// Starts sound notification interval and returns inteval id
const startSoundNotification = () => {

    // Sets icon badge to 'ON' 
    chrome.browserAction.setBadgeText({ text: 'ON' })
    chrome.browserAction.setBadgeBackgroundColor({ color: '#FF9800' });


    // Starts listening to BTC price changes on BitFlyer
    const DEFAULT_INTERVAL_SEC = 5
    let listenerIntervalSec = DEFAULT_INTERVAL_SEC
    let BTCPriceListener = setInterval(() => {
        let request = new XMLHttpRequest();
        request.open('GET', 'https://api.bitflyer.jp/v1/ticker?product_code=BTC_JPY', true);
        request.responseType = 'json';

        request.onload = function () {
            let data = this.response;
            console.log(data.ltp);
            // Sends BTC price to popup.html.
            chrome.runtime.sendMessage({
                msg: "ltp-updated",
                data: {
                    ltp: data.ltp,
                    priceChange: currentPrice - previousPrice
                }
            });
            currentPrice = data.ltp
            if (previousPrice) {
                let absPriceDiff = currentPrice - previousPrice
                if (currentPrice > previousPrice) {
                    // Updates badge text and background color.
                    chrome.browserAction.setBadgeText({ text: '+' })
                    chrome.browserAction.setBadgeBackgroundColor({ color: 'green' });

                    readPrice('プラス', absPriceDiff)
                    console.log('UP')
                    sound_down.pause()

                    previousPrice = data.ltp
                } else if (currentPrice < previousPrice) {
                    // Updates badge text and background color.
                    chrome.browserAction.setBadgeText({ text: '-' })
                    chrome.browserAction.setBadgeBackgroundColor({ color: 'red' });

                    readPrice('マイナス', absPriceDiff)
                    console.log('DOWN')
                    sound_samba.pause();

                    previousPrice = data.ltp
                } else {
                    console.log('NO CHANGE')
                }
            } else {
                previousPrice = data.ltp
            }
        };

        request.send();
    }, listenerIntervalSec * 1000)

    return BTCPriceListener
}

// Stores interval id to make it possible to stop it later.
let intervalID = ''

// Listens to sound-notification-toggled.
chrome.runtime.onMessage.addListener(
    function (request) {
        if (request.msg === "sound-notification-toggled") {
            if (!isSoundNotificationOn) {
                intervalID = startSoundNotification()
                alert('音声通知をONにしました')
                isSoundNotificationOn = true
            } else if (isSoundNotificationOn) {
                // Stops interval.
                alert('音声通知をOFFにしました')
                clearInterval(intervalID)
                isSoundNotificationOn = false

                // Forces playng sound to stop.
                sound_samba.pause()
                sound_down.pause()

                // Sets icon bage to 'OFF'
                chrome.browserAction.setBadgeText({ text: 'OFF' })
                chrome.browserAction.setBadgeBackgroundColor({ color: 'black' });
            }
        }
    }
);
```

## popup.js
background.jsとかぶる部分はここでは説明しませんが、その他の重要な点のみ書きます。

## popup.htmlでは直接scriptタグ内にスクリプトを書けない
セキュリティー上の理由でpopup.html内では直接scriptタグを埋め込めない使用なので
script srcでpopup.jsなど、別のスクリプトを読むこむ形式にする必要があります。

## popup.htmlへのDOMの変更が反映されず、エラーも出ない場合には
また、popup.jsではpopup.jsのDOMを操作します。
この時、DOM変更が反映されないことがありますが、その時は以下を試してみてください
すなわち、window.onloadでDOM操作に関するコードをラップしてみてください。

```js:popup.js(一部抜きだし)
window.onload = () => {
    document.getElementById('toggleSoundNotificationButton').onclick = () => {
        toggleSoundNotification()
    }
}

```
なぜかエラーが出なかったので、解決策に気づくまで時間がかかりました。
皆さんは同じ過ちをたどらないようにしてください。

以下にpopup.jsのソースを示します。コメントを書いているので大部分の説明は省略します。

```js:popup.js

// Fetches the latest ltp from bitFlyer Lightning API.

let request = new XMLHttpRequest();
request.open('GET', 'https://api.bitflyer.jp/v1/ticker?product_code=BTC_JPY', true);
request.responseType = 'json';
request.onload = function () {
    let data = this.response;
    updateLTPOnPopup(data.ltp)
}
request.send();



// Listens the latest ltp from background.js
chrome.runtime.onMessage.addListener(
    function (request) {
        if (request.msg === "ltp-updated") {
            updateLTPOnPopup(request.data.ltp)
        }
    }
);

// Updates ltp on DOM.
const updateLTPOnPopup = (ltp) => {
    document.getElementById('ltp-text').innerText = ltp
}

// Sends message to background.js to toggle sound notificaton.
const toggleSoundNotification = () => {
    chrome.runtime.sendMessage({
        msg: "sound-notification-toggled",
        data: {
        }
    });
}

window.onload = () => {
    document.getElementById('toggleSoundNotificationButton').onclick = () => {
        toggleSoundNotification()
    }

}


```


## 審査に向けて
拡張機能が完成した後は、Chrome Web Store Developer Dashboardで審査の準備をしました。
https://chrome.google.com/webstore/developer/dashboard

拡張機能の入っているフォルダをzip圧縮し、「＋新しいアイテム」をクリックしたら表示されるモーダルにドラッグ&ドロップしてください。
その後、拡張機能に関する各種情報をフォームに入力していきます。

##プライバシー セクション

入力欄はたくさんありますが、重要だと思われる部分について補足しておきます。

###「単一用途」

ここでは**単一の**用途を簡潔に説明する必要があります。フォーム横には以下のような注意があります。
>単一用途 拡張機能の用途は、単一で範囲の限られたわかりやすいものである必要があります。
単一でない場合は、審査に悪影響を与える必要があるため、なるべく機能の方向性に一貫性を持つようにしましょう。

今回の私の拡張機能の審査では、以下のように英語で明確に単一用途を説明しました。審査の体勢が不明なため、日本語が分からない審査官でもフェアな審査が行われて欲しかったので英語で書いておきました。音声と絡めて価格変化を通知するというタイトルとも、概要欄とも整合性の取れている内容を心がけました。

>This extension watches and notifies BTC last traded price changes via bitFlyer lightning API by playing samba music when the price goes up, and by playing sad music when it goes down. Also, these changes can be read by text-to-speech API. For example, when BTC price has increased by 100 YEN/BTC, it says "プラス100円なり。" which means the BTC price increased by that amount of number in English.

おそらく、複数の機能が不必要に詰め込まれていたり、「単一用途」に記載のない機能を追加していた場合に審査に悪影響が出るのではないかと思います。自分が正当な拡張機能を作っているとしても、明確な主張をしなければ取り下げられても文句は言えないので、プライバシーのセクションの記述は慎重に行いましょう。また、Googleという会社がアメリカの企業なので、日本でよくある「言わなくてもわかるだろう」という安易な考えは、審査を遅らせる原因となりうると個人的には思います。

### 「権限が必要な理由」
manifest.jsonのpermissionsに何らかの権限を与えている場合、以下のような表示がフォーム入力欄の上に出る可能性があります。
>お客様の拡張機能は、ホスト権限が要求されているため、詳しい審査が必要となり、公開が遅れる可能性があります。

これに対して、フォームの横には以下の説明があります。

>権限は、「activeTab」などの所定の文字列のリスト、または 1 つ以上のホストにアクセスを許可するマッチパターンのいずれかです。
拡張機能の単一用途に不要な権限はすべて削除してください。不要な権限をリクエストした場合、このバージョンは不承認となります。

つまり、単一用途で示した機能の実現に対して権限を最小限にとどめ、不要な権限は削除することが審査において重要だということでしょう。また、与えた権限については明確な根拠とともに説明することが求められます。

今回の場合は、bitFlyer Lightning APIから価格取得をする必要があるという旨を以下のように伝えました。

>This extension fetches BTC last traded price via bitFlyer lightning API  That is why the permisson for "https://api.bitflyer.jp/v1/ticker?product_code=BTC_JPY" is necessarry for the extension. For the API documentation, see https://lightning.bitflyer.com/docs?lang=en . The english version of document has .com domain instead of .jp domain, but it is up to developers which to use. In my case, I used .jp domain for the API url.


##プロモーション動画などの準備
審査を円滑にするために、英語と日本語でプロモーション動画を作成しました。
https://youtu.be/zMeAqRwjJWQ
また、スクリーンショットには矢印などで説明を書き込んでも大丈夫なようです。私の場合はOKでした。

##審査の送信から通過まで

11/28土 夕方
審査フォームを終えた後、送信しました。

11/29日 夜
Developer Dashboradを見ると「公開済み」となっていました。
ちなみに、公開の際にメールや通知などは一切受け取りませんでした。

日本時間の土曜日に送信して、日曜に公開されて驚きましたが、おそらく日本時間の日曜日のときに、アメリカは土曜で、審査を行っていたのではないかと思われます。それにしても審査が早く、かつ何のリジェクトもなくて驚きました。しっかりフォームで論理的に説明すれば、基本的に審査で困ることはないと思われます。後、ソースコードのコメントを全て英語で書いたことが、ソースの審査を円滑に進められる要因になったのではないかと考えています。

初めてストアを通過した喜びは、とても言葉には表せません。
自分の思いつきで作ったプログラムを全世界で配信できることには夢があります。
審査通過時の私のツイッターからも喜びの様子がお分かりいただけると思います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/614389/b268db1c-0478-4996-1162-d1ce21399c4f.png" width="400px">



#得られたもの
・Chrome拡張機能から外部APIへの通信方法を学べた
・ストアリリースの審査過程を経験できた
・自分のプログラムを全世界に配信できた

#今後の展望
Chrome拡張機能は便利ですが、マネタイズの面では難ありです。
今まで個人開発で４つほど非公開のChrome拡張機能を作りましたが、単価は一つ２−３万円ほどが目安でした。
今回初めてストア申請をし、公開までできたので、今後も趣味で作り続けたいと思います。

#Chrome拡張機能開発は簡単
JavaScriptがかければ、大抵の操作は実現できます。
慣れ親しんだWEB周りの技術を使って、あなたもオリジナルのChrome拡張機能を作ってみませんか？

#開発に役立ったサイト（英語）
**Chrome拡張機能公式ドキュメント(English)**
https://developer.chrome.com/extensions/devguide
どの二次情報よりも一番信頼でき、かつ汎用性が高く、頻繁に参照しました。

この公式ドキュメントで歯が立たない時は
**StackOverfrow(English)**
http://stackoverflow.com/
で類似の事例を探してみてください。
**日本語でChrome拡張機能の情報は極めて限られている**ので、躊躇わず英語で情報収集していきましょう。


#参考　本拡張機能のソースコード(GitHub)
ソースコードの書き方に問題などありましたら、プルリクエストどしどしお待ちしています。
https://github.com/kantasv/bitcoin-samba-ticker/tree/master