見出し画像

クローリングと形態素解析で「ぼくのかんがえたさいきょうのひっさつわざ」を考案してみた

はじめまして

homie株式会社のエンジニア片山です!

入門したばかりのGolangに慣れるために、クローリングと形態素解析でネタっぽいのをつくってみました。

大人になっても少年時代のロマンは忘れたくないものですね...

「さいきょうのひっさつわざ」の定義

筆者は思春期の聖書である週刊少年ジャンプやコロコロコミックで育った人間なので、ベタで王道な必殺技こそ正義であり、一番かっこいい必殺技なのだ!と思い込んでいます。

かなり偏った思想であることは自覚しているので異論は認めます。

今回はベストオブ王道な必殺技 = 「さいきょうのひっさつわざ」として定義して考えます。

完成したもの

レポジトリはこちらです。

スクリーンショット 2021-10-01 13.26.51

上記のように必殺技に含まれる単語の中で出現頻度が多いものをランキング形式で発表してくれます。
後は思うがままに組み合わせて自分だけの「さいきょうのひっさつわざ」を生み出しましょう!

やること

①WEBサイトをクローリングして必殺技データを収集する
②必殺技を形態素分析して名詞のみを抽出
③使われる頻度ごとにランキング形式で発表

①WEBサイトをクローリングして必殺技データを収集する

クローリング対象のWEBページ

今回クローリングさせて頂くWEBサイトはこちらです。

ページ構成としては、作品名ごとに五十音で分けられており、
「あ 」~「ん」までの51ページとなります。

収集させていただいた必殺技の総数は184392件となります。

クローリング用ライブラリ

今回はgoqueryを採用しました。

直感的にDOMが扱えるので結構気に入ってます。

実装

クローリング対象のエンドポイントは下記のようにhtmファイルのファイル名部分が可変となっており、「a」から「nn」までの五十音で分かれています。これらのエンドポイントを順番にクローリングしていきます

http://hissatuwaza.kill.jp/list/ka.htm  // 「か」から始まる作品の必殺技
http://hissatuwaza.kill.jp/list/ki.htm  // 「き」から始まる作品の必殺技

まず、五十音の文字列を1文字づつ分割し、それぞれをローマ字に変換することで、ローマ字の配列を生成します

const hiragana = "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん"
// ↑の文字列から↓のような配列を生成する
// romaAlphabets := ["a", "i", "u", "e", "o", "ka"....]

エンドポイントに含まれるローマ字はヘボン式・訓令式どちらでもないパターンが一部に見られ、一律に対応するのは不可能なため、
大部分をヘボン式に変換し、対応が難しいものは下記のようにそれぞれ対応しています。(イケてない...)

ちなみにヘボン式変換用のライブラリはこちらを拝借しました。


func genRomaAlphabetKanas() []string {
	kanaChars := strings.Split(hiragana, "")
	romaAlphabets := make([]string, len(kanaChars))
	for i, kc := range  kanaChars{
		switch kc {
		case "ち":
			romaAlphabets[i] = jaconv.ToHebon("ti")
			continue
		case "つ":
			romaAlphabets[i] = jaconv.ToHebon("tu")
			continue
		case "ふ":
			romaAlphabets[i] = jaconv.ToHebon("hu")
			continue
		case "を":
			romaAlphabets[i] = jaconv.ToHebon("wo")
			continue
		case "ん":
			romaAlphabets[i] = jaconv.ToHebon("nn")
			continue
		}
		romaAlphabets[i] = jaconv.ToHebon(kc)
	}
	return romaAlphabets
}

生成されたローマ字配列をもとにクローリングしていきます。

func fetchSkills(romaAlphabets []string) (skillStr string) {
	for _, ra := range romaAlphabets {
		fetchUrl := fetchUrlBase + ra +".htm" // ex. http://hissatuwaza.kill.jp/list/ki.htm
		resp, err := http.Get(fetchUrl)
		if err != nil {
			errors.New("failed get response from " + fetchUrl)
		}
		defer resp.Body.Close()
		doc, err := goquery.NewDocumentFromResponse(resp)
		if err != nil || resp.StatusCode != http.StatusOK {
			errors.New("failed get document from " + fetchUrl)
		}
		doc.Find(baseSelector).Each(func(i int, s *goquery.Selection) {
			s.Find("a").Each(func(j int, y *goquery.Selection) {
				// 必殺技の()内の読みがなを除去
				skill := regexp.MustCompile(`[((].*[))]`).ReplaceAllString(y.Text(), "")
				skillStr += " " + skill
			})
		})
	}
	return skillStr
}

