3月13日、ルームの参加者データだけで組織の人間関係マップを作った。36人のノード、155本のエッジ。「同じルームにいる人同士」をエッジでつなぐだけで、組織の形がくっきり浮かび上がった。
あの日は満足だった。
ただ、作った瞬間に気づく。このマップ、3月13日のスナップショットでしかない。人は入り、人は抜ける。ルームの構成も変わる。来週このマップを開いたとき、現実と合っている保証はどこにもない。
手動で更新し直す気にはならなかった。だったら毎晩勝手に更新させればいい。n8nの夜間バッチに載せる設計を組んでみた。
設計方針
やりたいことはシンプルだ。
- 毎晩深夜3時にn8nが全ルームのメンバーを取得する
- 前日のJSONと比較して差分を検出する
- 変化があればChatworkに通知を投げる
- マップ本体のJSONを上書き保存する
「誰が増えた」「誰がいなくなった」「誰がルームを移動した」。これだけわかれば十分だ。
n8nワークフローの全体像
ノード数は6つ。大したことはない。
Schedule Trigger (毎日 03:00 JST)
↓
HTTP Request (GET /rooms)
↓
Loop: 各ルームの /rooms/{id}/members を取得
↓
Function: 前日JSONとの差分検出
↓
IF: 差分あり → Chatworkに通知
↓
Write File: 最新JSONを保存
Schedule Triggerで毎晩起動し、全ルームの参加者を取得。前日のJSONファイルと比較して、差分があればChatworkに投げる。なければ何もしない。静かに終わる。まだn8n上には載せていないが、設計としてはこれで固まっている。
メンバー取得とグラフ構築
まず全ルームのメンバーを取得して、ノードとエッジの構造に変換する。ここは #3 で手動実装したロジックをそのまま流用する。
// n8n Functionノード: 全ルームのメンバーを取得して構造化
const rooms = $input.all();
const graph = { nodes: {}, edges: [] };
for (const room of rooms) {
const members = room.json.members;
const roomName = room.json.name;
// ノード追加
for (const m of members) {
if (!graph.nodes[m.account_id]) {
graph.nodes[m.account_id] = {
id: m.account_id,
name: m.name,
rooms: []
};
}
graph.nodes[m.account_id].rooms.push(roomName);
}
// エッジ追加(同じルームにいる人同士)
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
graph.edges.push({
source: members[i].account_id,
target: members[j].account_id,
room: roomName
});
}
}
}
return [{ json: graph }];
#3 と同じロジックだ。同じルームにいる人同士をエッジでつなぐ。10人のルームなら45本のエッジ(10C2)。これが17ルーム分回る。
ただし、このままだとエッジが重複する。同じ2人が3つのルームに同居していたら、エッジが3本できてしまう。これは後で潰す。
差分検出のロジック
ここが今回の設計の核心。前日のJSONと今日のJSONを突き合わせて、3種類の差分を出す。
// n8n Functionノード: 前日との差分検出
const today = $input.first().json;
const yesterday = JSON.parse(
require('fs').readFileSync('/data/relationmap-latest.json', 'utf8')
);
const diff = {
newMembers: [], // 新しく現れた人
leftMembers: [], // いなくなった人
roomChanges: [] // ルーム異動
};
// 新メンバー検出
for (const id of Object.keys(today.nodes)) {
if (!yesterday.nodes[id]) {
diff.newMembers.push(today.nodes[id]);
}
}
// 退出メンバー検出
for (const id of Object.keys(yesterday.nodes)) {
if (!today.nodes[id]) {
diff.leftMembers.push(yesterday.nodes[id]);
}
}
// ルーム異動検出
for (const id of Object.keys(today.nodes)) {
if (yesterday.nodes[id]) {
const todayRooms = new Set(today.nodes[id].rooms);
const yesterdayRooms = new Set(yesterday.nodes[id].rooms);
const joined = [...todayRooms].filter(r => !yesterdayRooms.has(r));
const left = [...yesterdayRooms].filter(r => !todayRooms.has(r));
if (joined.length > 0 || left.length > 0) {
diff.roomChanges.push({
name: today.nodes[id].name,
joined,
left
});
}
}
}
return [{ json: diff }];
やっていることは3つだけ。
- 今日いて昨日いない人 → 新メンバー
- 昨日いて今日いない人 → 退出メンバー
- 両方いるが所属ルームが変わった人 → ルーム異動
Setの差分演算で比較する。filterで片方にしかない要素を抽出するだけだ。アルゴリズムとしては泥臭いが、17ルーム・40人程度ならこれで十分速い。
エッジの重複排除
先ほど触れたエッジの重複問題。同じペアのエッジを1本にまとめて、代わりに「共通ルーム数」を重みとして持たせる。
// エッジの重複排除と重み付け
const edgeMap = {};
for (const edge of graph.edges) {
const key = [edge.source, edge.target].sort().join('-');
if (!edgeMap[key]) {
edgeMap[key] = {
source: edge.source,
target: edge.target,
rooms: [],
weight: 0
};
}
edgeMap[key].rooms.push(edge.room);
edgeMap[key].weight++;
}
graph.edges = Object.values(edgeMap);
ソートしてからキーにするのがミソだ。A-B と B-A を同一視するために、必ず小さい方を先にする。
weight: 3 なら「3つのルームで同居している」という意味になる。この数字が大きいほど関係が深い、と読める。正直、組織図より雄弁だ。
Chatwork通知
差分があったときだけ、Chatworkにサマリーを投げる。
// n8n Functionノード: Chatwork通知メッセージ生成
const diff = $input.first().json;
const today = new Date();
const dateStr = `${today.getMonth() + 1}/${today.getDate()}`;
let msg = `[info][title]🗺 RelationMap更新(${dateStr})[/title]`;
if (diff.newMembers.length > 0) {
msg += `■ 新メンバー: ${diff.newMembers.length}名\n`;
for (const m of diff.newMembers) {
msg += ` - ${m.name}(${m.rooms.join(', ')})\n`;
}
msg += '\n';
}
if (diff.leftMembers.length > 0) {
msg += `■ 退出: ${diff.leftMembers.length}名\n`;
for (const m of diff.leftMembers) {
msg += ` - ${m.name}\n`;
}
msg += '\n';
}
if (diff.roomChanges.length > 0) {
msg += `■ ルーム異動: ${diff.roomChanges.length}名\n`;
for (const c of diff.roomChanges) {
const parts = [];
if (c.left.length > 0) parts.push(c.left.join(', '));
if (c.joined.length > 0) parts.push(c.joined.join(', '));
msg += ` - ${c.name}: ${c.left.join(', ') || '(なし)'} → ${c.joined.join(', ') || '(なし)'}\n`;
}
msg += '\n';
}
// ノード数・エッジ数のサマリー
const todayGraph = $('BuildGraph').item.json;
const nodeCount = Object.keys(todayGraph.nodes).length;
const edgeCount = todayGraph.edges.length;
msg += `ノード: ${nodeCount} / エッジ: ${edgeCount}`;
msg += '[/info]';
return [{ json: { body: msg } }];
通知は [info] タグで囲む。Chatworkだとこれがボックス表示になって視認性が上がる。
実際に届くメッセージのイメージはこうなる。
[info][title]🗺 RelationMap更新(3/24)[/title]
■ 新メンバー: 2名
- 田中太郎(プロジェクトA, 全体連絡)
- 佐藤花子(プロジェクトC)
■ ルーム異動: 1名
- 山田次郎: プロジェクトD → プロジェクトE
ノード: 38 / エッジ: 162[/info]
変化がなければ何も飛ばない。毎朝通知が来ないのは「昨日と同じ」というシグナルになる。沈黙も情報だ。
設計時に考慮したポイント 3つ
1. APIレート制限
Chatwork APIのレート制限は5分間に300リクエスト。17ルーム分のメンバーを連続で叩く程度なら余裕に見えるが、他のワークフロー(メッセージ取得・タスク処理等)が同時に動いていると枠を食い合う。#14 の全ルーム巡回でこの罠は経験済みだ。
n8nのHTTP Requestノードには「Retry on Fail」設定がある。これをONにして、リトライ回数3、間隔1000msに設定。さらに「Wait Between Requests」を500msに入れる設計にした。
HTTP Request ノード設定:
Retry on Fail: true
Max Tries: 3
Wait Between Tries: 1000ms
Loopノード設定:
Wait Between Iterations: 500ms
これで17ルーム回しても安定するはず。見積もりでは合計の実行時間は約10秒。深夜3時に10秒かかっても誰も困らない。
2. JST日付ズレ
n8nのSchedule TriggerはUTC基準で動く。「毎日3時」と設定すると3:00 UTCに動く。JSTでは正午だ。n8nを触っているとこの手のタイムゾーンの罠には何度もハマる。
JST 03:00に動かしたければ、UTC 18:00(前日)に設定する必要がある。
Schedule Trigger設定:
Hour: 18 ← UTC 18:00 = JST 03:00(翌日)
Minute: 0
Timezone: UTC
もう一つの罠。ファイル名の日付を new Date().toISOString() で生成すると、UTC基準になる。JSTで3月24日の深夜3時はUTCだとまだ3月23日だ。ファイル名が1日ズレる。
// NG: UTCで日付がズレる
const dateStr = new Date().toISOString().slice(0, 10);
// → "2026-03-23" になる(JSTでは3/24なのに)
// OK: JSTで明示変換
const jst = new Date(Date.now() + 9 * 60 * 60 * 1000);
const dateStr = jst.toISOString().slice(0, 10);
// → "2026-03-24"
地味だが、これを踏むとバックアップファイルの日付が全部1日ズレる。ログを遡ったときに混乱する。
3. エッジ数の爆発
10人のルームはエッジ45本。15人なら105本。20人なら190本。組み合わせ(nC2)なので人数の二乗に比例して増える。
前回(#3)でRelationMapを手動で作ったとき、17ルーム全部のエッジを素直に足し上げたら重複込みで800本以上になった。重複排除して155本。この経験がなければ、重複排除の設計は後回しにしていたと思う。
ルーム単位のエッジ数を見ると、偏りがはっきりする。
全体連絡ルーム(20人): 190エッジ ← ここだけで全体の2割超
プロジェクトA(8人): 28エッジ
プロジェクトB(5人): 10エッジ
1対1ルーム(2人): 1エッジ
全員が入っている「全体連絡」ルームのエッジは、実態として「同じ組織にいる」程度の意味しかない。本当に意味のある関係は、小さなルームで生まれる。
対策として、ルームの人数に応じてエッジの重みを逆数にした。20人ルームのエッジは 1/20 = 0.05、2人ルームのエッジは 1/2 = 0.5。少人数ルームほど太い線になる。
// ルーム人数の逆数で重み付け
for (const edge of graph.edges) {
const roomSize = rooms.find(r => r.name === edge.room).members.length;
edge.weight = 1 / roomSize;
}
動かしたら何が見えるか
まだn8n上で実際には回していない。設計ができた段階だ。ただ、#3 で手動で作った36ノード・155エッジのマップから、動かしたときに何が見えるかは想像がつく。
- 変化がない日が続く → それ自体が「組織が安定している」というシグナルになる。毎朝通知が来ないのは「昨日と同じ」の証拠だ
- 新メンバーが現れた日 → 「どのルームに入ったか」がわかるだけで、その人の役割が推測できる
- エッジが減った日 → 誰かがルームから抜けた。プロジェクト終了か、異動か
静的なマップでは見えなかったものが、差分を毎日取ることで時系列に浮かび上がる。RelationMapの本来の価値はスナップショットではなく、この「変化の検出」にある。
JSONファイルの保存と履歴
最新のマップは /data/relationmap-latest.json に上書き保存する。同時に、日付つきのバックアップを /data/relationmap-history/ に残す。
// n8n Functionノード: JSONファイル保存
const fs = require('fs');
const graph = $input.first().json;
// JSTで日付取得
const jst = new Date(Date.now() + 9 * 60 * 60 * 1000);
const dateStr = jst.toISOString().slice(0, 10);
// 最新版を上書き
fs.writeFileSync(
'/data/relationmap-latest.json',
JSON.stringify(graph, null, 2)
);
// 日付つきバックアップ
fs.writeFileSync(
`/data/relationmap-history/${dateStr}.json`,
JSON.stringify(graph, null, 2)
);
return [{ json: { saved: dateStr } }];
履歴を残しておけば、「1か月前のマップ」と「今のマップ」を比較できる。日々の差分は小さくても、1か月単位で見ると組織の動きがはっきり見えるはずだ。
n8nワークフローの全コード
最終的なワークフローの流れをまとめておく。
[Schedule Trigger] 毎日 UTC 18:00 (= JST 03:00)
↓
[HTTP Request] GET /v2/rooms
Headers: X-ChatWorkToken: {{$credentials.chatworkApi}}
↓
[Loop] 各ルームに対して
│ [HTTP Request] GET /v2/rooms/{{roomId}}/members
│ Wait: 500ms
└→ 結果を配列に蓄積
↓
[Function] BuildGraph — ノード・エッジ構築 + 重複排除
↓
[Function] DetectDiff — 前日JSONとの差分検出
↓
[IF] diff.newMembers.length > 0
|| diff.leftMembers.length > 0
|| diff.roomChanges.length > 0
│
├→ true: [Function] FormatMessage → [HTTP Request] POST /v2/rooms/{id}/messages
│
└→ false: (何もしない)
↓
[Function] SaveJSON — latest.json上書き + 日付バックアップ
全部で6種のノード。n8nのGUIで組めば10分かからないはず。Functionノードの中身が本体だ。
次にやること
設計はできた。これからn8nに載せて実際に回す。
やることは明確だ。
- n8nにワークフローを組む — 上のノード構成をGUIで組んで、Schedule Triggerを設定する
-
実データで差分検出を検証する — #3 で作った
relationmap-latest.jsonを初期状態として、実際のAPI応答と突き合わせる - 1週間回して観察する — 差分の粒度が適切か、通知が邪魔すぎないか、実運用に耐えるか
差分検出が安定したら、次は「変化のパターン」を分析したい。
- 特定の人が複数ルームに急に追加された → 新プロジェクト立ち上げ?
- あるルームから3人同時に抜けた → プロジェクト終了?
- エッジの重みが急増した人 → ハブ化している?
APIが返すのはただの配列だ。それを毎日比較するだけで、組織の動きが見えてくる。 #3 で静的なマップを作り、 #19 で動的な観測の設計を組んだ。実装して回し始めたら、また報告する。
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のファイル機能、使ったことある?
- #14 n8nで全ルーム巡回
- #15 タスク機能をAPIで使い倒す
- #16 MCPを2アカウント同時接続したら、仕事用と事務局用が1画面で回った
- #17 【世界初かもしれない】ChatworkでClaude Code Channelsを実装してみた
- #18 Chatwork × Dify × GASで問い合わせ回答を自動提案する
- #19 RelationMapを夜間バッチで毎日自動更新する(この記事)
- #20 17記事書いて見えた、Chatwork APIエコシステムに足りないもの
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のファイル機能、使ったことある?
- #14 n8nで全ルーム巡回
- #15 タスク機能をAPIで使い倒す
- #16 MCPを2アカウント同時接続したら、仕事用と事務局用が1画面で回った
- #17 【世界初かもしれない】ChatworkでClaude Code Channelsを実装してみた
- #18 Chatwork × Dify × GASで問い合わせ回答を自動提案する
- #19 RelationMapを夜間バッチで毎日自動更新する(この記事)
- #20 17記事書いて見えた、Chatwork APIエコシステムに足りないもの
- #21 Googleフォームの回答をChatworkに自動投稿するGAS
- #22 Chatworkの会話を毎日AIが要約してくれる仕組みをn8nで作った話
- #23 chatwork-cliを入れたら、シェルからChatworkが操作できて世界が変わった
- #24 ChatworkのWebhookをn8nで受けるなら、HMAC署名検証は必ずやれ
- #25 Chatwork × GAS × Claude Codeで会員制講座の運用を自動化した