曲の歌詞を全て魚の名前にしたい。(単語編)


前置き

サカナ芸人ハットリさんをご存知だろうか?

彼は曲の歌詞を全て魚に変えるネタで有名な芸人である。

この記事を読む前に次の動画を見ていただきたい。

さかな芸人ハットリ 魚の名前で小さな恋の歌

僕も魚に詳しくなくても魚の替え歌を歌えるようになりたいッッッッッッッッ!!

そういえば、数年前に芸人のゴー☆ジャスさんを作っている先輩がいた気がする。

@atsukoba23 ゴー☆ジャス(宇宙海賊)をつくる

これの応用でどうにか作れないだろうか?

まずは魚の名前一覧を集める。

世界には2万種を超える魚が存在し、約3000種が日本の近海で獲れる魚らしい。

以下の魚図鑑サイトが一番スクレイピングしやすそうだったので、一旦そこから拝借します。

'https://aqua.stardust31.com/namae.shtml'

以下スクレイピングコード

scrape.py
import requests
from lxml import html
from bs4 import BeautifulSoup
from pprint import pprint as pp
import re

URL = 'https://aqua.stardust31.com/namae.shtml'


def creat_fish_csv():
    session = requests.session()
    res = session.get(URL) 
    res.encoding = res.apparent_encoding
    res.raise_for_status() # ステータスエラーを検知
    soup_data = BeautifulSoup(res.text, 'html.parser') # レスポンスをXpathで検索できる形式にパース
    lxml_data = html.fromstring(str(soup_data))
    fish_name_span_tag = lxml_data.xpath('//*[@id="aqua-main"]//table//table//a[contains(@href,"/")]//*[string-length(normalize-space(text()))> 1]')
    fish_name = [fish.text_content() for fish in fish_name_span_tag]
    fish_list = [re.sub(r'(\(|\)|\s)','',fish) for fish in fish_name]
    pp(fish_list)
    with open('fishes.csv', 'w') as f:
        for fish in fish_list:
            f.write(fish+',')

if __name__ == '__main__':
    creat_fish_csv()

結果的に 742のデータが集まりました。

日本の近海にいる魚が3000種類ほどいることを考えると少し微妙ですが、ここに関しては今後随時精度を高めていくことにします。

単語を魚の名前に変換する

次にレーベンシュタイン距離を用いて、歌詞に最も近い魚の名前を推定します。

ゴー☆ジャス(宇宙海賊)をつくる

こちらのコードをがっつり参考にすることで割と簡単に作ることができました。

wtf.py
import MeCab
from pykakasi import kakasi
import re
from pprint import pprint as pp
from Levenshtein import distance as D
import requests
from lxml import html
from bs4 import BeautifulSoup
import re

