BlueAnalysis_icatch

ブルーアーカイブの生徒情報を統計的に見たい

2024/12/26
2025/01/30

私、ブルーアーカイブというスマホゲームにハマっているんですが、「生徒が一番使ってる銃は何だろ?」、「実装生徒の多い学園はどこだろ?」とかが気になって生徒の情報を見れるwebアプリを作ったので軽く紹介&説明します

BlueAnalysis

ゲームのタイトルである「ブルーアーカイブ」と分析という意味の「analysis」を掛け合わせてBlueAnalysisって名前で作りました。

作ったページはこちら

機能としては生徒の一覧表示とデータ分布のグラフ表示になります。

BlueAnalysis

生徒の一覧表示

BlueAnalysis_table

画面左側(スマートフォンでは上側)には生徒の情報を一覧表示するテーブルを置いてます。

表示項目は多いので割愛しますが、wikiに記載されている項目は一通り実装しています。

初期表示は、名前・武器種・クラス・学校・攻撃属性・実装日のみでそれ以外の列は非表示にしています。右上の「表示の設定」で列の表示・非表示を切り替えられます。

また、それぞれの列でフィルタリングとソートができます。フィルタリングは各列の「フィルター」から、ソートは列名をクリックするとできます。

データ分布のグラフ表示

BlueAnalysis_chart

画面右側(スマートフォンでは下側)にはデータの分布を表すグラフを置いてます。

左上のセレクトボックスでグラフを表示する項目を選択できます。

生徒一覧のテーブルと連動していて、フィルタリングを行うとその範囲内のデータだけを使ってグラフを表示できます。例えば、「トリニティ生が使っている武器種の分布」、「SMGを使う生徒が一番多い学校」とかを調べることができます。

開発環境

Githubリポジトリはこちら

最初はデータフレーム使ってテーブルなりグラフなり出せばいいと思ってpythonとstreamlitを使うつもりでしたが、いざ作ってみると思った以上にテーブルとグラフのカスタマイズが必要そうで断念。

結局、データの用意はpythonでやってwebアプリにする部分はreactで作りました。ホスティングはお金がかからないvercelでやっています。

データの作成

BlueAnalysisではあらかじめ生徒データをjsonファイルで持っておき、それを表示させています。フロント側でスクレイピングして表示させることもできますが、スクレイピングはどうしても時間がかかってしまうのでこの方法を取りました。

スクレイピングはwikiのテーブル要素をデータフレームで取ってくるためにpandasで取得しています。

python
1# importは省略
2def get_student_list() -> pd.DataFrame:
3    url = "https://bluearchive.wikiru.jp/?%E3%82%AD%E3%83%A3%E3%83%A9%E3%82%AF%E3%82%BF%E3%83%BC%E4%B8%80%E8%A6%A7"
4    response = requests.get(url)
5    response.encoding = "utf-8"
6
7    # テーブルの読み込み
8    df = pd.read_html(StringIO(response.text), attrs={"id": "sortabletable1"})[0]
9    df = df.dropna(subset=["名前"])
10    df.columns = df.columns.str.replace(" ", "")
11    df = df.apply(lambda x: x.str.replace(" ", "") if x.dtype == "object" else x)
12    df = df.drop(["画像", "募集", "追加"], axis=1)
13
14    return df

生徒情報からテーブルを丸ごとデータフレームとして取得し、空白を削除したり不要な列は削除したりしています。このページだけでは生徒の実装日だったり、誕生日だったりが取れないので他のページからもデータを取ります。

まず、実装日をキャラクター実装履歴から取得します。コードは上記のコードとほとんど変わらないので割愛します。

残りの誕生日だったり身長だったりの取得ですが、生徒ページから一人ずつ情報を取っていきます。

