【Laravel】 Goutteとmecabで形態素解析やってみた  

## 　はじめに  
### 「形態素解析」って？  
* 文章を言葉が意味を持つまとまりの単語の最小単位(形態素)にまで分割する技術のこと。  
例えば、「私は台所で料理します」という文章を形態素解析すると  
  * 私(代名詞)   
  * は（副助詞）  
  * 台所(名詞)  
  * で(助詞)  
  * 料理（名詞）  
  * し(動詞)  
  * ます（助動詞）  
という具合に分けられます。

<br>

* 形態素解析が使えるライブラリは  
   * mecab(C)  
   * Janome(python)    
   * sudachi(Java)  

などがあります。
今回はmecabを使ってみます!

## やってみよう  
### 開発環境　　  
* MAMP 
* PHP7.4.21  
* Laravel 8
* MySQL  

### ライブラリなど  
* [mecab](https://taku910.github.io/mecab/) : 形態素解析ライブラリ    
 * [ateliee/mecab](https://github.com/ateliee/mecab) : mecabをPHPで使えるようにするライブラリ 
* [POS tagger](https://nlp.stanford.edu/software/tagger.shtml) : mecabだと、英語単語の品詞が取得できないので英単語はこのライブラリを使って判断
* [Laravel Goutte](https://specially198.com/scraping-with-goutte-on-laravel8/) :Laravelのスクレイピングライブラリ
* [スクレイピングするサイト](https://www.uta-net.com/artist/6733/)：Uta-Netのアーティストページを今回は使用

### ざっくり処理の流れ
1. Goutteを使って[スクレイピングするサイト](https://www.uta-net.com/artist/6733/)から歌詞を取得
2. 取得した歌詞をDBへINSERT 
3. 2の結果をmecabを使って解析し、DBへINSERT

### DBイメージ
こんな感じ：
 ![スクリーンショット 2024-03-31 18.36.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2809857/34e299d9-f1b4-e8ed-bc58-0346a87f8247.png)


### つくってみる
ブラウザで動かすのもなんだかなーと思ったので、Batchにして見ました。
今回はスクレイピングするBatchと形態素解析するBatchに分けてみました。

```app\Console\Commands\insertLyrics.php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use Weidner\Goutte\GoutteFacade as Goutte;
use App\Models\Songs;
use App\Models\Artists;

class insertLyrics extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'insert_lyrics {artistID}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'insert Lyrics!';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $artistID = $this->argument('artistID');
        $exist_songs = Songs::where('artist_id', '=', $artistID)->get();

        if (count($exist_songs) > 0){
            Songs::where('artist_id', '=', $artistID)->delete();
        }

        $artist_url = "https://www.uta-net.com/artist/{$artistID}/";
        $songs = [];
        //URL
        $crawler = Goutte::request('GET', $artist_url);
        $urls[] = $crawler->filter('a.py-2.py-lg-0')->each(function ($node) {
            return $node->attr('href');  
        });

        //title
        $titles[] = $crawler->filter('.songlist-title')->each(function ($node) {
            return $node->text();
        });

        for ($i = 0; $i < count($urls[0]); $i++){
            $crawler = Goutte::request('GET', 'https://www.uta-net.com/'.$urls[0][$i]);
            $lyric = $crawler->filter('#kashi-box')->each(function ($node) {
                return $node->text();
            });
            $song = new \App\Models\Songs;
            $song->fill([
                'artist_id' => $artistID,
                'title' => $titles[0][$i],
                'lyric_url' => 'https://www.uta-net.com'.$urls[0][$i],
                'lyric' => $lyric[0],
            ])->save();
        }

        $update_songs = Songs::where('artist_id', '=', $artistID)->get();

        //不要な文言を消す
        foreach ($update_songs as $update_song) {
            $update_song->lyric = preg_replace('/ この歌詞をマイ歌ネットに登録 >このアーティストをマイ歌ネットに登録 >$/', '', $update_song->lyric);
            $update_song->save();
        }

        echo("finished!");
        return 0;
    }
}
```


形態素解析する方
```app\Console\Commands\analyzeLyrics.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Models\Songs;
use App\Models\Artists;
use App\Models\AnalyzedLyrics;
use meCab\meCab;
use StanfordNLP\POSTagger;

class analyzeLyrics extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'analyze_lyrics {artistID}';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }
    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        $artistID = $this->argument('artistID');
        $mecab = new meCab();
        $modelPath = storage_path('app/stanford-postagger/models/english-left3words-distsim.tagger');
        $jarPath = storage_path('app/stanford-postagger/stanford-postagger-4.2.0.jar');
        $pos = new POSTagger($modelPath, $jarPath);

        $songs = Songs::where('artist_id', $artistID)->get();
        AnalyzedLyrics::where('artist_id', $artistID)->delete();

        foreach ($songs as $song){

            $songID = $song->id;
            $lyric = $song->lyric;
            $pattern = '/\b[a-zA-Z]+\b/';
            $japaneseLyric = preg_replace($pattern, '', $lyric);
            $englishLyric = preg_replace('/[^a-zA-Z\s\']/', ' ', $lyric);

            $japaneseAnalyzedLyrics = $mecab->analysis($japaneseLyric);
            foreach ($japaneseAnalyzedLyrics as $nodes){
                $text = trim($nodes->getText());

                if (!preg_match('/[\p{P}\p{S}]/u', $text)){
                        $speech = $nodes->getSpeech();                    

                    $analyzedLyrics = new AnalyzedLyrics;
                    $analyzedLyrics->fill([
                        'artist_id' => $artistID,
                        'song_id' => $songID,
                        'pos' => $speech,
                        'text' => $text,
                    ])->save();
                }
            }

            $englishAnalyzedLyrics = $pos->tag(explode(' ', $englishLyric));
            foreach ($englishAnalyzedLyrics as $node1){
                foreach ($node1 as $node2){
                    $speech = $node2[1];
                    $text = $node2[0];

                    $analyzedLyrics = new AnalyzedLyrics;
                    $analyzedLyrics->fill([
                        'artist_id' => $artistID,
                        'song_id' => $songID,
                        'pos' => $speech,
                        'text' => $text,
                    ])->save();
                }
            }


        }
        echo("finished!");

        return 0;
    }

    public function getPOSofNotJapanese($text)
    {
        $modelPath = storage_path('app/stanford-postagger/models/english-left3words-distsim.tagger');
        $jarPath = storage_path('app/stanford-postagger/stanford-postagger-4.2.0.jar');
        $pos = new POSTagger($modelPath, $jarPath);
        $result = '';

        $analyzedWord = $pos->tag(explode(' ', $text));
        foreach ($analyzedWord as $node1){
            foreach ($node1 as $node2){
                $result = $node2[1];
            }
        }
        return($result);
    }

}
```

これを実行するとDBはこんな感じ
* Songs(スクレイピングしてINSERTしたデータ)
 ![スクリーンショット 2024-03-31 18.48.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2809857/70e8eb3d-0c11-c263-e61c-3f9312ff8d49.png)

<br>

* analyzed_lyrics(形態素解析した結果)![スクリーンショット 2024-03-31 18.50.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2809857/0bb9aea8-b662-7b8b-6397-6d69e49a854e.png)

<br>

* 英単語はこんな感じ　![スクリーンショット 2024-03-31 18.53.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2809857/f9d05a20-d0fe-282a-536c-c2c006bed546.png)
「VBZ」や「NN」などの意味はこちらを参考にしたよ
https://sketchengine.eu/penn-treebank-tagset/

## まとめなど
* これを使ってwordcloudを作るぞ！と言うか作ったのですが、MAMPがいきなり立ち上がらなくなると言う不具合が起きてしまい、再インストールする羽目に・・・いろいろ設定終わったら続編書きます。
* POS taggerはあくまでも英単語を品詞分けするのであって、例えばドイツ語が来た時は対応してくれないのでどうにかならないかなと考えています。良い方法ないかなー。
* これを作ろうと思ったきっかけの記事はこちら：[ミスチルの歌詞をJanomeで解析してWordCloudで可視化してみた](https://zatsugaku-engineer.com/python/janome-mrchildren/) 

最後までお読みくださってありがとうございました！