結果がこちら
ひとまずクローリングできてそうですね!

忍法 床磨きの術 ブラッド流士魂召封術 おなら通信 セクシービーム  プリブーメラン プリプリクラゲパチンコ プリプリクラゲ点火 プリプリロケット アースクエイク ウインドスラッシャー エクスプロ-ジョン 
ゲイルフラッシュ シャパ 鬼神流影破 虎影斬 荒獅子太鼓 桜花雷爆斬 呪縛剣 呪縛拳 真空斬 戦いの小太鼓 旋風激蹴 退魔光弾 風雷波 滅掌烈破 紋次斬り 流星爆 韋駄天のオカリナ ドリームロック
イクスクレイト ヴァニッシュ オーケストラヒット かくせい キラキラ グルガタックル グルガチャージ サンダーストーム しあわせ光線 スーパーノヴァ スケープゴート ディスペル デス トランスエネミー パシャパシャ 
ハリケーンアタ鬼心法 時限爆弾 天の裁き 刀破斬 竜牙剣 乱れ撃ち MU・SO・U Ω・BREAKER Ω・BURST Ω・BUSTER アタッカード インパルスボム ウォータークランブル エアブラスト カーディッシュ 
ギガ・プラズマ クリスタルダスト クロスジャベリン ダークエアクス 種飛ばし  眠りの香り  ∑ーゲイル アクセルタイフーン サウザンドバレッツ シャープシューター スティンガーレイン スラングナイフ バインドアロー 
ハンティングアロー ヒートキャノン ファイヤースラング ベノムアロー ローリ 覚醒剣 吸血の牙 疾風兜割り 真空破弾 風刃円舞 風刃乱舞 龍牙覚醒剣 アーススピリット クラーフスピリット ランサースピリット 
イカすプロポーション ねがえりキック 急所突き 高笑い 分身の術 MAXパワー  ストロングシュート  すぺ沫 陽炎 削颶風 鋼戈 渾身励機 灼刃 樹界 浄天 空亙 土塁 銃火弾 時軛 撤雷 爆焔 舞疾風 雷槍 G-スピリットタイフーン 

②必殺技を形態素分析して名詞のみを抽出

形態素解析とは

まず、形態素とは意味をもつ表現要素の最小単位のことであり、
形態素解析とは対象となる言語の文法や単語の品詞情報をもとに、文章を形態素に分解する解析方法のことです。

例えば、「吾輩は猫である」という文を形態素解析した場合以下のような結果が得られます。

BOS/EOS,*,*,*,*,*,*,*,*
吾輩 名詞,代名詞,一般,*,*,*,吾輩,ワガハイ,ワガハイ
は 助詞,係助詞,*,*,*,*,は,ハ,ワ
猫 名詞,一般,*,*,*,*,猫,ネコ,ネコ
で 助動詞,*,*,*,特殊・ダ,連用形,だ,デ,デ
ある 助動詞,*,*,*,五段・ラ行アル,基本形,ある,アル,アル
BOS/EOS,*,*,*,*,*,*,*,*

形態素解析は様々なシチュエーションで使われていて、

- GOOGLEの検索エンジンにおいて、検索欄に入力された文章からキーワードのみを抽出​
- 某ニュースアプリの見出し調整(ユーザーが見やすいように改行位置を調節)​​- 自然言語処理の前準備

など、結構身近に感じる機会も多いと思います。
今回はこの形態素分析を使って必殺技を形態素レベルに分解し集計していきます。

使用するライブラリ

golangで形態素解析エンジンMeCabを使用できるmecab-golangを採用しました。
導入手順は以下になります。

// MeCab及び辞書のinstall
$ brew install mecab
$ mecab -v // installが成功しているか確認
$ brew install mecab-ipadic // mecab-ipadicはMeCabが形態素解析に使用する辞書の1つ。日本語の単語情報が掲載されている

// mecab-ipadic-NEologdはmecab-ipadicに用語追加するためのシステム辞書
$ git clone --depth 1 git@github.com:neologd/mecab-ipadic-neologd.git
$ cd mecab-ipadic-neologd
$ ./bin/install-mecab-ipadic-neologd -n

