ゲーム攻略サイトからデータを抽出するコツ（pandas、正規表現、Python）

　ゲームの情報等はWiki等によくまとめられていますが、データ解析に扱いやすい形になっているとは限りません。それを解析しやすい形にするために、スクレイピングやpandas等を使ってアレコレしていったので、その手順やコツをまとめてみました。整列したデータを使って実際に解析した記事は[これ](https://deux-hibi.hatenablog.com/entry/2021/04/20/002101)になりますが、ゲームの内容そのものへの言及を多分に含むため、自分のブログに投稿しました。本記事で扱う内容は、飽くまでプログラミングに関わる内容になり、Wikiからデータフレームを作成し、各種解析をするまでの、Pythonにおける手順になります。

　本記事で対象とするのはこんな感じのサイトです。内容としてはこのサイトに特化していますが、他のサイト（ゲーム）であっても、Wikiであったりすれば特に内容は変わらないと思いますし、最悪ぜんぜん別のレイアウトであっても、正規表現でゴリゴリすれば対応可能かと思います。

# ライブラリのインポート

```python
import csv
import os
import pickle
import re

import numpy as np
import pandas as pd
import requests
from matplotlib import pyplot as plt
from matplotlib import rcParams
from scipy.spatial import distance
from sklearn.decomposition import PCA
```

　後、カテゴリだけ手動で設定しておきます。

```python
params = ['food', 'leaf', 'topic', 'gift']
```

# インデックスの取得

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/288125/62d64694-111f-e8ff-b5e6-7eeff2442887.png)

　ソースを覗きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/288125/764e670b-8943-24be-c48b-26f10ce400fb.png)

　一括で抽出するのは大変そうなので、この段落だけ正規表現で抽出（`pattern = r'>各ユニット(.*?)</div>'`）した後、更に個別の情報を正規表現で絞り込む（`pattern = r'"">(.*?)</a>'`）、という2段階の作戦でいきます。基本的に、定型文`hoge`と`fuga`に挟まれた中身、つまり`hoge`+`x`+`fuga`から`x`を抽出した場合、`r'hoge(.*?)fuga`という正規表現をぶつければなんとかなります。

```python

if not os.path.exists('units.pickle'):
    url = 'https://www.pegasusknight.com/wiki/fe16/?%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88/%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88%E4%B8%80%E8%A6%A7'
    get = requests.get(url)
    text = get.text.replace('\n', '')
    pattern = r'>各ユニット(.*?)</div>'
    unit_text = re.findall(pattern, text)[0]
    pattern = r'"">(.*?)</a>'
    units = re.findall(pattern, unit_text)
    units.remove('テンプレート')
    units.remove('主人公男')
    units.remove('主人公女')
    pickle.dump(units, open('units.pickle', 'wb'))
units = pickle.load(open('units.pickle', 'rb'))

```

　これをリストとして保存し、これから作るデータフレームのインデックスとしていきます。以降の作業でもそうですが、ローカルに`pickle`ファイルがなければスクレイピングしてデータをローカルに作成、あればそのまま読み込む、という形にして、無駄なスクレイピングの量を減らしています（`if not os.path.exists('units.pickle'):`による分岐）。

# ソースの取得

　インデックスに対応したデータソースをスクレイピングします。

```python
if not os.path.exists('profile_data.pickle'):
    profile_data = {}

    for unit in units:
        print(f'searching {unit}...')
        url = f'https://www.pegasusknight.com/wiki/fe16/?%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88/{unit}#ff12631d'
        get = requests.get(url)
        text = get.text.replace('\n', '')
        profile_data[unit] = text

    pickle.dump(profile_data, open('profile_data.pickle', 'wb'))
profile_data = pickle.load(open('profile_data.pickle', 'rb'))
```

# データフレームの作成

　このような形でデータがまとめられているので、それぞれ解析していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/288125/e12a0341-3643-bcd9-3b46-65dede61168f.png)

　ソースを覗いてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/288125/f5207778-7a79-4148-b7d0-70c2a4ad5ddc.png)

