3
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?

【n8n × Notion】各チームの議事録を自動収集して一元管理する!!

Last updated at Posted at 2025-12-16

※こちらはtoggle Advent Calendar 2025の17日目の記事です。

はじめに

突然ですが、ナレッジや議事録の管理って難しくないですか?

弊社では4~5名規模の開発チームが複数存在しており、その多くがスクラム開発を行っています。
そのため、デイリー、プランニング、レトロスペクティブ、スプリントレビューなどあらゆる会議体からの議事録が同時多発的に積み上がっていきます。

ドキュメント管理は Notion で、コミュニケーションは Slack で行っていますが、これらがきっちりと連携されないことも多いです。

ある時は Slack で議論が止まり、ある時は GitHub のコメントが最新の決定事項であり、そして Notoin がいつの間にか古くなっている、というように、重要な情報資産がバラバラに散逸してしまいます。

私はここにかなり悩んでいました。

ただし、これは人が手作業で管理している限り、解決は難しいと思います。
どれだけ厳格なルールを決めても、忙しい現場でその徹底をメンバー全員に強いるのは現実的ではないと考えるからです。

なので手っ取り早く自動化することにしました。
ルールさえ決めてしまえば、あとはルールを満たすように自動化を進めるだけです。

ということで、以下のような構成を考えてみました。

構成

  1. 社内会議は Google Meet を通じて行う
    (議事録生成が自動で行われるため)
  2. 会議議事録は Notion に貯める
  3. 貯めた議事録は、横断的に見ることができる
  4. チーム単位で見ることもできる(カテゴリー分けされている)

早速、完成図

忙しい人が多いと思うので、完成図から出します。
前項の条件を満たすように n8n を用いて、実装してみました!!

n8n とは
ノーコード/ローコードで多様なアプリやサービスを連携させ、業務プロセスを自動化できるオープンソースのワークフローツール

n8n を使ってみたかったという気持ちが先行しているので、「GAS書いたほうが早いじゃん。」などあるかもしれませんがご容赦ください。

image.png

処理の流れ

  1. トリガー スケジュール実行でGmailを確認
  2. メール取得 gemini-notes@google.com からの未処理メールを取得
  3. URL抽出 メール本文のHTMLから議事録(Google Docs)のURLを抽出
  4. 内容取得 Google Docs APIで中身のテキスト(タブ対応)を取得
  5. 整形 Geminiの出力フォーマット(まとめ・詳細・ネクストステップ)を解析
  6. Notion連携 チームごとのDBへ、見やすいブロック形式で追加
  7. 完了処理 メールに「連携済み」ラベルを付与して終了

なぜこの構成になったか

Google Meetのログを右から左へ流すだけと思ってましたが、実際に実装してみるといくつかの壁にぶつかったからです。端的に列挙します。

1. 議事録ファイルへのアクセス権限問題

Google Meetで自動作成される議事録ファイルは、会議主催者の MyDrive に格納されます。
そのため、n8nから単純にドライブ全体を監視しようとしても、個人の MyDrive にあるファイルにはアクセスできない、あるいはトリガーが発火しないという問題が発生しました。

2. Notion APIの2000文字制限

Google Meetの文字起こしは、会議が長引けば数万文字になることもあります。
しかし、Notionの仕様上、1つのBlockには最大2000文字までしか格納できません。
そのまま全文を投げるとエラーが返ってきてしまいます。

3. Google Docs はタブ分けされていると、n8n では最初のタブ情報しか取得できない

文字起こしが取得できないので、AIステップを噛ませたりできなくなるので不便です。

処理詳細

具体的に処理の中身を解説します。

1. トリガー

1時間に一回動くように設定しています。

image.png

2. メール取得

Google Meetの議事録生成は参加者であれば、会議終了後の数分以内に、議事録生成完了のメールが届きます。
そのメールを取得します。

Filter のSearchでメッセージを絞り込むことができるので、下記のように指定します。

from:gemini-notes@google.com -label:notion-連携済み

すでに、Notionに連携したメールは、ラベルをつけて管理するようにしました。
そのため、絞り込み条件から対象のラベルを含まないメールを検索しています。

