TypeScript + Reactでクリスマスを消すChrome拡張を作る

#見たくない記事ありますか？

インターネットでの情報収集は日課の一つである今日このごろ……
皆さんは見たくない記事を目にしてしまったこと、ありませんか？

とある友人はこう言いました。
「クリスマス？ そんな暇あるか」と、

彼はきっと「クリスマス」という文字すら見たくないはず。

ならばせめて、ブラウザ上だけでも「クリスマス」を消してしまおうではないか、と考えました。
そう、彼へのささやかなクリスマスプレゼントとして……

# Chrome拡張
茶番はほどほどにして、真面目な話をします。

Chrome拡張（Chrome extension）とは、Chromeのプラグインのことです。
Chromeに様々な機能を追加することができ、Chrome ウェブストアから簡単にインストールすることができます。いくつかお世話になっている人もいるでしょう。

Chrome拡張ではWebページ取得後にDOM操作が可能なので、Webページから「クリスマス」のテキストを消すこともできます。
この記事では環境構築の簡単な説明と如何にして「クリスマス」を消したかについて、話したいと思います。

# 開発環境

| 項目 | 値 |
|:-----------|:------------|
| 言語 | TypeScript |
| ライブラリ | React, Material-UI |

Chrome拡張はHTML、CSS、JavaScriptで書くことができますが、型チェックと仮想DOMに惹かれて上記のような構成にしました。
TypeScriptはJavaScriptの上位互換で、強力な型チェックが強みです。
Reactの仮想DOMで描画の高速化を図ろうかと思ったのですが、今回はあまり有効活用できませんでした。

# 実装機能
- 任意のNGワードを設定
- WebページにNGワードがヒットした場合、**その要素を消す**

それでは解説を始めます。

# 環境構築
Chrome拡張の開発はマイノリティ？なのか情報が少なく苦戦しました。
Chrome拡張ならではの制約が意外と多かったです。
とはいえ、全部を説明すると長くなってしまうので、つまづいたところを中心に説明します。
Chrome拡張開発の基本的はことは下記のリンクにうまくまとめられていました。
>Chrome拡張の開発方法まとめ　その1：概念編
>https://qiita.com/k7a/items/26d7a22233ecdf48fed8

## Create React App
Chrome拡張の開発環境は純粋なJavaScriptで作成するのであれば、ほとんど何もする必要はありません。
今回はReactとTyepScriptで開発を行うため、少々準備がいります。

以前はReactの環境構築にかなりの手間がかかっていたようですが、今ではコマンド一発で作成可能です。TypeScript対応もオプションの指定で簡単に作成できます。

```terminal:terminal
npm install -g yarn
npm install create-react-app
npx create-react-app my-app --typescript
cd my-app
yarn eject
```

ないものは適宜インストールしましょう。
`yarn eject`はビルドの依存関係や構成ファイルをコピーするものです。詳細に設定を変更したくなければ、`yarn eject`しなくても大丈夫なのですが、今回はいくつかの設定をいじる必要があるので実行します。

下記のようなフォルダ構成になっているはずです。

```terminal
my-app/
  |- config/
  |- node_modules
  |- public/
  |- scripts/
  |- src/
  ︙
  
```

## manifest.jsonを作成
まず、publicフォルダ内に`manifest.json`を作成します。
`manifest.json`はChrome拡張の基本設定ファイルで、これがないとChromeにプラグインを認識してもらえません。
最低限、必要な項目は"manifest_version"、"name"、"version"です。今回はContent Scriptとoption pageを使いたいので、これらの項目についても設定しています。
詳しくは公式ドキュメントを参照しましょう。
>Manifest File Format
>https://developer.chrome.com/extensions/manifest

```json:manifest.json
{
    "name": "Bye NG Word",
    "description": "Remove a specified word from web page.",
    "version": "1",
    "manifest_version": 2,
    "content_scripts": [
        {
            "matches": ["http://*/*", "https://*/*"],
            "js": ["static/js/content.js"],
            "run_at": "document_end"
        }
    ],
    "options_ui": {
        "page": "options.html",
        "open_in_tab": true
    },
    "permissions": [
        "storage"
    ]
}

```

Content Scriptは今回DOM操作を行う主役になります。option pageはNGワード設定用のページです。
ここまでは誰でもすぐにできると思います。

## webpack.config.jsを編集
ハマったポイントその1です。
そもそも、なぜWebpack.config.jsを編集する必要があるかというと、デフォルトの設定では出力されるjsファイルが分割されたり、ファイル名にハッシュが入ってしまいます。このままだと、`manifest.json`からjsファイルを指定できないので、出力されるファイル名を固定する必要があります。