　`pattern = r'<th class="style_th" style="text-align:center;">(.*?)</th><td class="style_td" style="text-align:center;">(.*?)</td>'`で絞り込めば、項目名と好き嫌いのタプルが抽出できそうです。○と×では扱いづらいので、`pair = {'◯': 1, '': 0, '×': -1}`なる辞書を用意して数値に変換します。

　また、違うレイアウトのデータもあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/288125/15b13a2b-7ce6-2317-83c0-82bcebe16b44.png)

　ソースを覗いてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/288125/22fe4df6-1a27-e57a-2ae8-063f518e5da4.png)

　下は`hoge`+`x`+`fuga`なので今までの作戦でいけますが、上はそうはいきません。ソースを観察すると、`<br class="spacer" />`なるタグで区切られていることがわかります。つまり`x` + `hoge` + `y` + `hoge` + ... + `hoge` + `z`という形です。**このような時には`split`メソッドが役立ちます。**

　以上を踏まえたデータ作成手順が以下になります。

```python
if not os.path.exists('fav_data.pickle'):
    data = {}
    for p in params:
        data[p] = pd.DataFrame(index=units)

    for d in profile_data:
        text = profile_data[d]
        # food_searching
        pattern = r'>食事の好み(.*?)</div>'
        food_text = re.findall(pattern, text)[0]
        pattern = r'<th class="style_th" style="text-align:center;">(.*?)</th><td class="style_td" style="text-align:center;">(.*?)</td>'
        fav_foods = re.findall(pattern, food_text)
        print(fav_foods)
        pair = {'◯': 1, '': 0, '×': -1}
        for f in fav_foods:
            data['food'].at[d, f[0]] = pair[f[1]]
        # leaf_searching
        pattern = r'>お茶会(.*?)</div>'
        tea_text = re.findall(pattern, text)[0]
        pattern = r'<tr><td class="style_td" colspan="4" style="text-align:center;">(.*?)</td></tr>'
        result = re.findall(pattern, tea_text)[0]
        fav_leaves = result.split('<br class="spacer" />')
        print(fav_leaves)
        for f in fav_leaves:
            data['leaf'].at[d, f] = 1
        # topic_searching
        pattern = r'<td class="style_td">(.*?)</td>'
        fav_topics = re.findall(pattern, tea_text)
        print(fav_topics)
        for f in fav_topics:
            data['topic'].at[d, f] = 1
        pattern = r'">贈り物(.*?)</div'
        # gift_searching
        gift_text = re.findall(pattern, text)[0]
        pattern = r'<th class="style_th" style="text-align:center;">(.*?)</th><td class="style_td" style="text-align:center;">(.*?)</td>'
        fav_gifts = re.findall(pattern, gift_text)
        print(fav_gifts)
        pair = {'◯': 1, '': 0, '×': -1}
        for f in fav_gifts:
            data['gift'].at[d, f[0]] = pair[f[1]]

    for p in params:
        data[p] = data[p].fillna(0).astype('int64')

    # special_process_for_topic
    sum_data = data['topic'].sum()
    data['topic'] = data['topic'].loc[:, sum_data > 1]

    pickle.dump(data, open('fav_data.pickle', 'wb'))
data = pickle.load(open('fav_data.pickle', 'rb'))
```

　カテゴリごとに、インデックスだけを用意した空のデータフレームを作成します。その後、データを抽出するごとに、`data.at[d,f]`でデータを入力していきます。`at`メソッドは、**マッチする行（インデックス）と列（カラム）があればそこのマスにデータを記入し、なければ新たに行または列を作成してくれる**という便利な命令です。この時、**指定されていないマスにはすべて`NaN`が記入される**ので、後でそこを置換しておく必要があります。

# 解析

　複数のデータに似たような手順を適用するので、データフレームと操作をひとまとめにしたクラスを作っておきます。ここでは`Analyzer`クラスと名付けています。