ここで、「朝会」「プランニング」とか特定のタイトルのメールだけ拾うように絞ってもよいかもしれません。
n8n の IFノードでも絞れます。

image.png

3. バッチ指定

ここイケてない実装です。
n8n に慣れてなかったので、一旦こうしています。
前のステップで複数のメールが取得できた場合に、1件ずつ処理を回すために、バッチの実行数を1に絞ってループさせています。

image.png

4. URL取得

HTMLという Node があったのでそれを利用しました。
Gmailは HTMLメールになっているので、中の HTML を解析します。
Gemini議事録のリンクは .link-button でアクセスできました。
Attribute の href を指定することで、リンクを取得します。

image.png

5. URLのパース

n8n はコードもかけるので便利ですね。
Gmailから取得した議事録のリンは、以下のような形式になっています。

https://docs.google.com/document/d/1AbCdEfGhIjKlMnOpQrStUvWxYz12345/edit

しかし、後続の Google Docs API でドキュメントの中身を取得するには、URL全体ではなくDocument IDだけが必要です。

そこで、n8nの Code ノードを使い、正規表現でID部分だけを抽出する処理を挟みました。

image.png

6. Google Docs のIDを取得

ここで

Google Docs はタブ分けされていると、n8n では最初のタブ情報しか取得できない

という問題が発生したので、HTTP Request ノードを使うことにしました。

image.png

7. Docs APIのレスポンスをパースする

ここもコードで解決します。
n8n は困ったときに気軽にコードの力技で解決できるのが良いです。

image.png

// 結果を格納する配列
const newItems = [];

function extractTextFromContent(contentArray) {
  let text = "";
  if (!contentArray) return text;

  for (const item of contentArray) {
    if (item.paragraph && item.paragraph.elements) {
      for (const element of item.paragraph.elements) {
        if (element.textRun && element.textRun.content) {
          text += element.textRun.content;
        } 
        else if (element.person && element.person.personProperties) {
          text += `[${element.person.personProperties.name}] `;
        }
        else if (element.richLink && element.richLink.richLinkProperties) {
          text += `[Link: ${element.richLink.richLinkProperties.title}] `;
        }
      }
    }
    else if (item.table && item.table.tableRows) {
      for (const row of item.table.tableRows) {
        for (const cell of row.tableCells) {
          text += extractTextFromContent(cell.content).trim() + " | ";
        }
        text += "\n";
      }
    }
  }
  return text;
}

for (const item of $input.all()) {
  const doc = item.json;
  const output = {
    docId: doc.documentId,
    title: doc.title,
    memo: "",
    transcription: ""
  };

  if (doc.tabs) {
    for (const tab of doc.tabs) {
      const tabTitle = tab.tabProperties.title;
      let tabContent = "";
      if (tab.documentTab && tab.documentTab.body && tab.documentTab.body.content) {
        tabContent = extractTextFromContent(tab.documentTab.body.content);
      }
      if (tabTitle === "文字起こし") {
        output.transcription = tabContent;
      } else if (tabTitle === "メモ") {
        output.memo = tabContent;
      }
    }
  } 
  else if (doc.body && doc.body.content) {
    output.memo = extractTextFromContent(doc.body.content);
  }

  newItems.push({ json: output });
}

return newItems;

8. Notion に追加

Notion ノードを利用してページを作成します。

今回は Notion に Database を作り、自動で議事録を貯めるようにしました。
From list から探せるので便利です。

Title にはJavaScriptの変数を埋め込むことができます。

image.png

9. 作成したNotionページに追記する

Notion のブロックは2000文字という制限があるので、文字を2000文字以内にチャンクする必要があります。

そのため今回もCode で力技解決を試みます。
ついでに Notion へ追記したときに、いい感じに表示されるようにフォーマットもいじります。

// ノード: Chunk Text (または Structure Text にリネーム推奨)
// 目的: Geminiのメモを解析して、見やすいNotionブロックの配列を作成する

const pageId = $input.item.json.id;
// ペアリングされた前のノードからメモを取得
const memoRaw = $("Parse Text").item.json.memo || "";