// mecab-golang導入
$ export CGO_LDFLAGS="`mecab-config --libs`"
$ export CGO_CFLAGS="-I`mecab-config --inc-dir`"
$ go get github.com/bluele/mecab-golang

必殺技を形態素分析して名詞のみを抽出

世に出回っている必殺技は名詞+名詞のパターンがほとんどなので、
今回は品詞が名詞の形態素のみに絞って抽出します。
実装は以下の通りです。

func parseToNode(skillWords []string) (map[string]int, error) {
	var nounNodes []string
	nounMap := make(map[string]int) // key: 形態素, val: 出現回数

	m, err := mecab.New("-Owakati")
	if err != nil {
		return nil, errors.New("failed init mecab")
	}
	defer m.Destroy()

	tg, err := m.NewTagger()
	if err != nil {
		return nil, errors.New("failed init tagger")
	}
	defer tg.Destroy()
	for _, w := range skillWords {
		lt, err := m.NewLattice(w)
		if err != nil {
			return nil, errors.New("failed init lattice")
		}
		defer lt.Destroy()

		node := tg.ParseToNode(lt)
		for {
			features := strings.Split(node.Feature(), ",")
			if features[0] == "名詞" {
				nounNodes = append(nounNodes, node.Surface())
			}
			if node.Next() != nil {
				break
			}
		}
		for _, n := range nounNodes {
			if _, ok := nounMap[n]; ok {
				nounMap[n]++
			} else {
				nounMap[n] = 1
			}
		}
	}
	return nounMap, nil
}

③使われる頻度ごとにランキング形式で発表

今回は自分で作った構造体(sortedMap)のソートを実現したいのでsort.Interface のインタフェースに含まれるLen(), Less(), Swap()を定義します。

type sortedMap struct {
	m map[string]int  // key: 形態素, val: 出現回数
	s []string // sort用の形態素配列
}

func sortedKeys(m map[string]int) []string {
	sm := new(sortedMap)
	sm.m = m
	sm.s = make([]string, len(m))
	i := 0
	for key, _ := range m {
		sm.s[i] = key
		i++
	}
	sort.Sort(sm)
	return sm.s
}

func (sm *sortedMap) Len() int {
	return len(sm.m)
}
func (sm *sortedMap) Less(i, j int) bool {
	return sm.m[sm.s[i]] > sm.m[sm.s[j]]
}
func (sm *sortedMap) Swap(i, j int) {
	sm.s[i], sm.s[j] = sm.s[j], sm.s[i]
}

main()はこんな感じです

func main() {

	romaAlphabets := genRomaAlphabetKanas()
	skillWords, err := fetchSkillsByCrawling(romaAlphabets)
	if err != nil {
		fmt.Println("failed fetch skills by crawling", err)
	}
	nounMap, err := parseToNode(skillWords)
	if err != nil {
		fmt.Println("failed parse to node", err)
	}
	// ランキング表示
	res := sortedKeys(nounMap)
	for i, v := range res {
		fmt.Println(fmt.Sprintf(`第%d位: %s %d回`, i+1, v, nounMap[v]))
		if i >= 9 {
			return
		}
	}
}

実装があらかたできたので実行してみましょう!

$ go run main.go1位: 術 101660回
第2位: 拳 89720回
第3位: 忍法 81679回
第4位: 剣 64758回
第5位: パンチ 52951回
第6位: キック 52804回
第7位: アタック 48256回
第8位: 炎 48041回
第9位: 魔 46811回
第10位: 弾 45937

うーん、1文字の形態素が多くあまり必殺技感がありませんね....

次はコマンドライン引数で形態素の最低文字数を決められるようにしました

func main() {
	var minWordLen = 0
	flag.Parse()
	if len(flag.Args()) >= 1 {
		minWordLen, err = strconv.Atoi(flag.Args()[0])
		if err != nil {
			fmt.Println("failed find minWordLen", err)
		}
	}
	romaAlphabets := genRomaAlphabetKanas()
	skillWords, err := fetchSkillsByCrawling(romaAlphabets)
	if err != nil {
		fmt.Println("failed fetch skills by crawling", err)
	}
	nounMap, err := parseToNode(skillWords, minWordLen)
	if err != nil {
		fmt.Println("failed parse to node", err)
	}
	// ランキング表示
	res := sortedKeys(nounMap)
	for i, v := range res {
		fmt.Println(fmt.Sprintf(`第%d位: %s %d回`, i+1, v, nounMap[v]))
		if i >= 9 {
			return
		}
	}
}
func parseToNode(skillStr string, minWordLen int) map[string]int {
    
    //前後は省略します
	node := tg.ParseToNode(lt)
	for {
		features := strings.Split(node.Feature(), ",")
		if features[0] == "名詞" && utf8.RuneCountInString(node.Surface()) >= minWordLen {
			nounNodes = append(nounNodes, node.Surface())
		}
		if node.Next() != nil {
			break
		}
	}
	//前後は省略します
}