```python
class Analyzer:

    def __init__(self, data=None):
        self.data = data

    def summarize(self):
        sum_data = self.data.sum().sort_values(ascending=False)
        print(sum_data)
        sum_data2 = self.data.sum(axis=1).sort_values(ascending=False)
        print(sum_data2)

    def visualize(self):
        num_data = np.array(self.data)
        units = self.data.index.values

        dist_M = distance.cdist(num_data, num_data, metric='euclidean')
        all_min = np.min(dist_M[dist_M > 0])
        all_max = np.max(dist_M)

        pca = PCA(n_components=2).fit_transform(num_data)
        t_pca = pca.transpose()

        rcParams['font.family'] = 'MS Gothic'
        fig, ax = plt.subplots(figsize=(10, 10))
        ax.scatter(t_pca[0], t_pca[1], alpha=0.1)
        for unit, PC in zip(units, pca):
            ax.annotate(unit, PC)
        plt.show()

    def partner(self):
        num_data = np.array(self.data)
        units = self.data.index.values

        dist_M = distance.cdist(num_data, num_data, metric='euclidean')
        all_min = np.min(dist_M[dist_M > 0])
        all_max = np.max(dist_M)

        for unit, d in zip(units, dist_M):
            where_min = np.where(d == all_min)
            if len(where_min[0]) > 0:
                best_partner = units[where_min[0][0]]
                print(f'{unit} と {best_partner} が最も趣味が合いそうです')
            where_max = np.where(d == all_max)
            if len(where_max[0]) > 0:
                worst_partner = units[where_max[0][0]]
                print(f'{unit} と {worst_partner} はあまり趣味が合わないかも……')
```

 `summarize`では、行、列ごとに合計値を集計値しソートしています。今回の例では、好き嫌いが多いユニットや、逆に人を選ぶ食事などがわかります。

　`visualize`では、2次元の主成分分析を起こった後にそれをグラフにすることにより、各人の趣味の近さを図示することができます。

　`partner`では、各人の趣味の近さを計算し、最も趣味が合いそうな二人と、逆に最も趣味が合わなさそうな二人を決定します。

```python
analyzer = Analyzer()

for p in params:
    analyzer.data = data[p]
    analyzer.summarize()

all_data = pd.concat([data[p] for p in params], axis=1)
analyzer.data = all_data
analyzer.visualize()
analyzer.partner()
```

　例えば、`visualize`の結果はこのような感じになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/288125/21b1d4ca-73b9-3275-7592-e413a48e38b1.png)

# 全ソースコード