`yarn eject`実行後に生成されるconfigフォルダの`webpack.config.js`を編集します。
Webpackのバージョンは4です。

```javascript:webpack.config.json
...
entry: {
  main: [
      isEnvDevelopment &&
        require.resolve('react-dev-utils/webpackHotDevClient'),
      paths.appIndexJs,
    ].filter(Boolean),
    content: "./src/content.tsx",  //追加
    option: "./src/options.tsx",  //追加
    output: {
      path: isEnvProduction ? paths.appBuild : undefined,
      pathinfo: isEnvDevelopment,
      filename: isEnvProduction
      // ? 'static/js/[name].[contenthash:8].js'  //変更前
         ? 'static/js/[name].js'                  //変更後（[contenthash:8]削除）
        : isEnvDevelopment && 'static/js/bundle.js',
      futureEmitAssets: true,
      chunkFilename: isEnvProduction
     // ? 'static/js/[name].[contenthash:8].chunk.js'  //変更前
        ? 'static/js/[name].[contenthash:8].chunk.js'  //変更後（[contenthash:8]削除）
        : isEnvDevelopment && 'static/js/[name].chunk.js',
      publicPath: publicPath,
      devtoolModuleFilenameTemplate: isEnvProduction
        ? info =>
            path
              .relative(paths.appSrc, info.absoluteResourcePath)
              .replace(/\\/g, '/')
        : isEnvDevelopment &&
          (info => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/')),
      jsonpFunction: `webpackJsonp${appPackageJson.name}`,
      globalObject: 'this',
    },
    ...
  optimization: {
    ...
    splitChunks: false,  //変更
    runtimeChunk: false,  //変更
    ...
  }
...
```


変更は大きく3つあります。

- 出力ファイル名の変更
- 出力ファイルの追加
- ファイル分割の無効化

デフォルト設定のままだと、ビルドしたときにファイル名にハッシュ値が含まれてしまいます。毎回、`manifest.json`を編集するのは非常に手間なので、ファイル名からハッシュ値を削除します。
出力ファイルは今回の場合、`content.js`と`option.js`です。JSON形式でentryの中にそれぞれ追記します。これを書かないとどれだけ素晴らしいコードを書いたとしても、出力されません。
ファイル分割は`splitChunks`と`runtimeChunk`どちらも無効化しましょう。本来、この機能はファイルを分割することで読み込み・実行を早めるためのものですが、今回は非常に扱いづらくなってしまうため無効にします。
ここまで来てようやく設定が完了します。（手探りでやった結果、10時間以上かかりました...）

下で議論されていたものを参考にしました。
>Make extension compatible with Create React App v2.x
>https://github.com/satendra02/react-chrome-extension/issues/2?source=post_page-----137650de1f39----------------------

あとは`yarn build`で作成したbuildフォルダをプラグインとしてChromeに読み込ませれば、Chrome拡張を実行することができます。

# 「クリスマス」抹消機能作成
本日のメインになります。クリスマスの消し方です。

## どの要素を削除するのか
実は指定単語を消すプラグインはいくつか存在しています。ですが、いずれも手動でそれぞれのサイトに対して、非表示要素を指定するものでした。いちいち手動で指定する理由はなんとなく想像ができます。要素の特定が難しいからです。
あるページに指定単語があるかどうかチェックすることは難しいことではありません。`document.innerText`を取得することで、ページ全体のテキスト情報を取得できるからです。

しかしながら、「どの要素を削除すればいいのか」、となると話は別です。
Webページはいくつもの要素が配置されています。そして、要素がブラウザ側から削除されることなど想定されないため、むやみに要素を消すとデザインが大きく崩れることになります。
見たくない要素を消すといっても**もとのWebページへの影響は最小限**であることが望ましいです。

## テキスト「クリスマス」を含む最小構成の要素を消す
たどり着いた結論がこれです。
最近のWebページはいくつもの要素がところ狭しと並んでいます。
であるならば、「クリスマス」を含む最小構成の要素を消すことで、被害を最小限に抑えようということです。

## 実際の実装
Chrome拡張では取得したWebページのDOM要素に対して、要素の追加・更新・削除をContent Scriptから行うことができます。
今回の実装では何度もDOM操作を実行するため、「仮想DOMを実装すれば高速化が図れるのでは？」と思い、Reactを導入したのですが、もともと存在するDOM要素から仮想DOMを作成し、それらをバインドするというのは難しそうだったので断念しました。
見ての通り、コードも非常に短く収まったので、jQueryも使っていません。