5文字以上の形態素に絞って集計してみます。

$ go run main.go 51位: クラッシュ 13350回
第2位: トルネード 12611回
第3位: スラッシュ 11504回
第4位: フラッシュ 10532回
第5位: ストライク 10024回
第6位: スペシャル 8518回
第7位: ハリケーン 8515回
第8位: スマッシュ 7740回
第9位: インパクト 6449回
第10位: ブーメラン 5780

だいぶ必殺技感(?)が出てきましたね!
個人的にはNARUTOとかBLEACHのような漢字の必殺技のほうがワクワクするので、コマンドライン引数(charaType)で文字の種類を選べるようにしました。

func isMatchCharaType(charaType string, skill string) bool {
	for _, rune := range []rune(skill) {
		switch charaType {
		case charaTypeHiragana:
			if !unicode.In(rune, unicode.Hiragana) {
				return false
			}
			break
		case charaTypeKatakana:
			if !unicode.In(rune, unicode.Katakana) {
				return false
			}
			break
		case charaTypeKanji:
			if !unicode.In(rune, unicode.Han) {
				return false
			}
		}
	}
	return true
}
func parseToNode(skillStr string, minWordLen int) map[string]int {
    
    //前後は省略します
	node := tg.ParseToNode(lt)
	for {
		features := strings.Split(node.Feature(), ",")
		if features[0] == "名詞" && utf8.RuneCountInString(node.Surface()) >= minWordLen && isMatchCharaType(charaType, node.Surface()) {
			nounNodes = append(nounNodes, node.Surface())
		}
		if node.Next() != nil {
			break
		}
	}
	//前後は省略します
}

再度実行!!

$ go run main.go 5 kanji
第1位: 五十歩百歩 120回
第2位: 十字封神焔儀 84回
第3位: 摩訶不思議 72回
第4位: 一派歩射武弓術 62回
第5位: 六塵散魂無縫剣 50回
第6位: 南無阿弥陀仏 34回
第7位: 五右衛門風呂 18回

$ go run main.go 5 hiragana
第1位: ところてん 546回
第2位: かくれんぼ 281回
第3位: しゃくねつ 277回
第4位: まんじゅう 259回
第5位: おまじない 246回
第6位: しょうかん 219回
第7位: ちゃんちゃんこ 190回
第8位: すいりゅう 166回
第9位: がむしゃら 144回
第10位: ぶんまわし 137回

$ go run main.go 5 katakana
第1位: クラッシュ 13350回
第2位: スラッシュ 11504回
第3位: フラッシュ 10532回
第4位: ストライク 10024回
第5位: スペシャル 8518回
第6位: スマッシュ 7740回
第7位: インパクト 6449回
第8位: ファイナル 5177回
第9位: スプラッシュ 4921回
第10位: スパイラル 4762

ひらがなの必殺技はネタ感がすごいですね...

ちなみに一番長い必殺技は「スーパーウルトラミラクルエキセントリックサーブ」でした。
どうやら固有名詞として扱われてしまうらしく、この長さの形態素になるのは予想外でした...

$ go run main.go 231位: スーパーウルトラミラクルエキセントリックサーブ 16回
スーパーウルトラミラクルエキセントリックサーブ 名詞,固有名詞,組織,*,*,*,*

今後の展望

- アウトプットをテキストではなくグラフで表示するようにしたい(gonum/plot,gnuplotなど...)​
- 想定よりも文字数の多い形態素が多く見られたため、​
MeCaB辞書に用語を追加 or 別の形態素解析エンジンも試してみたい​
- 今回はクローリングと形態素解析を同じリポジトリで実装しているが、​
形態素解析部分はAPIに外出ししたい

ここまで読んで頂きありがとうございました!!

みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!