```python
import csv
import os
import pickle
import re

import numpy as np
import pandas as pd
import requests
from matplotlib import pyplot as plt
from matplotlib import rcParams
from scipy.spatial import distance
from sklearn.decomposition import PCA

# define_all_params

params = ['food', 'leaf', 'topic', 'gift']

# data_making

if not os.path.exists('units.pickle'):
    url = 'https://www.pegasusknight.com/wiki/fe16/?%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88/%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88%E4%B8%80%E8%A6%A7'
    get = requests.get(url)
    text = get.text.replace('\n', '')
    pattern = r'>各ユニット(.*?)</div>'
    unit_text = re.findall(pattern, text)[0]
    pattern = r'"">(.*?)</a>'
    units = re.findall(pattern, unit_text)
    units.remove('テンプレート')
    units.remove('主人公男')
    units.remove('主人公女')
    pickle.dump(units, open('units.pickle', 'wb'))
units = pickle.load(open('units.pickle', 'rb'))

if not os.path.exists('profile_data.pickle'):
    profile_data = {}

    for unit in units:
        print(f'searching {unit}...')
        url = f'https://www.pegasusknight.com/wiki/fe16/?%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88/{unit}#ff12631d'
        get = requests.get(url)
        text = get.text.replace('\n', '')
        profile_data[unit] = text

    pickle.dump(profile_data, open('profile_data.pickle', 'wb'))
profile_data = pickle.load(open('profile_data.pickle', 'rb'))

if not os.path.exists('fav_data.pickle'):
    data = {}
    for p in params:
        data[p] = pd.DataFrame(index=units)

    for d in profile_data:
        text = profile_data[d]
        # food_searching
        pattern = r'>食事の好み(.*?)</div>'
        food_text = re.findall(pattern, text)[0]
        pattern = r'<th class="style_th" style="text-align:center;">(.*?)</th><td class="style_td" style="text-align:center;">(.*?)</td>'
        fav_foods = re.findall(pattern, food_text)
        print(fav_foods)
        pair = {'◯': 1, '': 0, '×': -1}
        for f in fav_foods:
            data['food'].at[d, f[0]] = pair[f[1]]
        # leaf_searching
        pattern = r'>お茶会(.*?)</div>'
        tea_text = re.findall(pattern, text)[0]
        pattern = r'<tr><td class="style_td" colspan="4" style="text-align:center;">(.*?)</td></tr>'
        result = re.findall(pattern, tea_text)[0]
        fav_leaves = result.split('<br class="spacer" />')
        print(fav_leaves)
        for f in fav_leaves:
            data['leaf'].at[d, f] = 1
        # topic_searching
        pattern = r'<td class="style_td">(.*?)</td>'
        fav_topics = re.findall(pattern, tea_text)
        print(fav_topics)
        for f in fav_topics:
            data['topic'].at[d, f] = 1
        pattern = r'">贈り物(.*?)</div'
        # gift_searching
        gift_text = re.findall(pattern, text)[0]
        pattern = r'<th class="style_th" style="text-align:center;">(.*?)</th><td class="style_td" style="text-align:center;">(.*?)</td>'
        fav_gifts = re.findall(pattern, gift_text)
        print(fav_gifts)
        pair = {'◯': 1, '': 0, '×': -1}
        for f in fav_gifts:
            data['gift'].at[d, f[0]] = pair[f[1]]

    for p in params:
        data[p] = data[p].fillna(0).astype('int64')

    # special_process_for_topic
    sum_data = data['topic'].sum()
    data['topic'] = data['topic'].loc[:, sum_data > 1]

    pickle.dump(data, open('fav_data.pickle', 'wb'))
data = pickle.load(open('fav_data.pickle', 'rb'))

# analysis


class Analyzer:

    def __init__(self, data=None):
        self.data = data

    def summarize(self):
        sum_data = self.data.sum().sort_values(ascending=False)
        print(sum_data)
        sum_data2 = self.data.sum(axis=1).sort_values(ascending=False)
        print(sum_data2)

    def visualize(self):
        num_data = np.array(self.data)
        units = self.data.index.values

        dist_M = distance.cdist(num_data, num_data, metric='euclidean')
        all_min = np.min(dist_M[dist_M > 0])
        all_max = np.max(dist_M)

        pca = PCA(n_components=2).fit_transform(num_data)
        t_pca = pca.transpose()

        rcParams['font.family'] = 'MS Gothic'
        fig, ax = plt.subplots(figsize=(10, 10))
        ax.scatter(t_pca[0], t_pca[1], alpha=0.1)
        for unit, PC in zip(units, pca):
            ax.annotate(unit, PC)
        plt.show()

    def partner(self):
        num_data = np.array(self.data)
        units = self.data.index.values

        dist_M = distance.cdist(num_data, num_data, metric='euclidean')
        all_min = np.min(dist_M[dist_M > 0])
        all_max = np.max(dist_M)

        for unit, d in zip(units, dist_M):
            where_min = np.where(d == all_min)
            if len(where_min[0]) > 0:
                best_partner = units[where_min[0][0]]
                print(f'{unit} と {best_partner} が最も趣味が合いそうです')
            where_max = np.where(d == all_max)
            if len(where_max[0]) > 0:
                worst_partner = units[where_max[0][0]]
                print(f'{unit} と {worst_partner} はあまり趣味が合わないかも……')


analyzer = Analyzer()

for p in params:
    analyzer.data = data[p]
    analyzer.summarize()

all_data = pd.concat([data[p] for p in params], axis=1)
analyzer.data = all_data
analyzer.visualize()
analyzer.partner()
```