```typescript:content.tsx
const ngWords:Array<string> = ["クリスマス"];

// NGワード存在チェック
function find(str: string, keys: Array<string>): boolean {
    if (str == null) return false;
    for(let i = 0; i < keys.length; i++){
        if (keys[i] == null || keys[i] == '') {
            continue;
        }
        let result: number = str.toLowerCase().indexOf(keys[i].toLowerCase());
        if (result !== -1) {
            return true;
        }
    };
    return false;
}

// 該当した子要素を削除
function removeNGElement(children: NodeListOf<ChildNode>) : number{
    let removeCount: number = 0;
    for (let index = 0; index < children.length; index++) {
        let child = children[index];
        const text: string = child.textContent != null ? child.textContent : '';
        
        if (find(text, ngWords)) {  // NGワードがあれば
            if (!child.hasChildNodes()) { // 子要素をもっていなければ
                child.remove();  //その要素を削除
                removeCount++;
            }
            else {                        // 子要素をもっていれば再帰呼び出し
                removeCount += removeNGElement(child.childNodes);
            }
        }
    }
    return removeCount;
}

const removeNum: number = removeNGElement(document.childNodes);
if (removeNum > 0) {
    alert(removeNum + "個のテキストコンテンツが削除されました");
}
```
見るだけでも分かるかと思いますので、ポイントだけ

DOM操作は時間のかかる操作なので、なるべく計算量を減らすようにしました、。
`removeNGElement()`は渡された要素のテキストに対し、NGワードチェックを行います。
もし、その時点でNGワードがなければ、処理を終了します。
NGワードを発見した場合、その要素が子要素をもっていれば、再帰呼び出し。
子要素をもっていなければ、NGワードをもつ最小の要素であるということが分かるので、その要素を削除します。

## 実際の様子
Wikipediaのクリスマスの記事を見てみます。
https://ja.wikipedia.org/wiki/%E3%82%AF%E3%83%AA%E3%82%B9%E3%83%9E%E3%82%B9

### 適用前
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267005/9c329aa8-6a09-267e-527d-d120ed6c3164.png)

---

### 適用後
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267005/fbf9e523-0ddb-c115-0546-8129763a6703.png)

この例では173個中169個の「クリスマス」が抹消されました。削除に要した時間は0.5秒ほどでしょうか。
簡単な実装ではありますが、中々の精度かと思います。
消される要素が少なければ、要素が消されたことにほとんど気づきません。

とはいえ、消される部分によってはどうしても不自然になってしまいます。
悔しいですが、子要素の有無のみでは**自然に**「クリスマス」を消すことはできないというのが、今回の結論です。


また、いくつか消されなかった要素について、原因は**後から要素が追加された**からです。

Webページによっては後から要素が次々と追加されるものもありますので、そういったサイトに対しては無力かもしれません。
オブザーバーを設定することで、更新されるたびに再度走査をかけることも可能ですが、表示速度が著しく低下することが想像されるので今回は見送りました。

また、要素を絞ることによって、動的に変化するページでも特定の要素を消す実装例が下記の記事に載っていました。
この記事を書くきっかけになった記事でもあります。
>Twitterから「いいね」を消し去るChrome拡張を作る
>https://qiita.com/hal1437/items/59d7a55124027d2ff492

## あれ？Reactは？
実はここまでReactを使っていません。本当はDOM操作を高速化するために導入したのですが、前述したとおり見送りとなりました。
悔しかったので、NGワード設定ページ（options.tsx）にMaterial-UIを使って実装しました。
今後、紹介するかもしれませんが、今回は趣旨とは外れてしまうので見送らせていただきます。

# 感想
思いつきから始まったこの企画ですが、未知のものが多くてとても面白かったです。
Chrome拡張機能開発（初）、TypeScript（初）、React（初）と、
どれもこれもが初めての開発だったので、つまづくことも多々ありましたが、なんとか形になってくれました。

「やってやれないことはない」とは、よくいったものです。
特にTypeScriptの型チェックには助けられました。JavaScriptと比較して体感で2倍ぐらい早く開発できたのではと思います。

とはいえ、まだまだ改良点や気になるところはあるので、もう少し研究して見ようかと思います。
見せられる形になったら、全体のコードも公開する予定です。

# 最後に
お気づきの方もいらっしゃるでしょうが、NGワードに「クリスマス」を設定した場合、当然ながら当記事は読めたものではありません。「クリスマス」を消すための記事なのに、消される対象になってしまうとはなんとも悲しいことです。

それでは、よいクリスマスを！


>※当記事はクリスマスを貶める目的で書いたものではありません。あくまで、クリスマスを題材とした、技術共有の目的で執筆したものです。