こんにちは!
突然ですがみなさんのTLはポジティブですか?たまにはネガティブなツイートも流れてきますよね。
文章の力は偉大です。
ネガティブな文章は人をネガティブに、ポジティブな文章は人をポジティブに変える力を持っています。
できればポジティブなツイートに囲まれて幸せなツイッターライフを満喫したいところです。
ポジティブといえば誰を思い浮かべますか?
そうですね。松岡修造さんですよね。
ネガティブなTweetは松岡修造さんのお言葉から同等の意味のものを拝借することで伝えたいことを崩さずにTLをポジティブな空間にすることができるかもしれません。
ということでCOTOHA APIを使ってポジティブでないTweetを似た意味の松岡修造語録に書き換えちゃうChrome拡張機能を作ってみたいと思います。
COTOHA API
COTOHA APIはNTTコミュニケーションズさんが提供する自然言語処理・音声処理APIプラットフォームです。
今回はその中でも『感情分析API』を使ってポジティブではないツイートを検出し、『類似度算出API』を使って最も近しい松岡修造語録を見つけたいと思います。
つくってみる。
Chrome拡張機能の作り方のベースは「Chrome 拡張機能でタイムライン上のツイートを「ばぶ」らせてみた - Qiita」を多大に参考にさせていただきました。
とりあえず、適当にワークスペースを作って開発にとりかかります。
マニフェストファイルを作成する
Chrome拡張機能の基本的な設定や情報を記載するmanifest.json
を作成します。
今回はChrome拡張のアイコンをクリックしたら処理が実行されるようなアプリケーションを検討しているので、以下のようにmanifest.json
を記述します。
{
"name": "positter",
"description": "positter deletes negative tweets from your time line.",
"manifest_version": 2,
"version": "1.0",
"browser_action": {},
"content_scripts": [
{
"matches": [ "https://twitter.com/*" ],
"js": [ "js/jquery.min.js", "js/content.js" ]
}
],
"background": {
"scripts": [ "js/background.js" ],
"persistent": false
},
"permissions": [
"https://xxxxxxxxxx.com/*" // COTOHA APIのアカウントホームページでBase URLを確認
]
}
特に重要な奴らを紹介します。
content_scripts
content_scripts
は読み込むjsファイルとそれを読み込むページを定義しています。
今回はTwitterのページに限り、js/jquery.min.js
とjs/content.js
を読み込みます。これらのファイルは後々作成します。
background
background
はバックグラウンドで動くjsを定義しています。今回はjs/background.js
を読み込みます。
persistent
をfalse
に指定することで特定のイベント時のみ起動するようになります。(これをしないとずっとバックグランドで処理が動いちゃうので非推奨です:Manage Events with Background Scripts - Google Chrome)
permissions
permissions
は利用できる外部APIの穴あけを定義する箇所です。今回はCOTOHA APIのbase url
を設定しておきます。base url
は
jqueryを使えるようにする
まずはjQueryを使ってささっと開発をしたいので、公式HPからファイルをダウンロードしてjs/jquery.min.js
として保存します。
backgroundでアイコンクリックを検知する
background
でアイコンクリックを検知して、content_scripts
にそれを通知してあげます。
chrome.browserAction.onClicked.addListener(function(tab) {
chrome.tabs.sendMessage(tab.id, "clicked")
})
chrome.browserAction.onClicked.addListener
でChrome拡張のアイコンがクリックされたイベントを検知して、chrome.tabs.sendMessage
でcontent_scripts
にtab.id
とclicked
というメッセージを送信しています。
content_scripts
でこのclicked
というメッセージを受け取った場合の処理をコーディングしていきます。
content_scriptsでメッセージを受信する
content_scripts
がメッセージを受診した後にやるべきことは以下です。
- 現在開いているページのTLからTweetsの内容を取得(
get_tweets
) - COTOHA APIのアクセストークンを取得する(
get_access_token
) - 取得したTweetsのネガポジ判定をする(
get_tweet_sentiment
) - ポジティブでないTweetsと近しい内容の松岡修造語録を取得する(
get_similar_shuzo_quote
) - ポジティブでないTweetsの内容を松岡修造語録に変換する(
change_tweet
)
ひとまずそれぞれの処理の詳細はおいておいて、background
のメッセージを受け取って、それぞれの処理を実行する外形を作ります。
// backgroundからメッセージを受信して処理を実行する
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
if ( request == "clicked" ) {
// 現在開いているページのTLからTweetsの内容を取得(`get_tweets`)
tweets = get_tweets()
// COTOHA APIのアクセストークンを取得する(`get_access_token`)
get_access_token().then((access_token) => {
// tweetsから1件ずつtweetに格納して処理する
for (let tweet of tweets) {
// 取得したtweetのネガポジ判定をする(`get_tweet_sentiment`)
get_tweet_sentiment(access_token, tweet).then(sentiment => {
// tweetがポジティブでないと判断した場合、処理を継続。
if (sentiment != "Positive") {
// ポジティブでないtweetと近しい内容の松岡修造語録を取得する(`get_similar_shuzo_quote`)
get_similar_shuzo_quote(access_token, tweet).then(new_tweet => {
// ポジティブでないtweetの内容を松岡修造語録に変換する(`change_tweet`)
change_tweet(tweet, new_tweet)
})
}
})
}
})
}
})
かなり段階的になってしまっていますが、内容自体は単純です。あとは5つの関数を定義していくだけです。
メッセージのやりとりについては「Chrome 拡張機能で background scripts から content scripts にメッセージを送信する - to-me-mo-rrow - 未来の自分に残すメモ -」の記事も参考にさせていただきました。
それでは、ここからは各ファンクションをコーディングしていきます!
get_tweets
get_tweets
はTLからTweetを抜き出してくる機能にします。
jQueryで文字列は要素.text()
で抽出することができます。要素の特徴(id
とかclass
とか)さえわかってしまえば造作もありません。
ということでTwitterのTLの構造を理解するためにbody
タグを抽出してみます!
<body cz-shortcut-listen="true" style="background-color: rgb(255, 255, 255);">
<!-- 省略 -->
<div id="react-root" style="height:100%;display:flex;">
<div class="css-1dbjc4n r-13awgt0 r-12vffkv" data-reactroot="">
<div class="css-1dbjc4n r-13awgt0 r-12vffkv">
<!-- 省略 -->
<div class="css-1dbjc4n r-18u37iz r-1pi2tsx r-13qz1uu r-417010" data-at-shortcutkeys="{"n":"新しいツイート","Cmd Enter":"ツイートを送信","m":"ダイレクトメッセージを作成","/":"検索","l":"いいね","r":"返信","t":"リツイート","s":"ツイートを共有","b":"ブックマーク","u":"アカウントをミュート","x":"アカウントをブロック","Enter":"ツイートの詳細を開く","o":"画像を開く","?":"ショートカットのヘルプ","j":"次のツイート","k":"前のツイート","Space":"ページ下へ移動",".":"最新ツイートを読み込む","g h":"ホーム","g e":"話題を検索","g n":"通知","g r":"@ツイート","g p":"プロフィール","g l":"いいね","g i":"リスト","g m":"ダイレクトメッセージ","g s":"設定","g b":"ブックマーク","g u":"プロフィールページを見る...","g d":"表示設定"}" aria-hidden="false" style="min-height: 789px;">
<!-- 省略 -->
<main role="main" class="css-1dbjc4n r-1habvwh r-16y2uox r-1wbh5a2">
<div class="css-1dbjc4n r-150rngu r-16y2uox r-1wbh5a2 r-1obr2lp">
<div class="css-1dbjc4n r-aqfbo4 r-16y2uox">
<div class="css-1dbjc4n r-1oszu61 r-1niwhzg r-18u37iz r-16y2uox r-1wtj0ep r-2llsf r-13qz1uu">
<div class="css-1dbjc4n r-14lw9ot r-1tlfku8 r-1ljd8xs r-13l2t4g r-1phboty r-1jgb5lz r-11wrixw r-61z16t r-1ye8kvj r-13qz1uu r-184en5c" data-testid="primaryColumn">
<div class="css-1dbjc4n">
<!-- 省略 -->
<div class="css-1dbjc4n r-1jgb5lz r-1ye8kvj r-13qz1uu">
<div class="css-1dbjc4n">
<div class="css-1dbjc4n">
<section aria-labelledby="accessible-list-1" role="region" class="css-1dbjc4n">
<h1 aria-level="1" dir="auto" role="heading" class="css-4rbku5 css-901oao r-4iw3lz r-1xk2f4g r-109y4c4 r-1udh08x r-wwvuq4 r-u8s1d r-92ng3h" id="accessible-list-1">ホームタイムライン</h1>
<div aria-label="タイムライン: ホームタイムライン" class="css-1dbjc4n">
<div style="padding-bottom: 0px;">
<div style="padding-top: 0px; padding-bottom: 9800px;">
<!-- ここから1つのTweetはじまり -->
<div>
<div class="css-1dbjc4n r-my5ep6 r-qklmqi r-1adg3ll">
<article aria-haspopup="false" role="article" data-focusable="true" tabindex="0" class="css-1dbjc4n r-1loqt21 r-1udh08x r-o7ynqc r-1j63xyz">
<div class="css-1dbjc4n">
<div class="css-1dbjc4n r-1j3t67a">
<div class="css-1dbjc4n r-18u37iz r-thb0q2">
<div class="css-1dbjc4n r-1iusvr4 r-16y2uox r-5f2r5o r-m611by">
<!-- 省略(●●がリツイート、など) -->
</div>
</div>
<div class="css-1dbjc4n r-18u37iz r-thb0q2" data-testid="tweet">
<div class="css-1dbjc4n r-1awozwy r-18kxxzh r-5f2r5o" style="flex-basis: 49px;">
<!-- 省略(ユーザーアイコン) -->
</div>
<div class="css-1dbjc4n r-1iusvr4 r-16y2uox r-1777fci r-5f2r5o r-1mi0q7o">
<div class="css-1dbjc4n">
<!-- 省略(ユーザー名など) -->
</div>
<div class="css-1dbjc4n">
<div class="css-1dbjc4n">
<div lang="ja" dir="auto" class="css-901oao r-hkyrab r-gwet1z r-a023e6 r-16dba41 r-ad9z0x r-bcqeeo r-bnwqim r-qvutc0">
<span class="css-901oao css-16my406 r-gwet1z r-ad9z0x r-bcqeeo r-qvutc0">ここにTweetの文字列が表示されます!!</span>
</div>
</div>
<div class="css-1dbjc4n">
<!-- 省略(リンク・画像など) -->
</div>
<div role="group" class="css-1dbjc4n r-18u37iz r-1wtj0ep r-156q2ks r-1mdbhws">
<!-- 省略(リツイートアイコンなど) -->
</div>
</div>
</div>
</div>
</div>
</div>
</article>
</div>
</div>
<!-- ここまで1つのTweet -->
<!-- あとはこのTweetブロックの繰り返し -->
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</div>
<div class="css-1dbjc4n r-aqfbo4 r-zso239 r-1jocfgc" data-testid="sidebarColumn">
<!-- 省略(サイドメニューなど) -->
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</div>
</div>
<!-- 省略(scripts) -->
</body>
なにこれ長っ!深っ!複雑!
「ここにTweetの文字列が表示されます!!」っていうところが実際のTweetの文字列なんですが、深すぎる。複雑すぎる!すごくやめたいと思いました笑
ここは泥臭く構造を辿っていくしかないですね...特出すべきは各Tweetの文字列のspan
の直前のdiv
タグにだけlang="ja"
属性がついていることでしょうか。これをキーワードにして属性を取り出し、その中のテキストをtext()
メソッドを使って取り出してみます。
...
function get_tweets() {
tweets = []
$("div[lang='ja']").each(function(i, target) {
tweets.push($(target).text())
})
return tweets
}
...
これで表示されているTweetの文字列を配列で関数の呼び出し元に返してあげるプログラムを組むことができました。
このtweetsをCOTOHA APIで解析するために次はアクセストークンの払い出しを実行します。
get_access_token
ここからはCOTOHA APIと通信を行っていくのですが、CROBが効いてしまうのでcontent.js
からbackground.js
にメッセージを送り、background.js
経由でCOTOHA APIを利用するようにします。
...
function get_access_token() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{
contentScriptQuery: "get_access_token"
},
(response) => { resolve(response) }
)
})
}
...
content.js
からはcontentScriptQuery: "get_access_token"
をキーワードにしてメッセージを送ります。
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
cotoha_api_base_url = "https://xxxxxxxxxx.com/" // COTOHA APIのアカウントホームページで確認
// アクセストークンの払い出し
if (request.contentScriptQuery == "get_access_token") {
cotoha_access_token_publish_url = "https://xxxxxxxxxx.com/access_token" // COTOHA APIのアカウントホームページで確認
cotoha_client_id = "xxxxxxxxxx" // COTOHA APIのアカウントホームページで確認
cotoha_client_secret = "xxxxxxxxxx" // COTOHA APIのアカウントホームページで確認
fetch(cotoha_access_token_publish_url, {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
grantType: "client_credentials",
clientId: cotoha_client_id,
clientSecret: cotoha_client_secret
})
})
.then(response => response.json())
.then(body => sendResponse(body.access_token))
}
return true
}
)
background.js
では、chrome.runtime.onMessage.addListener
でメッセージを受け取ります。request
の中に先ほどのcontentScriptQuery
といった引数を受け取ることができ、sendResponse
にデータを入れることでメッセージをお返しします。
APIはfetch
を使って実行しています。
使い方は「Fetch を使う - Web API | MDN」などを参考に。
APIはかなりシンプルなので、特に迷うことはないかと思います。 => 「アクセストークン取得 | リファレンス | COTOHA API」
最後にsendResponse(body.access_token)
でAPIのレスポンスからaccess_token
を取得してcontent.js
に投げ返してあげています。
これで他のAPIを使う準備ができたので、次はTweetsのネガポジ判定をしてみましょう!
get_tweet_sentiment
content.js
ではtweets
とaccess_token
を取得した状態になっており、get_tweet_sentiment
が実行されるタイミングではtweets
からひとつずつtweet
を取り出してget_tweet_sentiment
にaccess_token
とtweet
を引き渡しています。
今回もAPIはかなりシンプルですので、とくに難しいところはないです。 => APIリファレンス | COTOHA API(感情分析)
sentence
に対象の文章を与えるだけで、その文章の感情分析や感情を強く感じる単語を抽出してくれます。レスポンスとして、result.sentiment
でその文章が『Positive』なのか『Negative』なのか『Neutral』なのかを教えてくれます。
...
function get_tweet_sentiment(access_token, tweet) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{
contentScriptQuery: "get_tweet_sentiment",
access_token: access_token,
tweet: tweet
},
(response) => { resolve(response) }
)
})
}
...
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
...
// ツイートのネガポジ判定
if (request.contentScriptQuery == "get_tweet_sentiment") {
cotoha_sentiment_api_endpoint = "nlp/v1/sentiment"
fetch(cotoha_api_base_url + cotoha_sentiment_api_endpoint, {
method: 'POST',
headers: {
"Content-Type": "application/json; charset=UTF-8",
Authorization: `Bearer ${request.access_token}`
},
body: JSON.stringify({
sentence: request.tweet
})
})
.then(response => response.json())
.then(body => {
sendResponse(body.result.sentiment)
})
}
...
return true
}
)
content.js
から送っているtweet
を検査対象として、sendResponse
で感情分析結果をcontent.js
に返しています。
感情分析結果はPositive
, Neutral
, Negative
の3種類から判定されますが、今回はPositive
なTweetであふれかえすのが目的なのでNeutral
とNegative
を松岡修造語録へ変換する対象とします。
...
get_tweet_sentiment(access_token, tweet).then(sentiment => {
// tweetがポジティブでないと判断した場合、処理を継続。
if (sentiment != "Positive") {
// ポジティブでないtweetと近しい内容の松岡修造語録を取得する(`get_similar_shuzo_quote`)
get_similar_shuzo_quote(access_token, tweet).then(new_tweet => {
// ポジティブでないtweetの内容を松岡修造語録に変換する(`change_tweet`)
change_tweet(tweet, new_tweet)
})
}
})
...
そのため、content.js
でも!= "Positive"
として、Positiveでなければその次のget_similar_shuzo_quote
に進むようにコーディングしています。
get_similar_shuzo_quote
次に、Positiveではないと判定されたtweetに対して、一番内容の近い松岡修造語録を取得する関数を作っていきます。
COTOHA APIの類似度算出APIを利用しますが、これも大変シンプルで2つの文章(s1
, s2
)を与えるだけでその2つの文章の内容の類似度を教えてくれます。 => APIリファレンス | COTOHA API(類似度算出)
松岡修造さんのポジティブな名言は色々とあるのですが、COTOHA APIの無料枠では1日1000リクエスト/APIまでと決められていることもあり、5つに厳選してみました。
100回叩くと壊れる壁があったとする。でもみんな何回叩けば壊れるかわからないから、90回まで来ていても途中であきらめてしまう。
みんな!!竹になろうよ。竹ってさあ台風が来てもしなやかじゃない。台風負けないんだよ。雪が来てもね。おもいっきりそれを跳ね除ける!!力強さがあるんだよ。そう、みんな!!!竹になろう!!!バンブー!!!
ベストを尽くすだけでは勝てない。僕は勝ちにいく。
もっと熱くなれよ!熱い血燃やしてけよ!!人間熱くなったときがホントの自分に出会えるんだ!!
一番になるっていったよな?日本一なるっつったよな!ぬるま湯なんかつかってんじゃねぇよお前!!
どれも元気が出ますね。
Positiveと判断されなかったtweetとこの5つの名言をひとつずつ類似度算出APIで比較し、最も類似度の高かった名言を返却する関数を作ります。
...
function get_similar_shuzo_quote(access_token, tweet) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{
contentScriptQuery: "get_similar_shuzo_quote",
access_token: access_token,
tweet: tweet
},
(response) => { resolve(response) }
)
})
}
...
content.js
からはPositive判定されなかったtweetをメッセージングします。
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
...
// ポジティブでないツイートを松岡修造の熱い名言から最も近しいものに変える
if (request.contentScriptQuery == "get_similar_shuzo_quote") {
cotoha_similarity_api_endpoint = "nlp/v1/similarity"
function get_similarity(s1, s2) {
return new Promise((resolve, reject) => {
fetch(cotoha_api_base_url + cotoha_similarity_api_endpoint, {
method: 'POST',
headers: {
"Content-Type": "application/json; charset=UTF-8",
Authorization: `Bearer ${request.access_token}`
},
body: JSON.stringify({
s1: s1,
s2: s2
})
})
.then(response => response.json())
.then(body => resolve([s2, body.result.score]))
})
}
Promise.all([
get_similarity(request.tweet, "100回叩くと壊れる壁があったとする。でもみんな何回叩けば壊れるかわからないから、90回まで来ていても途中であきらめてしまう。"),
get_similarity(request.tweet, "みんな!!竹になろうよ。竹ってさあ台風が来てもしなやかじゃない。台風負けないんだよ。雪が来てもね。おもいっきりそれを跳ね除ける!!力強さがあるんだよ。そう、みんな!!!竹になろう!!!バンブー!!!"),
get_similarity(request.tweet, "ベストを尽くすだけでは勝てない。僕は勝ちにいく。"),
get_similarity(request.tweet, "もっと熱くなれよ!熱い血燃やしてけよ!!人間熱くなったときがホントの自分に出会えるんだ!!"),
get_similarity(request.tweet, "一番になるっていったよな?日本一なるっつったよな!ぬるま湯なんかつかってんじゃねぇよお前!!")
]).then((results) => {
// 類似度を比較して一番類似している名言をsendResponseで返却する。
most_similar_quote = ["", 0] // [名言, 類似度(score)]
for (result of results) {
if (most_similar_quote[1] < result[1]) {
most_similar_quote = result
}
}
sendResponse(result[0])
})
}
return true
}
)
これはかなり試行錯誤しました...javascript不慣れなもので非同期処理がかなりつまづいた...
類似度算出APIについては先ほどものべた通りとてもシンプルです。APIを叩く部分をget_similarity
に関数化しました。
今回は5つの文章との比較が全部終わったあとで類似度を比較して一番近い名言を決める、ということがしたかったのですが、javascriptは非同期に処理を勧めてくれるおかげで、他の言語を書いているイメージのまま書くとAPIのレスポンスが返ってくる前に次の処理が進んでしまうなんてことがありました。
これを回避するために、今回はPromise
とPromise.all
を使っています。
細かい使い方は「JavaScriptのPromise - Qiita」を参考にさせていただきました!
Promise.all
のおかげで、5つの名言とtweetの類似度比較が全て終わってから類似度の比較を行うことができるようになっています。
比較の結果、最も類似度の高い名言がcontent.js
にレスポンスとしてメッセージングされています。
さて、これでPositiveではないTweetに変わる松岡修造語録をゲットすることができました!
あとはこれをViewに反映するだけです!!
change_tweet
最後にDOM操作です。Positiveではないと判定されたtweet
の文字列を、それに類似した松岡修造語録new_tweet
に書き換えてあげます。
ついでに文字も真っ赤に燃やしてみましょう。
...
function change_tweet(tweet, new_tweet) {
target = $(`div[lang='ja']:contains(${tweet})`)
$(target).text(new_tweet)
$(target).css("color", "red")
}
...
最初の方で解析した通り、Tweetの文字列はlang='ja'
属性がついているdiv
タグの配下にあります。contains
を使ってPositiveではないと判断されたtweet
をもつ要素を探してtarget
に格納します。
そしてそのtarget
に対してtext
で文字列を松岡修造語録に変え、css
でテキストカラーを真っ赤に変えてみました。
あんなにネガティブだった僕のプロフィールページがこんなにもポジティブに!!
さて、ここまでできたので、Chrome extensionをchrome://extensions/
からインストールして動作確認をしてみます!
他の方のtweetを出すのもあれなので自分のアカウントのプロフィールページでやっていますが、トップページでもどうように動きます。
各tweetに対して非同期で処理を実行しているのでAPIの処理などが終わったtweetから反映されていくんですね。
まとめ
あんなにネガティブだった僕のTLをCOTOHA APIと松岡修造さんのおかげでこんなにもポジティブで熱いTLに変えることができました。
類似度のところは毎回 tweets x quotes 回API叩くのも無駄ですね...Doc2Vecとかならうまくいくのかな...(初心者)
とりあえず自信を持って生きていこうと思います!!
ソースコード
今回のソースコードをGitHubにあげました。