python
1def get_student_profile(name: str, switch=False) -> pd.DataFrame:
2    # URLを指定
3    url = f"https://bluearchive.wikiru.jp/?{name}"
4
5    response = requests.get(url)
6    response.encoding = "utf-8"
7
8    soup = BeautifulSoup(response.text, "html.parser")
9    # urlパラメータの取得
10    parm = url.split("?")[1]
11    param_parsed = urllib.parse.unquote(parm)
12    # ホシノ臨戦(切り替え可能な生徒)の場合はplaywrightでスクレイピング(後述)
13    if param_parsed == HOSHINO:
14        profile, table = get_student_profile_for_playwright(url, switch)
15    else:
16        profile = {
17            "名前": soup.select_one(
18                "#content_1_0+div>table > tbody > tr:nth-child(2) > td:nth-child(3)"
19            ).text.strip(),
20            # 他の値も同じように処理をする(省略)
21            "身長": soup.select_one(
22                "#content_1_0+div>table > tbody > tr:nth-child(16) > td"
23            ).text.strip(),
24        }
25        table = soup.select_one("#content_1_0+div+div>table")
26    df_profile = pd.DataFrame(data=[profile], columns=profile.keys())
27
28    # ゲーム上のステータス取得
29    df_status = pd.read_html(StringIO(str(table)))[0]
30    df_status = df_status.head(5)
31
32    # データフレームの整形
33    columns = []
34    values = []
35    for i in range(0, len(df_status), 2):
36        columns.extend(list(df_status.iloc[:, i].values))
37        values.extend(list(df_status.iloc[:, i + 1].values))
38
39    df_status = pd.DataFrame(data=[values], columns=columns)
40    # 欠損値を0埋め
41    df_status = df_status.fillna(0)
42    # 並び替え
43    sorted_columns = [
44        # 列名のリスト(省略)
45    ]
46    df_status = df_status[sorted_columns]
47
48    # HP、攻撃力、治癒力のステータスは★5Lv90のみ記録
49    value_HP = df_status.iloc[0, 0].split("/")
50    df_status.iloc[0, 0] = f"{value_HP[-1]}"
51
52    value_ATK = df_status.iloc[0, 1].split("/")
53    df_status.iloc[0, 1] = f"{value_ATK[-1]}"
54
55    value_RCV = df_status.iloc[0, 2].split("/")
56    df_status.iloc[0, 2] = f"{value_RCV[-1]}"
57
58    df = pd.concat([df_profile, df_status], axis=1)
59
60    return df

こちらの処理ではテーブルから丸ごと取るより部分的に取ったほうがいい部分もあるので、生徒のプロフィールはBeautifulsoup、ゲーム上のステータスはpandasの取得を併用しています。あくまでBeautifulsoupとpandasの取得は別々なので最終的には二つのデータフレームを結合しています。urlのname部分には生徒の名前が入るので、先ほど取得した生徒一覧データから名前を取ってきてそれを使います。

基本的にはこの方法で取れるのですが、状態を切り替えられる生徒(現状は臨戦ホシノのみ)についてはプロフィール部分が取れないのでplaywrightで取得します。

python
1def get_student_profile_for_playwright(url, switch=False) -> dict:
2    with sync_playwright() as p:
3        browser = p.chromium.launch()
4        page = browser.new_page()
5
6        page.goto(url)
7
8        # 指定要素は切り替えの有無で変える
9        if switch:
10            profile = {
11                "名前": page.locator(
12                    "#content_1_0+p+div>div:nth-child(3) table > tbody > tr:nth-child(2) > td:nth-child(3)"
13                ).text_content(),
14                # 同じような処理なので省略
15            }
16            table = page.locator(
17                "#content_1_0+p+div>div:nth-child(3)>div:nth-child(3)"
18            ).inner_html()
19        else:
20            profile = {
21                # switch==Trueのときと指定する要素が違うだけなので省略
22            }
23            table = page.locator(
24                "#content_1_0+p+div>div:nth-child(2)>div:nth-child(2)"
25            ).inner_html()
26        browser.close()
27
28        return profile, table

必要なデータは以上なので、重複する列の削除やデータの整形等を行い結合してjsonファイルとして保存します。

