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シリーズ #19】RelationMapを夜間バッチで毎日自動更新する

0
Last updated at Posted at 2026-03-24

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つだけ。

  1. 今日いて昨日いない人 → 新メンバー
  2. 昨日いて今日いない人 → 退出メンバー
  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-BB-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に載せて実際に回す。

やることは明確だ。

  1. n8nにワークフローを組む — 上のノード構成をGUIで組んで、Schedule Triggerを設定する
  2. 実データで差分検出を検証する — #3 で作った relationmap-latest.json を初期状態として、実際のAPI応答と突き合わせる
  3. 1週間回して観察する — 差分の粒度が適切か、通知が邪魔すぎないか、実運用に耐えるか

差分検出が安定したら、次は「変化のパターン」を分析したい。

  • 特定の人が複数ルームに急に追加された → 新プロジェクト立ち上げ?
  • あるルームから3人同時に抜けた → プロジェクト終了?
  • エッジの重みが急増した人 → ハブ化している?

APIが返すのはただの配列だ。それを毎日比較するだけで、組織の動きが見えてくる。 #3 で静的なマップを作り、 #19 で動的な観測の設計を組んだ。実装して回し始めたら、また報告する。

Chatworkシリーズ


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?