ルームが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から読むだけでいい。NetworkX も D3.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シリーズ
- #1 なぜ2026年にまだChatworkを使い倒しているのか
- #2 chatwork-client-gas、ぶっちゃけいるの?
- #3 ルームの参加者データだけで、組織の人間関係マップを作った(この記事)
- #4 「Chatworkに確定連絡が来たら請求書を送る」をGASで自動化する
- #5 Chatwork MCPを繋いだら、17ルームの未読が10秒で片付いた
- #6 MCP vs GAS — Chatwork自動化の「正解」はどっちか
- #7 コンタクト承認をn8nで自動化しようとしたら、3つの罠にハマった
- #8 ChatworkにAIチームを住まわせたら、勝手に会話が始まった
- #9 Chatwork 8ルームの全メッセージからFAQ429件を自動抽出した
- #10 Webhook署名検証を入れたら全メッセージが消えた
- #11 過去メッセージを全件取得しようとしたら、APIの「100件の壁」にハマった
- #12 Chatwork APIの「既読」は自分で制御できる
- #13 Chatwork APIのファイル機能、使ったことある?