フロントエンド

テーブルではTanstack Table、グラフではrechartsを使っています。その他テキストやボタンのコンポーネントにMaterial UIを使っています。

typescript
1const columns = useMemo(
2    () => [
3      columnHelper.accessor("レア", {
4        header: "レア度",
5        filterFn: multiSelectFilterFn,
6        enableSorting: true,
7      }),
8     // 途中省略
9      columnHelper.accessor("市街", {
10        header: "市街",
11
12        filterFn: multiSelectFilterFn,
13        cell: (info) => (
14          <img
15            src={`/icons/face_${info.getValue().charAt(0)}.png`}
16            alt={info.getValue()}
17            title={info.getValue()}
18            style={{ width: "24px", height: "24px" }}
19          />
20        ),
21      }),
22      // 途中省略
23      columnHelper.accessor("実装日", {
24        header: "実装日",
25
26        filterFn: (row, _columnId, filterValue: DateRange) => {
27          if (
28            !filterValue?.startYear &&
29            !filterValue?.startMonth &&
30            !filterValue?.endYear &&
31            !filterValue?.endMonth
32          )
33            return true;
34
35          const date = row.getValue("実装日") as string;
36          if (!date) return false;
37
38          const [year, month] = date.split("/");
39          const rowDate = `${year}${month}`;
40
41          const start =
42            filterValue.startYear && filterValue.startMonth
43              ? `${filterValue.startYear}${filterValue.startMonth}`
44              : "000000";
45          const end =
46            filterValue.endYear && filterValue.endMonth
47              ? `${filterValue.endYear}${filterValue.endMonth}`
48              : "999999";
49
50          return rowDate >= start && rowDate <= end;
51        },
52      }),
53      // 途中省略
54      columnHelper.accessor("誕生日", {
55        header: "誕生日",
56
57        filterFn: (row, _columnId, filterValue: string) => {
58          if (!filterValue) return true;
59          const date = row.getValue("誕生日") as string;
60          if (!date) return false;
61
62          const [month] = date.split("/");
63          return month === filterValue;
64        },
65      }),
66      columnHelper.accessor("身長", {
67        header: "身長",
68
69        filterFn: (row, _columnId, filterValue) => {
70          if (!filterValue) return true;
71          if (filterValue === "unknown") {
72            const height = parseInt(row.getValue("身長"));
73            return isNaN(height);
74          }
75          const height = parseInt(row.getValue("身長"));
76          const filterHeight = parseInt(filterValue as string);
77          if (isNaN(height)) return false;
78
79          // 5cm刻みの範囲内かチェック
80          const lowerBound = filterHeight;
81          const upperBound = filterHeight + 4;
82          return height >= lowerBound && height <= upperBound;
83        },
84      }),
85      // 途中省略
86      columnHelper.accessor("コスト回復力", {
87        header: "コスト回復力",
88
89        filterFn: multiSelectFilterFn,
90      }),
91    ],
92    [columnHelper]
93  );

テーブルのカラム設定ではソートと選択式のフィルタリングを指定しつつ、地形適正の列を画像にしたり、実装日や誕生日の列は月(または年)ごとのフィルタリングにしたりというのを適宜やっています。

html
1const table = useReactTable({
2  data: students,
3  columns,
4  state: {
5    sorting,
6    columnFilters,
7    columnVisibility,
8  },
9  onSortingChange: setSorting,
10  onColumnFiltersChange: setColumnFilters,
11  onColumnVisibilityChange: setColumnVisibility,
12  getCoreRowModel: getCoreRowModel(),
13  getSortedRowModel: getSortedRowModel(),
14  getFilteredRowModel: getFilteredRowModel(),
15});
16//途中省略
17<FormGroup
18  row={!isMobile}
19  sx={{
20    flexWrap: "wrap",
21    gap: "0.5rem",
22  }}
23>
24  {table.getAllLeafColumns().map((column) => {
25    return (
26      <FormControlLabel
27        key={column.id}
28        control={
29          <Checkbox
30            checked={column.getIsVisible()}
31            onChange={column.getToggleVisibilityHandler()}
32          />
33        }
34        label={column.columnDef.header as string}
35      />
36    );
37  })}
38</FormGroup>;

