ニコニコ動画のNGコメントフィルターをJavaScript (WebExtensions)で作ってみよう

# どうでもいいから機能を使いたい人は

[ニコニコ動画コメントフィルター - Firefox 向けアドオン](https://addons.mozilla.org/ja/firefox/addon/%E3%83%8B%E3%82%B3%E3%83%8B%E3%82%B3%E5%8B%95%E7%94%BB%E3%82%B3%E3%83%A1%E3%83%B3%E3%83%88%E3%83%95%E3%82%A3%E3%83%AB%E3%82%BF%E3%83%BC/)
を入れてみましょう。筆者は未導入なので保証はできません…。

この記事は上記のアドオンのソースコードを見ながら行った、劣化再実装の記録です。

# 動作検証環境

* Windows7 64bit
* Firefox Developer Edition 62.0b1

検証は非ログイン状態のHTML5プレイヤー動画で行いました。
検証といってもまだまだ全然動かし足りていませんが。


# 本題とは関係ないここまでの経緯

ニコニコ動画の独自NGフィルターといえば、オミトロン([Proxomitron - Wikipedia](https://ja.wikipedia.org/wiki/Proxomitron))などを使ったプロキシでの書き換えが有名でしょうか？

私も一度導入してみようかと見てみましたが、どうにもニコニコ動画のためだけにオミトロンを立ち上げブラウザに串を通すということが煩わしくてやめました。~~けっして難しくてわからなかったわけではありません~~

だからと言って代案があるわけでもなく、FLV時代から時たま思い出しては動画ページのコードや通信を覗いては、JSが持っているデータを改ざんしたり、コメントをCSSで非表示に出来ないかなどうんうんうなっては時間を無駄にしていました。

切実に困っているわけではないですが、一般会員の10枠が少ないのも事実。

HTML5になったときは「これならいじれる！？」と思ったものの、よくわからず`canvas`に敗北。
しばらく遠ざかっていましたが…先日ひさしぶりにググってみて良さそうなアドオンを発見。
それが
[ニコニコ動画コメントフィルター - Firefox 向けアドオン](https://addons.mozilla.org/ja/firefox/addon/%E3%83%8B%E3%82%B3%E3%83%8B%E3%82%B3%E5%8B%95%E7%94%BB%E3%82%B3%E3%83%A1%E3%83%B3%E3%83%88%E3%83%95%E3%82%A3%E3%83%AB%E3%82%BF%E3%83%BC/)
でした。
しかし利用者が少ない…コードがGitHubにあるわけでもない…とちょっと不安になったのと、ニコニコの通信内容と改ざん技術に興味があったのでソースコードを見ることにしました。

方法はアドオンのインストールURLをIEなどで開いてxpiファイルとしてダウンロード。
拡張子をzipにして解凍すれば生のファイルを見ることができます。

# まずWebExtensionsを作ろう

WebExtensions APIを動かしてみるにはともあれアドオンの体をなしていなければなりません。
WebExtensionsの作り方自体、私は未熟なので他の記事にまかせます。
昔書いた記事の参考資料以上に追加で調べていないのでご参照いただければ。

* [Firefoxでタブを分離したときに最大化するアドオン - Qiita](https://qiita.com/khsk/items/52bf7c837505d0320a80#_reference-43b4e81fadeddd45e71c)

今回はリクエストを操作する単純な機能だけを触るので、
「おやくそく」の`manifest.json`と実際に動く`background.js`(ファイル名は任意)があればOKです。
どこかからコピペして持ってきてもいいでしょう。しました。

```js:manifest.json
{
  "manifest_version": 2,
  "name": "nico ng",
  "version": "1.0",
  "description": "niconico movie's regex NG filter",
  "permissions": [
    "webRequest",
    "webRequestBlocking",
    "http://www.nicovideo.jp/watch/*" ,
    "http://nmsg.nicovideo.jp/api.json/"
  ],
  "background": {
    "scripts": ["background.js"],
    "persistent": false
  }
}
```

重要なのはpermissionsで使う機能と対象アドレスの指定をして、動かすスクリプト名を`background.scripts`で指定するぐらいですね！
タブそれぞれではなく、Firefox全体で働くスクリプトなイメージです。
タブそれぞれで動くスクリプトは`content_scripts.js`ですが、今回は使いません。


2つのファイルを一つのディレクトリに設置したら、一時的な拡張機能として読み込みましょう。
(persistentはChrome用の記述でFxではエラーが出ますが無問題です)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/8149e90a-bb46-26aa-57a0-1bc86190a59a.png)


ファイルを書き換えたら**再読み込み**で反映し、アドオンの`console.log`などは**デバッグ**で開く、普段とは別な開発ツールに表示されるので開いておきましょう。

# リクエストをキャッチしてみる

はじめて使うAPIなので、サンプルを参考に徐々に書いていきます。
まずは干渉対象のリクエストを取得してみます。

* [HTTP リクエストへの介入 - Mozilla | MDN](https://developer.mozilla.org/ja/Add-ons/WebExtensions/Intercept_HTTP_requests)

```background.js
chrome.webRequest.onBeforeRequest.addListener(
  /* listener */details => {
    console.log('details', details)
  },
  /* filter */{ urls: ['http://nmsg.nicovideo.jp/api.json/'], types: ['xmlhttprequest', 'main_frame'] },
  /* extraInfoSpec */['blocking'])
```

だいたい意味は取れるでしょうか。
filterに一致するリクエストがあれば、listenerを実行するイベントリスナーですね。
`/* extraInfoSpec */['blocking']`はこのあとする改ざんのために必要なオプションの先取りで、同期的に扱うためのオプションです。

(typesは参考アドオンをそのまま使ったので、コメントフィルターだけの場合なら冗長なものが含まれている可能性が高いです。)

* [webRequest.onBeforeRequest - Mozilla | MDN](https://developer.mozilla.org/ja/Add-ons/WebExtensions/API/webRequest/onBeforeRequest)

MDNに仕様は書かれていますが、出力を見てみましょう。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/f21ac088-5745-e48d-b1b5-5ffa92cdcff2.png)

目的URLの通信のようですが、detailsにはbodyは見当たりません。

# bodyを見てみる

リクエストのデータを見るには
[webRequest.filterResponseData() - Mozilla | MDN](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest/filterResponseData)
を使います。

```background.js
chrome.webRequest.onBeforeRequest.addListener(
  details => {
    console.log('details', details)
    let filter = browser.webRequest.filterResponseData(details.requestId)
    console.log('filter', filter)
    let decoder = new TextDecoder("utf-8");
    let encoder = new TextEncoder();

    filter.ondata = event => {
      console.log('event', event)
      // 複数回に分かれて受信される
      let str = decoder.decode(event.data, {stream: true});
      console.log('str', str)
    }
  },
  { urls: ['http://nmsg.nicovideo.jp/api.json/'], types: ['xmlhttprequest', 'main_frame'] },
  ['blocking'])
```

`filter`オブジェクトの`ondata`イベント時にバイトストリームを取得できるので、それをデコードすれば`string`として内容を読むことができます。

出力はこんな感じ

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/062d68d2-c7ec-41ea-3686-8b0dd59350ff.png)

JSONがとれてそうですね。
ただし！コメントにもありますが、一回のstrにはすべてのbodyが入っているわけではなく、このままではJSONとして扱えません。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/441f7b25-b1f5-583b-ce12-5b8d70643336.png)

最後がJSONとして正しくない終わり方です。


streamに詳しく無いのでここは悩んだのですが…そこはそれ、先人のコードを参考にします。

```js:参考アドオン
        filter.ondata = e => {
            let beginByte = getInt8(e.data, 0), endByte = getInt8(e.data, e.data.byteLength - 1);
            if (details.type === 'main_frame') { _mfBuffs.push(e.data); _doneInfo = true; }
            if (beginByte === 91) _catch = true;
            if (beginByte === 123) _catchInfo = true;
            if (_catch) _xrBuffs.push(e.data); else filter.write(e.data);
            if (_catchInfo) _xrInfoBuffs.push(e.data);
            if (endByte === 93) { _catch = false; _done = true; }
            if (endByte === 125) { _catchInfo = false; _doneInfo = true; }
            if (_done && _doneInfo) {
                if (_mfBuffs.length > 0) getTagsFromMainFrame(); else getTagsFromXhrInfo();
                commentWrite();
            }
        };
```

自分も全然コードが読めないので雰囲気で読み解きながら意訳すると、
**各dataはバッファして、出力が終わったと判定できたら処理をする。**
になりました。
この方はバイトで見ているので、判定の91,123,93,125は送られてくるJSONの最初と最後の`[{}]`を見ていることがわかります。

(私が出来うる)簡略なコードで書くと～

```background.js
chrome.webRequest.onBeforeRequest.addListener(
  details => {
    console.log('details', details)
    let filter = browser.webRequest.filterResponseData(details.requestId)
    console.log('filter', filter)
    let decoder = new TextDecoder("utf-8");
    let encoder = new TextEncoder();

    let buffer = ''

    filter.ondata = event => {
      console.log('event', event)
      // 複数回に分かれて受信される
      let str = decoder.decode(event.data, {stream: true});
      console.log('str', str)

      buffer += str

      if (buffer.substr(-1) === ']') {
        console.log('buffer', buffer)
        console.log('buffer json', JSON.parse(buffer))
      }
    }
  },
  { urls: ['http://nmsg.nicovideo.jp/api.json/'], types: ['xmlhttprequest', 'main_frame'] },
  ['blocking'])
```

ストリームが`]`で途切れると誤判定になってしまう可能性大ですが…`}]`にするとほんのり強くなるかな？
とにかくdataをつなぎ合わせます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/1ab6f86e-4673-5aaf-5b95-8c0025bcf78c.png)

見事！JSONとして扱えるようになり、Objectに変換できました。

# ニコニコ動画のコメントのJSON仕様を知ろう

改ざん対象が取得できましたが、どこを触ればいいのか調べます。
ここは詳しく解説してくれている記事に丸投げです。謝謝。

[ニコニコ動画のコメントをJSONで取得する - Qiita](https://qiita.com/tor4kichi/items/74939b49954d3e72d789)

> # レスポンスのchat
>
"thread": "1502018914",　// スレッドID
"no": 168,　　　　　　　　// コメント番号
"vpos": 58161,　　　　　　// コメントの動画時間上の位置 1vpos＝10ミリ秒 100vposで１秒
"leaf": 9,　　　　　　　　　// ？
"date": 1502019822,　　　// 投稿時間のUNIX時間
"date_usec": 257855,　　　// 投稿時間の1秒以下の時間 例ではdate+0.257885秒に投稿された
"premium": 1, // コメント投稿ユーザーがプレミアム会員であれば 1
"anonymity": 1,　　　　　// 匿名コメント
"user_id": "NOYnNqmzAwmdb6duA4IO0ogncNM",　// ユーザーID（匿名の場合は１週間でリセットされる？）
"mail": "184",　　　　　　// コメントのコマンド
"content": "草生える"　　 // コメント本文
"deleted": 2　　　　　　　// 1以上で 削除済み、数字は削除理由によって異なる詳細不明

各オブジェクトの`chat.content`がコメント本文なので、これをチェックすればNGフィルターとして扱えそうです！
そして論理削除っぽいので、削除フラグを手動で立ててやれば画面には表示されないのでは…！？

# コメントデータを改ざんしてみよう

オブジェクトの走査は本当…下手なのですが…こうなりました…

```background.js
chrome.webRequest.onBeforeRequest.addListener(
  details => {
    console.log('details', details)
    let filter = browser.webRequest.filterResponseData(details.requestId)
    console.log('filter', filter)
    let decoder = new TextDecoder("utf-8");
    let encoder = new TextEncoder();

    let buffer = ''

    filter.ondata = event => {
      console.log('event', event)
      // 複数回に分かれて受信される
      let str = decoder.decode(event.data, {stream: true});
      console.log('str', str)

      buffer += str

      if (buffer.substr(-1) === ']') {
        console.log('buffer', buffer)
        console.log('buffer json', JSON.parse(buffer))

        let json = JSON.parse(buffer)
        buffer = ''

        Object.keys(json).forEach(index => {
          const obj = json[index]
          console.log('obj', obj)
          // chatなのにcontentプロパティ自体がなく、deletedが2以上で生えていて削除済みみたい。
          if (!obj.chat || !obj.chat.content || obj.chat.deleted > 1) {
            return
          }
          console.log('content', obj.chat.content)
          // MGコメントを判定して削除
          if (/むく/i.test(obj.chat.content)) {
            delete obj.chat.content
            console.log('deleted obj', obj)
            // deletedプロパティをはやしてあげなくても動くみたい
          }
        })
      }
    }
  },
  { urls: ['http://nmsg.nicovideo.jp/api.json/'], types: ['xmlhttprequest', 'main_frame'] },
  ['blocking'])
```

はい、forEachで回して`chat.content`をチェックしてNGなら`delete`で消しています。

NGワード集の作り方は本題ではないので作り込んでいませんが、過去に似たものを作った気がするのでそんな感じにすると思います。

[人間性をさがせよ QiitaのTypo検出 【緩募】 - Qiita #コード](https://qiita.com/khsk/items/085db0947c650e2db463#%E3%82%B3%E3%83%BC%E3%83%89)

```js:ダメなワードディレクトリを使ったチェック例
    const textNodes = getTextNodesIn(document.querySelector('html'), textNodeFilter);
    textNodes.forEach(textNode => {
        Object.keys(DICTIONARY).forEach(correct => {
            // Firefoxのみ/(否定)?後読み/実装がまだなので、置換で消しておく
            let regexp = new RegExp('(' + DICTIONARY[correct].join('|').replace(/\(\?<!.+?\)/, '') + ')', 'gi');
            textNode.textContent = textNode.textContent.replace(regexp, replaceFunction.bind(null,correct));
        });
    });
```

`(|)`を使い一つの正規表現としてしまうか、フラグの柔軟性を求めてひとつひとつRegExpを作ってチェックするか…
オブジェクトにすると結構な数のループになってしまうので、JSON stringを対象にうまく置換処理で消す方法もありそうですね。

([textlint/regexp-string-matcher: Regexp-like string matcher.](https://github.com/textlint/regexp-string-matcher)はnode.js以外でも使えるのかしら？)

ま、ま、今回の記事の重要な部分ではないのでお許しください。

# 書き換えたデータを送ってみよう

最後に、書き換えたJSONを本来のdataの代わりに送って終了です。
コードは
`filter.write(data)`
`filter.disconnect()`
です。
参考アドオンでは`diconnect`は`onstop`イベントで実行していましたが、MDNではそのまま`ondata`内で実行してますね。
どう違うのでしょう？

* [webRequest.filterResponseData() - Mozilla | MDN](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest/filterResponseData)

```background.js
chrome.webRequest.onBeforeRequest.addListener(
  details => {
    console.log('details', details)
    let filter = browser.webRequest.filterResponseData(details.requestId)
    console.log('filter', filter)
    let decoder = new TextDecoder("utf-8");
    let encoder = new TextEncoder();

    let buffer = ''

    filter.ondata = event => {
      console.log('event', event)
      // 複数回に分かれて受信される
      let str = decoder.decode(event.data, {stream: true});
      console.log('str', str)

      buffer += str

      if (buffer.substr(-1) === ']') {
        console.log('buffer', buffer)
        console.log('buffer json', JSON.parse(buffer))

        let json = JSON.parse(buffer)
        buffer = ''

        Object.keys(json).forEach(index => {
          const obj = json[index]
          console.log('obj', obj)
          // chatなのにcontentプロパティ自体がなく、deletedが2以上で生えていて削除済みみたい。
          if (!obj.chat || !obj.chat.content || obj.chat.deleted > 1) {
            return
          }
          console.log('content', obj.chat.content)
          // MGコメントを判定して削除
          if (/むく/i.test(obj.chat.content)) {
            delete obj.chat.content
            console.log('deleted obj', obj)
            // deletedプロパティをはやしてあげなくても動くみたい
          }
        })
        filter.write(encoder.encode(JSON.stringify(json)))
        filter.disconnect()
      }
    }
  },
  { urls: ['http://nmsg.nicovideo.jp/api.json/'], types: ['xmlhttprequest', 'main_frame'] },
  ['blocking'])
```

オブジェクトをJSON文字列に変換し、さらにバイトデータに変換して送信。終了。です。
`JSON.stringify`はもとのdataと異なりキーが`""`でくくられるなど、完璧にcontentを消しただけのjsonにはならないのですが、動画ではちゃんと同じJSONとして処理してくれるようです。

# 結果！

[【カオス】ゆでたまごそのまま食べた音が忘れられなさすぎるｗｗｗ - ニコニコ動画](http://www.nicovideo.jp/watch/sm17792396)
の最初の2つのコメント(`/むく/`)を対象とします。(サンプルよくなかったですね)
動画に意味は無いです。短くて多少のコメントがある動画を探しました。

## アドオン未使用

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/dc78de6b-0391-a382-29ac-a9d4d46cd085.png)

## アドオン使用

![image.png](https://qiita-image-store.s3.amazonaws.com/0/62354/92e45159-bc78-92ea-3729-d2ed457a111a.png)

はい、最初の「むく？」「むくんでしょ？」が表示されなくなったことが確認できました。やったぜ。

# 終わり

ほぼ参考アドオンのコードを載せない構成になってしまいました。
短い変数名やワンライナーな記述が多くて個人的にはちょっと読みにくいですが、普通の方なら十分読めるはずなので、いろいろ多機能ですし見てみるのも面白いと思います！

そしてやはり未署名のアドオン、個人利用でいいから使いたい！！
せめてChromeのように再起動しても読み込んだままにするオプションをください。

# 参考

* [HTTP リクエストへの介入 - Mozilla | MDN](https://developer.mozilla.org/ja/Add-ons/WebExtensions/Intercept_HTTP_requests)
* [webRequest.onBeforeRequest - Mozilla | MDN](https://developer.mozilla.org/ja/Add-ons/WebExtensions/API/webRequest/onBeforeRequest)
* [webRequest.filterResponseData() - Mozilla | MDN](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest/filterResponseData)
* [webRequest.StreamFilter - Mozilla | MDN](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest/StreamFilter)
* [webRequest.StreamFilter.write() - Mozilla | MDN](https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest/StreamFilter/write)

***

* [ニコニコ動画のコメントをJSONで取得する - Qiita](https://qiita.com/tor4kichi/items/74939b49954d3e72d789)

***

* [Qiitaのトップページにアクセスした時タグフィードを表示するChrome拡張を作った - Qiita](https://qiita.com/gensobunya/items/957eb5a7e4291fdc4279)  
 リクエストを改ざんできるということは認知していましたが、これぐらい簡単なコードでできることが意外で、当時認識を新たにした記事でした。