class WordToFish:
    def __init__(self, **kwargs) -> None:
        k = kakasi()
        k.setMode('K', 'a')  # Katakana to ascii
        self.conv = k.getConverter()
        self.tagger = MeCab.Tagger()
        self.fishes = self.read_fishes(**kwargs)
        self.fishes_roman = [self.romanize(fish) for fish in self.fishes]
        self.fishes_roman_vowel = [self.extract_vowel(
        self.romanize(fish)) for fish in self.fishes]
        self.recent_answer = ""
        return

    def read_fishes(self, fname="fishes.csv", **kwargs) -> list:
        """
        Read csv file 
        https://aqua.stardust31.com/namae.shtml
        """
        with open(fname, "r") as f:
            fishes = f.read().split(",")
        return fishes

    def katakanize(self, s: str, morph=False, **kwargs) -> str:
        morphed = [re.split("[,\t\s\n]", w) for w in self.tagger.parse(s).split("\n")]
        morphed.remove([""])
        morphed.remove(["EOS"])
        k = [morph[1] if morph[1] != "*" else morph[0] for morph in morphed]
        if morph:  # morphlogical analysed output
            return k
        return "".join(k)

    def romanize(self, s: str, **kwargs) -> list:
        s = self.katakanize(s, **kwargs)
        if type(s) == str:
            s = [s]
        return [self.conv.do(w) for w in s]

    def extract_vowel(self, word: str, **kwargs) -> str:
        if type(word) == list:
            return [self.extract_vowel(w) for w in word]
        return "".join([l for l in word if l in ["a", "i", "u", "e", "o", "n"]])


    def done(self, sentence: str, **kwargs) -> str:
        """
        """
        # default kargs
        n_result = kwargs.get('n_result', 5)
        vowel = kwargs.get('vowel', False)

        print("INPUT: ", sentence)
        # sentence -> [words] -> [katakana] -> [roman]
        word_roman = self.romanize(sentence, **kwargs)
        print("ROMAN: ", word_roman)

        if vowel:
            word_vowel = self.extract_vowel(word_roman)
            print("VOWEL: ", word_vowel)
            dists = [D(word_vowel[-1], nation[0]) for nation in self.fishes_roman_vowel]
        else:
            dists = [D(word_roman[-1], nation[0]) for nation in self.fishes_roman]
        idx = sorted(range(len(dists)), key=lambda k: dists[k])

        # logging
        print("RESULT:")
        for i in range(n_result):
            if vowel:
                print(f"\tNo.{i+1} : {self.fishes[idx[i]]} ({self.fishes_roman_vowel[idx[i]]}) : ({dists[idx[i]]})")
            else:
                print(f"\tNo.{i+1} : {self.fishes[idx[i]]} ({self.fishes_roman[idx[i]]}) : ({dists[idx[i]]})")
        self.recent_answer = self.fishes[idx[0]]

        return self.recent_answer

テスト

試しにいくつかの短い単語を入れてみます。
まずは小さな恋の歌の歌い出しの「広い」と「宇宙の」を入力
本家は
「広い」→「ヒゴイ」
「宇宙の」→「ウツボ」

INPUT:  広い
ROMAN:  ['hiroi']
RESULT:
 No.1 : ニゴイ (['nigoi']) : (2)
 No.2 : アイゴ (['aigo']) : (3)
 No.3 : イトウ (['itoo']) : (3)
 No.4 : イラ (['ira']) : (3)
 No.5 : ギギ (['gigi']) : (3)

INPUT:  宇宙の
ROMAN:  ['uchuuno']
RESULT:
        No.1 : ウツボ (['utsubo']) : (4)
        No.2 : タチウオ (['tachiuo']) : (4)
        No.3 : アオウオ (['aouo']) : (5)
        No.4 : イタチウオ (['itachiuo']) : (5)
        No.5 : ウグイ (['ugui']) : (5)

ヒゴイは一覧に取得できていなかったので表示されませんでしたが、ニゴイでも十分に韻を踏めているので合格点といえます。また太刀魚とウツボが同率一位になっていますが、ウツボの方が韻が踏めているので、合格ラインです。

個人的に好きなサビ前の「二人を変える」はどうでしょうか。
本家 二人を変える → フタイロカエルウオ

INPUT:  二人を変える
ROMAN:  ['futariokaeru']
RESULT:
        No.1 : フタイロカエルウオ (['futairokaeruuo']) : (4)
        No.2 : クサビベラ (['kusabibera']) : (7)
        No.3 : アカメバル (['akamebaru']) : (8)
        No.4 : イタセンパラ (['itasenpara']) : (8)
        No.5 : ウスメバル (['usumebaru']) : (8)

これはしっかり本家と同じフタイロカエルウオができてますねぇ〜(ニッコリ)

考察

・単語の変換はそこそこの精度でできそう。
・魚の名前の取得が大変そう(スクレイピングが面倒、地域ごとに呼び名が違う)
・より長い単語を一匹の魚に置き換えれると面白いと感じる。
・レーベンシュタイン距離が同じであれば、より母音が一致している方が面白い
・単語の長さとレーベンシュタイン距離はトレードオフの関係にある
・歌詞の都合上、間奏の前後は繋げられない。

次回

歌詞を"適切な"長さに区切るコードを実装する。