Graphviz + NetworkXで複雑なレシピをグラフで可視化

下の図のように、グラフでレシピを可視化するのが目的です。
![gv.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813135/c970cd90-bdc3-498d-977d-e5f99ced3e49.png)
![gv.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813135/45bd34bb-f7a2-4d4b-a174-4a45a7cee6c4.png)

## はじめに
Satisfactoryというゲームをご存じでしょうか？
このゲームでは工場を作って、大量の部品を生産していきます。しかし、そのレシピ（製造フロー）が非常に複雑で、何をどのくらい作ればいいのかを考えるのが大変です。

### グラフで可視化すれば解決できるのでは？
そこで、レシピをグラフ(ネットワーク)で可視化すること解決を図りました。グラフはノード（頂点）とエッジ（辺）で構成されるデータ構造です。ネットワークとも言います。グラフを可視化することで、複雑なデータの関係性を直感的に理解しやすくなります。今回は、ノードをアイテム、エッジを「材料かどうか」の関係とし、レシピをツリー状に可視化しました。
実際に作成したグラフが、冒頭の図です。階層型の構造にすることで、どのアイテムがどの材料から作られるのかが一目で分かります。また、エッジの上には「1分間に何個生産すればよいか」を表示しました。こうすることでレシピと必要生産量が簡単に確認できます。

## 実装
### レシピのデータを取得する
まず、グラフの元となるレシピデータを用意する必要があります。
手作業でレシピを入力するのは大変なので、攻略Wikiからデータを抽出します。
https://satisfactory.wiki.gg/wiki/Recipes
このページには、ゲーム内のすべてのレシピが表形式で掲載されています。
この表を取得し、プログラムで扱いやすいデータに変換していきます。

ページの取得には requests、HTML解析には BeautifulSoup を使用します。
```
pip install requests beautifulsoup4
```

まずは、WikiのページのHTMLを取得します。
```python
import requests

url = "https://satisfactory.wiki.gg/wiki/Recipes"
response = requests.get(url)

html_content = response.content
```

次に、HTMLを解析して、レシピの表を取り出します。
```python
from bs4 import BeautifulSoup

# BeautifulSoupでHTMLを解析
soup = BeautifulSoup(html_content, "html.parser")

# 最初の<table>タグを取得
table = soup.find("table")

# 行データを取り出す
rows = table.find_all("tr")
data = []

# 各行のセル（thやtd）を取得
for row in rows:
    cells = row.find_all(["th", "td"])  # th: ヘッダー, td: データセル
    data.append(cells)
```

この時点で、HTMLの表をプログラムで扱えるリストの形に変換できました。
しかし、このままではHTMLのタグ情報が混ざっているため、次にデータを整理していきます。

```python
import re
from bs4 import Tag

def extract_number(text: str) -> float:
    # 文字列から数字部分だけを抽出

    # 正規表現で数字とカンマを抽出
    number_str = ''.join(re.findall(r'[0-9,]+', text))
    
    # カンマを取り除き、floatに変換
    return float(number_str.replace(',', ''))

def extract_materials_from_tag(tag: Tag) -> list:
    # HTMLタグから材料情報をリストとして取得
    
    recipe_items = tag.find_all('div', class_='recipe-item')

    materials = []
    for item in recipe_items:
        # 数量を取得
        quantity_text = item.find('span', class_='item-amount').text.strip()
        quantity = extract_number(quantity_text)
        
        # アイテム名を取得
        name = item.find('span', class_='item-name').text.strip()
        
        # 速度を取得 (数値部分のみ)
        speed_text = item.find('span', class_='item-minute').text.strip()
        speed = float(speed_text.split(' ')[0].replace(',', ''))  # "1,500 / min" -> "1500" -> 1500.0
        
        materials.append({
            "name": name,
            "quantity": quantity,
            "speed": speed # 分速
        })
    
    return materials


def extract_produces_in(tag: Tag):
    # 生産設備情報を取得
    
    machine, crafting_time = list(tag.stripped_strings)[:2] # 一番最初のものだけ
    
    crafting_time = extract_number(crafting_time)

    produced_in = {
        "name": machine,
        "crafting_time": crafting_time # 秒
    }

    return produced_in
    

def parse_row(row: list[Tag]):
    # 1つのレシピを解析し、辞書形式で返す
    
    recipe, ingredients, produced_in, products, unlocked_by = row

    return {
        "recipe": list(recipe.stripped_strings)[0],
        "ingredients": extract_materials_from_tag(ingredients),
        "produced_in": extract_produces_in(produced_in),
        "products": extract_materials_from_tag(products),
        "unlocked_by": unlocked_by.get_text(separator=" ")
    }
```


