0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Chatworkシリーズ #3】ルームの参加者データだけで、組織の人間関係マップを作った

0
Last updated at Posted at 2026-03-12

ルームが17個になったとき、正直もうわからなかった。

誰と誰が同じルームにいるか。誰が複数の事業をまたいでいるか。どの人がキーパーソンで、どの人が一つのルームにしかいないか。

全部、頭の中にあった。だから頭の中で消えた。

「あの人ってどっちの案件でも関わってましたっけ」と聞かれるたびに、ルームを開いてメンバー一覧を確認する。地味に時間がかかる。そして次に同じことを聞かれたとき、また確認する。

自動化するしかなかった。

Chatwork APIにはメンバー取得APIがある

#2でも触れたけど、GET /rooms/{room_id}/members でルームのメンバー一覧が取れる。

[
  {"account_id": 12345, "name": "山田 太郎", "role": "admin"},
  {"account_id": 67890, "name": "佐藤 花子", "role": "member"},
  ...
]

全ルームのメンバーを取れば、「誰がどのルームにいるか」がわかる。逆に言うと「誰と誰が同じルームにいるか」もわかる。

ここからネットワークグラフが作れると気づいた。

アプローチ:co-membership

やり方はシンプル。

同じルームにいる人同士にエッジを張る。

それだけ。

グラフ理論でいう「co-membership」という手法だ。学術的には組織ネットワーク分析(ONA: Organizational Network Analysis)と呼ばれる分野がある。でも俺がやったのはその入門版みたいなもので、難しいことは何もしていない。

ルームA: [太郎, 花子, 次郎]
ルームB: [花子, 三郎, 四郎]

このとき:

  • 太郎 ↔ 花子(ルームA)
  • 太郎 ↔ 次郎(ルームA)
  • 花子 ↔ 次郎(ルームA)
  • 花子 ↔ 三郎(ルームB)
  • 花子 ↔ 四郎(ルームB)
  • 三郎 ↔ 四郎(ルームB)

花子が2ルームに出てくるから、エッジも多い。ルームをまたいで関わっている人ほど、グラフの中心に来る。

Pythonで組んだ

依存ライブラリはゼロ。標準ライブラリだけで動く。

核心部分はこれだけ。

from collections import defaultdict
from itertools import combinations

def build_graph(rooms_data):
    edge_weights = defaultdict(int)   # (id1, id2) -> 共有ルーム数
    edge_rooms = defaultdict(set)     # (id1, id2) -> {ルーム名}

    for room_name, room_info in rooms_data.items():
        member_ids = [m["id"] for m in room_info["members"]]
        # 全ペアの組み合わせにエッジを張る
        for id1, id2 in combinations(member_ids, 2):
            key = (min(id1, id2), max(id1, id2))
            edge_weights[key] += 1
            edge_rooms[key].add(room_name)

    return edge_weights, edge_rooms

combinations(member_ids, 2) が全部やってくれる。

10人のルームなら C(10,2) = 45ペア。それを全ルーム分繰り返す。エッジの重みは「共有ルーム数」。2つのルームで一緒なら weight=2、5ルームで一緒なら weight=5。

ノイズカット

全エッジをそのまま出すと、1ルームしか共有していないペアで画面が埋まる。よく知らない人同士がエッジで繋がっても情報量がない。

フィルターをかけた。

# 重み2以上のエッジだけ残す
# ただし中心人物が絡むエッジは1でも残す
CENTER_IDS = {12345, 67890}  # 全事業を統括するキーパーソン

for (id1, id2), weight in edge_weights.items():
    if weight < 2 and id1 not in CENTER_IDS and id2 not in CENTER_IDS:
        continue
    # エッジとして採用

「2ルーム以上で一緒になって初めて、関係があると見なす」というルール。

これで視覚的なノイズが消えた。

vis.jsで見せる

グラフを画面に出す部分は vis.js に丸投げした。CDNから読むだけでいい。NetworkXD3.js も不要。

<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>

物理シミュレーションがデフォルトで入っているから、「繋がりが多い人は自然と中心に引き寄せられる」動きになる。つまりグラフの見た目が、そのままネットワーク構造を反映する。

デザインはダークネイビー×ゴールド。事業ごとに色分けして、フィルターボタンで絞り込めるようにした。

