Chrome 拡張機能でタイムライン上のツイートを「ばぶ」らせてみた

#タイムラインを「ばぶ」らせてみた

Chrome 拡張機能を[公開](https://chrome.google.com/webstore/detail/%E3%81%B0%E3%81%B6%E3%81%A3%E3%81%9F%E3%83%BC/ecpijnpoeekpejpadbkbeelaifhofmgb)した。アイコンをクリックすることで、タイムライン上のツイートの本文、ユーザー名、日時を「赤ちゃん言葉」に置き換えることができる[^1] 。

<img width="580" alt="スクリーンショット 2019-05-07 3.55.51.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25154/f0df4c02-cd05-b995-ba40-23b64f64e8d4.jpeg">

[^1]: ラブリーなアイコンは人に描いてもらいました。スペシャルサンクスです。ただ、画像サイズの指定をミスったので、細かすぎたかも。

![ponchie.001.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25154/1b0703a3-e03d-3bf4-79c5-498732400ce8.jpeg)

実際の動作は以下のツイートの動画から確認できる（リリース版とは見た目が多少異なる点に注意）。
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">タイムラインを幼児化する chrome 拡張機能「ばぶったー」を作りました <a href="https://t.co/syEnvUjCw7">pic.twitter.com/syEnvUjCw7</a></p>&mdash; みやったー🌧️修行中 (@38tter_miya) <a href="https://twitter.com/38tter_miya/status/1121738438212870149?ref_src=twsrc%5Etfw">2019年4月26日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

###5/8追記
github へのリンクは[こちら](https://github.com/38tter/bubtter)です。


#なぜこのような拡張機能を作ったのか

~~世界平和のため。~~私見だが、他人のツイートに反感を感じるとき、文章の内容と同じくらい文章の書き方にもイラつくことが多いような気がする。そこで、タイムライン上の全員のツイートを赤ちゃんレベルの見た目にまで戻してしまえば、少なくとも文章から受ける印象は均質化されるのではないか、と考えた。*我ながらムチャクチャな理屈である。*

（読みにくいことはこの上ないが）**全員が赤ちゃん言葉で喋っているタイムラインであれば、文書の印象から受ける単なる「感想」ではなく、文章の内容についての「意見」を持つことができるのではないか？**ということである。 ~~「みんな赤ちゃんみたいだなー」と思って終わるかもしれないけど。~~

もちろん、赤ちゃん化とまでいかなくとも、漢字をひらがな化するだけでもよさそうではあるが、そこは学習のためにもう一手間かけたくなった。単に自然言語処理の一種である形態素解析を用いて遊んでみたかったというのもある。

#対象とする読者

* これから Chrome 拡張機能を開発したい人
* すでに拡張機能を開発しているが、外部 API との通信で困っている人
* 手を動かしながら JS の勉強をしたい人
* ~~自分~~

#筆者について

非情報系理系院生。研究の関係（数値解析、統計処理）で C/C++/bash を 3 年ほど。C はリファレンスがなくてもおおよそ書けるが、ファイル入出力周辺は怪しい。C++ は研究関連の解析フレームワークを使用するために使っている。それ以外には、python, perl を多少齧った程度。

#JavaScript に興味を持ったきっかけ
そもそものきっかけは、機械学習による自然言語処理に興味を持ったこと[^2]。それに関連して学習データを用意するため、Web スクレイピングを勉強し始める。だがスクレピング技術の習得には Web ページの HTML 周りの基礎的な知識が不足していることを感じ、ぼちぼち勉強を始める。

JS もついでに勉強し始めたところ、コーディングの柔軟性や応用範囲の広さに魅力を感じた。また、以前から iOS アプリ開発にも関心があったのだが、敷居の高さを感じ手をつけていなかった。だが、最近は JS でもアプリ開発が可能である[^3]ことを知り、本腰を入れて JS を勉強することに決める。

そこで実際に手を動かしながら学習を進めるため、比較的お手軽そうな Chrome 拡張機能の開発に挑戦することに。この記事にはリリース版から抜き出したコードの一部も掲載した。我ながら読みづらいコードだと思うが、ご容赦ください...。

[^2]: @youwht さんの記事（https://qiita.com/youwht/items/0b204c3575c94fc786b8 ）を参考に、Word2Vec を使って新元号を予想したりして遊んでいた。これについてはそのうち記事にまとめたい。
[^3]: Facebook や Instagram なども使っている React Native なるものを使えばいけるらしい。次はこれを勉強しようと考えている。

#Chrome 拡張機能とは
Chrome 上で実行できるアプリケーションのようなもの。ブラウザ上でのアクション（ボタンをクリック、ページ上で右クリックなど）に連動してなんらかの処理を実行するようなものが多い。

拡張機能の本体は、機能の説明をまとめた JSON 形式のファイルと、実際の挙動を書いた JS ファイルから構成されている。

- Manifest File
 - 拡張機能に関する情報を書くファイル。 JSON 形式で書く。

- Content Script
 - 任意のページで実行できるスクリプト。
 - chrome.*API が一部のものしか用いることができない。
 - ページ内で定義されている変数や関数にアクセスできない。
 - DOM の取得・操作はできる。

- Background Page
 - バックグラウンドで動くページであり、そこで様々な処理を行うことができる。
 - 常にメモリを占有してしまう。

- Event Page
 - やはりバックグラウンドで動作するが、必要なときだけ立ち上がって動作が完了すると閉じる。

- Browser Action / Page Action
 - chrome のツールバーに追加できるボタン。
 - 特定の web ページを対象としないような拡張機能は Browser Action が吉。常にurlバーの右に表示されている
 - ボタンがクリックされたときに用意した html ファイルをポップアップで表示させることができる

##manifest file 

まずは拡張機能に関する情報をまとめて記述する manifest file の書き方について。manifest file の記述に必要な JSON 形式については[こちら](https://dev.classmethod.jp/etc/concrete-example-of-json/ )の記事が参考になった。


```json:manifest.json
{
  "manifest_version": 2,
  "name": "ばぶったー",
  "version": "0.0.4",
  "description": "ワンクリックで Twitter タイムラインを「赤ちゃん言葉」にします。",
  "short_name": "BBT",
  "content_scripts": [
    {
      "matches": [
        "https://twitter.com/*"
      ],
      "js": [ "js/content.js" ],
      "all_frames": true,
      "run_at": "document_end"
    }
  ],
  "icons": {
    "16": "akachan.jpg",
    "48": "akachan.jpg",
    "128": "akachan.jpg"
  },
  "browser_action": {
    "default_icon": {
      "19": "akachan.jpg"
    },
    "default_title": "Bubtter"
  },
  "background": {
    "scripts": [ "js/background.js" ],
    "persistent": false
  },
  "permissions": [
    "tabs",
    "activeTab",
    "background",
    "https://jlp.yahooapis.jp/MAService/V1/*"
  ]
}
```
* name, version, description 
  - chrome ストアに表示される内容を記述する。
* content_scripts
  - 実行するスクリプトのパスや、スクリプトが実行されるページの URL などを記述する。
* icons
  - ストア上や、ブラウザ上の管理画面に表示されるアイコン画像のパスを指定できる。
* browser_action
  - アドレスバー横のアイコンの画像の指定や、アイコンクリック時にポップアップされるページの html ファイルなどを記述できる（今回は未使用）。
* background
  - background で実行されるスクリプトを指定する。
* permissions
  - ユーザーにパーミッションを要求する内容を記述する。今回はブラウザで表示中の tab に対して操作をするので "tabs" を記述している。外部API を使用する場合など、表示しているページと別の URL と通信する場合はここに記述する必要がある。

##おおまかな実行内容のイメージ

1. アイコンのクリックを検知する
1. タイムライン上のテキスト（ツイート本文、ユーザー名、日時など）を取得する
1. 形態素解析API に fetch 経由でテキスト内容を送信する
1. xml 形式で結果（読みがな、品詞など）を読みこむ
1. 品詞に応じて文字列を操作する
1. ページ内のテキスト要素に、変更後の文字列を書き込む

以下、順を追って解説していく。

###アイコンのクリックを検知する

"background" として指定された js ファイルは、常にバックグラウンドで実行されている。
ここでブラウザ上でボタンがクリックされたことを検知し、メッセージを送信する。

```javascript:background.js
chrome.browserAction.onClicked.addListener(function(tab){
  chrome.tabs.sendMessage(tab.id, "myClick"); /* 第2引数の中味はなんでもよい */
});
```

contents_script 側では送信されたメッセージに反応して実行する処理を記述する。

```javascript:content.js
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse){
  if( request == "myClick") {
    makeInfa();
  }
});
```

具体的な実行内容を書いた関数 makeInfa() の内容は以下の通り。

```javascript:content.js
function makeInfa() {
  /* クリック時にページタイトルを変更。再度クリックで、ページタイトルの変更を検出してページ再読み込み。 */
  const bubTitle = "ばぶったー";
  let pageTitle = document.title;
  if (pageTitle === bubTitle) {
    document.title = "Twitter";
    chrome.runtime.sendMessage(
      {
        message: "fix it",
      },
      function(resp){
        return;
      }
    );
  } else {
    document.title = bubTitle;
  }

  /* ページ内要素の変更 */
  let darekaTweet = document.getElementsByClassName("TweetTextSize");
  for(let darekaTweetCnt=0;darekaTweetCnt<darekaTweet.length;darekaTweetCnt++){
    doConvert(darekaTweet[darekaTweetCnt]);
  }
  //...
}
```

content script では表示しているページ内の要素の取得、削除、改変などが可能だ。
Chrome の「要素を検証」（mac では opt + command + I）を使えば、ページ内で操作したい要素の Class 名や Tag 名が確認できる。

ブラウザ操作からの DOM の操作については[こちら](https://trycatchand.blogspot.com/2014/05/how-to-manipulate-dom-from-chrome-extension.html) が参考になった。

##タイムライン上のテキスト（ツイート本文、ユーザー名、日時など）を取得する

```javascript:contetnt.js
function doConvert(obj) {
  let set = "";
  /* 一行にまとめてから送信 */
  for (let i=0;i<obj.length;i++){
    if (i===obj.length-1){
      for(let k=0;k<obj[i].innerText.length;k++){
        if(obj[i].innerText[k].match(regMatchExp)){
          set += obj[i].innerText[k];
        }
      }
    } else {
      for(let k=0;k<obj[i].innerText.length;k++){
        if(obj[i].innerText[k].match(regMatchExp)){
          set += obj[i].innerText[k];
        }
      }
      set += separateChar; /* 区切り文字を挟んでおき、あとで分割する */
    }
  }

  /* API との通信のためのメッセージ送信 */
  chrome.runtime.sendMessage(
    {
      message: "do it",
      text: set
    },
    function(response){
      let arr = response.split(separateChar);
      for(let j=0;j<obj.length;j++){
        for (let regCnt=0;regCnt<regExp.length;regCnt++){
          arr[j] = arr[j].replace(regExp[regCnt], resTex[regCnt]);
        }
      }
    }
  );
};
```
なぜ content script 側で直接 API との通信を行わないのかというと、後述するCORB(クロスオリジン)の問題があるからである。

###形態素解析API に fetch 経由でテキスト内容を送信する => xml 形式で結果（読みがな、品詞など）を読みこむ => 品詞に応じて文字列を操作する

```javascript:background.js
chrome.extension.onMessage.addListener(
  function(request, sender, sendResponse) {
    if (request.message == "do it") {
      const sentence = request.text; /* content script 側で送った text */

      let lastURL = URL + `?appid=` + APIKEY + `&sentence=` + encodeURI(sentence);
      const method = 'POST';
      const headers = {
        'Accept': 'application/json',
        'Content-Type': `application/x-www-form-urlencoded; charset=utf-8`
      };
      const obj = JSON.stringify({
        appid: APIKEY,
        sentence: sentence,
        results: "ma"
      });
      const body = Object.keys(obj).map((key)=>key+"="+encodeURIComponent(obj[key])).join("&");
      fetch(lastURL,
        {
          method,
          headers,
          body
        }
      ).then(
        function(res){
          if(res.ok){
            return res;
          } else {
            sendResponse("しっぱいでちゅ");
            throw "error";
          }
        }
      ).then(res => res.text()) /* Yahoo! の API の場合は xml 形式で返ってくる */
      .then(function(text){
        let dom = new DOMParser().parseFromString(text, 'text/xml');
        return dom;
      })
      .then(function(dom){
        let responseSent = "";
        let text = dom.querySelectorAll("reading");
        let kind = dom.querySelectorAll("pos");
        let surf = dom.querySelectorAll("surface");

        for(let cnt=0;cnt<text.length;cnt++){
          if(kind[cnt].innerHTML === "助動詞" && cnt === text.length-1){
            responseSent += text[cnt].innerHTML + 'でちゅ。';
          } else if (kind[cnt].innerHTML === "名詞") {
            let matchFlg = 0;
            for(let nameCnt in Object.keys(youjiTxt)){
              if(surf[cnt].innerHTML === Object.keys(youjiTxt)[nameCnt]){
                responseSent += "" + youjiTxt[Object.keys(youjiTxt)[nameCnt]] + "";
                ++matchFlg;
                break;
              }
            }
            if (!matchFlg) responseSent += "" + text[cnt].innerHTML + "";
            if (cnt === text.length-1) responseSent += "でしゅ";
          } else if (kind[cnt].innerHTML === "助詞") {
            responseSent += text[cnt].innerHTML + '、';
          } else if (kind[cnt].innerHTML === "動詞" && cnt === text.length-1) {
            responseSent += text[cnt].innerHTML + 'だっちゃ。';
          } else if (kind[cnt].innerHTML === "形容詞") {
            let bufEnd = text[cnt].innerHTML.slice(-1,0);
            responseSent += text[cnt].innerHTML.slice(0,-1) + 'ー' + bufEnd;
          } else if (kind[cnt].innerHTML === "形容動詞") {
            responseSent += text[cnt].innerHTML + 'でちゅ';
          } else if (kind[cnt].innerHTML === "感動詞") {
            responseSent += text[cnt].innerHTML + '！';
          } else {
            responseSent += text[cnt].innerHTML;
          }
        }
        sendResponse(responseSent);
      });
    } else {
      chrome.tabs.getSelected(null, function(tab){
        let newURL = tab.url
        chrome.tabs.update(tab.id, {url: newURL});
      });
      sendResponse("hoge");
    }
    return true;
  });
```

Web 上のリソースに JavaScript からアクセスする方法は

* XHR
* jQuery.ajax
* fetch

と変化してきているという。昔の方法もまだ使えるが、現在は fetch の使用が推奨されているらしい。そこで素直に fetch を使用。記述に関しては[こちら](https://trycatchand.blogspot.com/2014/05/how-to-manipulate-dom-from-chrome-extension.html) が参考になった。
fetch で POST する際の記述の仕方でつまづいていたのだが、[こちら](https://qiita.com/legokichi/items/801e88462eb5c84af97d) を読んで解決。

具体的なテキスト処理では Yahoo! JAPAN の日本語形態素解析 API を使用した。詳細は[こちら](https://developer.yahoo.co.jp/webapi/jlp/ma/v1/parse.html) から。今回は使用しなかったが、単語ごとの出現頻度なども使用できるらしい。

結果は xml 形式で受け取るので、うまく必要な情報（読みがな、品詞名）を取り出すのに少々苦労した。JSON 形式にも対応しているのかは不明（すみません...）。

###ページ内のテキスト要素に、変更後の文字列を書き込む

content.js 内で background からのレスポンスを受け取ってからの処理である。
舌足らずな文章に見えるよう、文字列に対して置換操作を行う。その後、要素を変更するオブジェクトにテキスト内容を代入した。

幼児語については[こちら](https://ikuji-log.net/entry/baby-talk-list) を参考にした。~~つかれたでしゅ。~~

```javascript:content.js
let regMatchExp = /^[-:\/a-zA-Z0-9\u30a0-\u30ff\u3040-\u309f\u3005-\u3006\u30e0-\u9fcf、。「」【】]+$/;

let regExp = [ /でちゅ/g, /つ/g, /す/g, /ま/g, /ふ/g, /る/g, /おう/g, /いう/g,  /、{2,}/g, /、。/g ];
let resTex = [ "", "ちゅ", "しゅ", "みゃ", "ひゅ", "ゆ", "おー", "ゆー",  "、", "。" ];

chrome.runtime.sendMessage(
    {
      message: "do it",
      text: set
    },
    function(response){
      let arr = response.split(separateChar);
      for(let j=0;j<obj.length;j++){
        for (let regCnt=0;regCnt<regExp.length;regCnt++){
          arr[j] = arr[j].replace(regExp[regCnt], resTex[regCnt]); /* 愚直にテキスト置換 */
        }
      }
    }
  );

```

####クロスオリジン（CORB; Cross-Origin Read Blocking）問題

クロスオリジン問題とは、ある種の攻撃を防ぐために、画像デコーダやJavaScriptエンジンがクロスオリジンのリソースを読み込む前にブロックする仕組みだという。
content script 内にそのまま fetch の内容を書いたところ、この問題にぶつかった。

どうやら fetch で異なるドメインのリソースを使用するには、本来はクライアント側、サーバー側双方で設定する必要があるらしい。今回のように外部APIを使用する場合は如何ともしがたいのではないか...（と思っているのだが、間違っていたらすみません）。

その後も

* jQuery.ajax() ならクライアント側をいじるだけでなんとかなりそう？
* XHR なら permissions をいじるだけで API を使っても cross domain 問題で怒られないらしい。参考記事は[こちら](https://qiita.com/5a3i/items/a031427727e4923005d0) 。

など他の方法も検討したが、最終的には 

* fetch でも、contents 側ではなく background 側に記述すれば CORB は起こらない
ということを知った。参考記事は[こちら](https://qiita.com/nulltypo/items/4e5c494971955c767531) 。

##その他の苦労した点

###contents script に対応する js ファイルが読み込まれるタイミング

当初はブラウザアクションではなく、ページを読み込むタイミングで content script の内容を実行することを考えていた。
ページの読み込みがすべて終わってからでないと、実行時にツイート本文の取得がうまくいかない（manifest.json では "run_at" で実行タイミングを指定できそうな雰囲気があるが、ここに "document_end" を記述しただけではうまくいかなかった）。

ページ読みこみ時の実行では、以下のようなエラーが表示され Mutation Observer を機能させることができなかった。

`Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node’.`

対処法としては、[こちら](https://qiita.com/3mc/items/c3c580ca5de4a2d3990d) を参考にした。

結局、ブラウザアクション（ボタンをクリック）からスクリプトを実行するよう変更したので、上記の内容は実装されていないのだが。

###背景画像の変更
「ばぶったー」の名前にふさわしく、「幼児」「赤ちゃん」などでググると表示されるフリー素材画像を背景画像に設定しようと考えていたのだが、2015年7月から twitter の背景画像の変更機能は廃止されたそうだ。残念。

###ページ要素の変更を検出して実行

公開したバージョンでは通信量を減らすため実装されていないが、

* タイムラインを書き換えた後の、新たなツイートの読み込みを検出し、再度書き換えを実行すること
* 実行中に twitter.com 内の他のページに移動した際にページ上の書き換えた内容を保持すること

も可能である。[こちら](https://qiita.com/hal1437/items/59d7a55124027d2ff492 ) が参考になった。

##リリースについて

全ファイルを圧縮して、[chrome デベロッパーダッシュボード](https://chrome.google.com/webstore/developer/dashboard) にアップロードするだけ。初回だけ $ 5.0 支払う必要がある。

##今後の展望

改良したい点、できたらいいなと思う点など。

* 形態素解析を内部で処理する。現状使用している外部APIは通信量に制約があるので、mecab などを使うとか？
* ツイート内の画像を「ばぶ」感のあるテイストにして置き換える（手書き風とか）。画像認識系の機械学習ライブラリ案件か？道のりは長い。
* 何かアイデアがありましたらコメントいただけると幸いです。