```python
recipes_tag = data[1:] # 見出し行を除外
recipes = [parse_row(recipe_tag) for recipe_tag in recipes_tag]

# 確認
print(recipes[0])
```
出力例(見やすいように整形しています)：
```
{
    "recipe": "AI Expansion Server",
    "ingredients": [
        {"name": "Magnetic Field Generator", "quantity": 1.0, "speed": 4.0},
        {"name": "Neural-Quantum Processor", "quantity": 1.0, "speed": 4.0},
        {"name": "Superposition Oscillator", "quantity": 1.0, "speed": 4.0},
        {"name": "Excited Photonic Matter", "quantity": 25.0, "speed": 100.0}
    ],
    "produced_in": {"name": "Quantum Encoder", "crafting_time": 15.0},
    "products": [
        {"name": "AI Expansion Server", "quantity": 1.0, "speed": 4.0},
        {"name": "Dark Matter Residue", "quantity": 25.0, "speed": 100.0}
    ],
    "unlocked_by": "Tier 9 - Quantum Encoding\n"
}

```
レシピデータを辞書型に整理し、JSONに保存します。
アイテム名をキーにして辞書に変換すると、検索や処理がしやすくなります。
```python
# "recipe"をキーとして、残りの情報を値にする辞書を作成
recipes_dict = {recipe["recipe"]: {key: recipe[key] for key in recipe if key != "recipe"} for recipe in recipes}
```
JSONファイルとして保存。
```python
import json

with open("recipes.json", 'w', encoding='utf-8') as f:
    json.dump(recipes_dict, f, ensure_ascii=False, indent=4)
```

次は、これをもとにグラフを生成していきます。

### Networkx,Graphvizのインストール
グラフの作成にはNetworkXというライブラリを使用します。
```
pip install networkx
```
また、グラフの描画にはGraphvizを使います。NetworkXでも描画は可能ですが、階層型の描画にはGraphvizが必要です。
GraphvizはPythonライブラリではなく、システムにインストールする必要があります。

1. [Graphvizの公式サイト](https://www.graphviz.org/download/) からインストーラーをダウンロード
2. インストール時に **「Add Graphviz to the system PATH for current user」** にチェックを入れる
![スクリーンショット 2025-03-04 124713.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813135/a89a9919-d529-4702-af68-96c3616376fe.png)

インストールが完了したら、Python用のGraphvizライブラリをインストールします。
```
pip install graphviz
```

### グラフの作成・描画
保存しておいたレシピデータを読み込みます。
```python
import json

# JSONファイルを読み込んで辞書に変換
with open("recipes.json", "r", encoding="utf-8") as f:
    recipes = json.load(f)
```

グラフを作成する関数です。
```python
import graphviz
import networkx as nx

def get_ingredients_rate_tree(G, target, target_rate):
    """
    指定したアイテムを1分間にtarget_rate個作るために必要な材料の量を計算し、
    networkxのグラフに追加していく。

    引数:
        G (networkx.Graph): 作成するグラフオブジェクト
        target (str): 作成したいアイテム名
        target_rate (float): 目標の生産速度（1分間に何個作るか）
    """
    # レシピに target が存在しない場合は処理を終了
    if target not in recipes:
        return
    
    # 作成倍率を計算
    mul = target_rate / recipes[target]["products"][0]["speed"]
    
    # 必要な材料ごとに処理
    for ingredient in recipes[target]["ingredients"]:
        # 必要な材料の量を計算
        weight = ingredient["speed"] * mul
        ingredient_name = ingredient["name"]
        
        if G.has_edge(ingredient_name, target):
            # すでに同じエッジが存在する場合、重みを加算
            G[ingredient_name][target]['weight'] += weight
        else:
            # 新しいエッジとして追加（材料 -> 完成品 の流れ）
            G.add_weighted_edges_from([[ingredient_name, target, weight]])
        
        # 材料を再帰的に処理し、ツリー構造を作成
        get_ingredients_rate_tree(G, ingredient_name, weight)

```

グラフを描画する関数です。
```python
def draw_graph(G, filename="gv"):
    # グラフをGraphvizを使って描画
    
    dot = graphviz.Digraph(format="svg", filename=filename) # svgの方が文字が見やすい
    dot.attr(rankdir="LR") # ツリーを横向き（左から右）に描画

    # ノードを追加
    for node in G.nodes():
        dot.node(node, shape="box")

    # エッジを追加（重みをラベルとして表示）
    for u, v, data in G.edges(data=True):
        weight = f"{data['weight']:.2f}"  # エッジの重みを取得(小数点2桁に整形)
        dot.edge(u, v, label=weight)  # エッジのラベルに重みを設定
        # ちなみにパラメータは文字列で指定する必要がある

    dot.view()
```

### 実際にグラフを作成・描画
例:`Adaptive Control Unit`を1分間に1個作るためのグラフを描画。
```python
G = nx.DiGraph()
get_ingredients_rate_tree(G, "Adaptive Control Unit", 1)
draw_graph(G)
```
結果はこうなります。
![gv.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3813135/45bd34bb-f7a2-4d4b-a174-4a45a7cee6c4.png)

辺の上にある数字が1分間に必要な量です。
このように、レシピと必要な材料の関係が可視化されます。また、各材料の1分間あたりの必要量も表示されます。

ちなみに、`ExecutableNotFound: failed to execute WindowsPath('dot'), make sure the Graphviz executables are on your systems' PATH`というエラーが出たら、エディタを再起動すると直るかもしれません。

## 終わりに
グラフで可視化することで、複雑なレシピを把握しやすくできました。これで計画が立てやすくなります。

ちなみに、作った後に調べてみたらもうすでに同じことをやっているWebアプリがありました。
https://www.satisfactorytools.com/1.0/production
車輪の再発明...