超最低限の Chrome エクステンションを開発しウェブストアで公開するまでの手順

つい最近、初めて作った Google Chrome エクステンション「[Feedly をはてブ対応させる Chrome エクステンション](http://www.otchy.net/20161231/feedly-hatebu-chrome-extension-edition/)」をブログで紹介したので、その時の技術的なメモなどを Qiita に残しておきたいと思います。まあ検索すればすぐに集まる程度の情報ではあるものの、一箇所にまとまってなかったので、自分用の備忘録でもあります。

全てのソースコードは [github](https://github.com/otchy210/feedly-hatebu) 上で公開しているので、気になる方は参考にしてみて下さい。実際に書いたスクリプトの簡単な解説もこの記事の最後に付録として付けています、

# 超最低限な Chrome エクステンションの作成

Chrome エクステンションは基本的に、一つのフォルダに保存された複数のファイルからなります。エクステンションのルートに必要な最初のファイルは、manifest.json というファイルで、これがエクステンションの基本的な情報を含んでいます。
今回公開したエクステンションの場合下記のような具合です。

```json:manifest.json
{
  "name": "Feedly はてブ",
  "version": "0.9.0",
  "manifest_version": 2,
  "description": "はてなブックマークの情報を Feedly 上に表示します。",
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "content_scripts": [
    {
      "matches": ["https://feedly.com/*"],
      "css": ["css/style.css"],
      "js": ["js/script.js"]
    }
  ]
}
```

概ねこれが、最低限必要な情報ではないかと思います。入門用の記事ではよく、Browser actions とか Page actions とかいうエクステンションの種類や、Background-Page という常にバックグラウンドで動作しているページの話、あるいはユーザから取得しないといけない権限の指定方法などが出てきますが、最低限必要な動作にそれらは**必要ありません**。

この程度の内容であれば感覚で意味が分かるのではないかと思いますが、それぞれ下記の内容を表しています。

- name: エクステンションの名前
- version: エクステンションのバージョン
- manifest_version: マニフェストのバージョン、現在は 2。
- description: エクステンションの説明。後に、ウェブストアに登録した時に表示されます。
- icons: エクステンションを表示する際に使われるアイコン画像
- content_script:
    - matches: いつ、下記のファイルが発動するか
    - css: matches に合致したサイトをロードした時に読み込まれる css
    - js: matches に合致したサイトをロードした時に読み込まれる js

これだけの設定で、指定のサイトをロードした時に、指定の css と js を読み込むエクステンションを定義していることになります。後は、icon 用の画像ファイル、css、js を用意してそれぞれのフォルダに置けばとりあえずエクステンションの完成です。さしあたってこれだけで、大概のことは出来ると思いませんか？

# Chrome エクステンションのテスト

[chrome://extensions](chrome://extensions) を開いて、デベロッパーモードを有効にしたら、「パッケージ化されてない拡張機能を読み込む...」で先ほどマニフェストファイルなどを置いたフォルダを指定します。

![extensions-1.png](https://qiita-image-store.s3.amazonaws.com/0/24898/d0a8b243-5c5e-95a1-bd13-3da8e43e23d0.png)

![extensions-2.png](https://qiita-image-store.s3.amazonaws.com/0/24898/edc0729f-c378-d7ff-511f-cd77aafcc841.png)

すると、通常のエクステンションと同様に、そこで定義したエクステンションがローカルディスクから読み込まれます。

![extensions-3.png](https://qiita-image-store.s3.amazonaws.com/0/24898/843417da-183e-c6f8-c1c6-e835e84322b4.png)

あとはこの状態で、スクリプトのテストをしつつ開発を進めていくことになります。注意点は、js や css を変更しても即座には反映されない点です。よく読むと「リロード (⌘R)」と書かれている事が示唆しているように、ローカルファイルの変更を反映させるためには、この画面をリロードする必要があります。

# Chrome エクステンションのウェブストアへの登録

さて Chrome エクステンションが完成したら、Chrome ウェブストアへ登録することによって初めて、誰でもそのエクステンションを使えるようになります。Chrome のあるバージョンから、野良エクステンションや野良スクリプトのインストールが激しく警告 (あるいは制限) されるようになったので、真っ当に使えるエクステンションを公開しようと思ったら、ウェブストアへの登録は必須です。

まずは [Chrome ウェブストア](https://chrome.google.com/webstore/)からデベロッパーダッシュボードを開きます。

![webstore-1.png](https://qiita-image-store.s3.amazonaws.com/0/24898/f87f788a-becc-486a-26d3-89696c9ed3d1.png)
![webstore-2.png](https://qiita-image-store.s3.amazonaws.com/0/24898/2acff727-bf62-36d6-1dff-77c4e694e634.png)

ここで「新しいアイテムを追加する」ことで、エクステンションを公開出来るのですが、初めてエクステンションを登録する前に一度だけ $5 を払ってデベロッパー登録する必要があります。何でも無料でやらないと気が済まない人はここで諦めて下さい。最低限の質、スパムで無い事を担保するために、このデベロッパー登録は必須になっています。Google 様の力をもってしても、アルゴリズムだけでスパムを排除することは出来ないのです。

スクリーンショットは取り忘れてしまったのですが、画面下部で $5 が必要な事、一度限りである事、Google ウォレットへのクレジットカード登録が必要なことが説明されていたはずです。

さて、いったんデベロッパー登録が終われば、あとは自作のエクステンションを登録する段階になります。

![webstore-3.png](https://qiita-image-store.s3.amazonaws.com/0/24898/d359ff05-6828-d23b-567e-55764f512b11.png)

実際、Chrome のエクステンションの画面には「拡張機能のパッケージ化...」という項目があるのですが、ここで必要なのはその結果作成される crx ファイルではなく、シンプルにフォルダを zip で圧縮したファイルです。
zip ファイルをアップロードした後は、ウェブストアでエクステンションを紹介する時に必要な情報を画面の指示に従って入力していくことになります。

![webstore-4.png](https://qiita-image-store.s3.amazonaws.com/0/24898/b0de7bd7-a31c-f45b-c60a-7f2e205c4349.png)

最初のセクションはマニフェストファイルから読み取った内容ですね。そしてウェブストアでもまたアイコン画像の登録を促されるのですが、96 x 96 ピクセルの画像周りに透過 16　ピクセルを追加した 128 x 128 ピクセルの画像って、Google 様の力をもってしてもマニフェストから自動生成出来なかったんですかね…？せめて 96 x 96 ピクセルの画像をアップロードさせるわけには…？

まあそんなこんなで、つらつらと必要情報 (大半はオプション) を入力してエクステンションを公開すれば、晴れて完了になります。お疲れ様でした！

# (付録) いいぞ ES2016

もともと書いていた[ユーザースクリプト](http://www.otchy.net/20130606/feedly-hatebu/)が動かなくなって、エクステンションが必須だな、となった後もしばらく重い腰を上げられなかったのは、やはり上記で説明したような諸々が面倒そうだなー、と思っていたからでした。その重い腰を上げるきっかけになった一つは、そう、Chrome だけがターゲットという事は、「生の ES2016 で書いて問題無い」という事に気づいたからです。何と素敵な事でしょう。

というわけで、ES2016 で導入された文法含め、書いたスクリプトの簡単な解説を最後に付録として載せておこうと思います。[github](https://github.com/otchy210/feedly-hatebu) で公開しているソースコードにコメントで解説を追加したものになります。

```js
// const キーワードで後から変更しない function を定義しています。ラムダ式です。
// DOM ノードの classList がいわゆる配列 "風" オブジェクトで includes メソッドを持っていなかったので作りました。
const includes = (list, arg) => {
    return list && Array.prototype.includes.call(list, arg);
};
// DOM ノードの "data-" 属性を読み書きするための function です。
const setData = (node, name, value) => {
    node.setAttribute('data-' + name, value);
};
const getData = (node, name) => {
    return node.getAttribute ? node.getAttribute('data-' + name) : '';
};
// 新しい DOM ノードを簡易的に作る Utility function です。
// ES2016 のデフォルト引数や Object.entries あたりのおかげで、かなりシンプルに書けているのではないかと思います。
const newNode = (name, attrs = {}, children = []) => {
    const dom = document.createElement(name);
    Object.entries(attrs).forEach(([k, v]) => {
        dom.setAttribute(k, v);
    });
    children.forEach((c) => {
        dom.appendChild(c);
    })
    return dom;
};
// はてブ数の表示とそのブックマークページへのリンクを DOM ノードとして生成します。
// 前述の newNode function が活躍しています。
const generateHatebu = (url, className) => {
    const pos = url.indexOf('#');
    if (pos >= 0) {
        url = url.substr(0, pos) + '%23' + url.substr(pos+1);
    }
    return newNode(
        'a', {'href': 'http://b.hatena.ne.jp/entry/' + url, 'target': '_blank', 'class': className}, [newNode(
            'img', {'src': '//b.st-hatena.com/entry/image/' + url}
        )]
    );
};
// document に対する DOM の追加が行われたイベントを監視して、必要な時だけはてブ数の表示を差し込んでいます。
// 再レンダリングで DOM も再生成されているので、恐らく不要ではあるのですが、念のため data-fh-done 属性で、同じ要素に対して複数回処理を行わない制御をしています。
// 全くの余談ですが、Feedly が React 化されていて、どの位置にどう挿入したらいい感じに動くかは試行錯誤が必要でした。
document.addEventListener('DOMNodeInserted', (e) => {
    const node = e.target;
    if (getData(node, 'fh-done')) {
        return;
    }
    if (includes(node.classList, 'content')) {
        const link = node.querySelector('a');
        const hatebu = generateHatebu(link.getAttribute('href'), 'fh-list');
        node.insertBefore(hatebu, link);
        setData(node, 'fh-done', true);
    } else if (includes(node.classList, 'headerInfo')) {
        const header = node.parentNode.parentNode;
        const link = header.querySelector('a');
        const hatebu = generateHatebu(link.getAttribute('href'), 'fh-header');
        const left = node.querySelector('.left');
        left.appendChild(hatebu);
        setData(node, 'fh-done', true);
    }
});
```