// 1. 変な記号()や余計な空白をクリーニング
let cleanMemo = memoRaw.replace(//g, "").replace(/\n\n+/g, "\n");

// 2. セクションごとに分割するための正規表現マッチ
// Geminiの出力形式に依存しますが、提供いただいたデータを元にしています
const summaryMatch = cleanMemo.match(/まとめ\n([\s\S]*?)(?=\n詳細)/);
const detailsMatch = cleanMemo.match(/詳細\n([\s\S]*?)(?=\n推奨される次のステップ)/);
const nextStepsMatch = cleanMemo.match(/推奨される次のステップ\n([\s\S]*?)(?=\nGemini|$)/);

const children = [];

// --- A. まとめセクション (Calloutブロック) ---
if (summaryMatch && summaryMatch[1]) {
  children.push({
    object: "block",
    type: "heading_2",
    heading_2: {
      rich_text: [{ type: "text", text: { content: "まとめ" } }]
    }
  });
  
  children.push({
    object: "block",
    type: "callout",
    callout: {
      icon: { emoji: "💡" },
      color: "gray_background",
      rich_text: [{ type: "text", text: { content: summaryMatch[1].trim() } }]
    }
  });
}

// --- B. 詳細セクション (Toggleブロックの中に箇条書き) ---
if (detailsMatch && detailsMatch[1]) {
  // 詳細を行ごとに分解してブロック化
  const detailLines = detailsMatch[1].trim().split('\n');
  const detailBlocks = detailLines.map(line => {
    // もし行が「タイトル: 内容」の形式なら太字にするなどの処理も可能ですが、一旦シンプルに
    return {
      object: "block",
      type: "paragraph", // または bulleted_list_item
      paragraph: {
        rich_text: [{ type: "text", text: { content: line } }]
      }
    };
  });

  children.push({
    object: "block",
    type: "heading_2",
    heading_2: {
      rich_text: [{ type: "text", text: { content: "詳細" } }]
    }
  });

  // 長くなりがちなのでトグルに入れるとスッキリします
  children.push({
    object: "block",
    type: "toggle",
    toggle: {
      rich_text: [{ type: "text", text: { content: "詳細を開く" } }],
      children: detailBlocks // トグルの中身
    }
  });
}

// --- C. 次のステップ (ToDoリスト) ---
if (nextStepsMatch && nextStepsMatch[1]) {
  children.push({
    object: "block",
    type: "heading_2",
    heading_2: {
      rich_text: [{ type: "text", text: { content: "ネクストステップ" } }]
    }
  });

  const stepsLines = nextStepsMatch[1].trim().split('\n');
  stepsLines.forEach(line => {
    if (line.trim()) {
      children.push({
        object: "block",
        type: "to_do",
        to_do: {
          rich_text: [{ type: "text", text: { content: line.trim() } }],
          checked: false
        }
      });
    }
  });
}

// --- D. フォールバック (もし解析できなかったら全文をそのまま出す) ---
if (children.length === 0 && cleanMemo.trim().length > 0) {
    // 2000文字制限対策はここでも必要なら入れる
    children.push({
        object: "block",
        type: "paragraph",
        paragraph: {
            rich_text: [{ type: "text", text: { content: cleanMemo.substring(0, 1999) } }]
        }
    });
}

// n8nの次のノードに渡す構造
return [{
  json: {
    pageId: pageId,
    notionBlocks: children
  }
}];

10. Notionページを更新する

2000文字以下にチャンクしたNotionのリクエストを投げます。

URL: https://api.notion.com/v1/blocks/{{ $json.pageId }}/children

image.png

image.png

11. 完成!

自動で議事録がNotionに連携されるようになりました。
カテゴリ分けなども自動で行われており、バッチリです。

image.png

まとめ

今回は n8n と Notion を利用して、各チームの議事録を自動収集するワークフローを紹介しました!
n8n はAIワークフローが充実しているので、ステップに文字起こし → AI要約を挟むこともできます。
Geminiの標準の要約だけでなくオーダーメイドな議事録管理ができるので、使い勝手が上がると思います。
(今回は記事執筆までに間に合いませんでした。)

中々面白いツールだったので、自動化ができそうなところにはどんどん活用していきたいなと思いました!
参考になれば幸いです。

3
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
3
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?