const options = {
  physics: {
    solver: "forceAtlas2Based",
    forceAtlas2Based: {
      gravitationalConstant: -120,
      springLength: 160,
      avoidOverlap: 0.5,
    },
  },
};

forceAtlas2Based というソルバーが意外と良かった。有向グラフの可視化でよく使われるアルゴリズムで、クラスターが自然に分離してくれる。

動かしたら、見えてきたもの

8ルーム、36人、155エッジ。

最初に地図が表示されたとき、「あ、ヤバい」と思った。ネットワーク図は教科書で見たことがある。でも自分のデータで、自分の組織で出てきたのは初めてだ。

全事業を横断しているキーパーソンが、一目でわかった。

その人のノードは大きく、エッジは多い。5〜6ルームをまたいで繋がっている。頭の中では「いろんなところに関わってる人」くらいの認識だったのが、視覚情報になった瞬間、「こんなに繋がってたんか」と口に出てしまった。

事業をまたぐブリッジが誰かもわかった。

ある事業のメンバーが別の事業のルームにも1人だけいる。その人が唯一の接点。ネットワーク理論では「橋渡し(bridge)」と呼ぶ。もしいなくなったら、その事業との繋がりが切れる。「ちょっと危ういな」と思った。Chatworkのルームリストを眺めているだけでは、絶対に気づかなかった。

孤立しているルームもわかった。

他のルームと参加者がまったく被っていない。完全に独立した島。そのルームの話が社内で全然共有されない理由が、構造的に見えた。「あのルーム、なんでいつも話が届かないんだろう」がずっと謎だったんだけど、答えは参加者データの中にあった。

ルームの中を見るんじゃなくて、ルームの外を見る。それだけで組織の形が見えてくる。

夜間バッチで自動更新

launchdで毎日21時に自動実行している。

<!-- ~/Library/LaunchAgents/com.ai-team.evening-research.plist の一部 -->
<string>python3 /path/to/generate_relationship_map.py</string>

新しいルームができたり、メンバーが増えたりしても、翌朝には更新されている。

ただ、今は8ルームしかスキャンしていない。本当は17ルーム全部見たいし、メッセージの頻度(誰が誰に実際に話しかけているか)もエッジの重みに入れたい。co-membershipはあくまで「同じ部屋にいる」だけ。実際のコミュニケーション量は別の話だ。

そこはPhase 2の話。今は地図があるだけで十分役に立っている。

コード全文

実際のデータ(個人名・ルームIDなど)はさすがに出せないので、構造だけ置いておく。

#!/usr/bin/env python3
"""
Chatwork 人間関係マップ自動生成
- ルーム×メンバーデータからネットワーク図を生成
- vis.js インタラクティブHTMLとして出力
"""

import json
import os
from collections import defaultdict
from itertools import combinations

# ─── データ定義 ────────────────────────────────
ROOMS_DATA = {
    "プロジェクトA": {
        "group": "project_a",
        "members": [
            {"id": 1001, "name": "山田 太郎"},
            {"id": 1002, "name": "佐藤 花子"},
            {"id": 1003, "name": "鈴木 次郎"},
        ]
    },
    "プロジェクトB": {
        "group": "project_b",
        "members": [
            {"id": 1002, "name": "佐藤 花子"},  # 花子は2ルームに参加
            {"id": 1004, "name": "田中 三郎"},
            {"id": 1005, "name": "伊藤 四郎"},
        ]
    },
}

GROUP_CONFIG = {
    "project_a": {"color": "#E74C3C", "label": "プロジェクトA"},
    "project_b": {"color": "#3498DB", "label": "プロジェクトB"},
}

# ─── グラフ構築 ────────────────────────────────