テーブルの作成に必要な情報はtableにまとめられているので、列の表示・非表示はtableから列のリストを取得し、チェックボックスと列の表示を紐づけています。

typescript
1const filteredData = table.getFilteredRowModel().rows.map((row) => row.original);
html
1<div className="student-table__right-column">
2  <StudentStatsChart students={filteredData} />
3</div>;

filteredDataにフィルタリング後のデータを入れているので、グラフのコンポーネントにそれを渡すことでテーブルとグラフを連動させています。

typescript
1// 生徒の重複を除外する
2const getUniqueStudents = (students: Student[]): Student[] => {
3  const uniqueMap = new Map<string, Student>();
4  students.forEach((student) => {
5    // 名前から(.*)を除去して基本名を取得
6    const baseName = student.名前.replace(/(.*)$/, "");
7
8    // まだ登録されていない基本名の場合のみ追加
9    if (
10      !Array.from(uniqueMap.values()).some(
11        (s) => s.名前.replace(/(.*)$/, "") === baseName
12      )
13    ) {
14      uniqueMap.set(baseName, student);
15    }
16  });
17  return Array.from(uniqueMap.values());
18};
typescript
1// 名前以外の全てのカラムを取得し、除外項目をフィルタリング
2  const columns = Object.keys(students[0]).filter(
3    (key) => !EXCLUDED_FIELDS.includes(key as any)
4  );
5
6  const getChartData = (column: keyof Student): DataCount[] => {
7    const counts: { [key: string]: number } = {};
8
9    // 重複を除外すべきフィールドの場合は、getUniqueStudentsを使用
10    const targetStudents = UNIQUE_COUNT_FIELDS.includes(
11      column as UniqueCountField
12    )
13      ? getUniqueStudents(students)
14      : students;
15
16    if (column === "身長") {
17      // 身長の場合、5cm刻みでグループ化して集計
18      // 処理は省略
19    } else if (column === "誕生日") {
20      // 誕生日の場合は月ごとに集計
21      // 処理は省略
22    } else if (column === "実装日") {
23      // 実装日も同じ要領で集計
24      // 処理は省略
25    }
26
27    // 誕生日以外のカラムは従来通りの処理
28    targetStudents.forEach((student) => {
29      const value = String(student[column]);
30      counts[value] = (counts[value] || 0) + 1;
31    });
32
33    return Object.entries(counts)
34      .map(([name, count]) => ({ name, count }))
35      .sort((a, b) => b.count - a.count);
36  };
37
38  const data = getChartData(selectedColumn);

グラフ表示でも実装日や誕生日は月ごとまたは年ごとに集計するようにしています。また、学校だったり武器種だったり一部の項目については別衣装生徒のカウントをするかどうか切り替えられるようにしています。

その他・開発して思ったこと

僕は去年の10月くらいからエディタをVSCodeからCursorに乗り換えて今も使っているのですが、今年の7月にComposerという機能が追加されたらしいので今回の開発では今更ながら使ってみました。

Composer機能はざっくりいうとチャットに作りたいものを書くとAIがファイルの作成からコードの記述までやってくれる便利なサービスです。

ここでは感想だけにしますが、マニアックな機能とかでなければ間違ったコードはほとんど出力されないので、よほどタイピングが速い人でなければ実装方法が見えているかに関わらずAIに出力させたほうが開発の効率はいいと思いました。

ですが、完璧というわけでもなく細かい部分で不整合が起きていたり日本語のコメント部分が文字化けしたりといったこともあったので、Composer機能さえあれば誰でも実装できるわけではなく、多少なりスキルがある人が使えば開発効率が格段と上がるものかなと思います。