「アイネクライネナハトムジーク」に出てくる斉藤さんをミスチルの桜井さんとして再現してみた

# はじめに
伊坂幸太郎の小説[「アイネクライネナハトムジーク」](https://www.amazon.co.jp/%E3%82%A2%E3%82%A4%E3%83%8D%E3%82%AF%E3%83%A9%E3%82%A4%E3%83%8D%E3%83%8A%E3%83%8F%E3%83%88%E3%83%A0%E3%82%B8%E3%83%BC%E3%82%AF-%E5%B9%BB%E5%86%AC%E8%88%8E%E6%96%87%E5%BA%AB-%E4%BC%8A%E5%9D%82%E5%B9%B8%E5%A4%AA%E9%83%8E-ebook/dp/B0746CS4T6)という作品をご存知でしょうか。
小説好きな人なら分かるかもしれませんね。
割と日常っぽい感じの短編集なのですが、
その作品には「斉藤さん」という人物が登場します。
百円を払って「今、こんな気持ちです」「こんな状況です」と話をすると、
斉藤さんがパソコンでその客の気分にマッチする曲の一部を流してくれます。
曲は斉藤さんだけあって斉藤和義の曲の一部が流れるようになっています。

そこで感情分析を使って同じようなことができるのではないかと考え、
今回作ってみることにしました。
アーティストは何でも良いですが、私自身がミスチルファンということで
桜井さんというキャラクターにして作りました。

今回は[COTOHA API](https://api.ce-cotoha.com/contents/index.html)の感情分析を使います。
ちょうど[キャンペーン](https://zine.qiita.com/event/collaboration-cotoha-api/)もやってるみたいですし、参加も兼ねて。

# どんなものを作ったか
説明するよりまずはものを作ったのかを見たほうが早いということで。

* 入力前

```
あなた：
```

* 入力

```
あなた：毎日が単調でつまらない。何かいいことないかな。
```

* 出力

```
あなた：毎日が単調でつまらない。何かいいことないかな。
桜井さん：東京―パリ間を行ったり来たりして 順風満帆の20代後半だね バブリーな世代交代の波押し退けて クライアントに媚び売ったりなんかして [デルモ]
```

なんとでもとれますが、
パリにしょっちゅう行くような仕事をし、順風満帆な人生が来ることがあるよと解釈できそうです。
これはミスチルの楽曲「デルモ」の歌詞の一部となります。


# システム概要
大きく①データ構築と②フレーズ検索の処理の２つに分かれます。

![sakuraisan-image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/226827/08d1c318-edc7-2caa-ff2b-b92ce6f5a78c.png)

①データ構築
歌ネットから歌詞をスクレイピングして、
フレーズに分割する。（歌詞全体のうちの１段落を１フレーズとする）
各フレーズを感情分析APIにかけて分析結果をDBに保存。

②フレーズ検索
客が状況を説明してその内容を感情分析APIにかけて分析結果を取得。
その後、スコアが近いフレーズをDBから検索する。
検索は、スコアの範囲検索を、範囲を広めながら実施し、
複数のフレーズが取得できた場合はランダムに１つ選択し、客に返信する。

# 実行結果
いくつか実行して面白かったのを載せます。

* 仕事でいいことあったよ

```
あなた：今日仕事でめっちゃ褒められて嬉しい！
桜井さん：イライラして過ごしてんなら愛を補充 君へと向かう恋の炎が燃ゆる 向かいの家の柴犬にも「ハイ ポンジュール!」 あぁ世界は薔薇色 ここは そう CENTER OF UNIVERSE 僕こそが中心です あぁ世界は素晴らしい [CENTER OF UNIVERSE]
```
前半だけ見ると、あれイライラしてる？ってなったけど、
後半めっちゃ陽気なことを歌ってます。
仕事うまくいってる俺は世界の覇者なり！的な。

* 受験勉強に失敗しちゃった

```
あなた：受験勉強失敗した。また来年頑張ろうかな。。。
桜井さん：思い切り息を吸い込んで この想いを空に放ちたい 自分の世界に閉じこもった 冴えない気分から抜け出して どんなときだってサンライズ この胸に輝かせていたいんだ [SUNRISE]
```
とりあえず息吸い込んで冴えない気分から抜け出せよ的な。ネガティブな感じでてるけど、結構励ます感じも出てますね。

* とある曲のタイトルをそのまま気持ちにして伝えてみた

```
あなた：もう恋なんてしないなんて言わないよ絶対
桜井さん：聞こえてくる 流れてくる 君を巡る 抑えようのない想いがここにあんだ 耳を塞いでも鳴り響いてる [365日]
```
好きでたまらない気持ちがマッチしてますね。

* 小説に出てくる日高さんと同じ心境を伝えてみた

```
あなた：実は付き合っている彼女と別れようと思ってるんだ
桜井さん：もう いいでしょう!? これで終わりにしよう ねぇ どうでしょう!? 君だってそう思うでしょ!? [I]
```
もう終わらせていいよと伝えてるのかもしれません。



# コード
興味ある方はみてみてください。
とりあえず動くものを作ったのであまり整理されていないのはご承知の上で。。。

<details><summary>DBスキーマ</summary><div>

MYSQLを使っています。
今回は事前にMr.Childrenをアーティスト情報として登録します。

```mysql
create database sakurai;

create table artist
  (artist_id smallint auto_increment not null primary key,
  artist_name varchar(100));

insert into artist (artist_name) values('Mr.Children');

create table title
  (title_id smallint auto_increment not null primary key,
  title varchar(100),
  artist_id smallint);
 
create table lyric
 (title_id smallint,
  phrase_id int auto_increment not null primary key,
  phrase varchar(1000),
  score float,
  sentiment tinyint);
```

</div></details>

<details><summary>Pythonコード</summary><div>

```python:sakuraisan.py
# -*- coding: utf-8 -*-

import random, requests, json, sys, time
import urllib.request
import mysql.connector as mydb
import pandas as pd

from bs4 import BeautifulSoup

ARTIST_ID = 1 # DBに事前に登録するアーティストのID
AGENT_NAME = '桜井さん' # 答えてくるエージェントの名前

# COTOHA APIクラス
class CotohaApi():
    def __init__(self):
        self.COTOHA_ACCESS_INFO = {
            "grantType": "client_credentials",
            "clientId": "<ご自身のClient ID>",
            "clientSecret": "<ご自身のClient Secret>"
        }
        self.ACCESS_TOKEN_PUBLISH_URL = '<ご自身のAccess Token Publish URL>'
        self.BASE_URL = '<ご自身のAPI Base URL'

        self.ACCESS_TOKEN = self.get_access_token()

    # アクセストークンの取得
    def get_access_token(self):
        headers = {
            "Content-Type": "application/json;charset=UTF-8"
        }
        access_data = json.dumps(self.COTOHA_ACCESS_INFO).encode()
        request_data = urllib.request.Request(self.ACCESS_TOKEN_PUBLISH_URL, access_data, headers)
        token_body = urllib.request.urlopen(request_data)
        token_body = json.loads(token_body.read())
        self.access_token = token_body["access_token"]
        self.headers = {
            'Content-Type': 'application/json;charset=UTF-8',
            'Authorization': 'Bearer {}'.format(self.access_token)
        }

    # 感情分析APIを実施、分析結果を返却
    def sentiment_analysis(self, text):
        request_body = {
            'sentence': text
        }
        url = self.BASE_URL + 'nlp/v1/sentiment'
        text_data = json.dumps(request_body).encode()
        request_data = urllib.request.Request(url, text_data, headers=self.headers, method='POST')
        sentiment_result = urllib.request.urlopen(request_data)
        sentiment_result = json.loads(sentiment_result.read())
        return sentiment_result

    # Positive:1, Negative:-1, Neutral:0 に変換
    def convert_sentiment(self, sentiment_in_word):
        if sentiment_in_word == 'Positive':
            return 1
        elif sentiment_in_word == 'Neutral':
            return 0
        elif sentiment_in_word == 'Negative':
            return -1

# DB操作クラス
class DBHandler():
    def __init__(self):
        self.conn = mydb.connect(
            host = '<DBのホスト名>',
            port = '<DBのポート番号>',
            user = '<DBのユーザ名>',
            password = '<DBのパスワード>',
            database = '<DB名>',
            charset='utf8'
        )

        self.conn.ping(reconnect=True)
        self.cur = self.conn.cursor()

# データ構築クラス
class Learn():
    def __init__(self):
        self.FILE_NAME = 'list.csv'
        self.ARTIST_NUMBER = '684' # 歌ネットのアーティストNo.（Mr.Childrenは684）
        self.MAX_PAGE = 2 # 歌ネットのアーティストの曲数一覧のページ数（Mr.Childrenは２ページ存在）

    # 歌詞を歌ネットから収集
    def gather_lyric(self):
        #スクレイピングしたデータを入れる表を作成
        list_df = pd.DataFrame(columns=['曲名', '歌詞'])

        for page in range(1, self.MAX_PAGE + 1):
            #曲ページ先頭アドレス
            base_url = 'https://www.uta-net.com'

            #歌詞一覧ページ
            url = 'https://www.uta-net.com/artist/' + self.ARTIST_NUMBER + '/0/' + str(page) + '/'
            response = requests.get(url)
            soup = BeautifulSoup(response.text, 'lxml')
            links = soup.find_all('td', class_='side td1')

            for link in links:
                a = base_url + (link.a.get('href'))

                #歌詞詳細ページ
                response = requests.get(a)
                soup = BeautifulSoup(response.text, 'lxml')
                title = soup.find('h2').text
                print(title)
                song_lyrics = soup.find('div', itemprop='text')
                
                for lyric in song_lyrics.find_all("br"):
                    lyric.replace_with('\n')
                song_lyric = song_lyrics.text

                #サーバーに負荷を与えないため1秒待機
                time.sleep(1)

                #取得した歌詞を表に追加
                tmp_se = pd.DataFrame([title, song_lyric], index=list_df.columns).T
                list_df = list_df.append(tmp_se)

        #csv保存
        list_df.to_csv(self.FILE_NAME, mode = 'a', encoding='utf8')

    # 歌詞をフレーズに分割し、DBに感情分析結果も含めたデータを登録
    def add_lyric(self):
        db = DBHandler()
        df_file = pd.read_csv(self.FILE_NAME, encoding='utf8')
        song_titles = df_file['曲名'].tolist()
        song_lyrics = df_file['歌詞'].tolist()
        
        # 注意：曲数が多いとCOTOHAの1日に実行できるAPIの上限にひっかかかる（1日100曲程度が目安）
        for i in range(len(song_titles)):

            # タイトルの追加
            title = song_titles[i]

            print("Info: Saving {}...".format(title), end="")
            db.cur.execute(
                """
                insert into title (title, artist_id)
                values (%s, %s);
                """,
                (title, ARTIST_ID)
            )
            db.conn.commit()
            db.cur.execute(
                """
                select title_id from title
                where title= %s
                and artist_id = %s;
                """,
                (title, ARTIST_ID)
            )
            title_id = db.cur.fetchall()[-1][0]

            # 歌詞のフレーズの感情分析結果を登録
            # 二回改行が出現した場合をフレーズ区切りにする
            lyric = song_lyrics[i]
            lyric_phrases = lyric.split('\n\n')
            lyric_phrases = [lyric.replace('\u3000', ' ').replace('\n', ' ') for lyric in lyric_phrases]
            
            # フレーズごとに感情分析APIを利用し、感情分析結果をDBに登録
            cotoha_api= CotohaApi()
            for phrase in lyric_phrases:
                sentiment_result = cotoha_api.sentiment_analysis(phrase)['result']
                sentiment = cotoha_api.convert_sentiment(sentiment_result['sentiment'])
                score = sentiment_result['score']
                
                db.cur.execute(
                    """
                    insert into lyric (title_id, score, sentiment, phrase)
                    values (%s, %s, %s, %s);
                    """,
                    (title_id, score, sentiment, phrase)
                )
                db.conn.commit()

            print("Done")
                
        db.conn.close()
        if db.conn.is_connected() == False:
            print("Info: DB Disonnected")

    def execute(self):
        print("Info: 歌詞を収集中...")
        self.gather_lyric()
        print("Info: 歌詞をDBに追加中...")
        self.add_lyric()

# フレーズ検索クラス
class Search():
    def __init__(self):
        self.SEARCH_SCOPE = [0.01, 0.1, 0.3] # 検索するスコアの幅 SCORE±SEARCH_SCOPEの範囲でリストの順に検索

    def execute(self):
        print("あなた：", end="")
        input_data = input()
        print("{}：".format(AGENT_NAME), end="")
        
        cotoha_api= CotohaApi()
        sentiment_result = cotoha_api.sentiment_analysis(input_data)['result']
        sentiment = cotoha_api.convert_sentiment(sentiment_result['sentiment'])
        score = sentiment_result['score']
        
        db = DBHandler()

        find_flag = 0
        # 検索範囲を徐々に広げながらスコアの近いフレーズを検索
        for scope in self.SEARCH_SCOPE:

            # 最低1件あることを確認
            db.cur.execute(
                """
                select count(phrase_id) from lyric
                join title on lyric.title_id = title.title_id
                where sentiment = %s
                and score between %s and %s
                and artist_id = %s;
                """,
                (sentiment, score-scope, score+scope, ARTIST_ID)
            )
            hit_num = db.cur.fetchall()[-1][0]
            if hit_num > 0:
                find_flag = 1
                break
        
        # 検索結果が１件でも存在すれば、検索結果を取得し、客に返信
        if find_flag == 1:
            db.cur.execute(
                """
                select phrase,title from lyric
                join title on lyric.title_id = title.title_id
                where sentiment = %s
                and score between %s and %s
                and artist_id = %s;
                """,
                (sentiment, score-scope, score+scope, ARTIST_ID)
            )
            search_result = db.cur.fetchall()
            phrase_chosen = random.choice(search_result)
            print("{} [{}]".format(phrase_chosen[0], phrase_chosen[1]))
        else:
            print("いい歌詞が見つからなかった。")
        
        db.conn.close()
        

if __name__ == "__main__":
    args = sys.argv
    if len(args) == 2:
        process = args[1] # コマンドライン引数　learn: DBに歌詞情報を登録、search: DBから似た感情のフレーズを抽出
        if process == 'search':
            searcher = Search()
            searcher.execute()
        elif process == 'learn':
            learner = Learn()
            learner.execute()
        else:
            print("Error: コマンドライン引数を1つ指定 [learn/search]")
    else:
        print("Error: コマンドライン引数を1つ指定 [learn/search]")
```

実行方法は２通りあります。

* データ構築時

```shell
python sakuraisan.py learn
```

* フレーズ検索時

```shell
python sakuraisan.py search
```

</div></details>

# おわりに
今回は感情分析結果のスコアが近い歌詞のフレーズをとってくるというシンプルなアルゴリズムで実装しましたが、
COTOHA APIには単語の感情を取ることもできます。
例えば公式の例にあるように、
「謳歌」という単語には「喜ぶ」「安心」といった感情を付与してくれます。
このあたりの情報もうまく検索に埋め込めたらもっと良い結果が返ってくるのではないかなと思ってます。

また、LINE Botとかにしたら面白いのかなと思ったりしてます。

# 参考
以下の記事を参考にさせていただきました！

* [自然言語処理を簡単に扱えると噂のCOTOHA APIをPythonで使ってみた](https://qiita.com/gossy5454/items/83072418fb0c5f3e269f)
* [【Python】嵐の歌詞をWordCloudで可視化して、結成20年でファンに伝えたかったことを紐解いてみた](https://qiita.com/yuuuusuke1997/items/122ca7597c909e73aad5)
* [Python + mysql-connector-python の使い方まとめ](https://qiita.com/valzer0/items/2f27ba98397fa7ff0d74)