def build_graph(rooms_data, center_ids=None):
    """co-membershipでエッジを張る"""
    center_ids = center_ids or set()

    persons = {}
    edge_weights = defaultdict(int)
    edge_rooms = defaultdict(set)

    # 全人物を収集
    for room_name, room_info in rooms_data.items():
        for member in room_info["members"]:
            mid = member["id"]
            if mid not in persons:
                persons[mid] = {
                    "name": member["name"],
                    "groups": set(),
                    "rooms": set(),
                }
            persons[mid]["groups"].add(room_info["group"])
            persons[mid]["rooms"].add(room_name)

    # エッジ生成
    for room_name, room_info in rooms_data.items():
        member_ids = [m["id"] for m in room_info["members"]]
        for id1, id2 in combinations(member_ids, 2):
            key = (min(id1, id2), max(id1, id2))
            edge_weights[key] += 1
            edge_rooms[key].add(room_name)

    # ノード生成
    nodes = []
    for pid, pinfo in persons.items():
        room_count = len(pinfo["rooms"])
        nodes.append({
            "id": pid,
            "label": pinfo["name"],
            "group": list(pinfo["groups"])[0],
            "title": f"共有ルーム数: {room_count}",
            "size": 10 + room_count * 5,
        })

    # エッジフィルタリング(重み2未満はカット、中心人物は除外)
    edges = []
    for (id1, id2), weight in edge_weights.items():
        if weight < 2 and id1 not in center_ids and id2 not in center_ids:
            continue
        edges.append({
            "from": id1,
            "to": id2,
            "value": weight,
            "title": f"共有ルーム({weight}): {', '.join(edge_rooms[(id1, id2)])}",
        })

    return nodes, edges


def generate_html(nodes, edges, output_path="relationship-map.html"):
    """vis.js インタラクティブHTMLを生成"""
    html = f"""<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Chatwork 人間関係マップ</title>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
  body {{ margin: 0; background: #0a0f1a; }}
  #network {{ width: 100vw; height: 100vh; }}
</style>
</head>
<body>
<div id="network"></div>
<script>
const nodes = new vis.DataSet({json.dumps(nodes, ensure_ascii=False)});
const edges = new vis.DataSet({json.dumps(edges, ensure_ascii=False)});
const network = new vis.Network(
  document.getElementById("network"),
  {{ nodes, edges }},
  {{
    physics: {{
      solver: "forceAtlas2Based",
      forceAtlas2Based: {{
        gravitationalConstant: -120,
        springLength: 160,
        avoidOverlap: 0.5,
      }},
    }},
    nodes: {{
      shape: "dot",
      font: {{ color: "#e0e0e0" }},
    }},
    edges: {{
      color: {{ color: "rgba(255,255,255,0.2)" }},
      scaling: {{ min: 1, max: 6 }},
    }},
  }}
);
</script>
</body>
</html>"""

    with open(output_path, "w", encoding="utf-8") as f:
        f.write(html)
    print(f"出力: {output_path}  ノード: {len(nodes)}  エッジ: {len(edges)}")


if __name__ == "__main__":
    nodes, edges = build_graph(ROOMS_DATA)
    generate_html(nodes, edges)

ROOMS_DATA を自分のルームデータに差し替えれば動く。メンバーデータはChatwork APIで取れる。

import urllib.request

def get_room_members(room_id, api_token):
    url = f"https://api.chatwork.com/v2/rooms/{room_id}/members"
    req = urllib.request.Request(url, headers={"X-ChatWorkToken": api_token})
    with urllib.request.urlopen(req) as res:
        return json.loads(res.read().decode())

urllib.request で叩くだけ。外部ライブラリは要らない。


地図ができた瞬間、「これ、なんで今まで手元になかったんだろう」と思った。

「この人にお願いしたい」と思ったとき、その人がどのルームに関わっていて誰と繋がっているかがわかる。「このルームの話をあのルームにも共有したい」というとき、ブリッジになれる人を探せる。Chatworkを使い始めて何年も経つのに、こんな使い方があったとは。

ルームの中を読むのは今まで通りでいい。でもルームの外から俯瞰する目が一枚加わると、Chatworkの景色が変わる。

SlackやDiscordなら、最初からこんなカオスにはならなかった。チャンネル設計がしっかりしているから、17ルームが無秩序に増殖することもない。構造が最初からある。でもそういうことじゃないんだ。

よくできたツールだと思う、改めて。792万IDが使っているのに、APIで遊んでいる人がほとんどいない。だからまだ発見がある。

co-membershipは「同じ部屋にいる」だけ。実際に喋っているか、どっちが発信しているかは、また別の景色になるはずだ。そのあたりも検証が進んだら書く。


Chatworkシリーズ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?