0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Notion AI × GitHub Actions】議事録の文字起こしからPDF生成・リポジトリ保存までを自動化するパイプラインの構築

0
Posted at

会議終了後、録音の聞き返して議事内容の修正やフォーマット整形、PDF出力、そして共有フォルダへのアーカイブ保存までの「事務作業」に工数を奪われてはいないでしょうか。これらの定型業務は、慣れない担当者にとっては負担となり、プロジェクト全体の生産性を押し下げます。

昨今、生成AIの普及により、議事録の「作成」自体のハードルは下がりました。しかし、AIがドラフトを作成しても、整形し、適切な場所に格納する工程が手動のままでは、真の業務効率化とは言えません。

本記事では、Notion AIによる構造的な要約から、PDFの自動生成、そしてGitHubをSSOT(単一の信頼できる情報源)とした管理までを、GitHub Actionsでシームレスに完結させるパイプラインの構築手法を解説します。

1. 議事録パイプラインで実現できること

本システムは、「会議から納品までの時間軸」に沿って、人間とAI、そして自動化システムが順々に繋いでいく設計になっています。どのようなタイムラインで議事録が公式ドキュメント(PDF/Markdown)へと変わるのかみていきましょう。

ステップ1:会議実施とAIによる構造化(Notion AI フェーズ)

まず、Notionの議事録ページを新設して、出席者やアジェンダなどの事前情報を記入しておきます。そして、お客様とのオンライン会議が始まったら、まずはNotionで「ミーティングノート」を起動します。

会議終了時にレコーディングを停止すると、Notion AIが音声データの文字起こしを開始します。ここで重要なのがカスタムプロンプトによる構造化です。事前に設定したテンプレートに沿って、AIが「決定事項」「宿題事項」「議事詳細」を整理し、バラバラな発言記録を「読める議事録」のドラフトへと変換します。

↓会議前のnotionの議事録ページ(※メモに出席者やアジェンダは事前に記載しておきます)
image.png

↓会議終了後、文字起こしをして、テンプレートに沿ってAIが整えた議事録ページ
image.png

デモとしてNotion AIに吹き込んだ会議内容
エンジニア: お疲れ様です。プロジェクトAの要件定義もいよいよ大詰めですね。現在の進捗ですが、画面設計が45枚中38枚完了。API定義書については、認証周りを除いてほぼFixしています。

お客様: ありがとうございます。認証といえば、前回「二要素認証」の話が出ましたが、あれはコスト的にどうですか? SMS認証だと送信料がかかりますよね。

エンジニア: おっしゃる通りです。SMS認証だと1通あたり約10円〜15円のランニングコストが発生します。プロジェクトAの想定ユーザー数1万人で試算すると、月間10万円以上のコスト増になる可能性があります。
そこで検討した結果、決定として、追加コストのかからない「Google Authenticator」による二要素認証を採用します。これなら運用コストは0円です。

お客様: それは助かります。実装の手間はどうですか?

エンジニア: 実装には追加で「3人日」ほど工数をいただきますが、現在バッファを5日持っているので、全体納期への影響はありません。

お客様: わかりました。ではその方針で進めてください。あ、それとデータベースの移行についてですが、旧システムのデータがCSV形式で20GBほどあるんです。これを一気に流し込めますか?

エンジニア: 20GBですか……。そのまま流すとタイムアウトする恐れがあります。宿題として、来週の金曜、3月27日までに「バルクインサートによる段階的な移行プラン」を検討し、佐藤様に共有します。 その際、データのクレンジングが必要になるかもしれないので、その見積もりも添えますね。

お客様: 了解です。期待しています。次回の定例は、いつもの4月3日14時からTeamsで。

ステップ2:人間による品質担保(エンジニアフェーズ)

AIが自動で議事録を作成するといっても、人間がチェックを行います。AIは万能ではありません。音声が乱れた箇所にAIが自動付与する (要確認) マークを参考に、担当者が内容を微調整して議事録を完成させます。

人間のレビューが終わったら、ステータスを 「完了」 に変更します。この時、ステータスが「完了」かつIsArchivedチェックボックスが 「false(未チェック)」であるページがPDF変換対象として抽出され、自動回収されることになります。

image.png

ステップ3:17:00の自動検知とビルド(Actions フェーズ)

夕方17:00になると、GitHub Actionsのタイマーが作動し、パイプラインが自動起動します。PythonがNotion APIを経由してデータベースをスキャンし、条件に当てはまるページを抽出します。その後、スクリプトがブロック構造を解析してMarkdownを生成します。さらに、Pandoc と 議事録の見た目を整える用のcss を組み合わせ、きれいなレイアウトのPDFとMarkdownを作成します。

完成した .md.pdf は、リポジトリの minutes/ ディレクトリへ自動コミット・プッシュされます。プッシュが完了すると、システムは再びNotion APIを叩き、対象ページの IsArchivedtrue(チェック済み)へ更新します。この後処理により、次回の定期実行では処理対象から外れます。ちなみに、手動でもGithub actionsを起動できます。

↓markdown
image.png

↓PDF

image.png

※(要確認)が残ってますが、人のレビュー時に修正する想定です。

2. 議事録パイプライン導入のメリット

このパイプラインを導入することで得られるメリットは、事務作業の削減だけではありません。チーム全体に「規律」と「ガバナンス」をもたらします。

① 「17時の定期実行」が業務リズムを作る

本システムでは、Github actionsが毎日 17:00に実行されるようスケジュールしました。午前中~昼過ぎに会議を行い、午後の隙間時間にレビューを済ませます。17:00にシステムが自動回収・PDF化することで、会議の当日17時過ぎにはお客様の手元にPDFの議事録が届くようなイメージをしてます。

このスピード感と習慣が、顧客との信頼関係を築きます。逆に「議事録作成は後でいいや」という妥協は、情報の鮮度を落とします。システムが自動で締め切りを作ってくれることで、「その日のエビデンスはその日のうちに」という規律がチームに浸透します。

② AIと人間の「責任分界点」を最適化する

「AIに全部任せる」のではなく、「AIが得意なこと」と「人間にしかできないこと」を切り分けました。AIが浸透しより効率的な業務が求められました。結果、人間がメモと音声をもとに議事録を0からつくることは時代に逆行しています。人間は「内容が正しいか」という判断に専念します。タイピングや書式設定といった付加価値のない作業は、AIに丸投げです。

③ GitHubを「SSOT(単一の信頼できる情報源)」にする

Notionは「議論し、育てる場」として優秀ですが、文書の管理にはGitHubが適しています。Notionは「作業場」、GitHubは「成果物」と明確に役割を分けます。Markdown形式で蓄積することで、全文検索が可能になるだけでなく、AIを使った業務で活用することも容易になります。Markdownでドキュメントを残すことで、「AI Ready」な環境をつくります。

また、report.css でレイアウトを一括制御します。誰が作っても、どの案件であっても、「常に同じデザイン」のPDFが出力されます。この一貫性が、組織としてのブランド力を高めます。

④ 高い拡張性と「導入コスト0円」

魅力は、「自由度」と「経済性」にもあります。GitHub Actionsを基盤としているため、プラグイン感覚で機能を拡張できます。例えば「Pushと同時にSlackへ通知する」「格納先をSharePointやBacklogに変更する」といったカスタマイズも、YAMLファイルを書き換えるだけで完結します。また、GitHub Actionsの無料枠内で十分に運用可能です。

3. パイプラインの作り方

ここからは、実際にどのような技術を選定し、どのような手順でパイプラインを構築していくのかを解説します。

業務要件

まずは、システムが満たすべき最低限の要件を整理します。実務運用においては、「何をトリガーにするか」「二重処理をどう防ぐか」というルール作りが重要です。

項目 内容
抽出条件 Notion DBの Status == "完了" かつ IsArchived == false
実行頻度 定期実行(Cron: 毎日 17:00 JST)および 手動実行(Workflow Dispatch)
成果物 minutes/ 配下に .md(生データ)と .pdf(閲覧用)を生成
後処理 処理完了後、Notion側の IsArchivedtrue に自動更新

技術要素と選定理由

シンプルかつ柔軟なスタックを選定した背景をまとめています。

カテゴリ 選定技術 選定の決め手
AI Notion AI 精度のよい文字起こしに加え、テンプレート機能で「決定事項」等の抽出を構造化・標準化できるため。
実行基盤 GitHub Actions サーバー管理コストをゼロにしつつ、自動実行が可能なため。
言語 Python 3.10+ Notion SDKやファイル操作のライブラリが豊富で、自動化スクリプトの記述に適しているため。
変換エンジン Pandoc + wkhtmltopdf Markdownからビジネス用途に耐えうるPDFを生成するため。
スタイル管理 CSS3 (report.css) 汎用的なMarkdownを「AI Ready」なデータとして残しつつ、印刷時は「人間が見やすい」レイアウトを両立させるため。

導入方法①Notion側の準備

まずはNotion側の設定を行います。

1.データベースの作成: 議事録管理用のデータベースを作成し、プロパティに「ステータス」と「IsArchived(チェックボックス)」を追加します。「ステータス」は、「進行中」というステータスを削除して、「未着手」と「完了」だけにします。チェックボックスの項目は「IsArchived」に変更します。

image.png

2.テンプレートとAI設定: Notion AIの「カスタムAIブロック」を活用しましょう。文字起こし結果から「決定事項」「宿題事項」「次回予定」を自動抽出するプロンプトを仕込んだテンプレートを作成します。

新規ボタンの下向き矢印を押下して、「新規テンプレート」を押下します。

image.png

編集画面で、AIミーティングノートを追加(「/meet」と打つとAIミーティングノートのブロックが出現します)したり、メモ欄などをカスタマイズします。

image.png

AIミーティングノートのメモ欄のテンプレート
### ■ 会議概要

- **日時:** @今日
- **場所:** Teams会議 / 貴社オフィス
- **出席者(敬称略):**
    - **お客様側:** -
    - **自社側:** ---

### ■ 本日のアジェンダ

1. 
2. 

---

(※ここより下は、ページ作成後にAIを実行して生成させます)

次に、要約のチューニングをします。テンプレート編集画面のAI ミーティングノートの一番下に「形式」という要約フォーマットがあります。ここを修正し、AIに議事録用の指示を出します。「形式」の下向き矢印を押下して、「カスタム形式を追加」を押下します。

image.png

編集画面で以下のように議事録の型を指定します。

image.png

AIへの指示テンプレート
# 指示:以下のガイドラインに従い、指定された構造(### 以降)のみを出力してください。
# ※この指示自体や <aside> ブロック、および「📖文脈」「✍️出力フォーマット」などの説明文は出力に含めないこと。

<aside>
💡 **お客様提出用:高精度議事録生成モード**
この出力はPDF化されお客様へ納品されます。QCD(品質・コスト・納期)に関する事実関係の正確性と、議論のプロセス保持を最優先してください。
要約しすぎず、エンジニアが後で修正しやすい「高密度な情報」を抽出してください。
</aside>

📖 文脈
システム開発「プロジェクトA」の定例/仕様検討会議です。出席者は発注元(お客様)と受注側(エンジニア)です。

✍️ 出力フォーマット
以下の見出し構造で、文字起こしデータから情報を抽出・構成してください。

### ■ 決定事項
今回の会議で確定した事項を「[決定]」を付けて記載。特に仕様、納期、スコープ、コストに関する合意を最優先。

### ■ 宿題事項(Next Actions)
「- [ ] (担当者) 内容 (期限)」の形式で抽出。不明な点は「(要確認)」と付記。

### ■ 議事詳細(議論のプロセス)
話の流れがわかるよう、要約しすぎず詳細に記述してください。
- 各トピックの議論の背景(なぜその話になったか)
- 提示された選択肢とそれぞれのメリット・デメリット
- お客様からの具体的な要望・懸念と、エンジニア側の技術的回答
- 5〜10分程度の議論を1つの段落にまとめ、文脈を維持する

### ■ 次回会議予定
決定していれば、日時・場所を記載。未定なら「別途調整」と記載。

🎨 サマリのスタイルと制約
- **マーキング**: 音声不明瞭な箇所は「(要確認・文字起こし不能)」と明記。
- **構造**: Pandoc変換を想定し、見出し(###)と箇条書き(-)を正確に使用。
- **引用**: お客様の重要発言は「 」で直接引用。
- **言語・語尾**: 日本語、ビジネスライクな敬体(です・ます調)。

ここまでで、テンプレートをつくれたので、「プロジェクトA_議事録」のデータベースのページで、テンプレートをデフォルトに設定しましょう。

image.png

3.インテグレーションの作成: 内部インテグレーションを作成し、API通信に必要な「秘密キー(Token)」を取得します。

設定>コネクト の「インテグレーションを作成または管理する」を押下
image.png

「新しいインテグレーションを作成」を押下し、以下の内容を入力し、「作成」を押下します。

  • インテグレーション名:Project-PDF-Generator
  • 関連ワークスペース:自分のワークスペース

image.png

作成ができたので、中身を確認します。

  • 内部インテグレーションシークレットの「表示」を押して、「ntn_~」の文字列を控えておきましょう。Githubに設定する際に使用します。
  • 機能のところに以下の3つにチェックが入っていることを確認してください。
    • コンテンツを読み取る
    • コンテンツを更新する
    • コンテンツを挿入する

image.png

4.DBへのアクセス権付与: 対象データベースの「接続先」メニューから、作成したインテグレーションを追加します。これを忘れるとAPIからDBが見えないので注意が必要です。

データベースのページから右上の「・・・」>「接続」から、作成したインテグレーションを選択します(Project-PDF-Generator)

image.png

これでデータベースに対してAPIがアクセスできるようになります。

次に、「どの」データベースを見ればいいかを特定するためのデータベースIDを取得します。

データベースページのURLを見てください。「https://www.notion.so/ ~」となっていると思います。soのスラッシュの後からクエスチョンマークの前までの、32桁の英数字をコピーして控えておいてください。これがデータベースIDです。

https://www.notion.so/ [ここがデータベースID] ?v=...

導入方法②GitHub Actionsの設定

次に「工場」であるGitHub側のリポジトリを構成します。

1.リポジトリの作成します。公開範囲を「プライベート」で作成します(Githubアカウントを作っていない人は作成しましょう)。「project-minutes-pdf」というリポジトリ名にしました。

image.png

2.リポジトリ構成: 以下のディレクトリ構造を参考に、ファイルを配置します。

.
├── .github/
│    └── workflows/
│       └── notion_sync.yml # GitHub Actions設定 (Cron: 17:00 JST)
├── minutes/ # 【自動生成】同期されたMarkdownとPDFが保存される
├── main.py # Notion API連携・Markdown生成メインスクリプト
├── report.css # PDFレイアウト用スタイルシート
├── requirements.txt # Python依存ライブラリ (notion-client)
├── .gitignore
└── README.md 

2.ワークフローの定義:配置したファイルの中身を記述します。

.github/workflows/notion_sync.yml

name: Notion Sync

on:
  schedule:
    # 日本時間(JST) 17:00 に実行 (17 - 9 = 8時 UTC)
    - cron: "0 8 * * *"
  workflow_dispatch: # 手動実行ボタンを表示

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: write # リポジトリへの書き込み(Push)に必要

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.10"

      # --- 1. 変換エンジンのインストール ---
      - name: Install Pandoc, PDF Engine & Fonts
        run: |
          sudo apt-get update
          sudo apt-get install -y pandoc wkhtmltopdf fonts-noto-cjk

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run main script
        env:
          NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
          NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}
        run: python main.py

      # --- 2. PDFビルド(今回 main.py が更新した .md のみ。全件変換すると差分だけで毎回コミットされやすい)
      - name: Build PDF with Pandoc
        run: |
          if [ ! -f minutes/.updated_this_run ]; then
            echo "No Markdown updated this run; skip Pandoc."
            exit 0
          fi
          while IFS= read -r f; do
            [ -n "$f" ] || continue
            [ -f "$f" ] || continue
            echo "Converting $f to PDF..."
            pandoc "$f" -o "${f%.md}.pdf" \
              --css=report.css \
              --pdf-engine=wkhtmltopdf \
              --metadata pagetitle="Meeting Minutes"
          done < minutes/.updated_this_run

      # 今回 Notion から .md を書いたか(.gitignore のため hashFiles は使わない)
      - name: Export status for artifact
        id: export_check
        run: |
          if [ -f minutes/.updated_this_run ]; then
            echo "exported=true" >> "$GITHUB_OUTPUT"
            echo "This run exported from Notion; artifact will upload."
          else
            echo "exported=false" >> "$GITHUB_OUTPUT"
            echo "No Notion export this run; skip artifact (repo checkout copy is not 'new work')."
          fi

      # 生成されたファイルがあるか確認(デバッグ用)
      - name: Check generated files
        run: ls -R minutes/ || echo "No files found in minutes directory."

      # この実行で Notion から書き出したときだけ(手元ダウンロード用。ヒット0の実行ではアップロードしない)
      - name: Upload artifact
        if: steps.export_check.outputs.exported == 'true'
        uses: actions/upload-artifact@v4
        with:
          name: generated-documents
          path: |
            minutes/*.md
            minutes/*.pdf

      # リポジトリに自動コミットしてPush
      - name: Commit and Push
        run: |
          git config --local user.email "github-actions[bot]@users.noreply.github.com"
          git config --local user.name "github-actions[bot]"

          # 一致するファイルがないと git add が pathspec エラーになるため nullglob で扱う
          shopt -s nullglob
          to_add=(minutes/*.md minutes/*.pdf)
          if [ ${#to_add[@]} -eq 0 ]; then
            echo "No files under minutes/ to commit."
            exit 0
          fi
          git add "${to_add[@]}"

          # 変更がある場合のみコミット
          if ! git diff --cached --exit-code --quiet; then
            git commit -m "docs: Notionから議事録とPDFを自動同期 ($(date +'%Y-%m-%d'))"
            git push
          else
            echo "No changes to commit."
          fi

main.py

import json
import os
import re
from notion_client import Client

# 1. 環境変数の取得
#    Notion の API トークンと対象データベース ID を環境変数から読み込む。
#    DATABASE_ID は半角英数字のみ残すことで、入力時に余分なハイフンなどが入っても扱える。
NOTION_TOKEN = os.environ.get("NOTION_API_TOKEN", "").strip()
RAW_DATABASE_ID = os.environ.get("NOTION_DATABASE_ID", "").strip()
DATABASE_ID = re.sub(r'[^a-zA-Z0-9]', '', RAW_DATABASE_ID)

notion = Client(auth=NOTION_TOKEN)
LATEST_VERSION_HEADER = {"Notion-Version": "2026-03-11"}

UPDATED_MD_MARKER = os.path.join("minutes", ".updated_this_run")


def _log_query_summary(results):
    """IsArchived 切り分け用: API が返した各ページの ステータス / IsArchived を一覧する"""
    print(
        "--- databases.query サマリ(フィルタ: ステータス==完了 かつ "
        "properties.IsArchived==False) ---"
    )
    print(f"ヒット件数: {len(results)}")
    for i, page in enumerate(results):
        props = page.get("properties", {})
        # Notion データベースのプロパティ名は日本語/英語どちらの場合もあるため両対応で取得する
        name_parts = (
            props.get("名前", {}).get("title")
            or props.get("Name", {}).get("title")
            or []
        )
        name = name_parts[0].get("plain_text", "") if name_parts else ""
        st = (
            (props.get("ステータス") or {}).get("status")
            or (props.get("Status") or {}).get("status")
            or {}
        )
        status_name = st.get("name")
        archived_cb = (props.get("IsArchived") or {}).get("checkbox")
        top_archived = page.get("is_archived", page.get("archived"))
        print(
            f"  [{i}] id={page.get('id')} "
            f"Name={name!r} "
            f"ステータス={status_name!r} "
            f"properties.IsArchived={archived_cb!r} "
            f"page.is_archived={top_archived!r}"
        )
    print("--- サマリ終わり ---")


def get_block_text(block_id, depth=0):
    """子ブロックを再帰的に取得し、階層構造を保ったままリスト化する"""
    # block_id が未定義の場合や深すぎる再帰の場合は無理に続けない
    if not block_id or depth > 3:
        return []

    try:
        elements = []
        cursor = None
        while True:
            response = notion.blocks.children.list(
                block_id=block_id,
                extra_headers=LATEST_VERSION_HEADER,
                start_cursor=cursor,
            )
            children = response.get("results", [])

            for child in children:
                ctype = child["type"]
                data = child.get(ctype, {})
                has_children = child.get("has_children", False)

                # rich_text を持つブロックのみを Markdown 用に抽出する
                if isinstance(data, dict) and "rich_text" in data:
                    text = "".join([t["plain_text"] for t in data["rich_text"]]).strip()
                    if not text and not has_children:
                        continue

                    el_type = "paragraph"
                    if ctype.startswith("heading_"):
                        el_type = "heading"
                    elif ctype == "bulleted_list_item":
                        el_type = "list"
                    elif ctype == "to_do":
                        el_type = "list"

                    content = text
                    if ctype == "bulleted_list_item":
                        content = f"- {text}"
                    elif ctype == "to_do":
                        checked = "x" if data.get("checked") else " "
                        content = f"- [{checked}] {text}"
                    elif text.startswith("[ ]") or text.startswith("[x]"):
                        content = text.replace("[ ]", "- [ ]").replace("[x]", "- [x]")
                        el_type = "list"

                    elements.append({"type": el_type, "content": content, "depth": depth})

                    if has_children:
                        elements.extend(get_block_text(child["id"], depth + 1))

            if not response.get("has_more"):
                break
            cursor = response.get("next_cursor")

        return elements
    except Exception as e:
        print(f"⚠️ get_block_text error: block_id={block_id!r} depth={depth} {e}")
        return []

def format_meeting_notes(title, elements):
    """要素を解析し、指定された順番とルールで結合する"""
    order = ["会議概要", "アジェンダ", "決定事項", "宿題事項", "次回会議予定", "議事詳細"]
    sections = {k: [] for k in order}
    sections["その他"] = []
    
    current_section = "その他"
    
    for el in elements:
        content = el["content"]
        if any(k in content for k in order) and (content.startswith("#") or "" in content):
            for k in order:
                if k in content:
                    current_section = k
                    break
            continue
        
        if content in ["文字起こし", "メモ", "要約"] or "ここより下は" in content:
            continue
            
        sections[current_section].append(el)

    final_md = f"# {title}\n\n"
    for k in order:
        if not sections[k]: continue
        final_md += f"## {k}\n\n"
        
        for i, el in enumerate(sections[k]):
            content = el["content"]
            indent = "  " * el["depth"]
            
            if k == "アジェンダ":
                final_md += f"{i+1}. {content}\n"
            elif el["type"] == "list":
                final_md += f"{indent}{content}\n"
                if i + 1 < len(sections[k]) and sections[k][i+1]["type"] != "list":
                    final_md += "\n"
            else:
                final_md += f"{indent}{content}\n\n"
        
        if not final_md.endswith("\n\n"): final_md += "\n"
            
    return final_md

def main():
    # 実行前に必須環境変数が揃っているか確認する
    if not NOTION_TOKEN or not DATABASE_ID:
        print(
            "❌ NOTION_API_TOKEN と NOTION_DATABASE_ID の両方が必要です。"
            " 環境変数を確認してください。"
        )
        return

    print(f"🔍 実行中... (Target DB: {DATABASE_ID})")

    try:
        if os.path.isfile(UPDATED_MD_MARKER):
            os.remove(UPDATED_MD_MARKER)

        # --- フィルタ条件: Status="完了" かつ IsArchived=False ---
        query_response = notion.databases.query(
            database_id=DATABASE_ID,
            filter={
                "and": [
                    {"property": "ステータス", "status": {"equals": "完了"}},
                    {"property": "IsArchived", "checkbox": {"equals": False}}
                ]
            },
            extra_headers=LATEST_VERSION_HEADER,
        )
        results = query_response.get("results", [])
        _log_query_summary(results)

        if os.environ.get("NOTION_LOG_FULL_QUERY") == "1":
            print("--- databases.query レスポンス(JSON・全文) ---")
            print(json.dumps(query_response, ensure_ascii=False, indent=2))
            print("--- databases.query レスポンス終わり ---")

        for page in results:
            p_id = page.get("id")
            if not p_id:
                print("⚠️ skipped page without id in query results")
                continue

            properties = page.get("properties", {})
            title_parts = (
                properties.get("名前", {}).get("title")
                or properties.get("Name", {}).get("title")
                or []
            )
            title = title_parts[0].get("plain_text", "(無題)") if title_parts else "(無題)"
            print(f"🚀 処理中: {title}")
            
            response = notion.blocks.children.list(block_id=p_id, extra_headers=LATEST_VERSION_HEADER)
            blocks = response.get("results", [])
            
            all_elements = []
            for block in blocks:
                # transcription ブロックを探し、サマリ/ノート用のサブブロック ID を取り出す
                if block["type"] == "transcription":
                    data = block.get("transcription", {})
                    c_ids = data.get("children") or {}
                    if isinstance(c_ids, dict):
                        all_elements += get_block_text(c_ids.get("summary_block_id"))
                        all_elements += get_block_text(c_ids.get("notes_block_id"))
                    break

            if not all_elements:
                print(
                    f"⚠️ transcription block not found or empty for page {title!r} "
                    f"(id={p_id})"
                )
                continue

            content = format_meeting_notes(title, all_elements)

            output_dir = "minutes"
            os.makedirs(output_dir, exist_ok=True)

            safe_title = re.sub(r'[\\/:*?"<>|]', "_", title)
            filename = os.path.join(output_dir, f"{safe_title}.md")

            with open(filename, "w", encoding="utf-8") as f:
                f.write(content)
            print(f"✅ 保存完了: {filename}")

            with open(UPDATED_MD_MARKER, "a", encoding="utf-8") as mf:
                mf.write(filename + "\n")

            # --- 処理完了後、Notion側の IsArchived を true に更新 ---
            notion.pages.update(
                page_id=p_id,
                properties={"IsArchived": {"checkbox": True}},
                extra_headers=LATEST_VERSION_HEADER,
            )
            print(f"📦 Notionアーカイブ完了: {title}")

    except Exception as e:
        print(f"❌ メイン処理エラー: {e}")

if __name__ == "__main__":
    main()

report.css

/* 議事録用標準スタイルシート (report.css)
  A4サイズ・ビジネス向けレイアウト
*/

@page {
  size: A4;
  margin: 25mm 20mm; /* 上下左右の余白 */
}

body {
  font-family:
    "Noto Sans CJK JP", "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo,
    sans-serif;
  line-height: 1.7;
  color: #333;
  max-width: 100%;
  margin: 0 auto;
  font-size: 10.5pt;
}

/* タイトル (H1) */
h1 {
  font-size: 24pt;
  text-align: center;
  margin-bottom: 50px;
  padding-bottom: 10px;
  border-bottom: 2px solid #333;
}

/* セクション見出し (H2) */
h2 {
  font-size: 16pt;
  margin-top: 30px;
  margin-bottom: 15px;
  padding-left: 10px;
  border-left: 5px solid #0055ff; /* アクセントカラー:青 */
  background-color: #f8f9fa;
  line-height: 2;
}

/* 小見出し (H3) */
h3 {
  font-size: 13pt;
  margin-top: 20px;
  border-bottom: 1px solid #ddd;
}

/* 箇条書きリスト */
ul,
ol {
  padding-left: 25px;
  margin-bottom: 15px;
}

li {
  margin-bottom: 5px;
}

/* タスクリスト ([ ] [x]) */
li input[type="checkbox"] {
  margin-right: 8px;
}

/* 議事詳細の段落 */
p {
  margin-bottom: 15px;
  text-align: justify; /* 両端揃えで綺麗に見せる */
}

/* 強調 */
strong {
  color: #d32f2f; /* 決定事項などが目立つように赤系に */
}

/* コードブロック(API定義など) */
pre {
  background-color: #f4f4f4;
  padding: 15px;
  border-radius: 5px;
  border: 1px solid #ddd;
  font-size: 9pt;
  overflow-x: auto;
  white-space: pre-wrap;
}

/* 改ページ制御:H2の前で改ページさせない、または適切な場所で切る */
h2 {
  page-break-after: avoid;
}

/* 署名欄や日付用の右寄せクラス(必要に応じてMarkdown内で使用) */
.text-right {
  text-align: right;
}

requirements.txt

notion-client==2.2.1

.gitignore

# CI 用マーカー(コミットしない)
minutes/.updated_this_run

3.Secretsの登録: リポジトリの Settings > Secrets and variables > ActionsNOTION_API_TOKENNOTION_DATABASE_ID を登録します。機密情報をコードにベタ書きしないためのステップです。

作成したリポジトリの画面上部にある [Settings] タブをクリック。左側のメニューから [Secrets and variables] → [Actions] を選択します。

[New repository secret] ボタンをクリックし、APIトークンとデータベースIDを1つずつ登録します。

  • ① Notion APIトークンの登録
    • Name: NOTION_API_TOKEN
    • Secret: 先ほど控えた 「ntn_...」 で始まるインテグレーションシークレットを貼り付け
    • [Add secret] をクリック
  • ②データベースIDの登録
    • Name: NOTION_DATABASE_ID
    • Secret: 先ほど控えた32桁の英数字(データベースID)を貼り付け
    • [Add secret] をクリック

image.png

導入方法③動作確認

設定が完了したら、いよいよ「動作確認」です。

1.テストデータの作成: Notion側でテスト用の議事録を作成し、内容を確認してステータスを「完了」に変更します

image.png

2.手動実行(workflow_dispatch): GitHubのActionsタブからワークフローを選択し、「Run workflow」をクリックします。17時を待たずに即時テストが可能です

Githubの議事録リポジトリから Actions >Notion Sync 押下し、「Run workflow」から「Run workflow」を押下します。
image.png

statusがSuccessになればコード自体は動きました。
image.png

3.成果物の確認: 1分後、リポジトリの minutes/ フォルダに新しい .md.pdf がコミットされていれば成功です!

4.アーカイブ処理の確認: 最後にNotion側を見て、IsArchived プロパティが自動で「チェック済み」に更新されていることを確認してください。

image.png

4. 【技術ハイライト】実務運用に耐えうるための工夫

ここでは、試行錯誤したところを記載します。

① 実行環境での「日本語消失」問題

GitHub Actionsの実行環境(Ubuntu)で最初にPDFを生成したとき、出力されたのは「文字が消えた真っ白なPDF」でした。

  • 原因: クラウド上のUbuntuサーバーには日本語フォントが標準搭載されていないため、PDFレンダリング時にフォントが見つからず、すべて空文字として処理されていました
  • 突破策: ワークフロー定義(notion_sync.yml)内で、PDF生成の直前に apt-get を実行し、fonts-noto-cjk を明示的にインストールするようにしました

② 変換エンジン選定:最新よりも「枯れた技術」の安定性

当初は、モダンな WeasyPrint を検討していましたが、Ubuntu 24.04(noble)環境では依存ライブラリ(libpango 等)のバージョン競合により、パッケージが見つからないエラーに直面しました。

  • 葛藤: 最新のライブラリで実装したい気持ちもありましたが、環境構築の複雑化はメンテナンスコストを上げます
  • 決断: 最終的には、インストールが容易で動作も安定している wkhtmltopdf を採用しました

③ Notion API の「最新バージョン」への追従

Notion APIを使用していて、特定のプロパティが取得できたりできなかったりと、挙動が不安定になる場面がありました。

  • 突破策: APIリクエストのヘッダーに、明示的に最新の Notion-Version: 2026-03-11 を指定しました
  • 背景: APIの進化が非常に速いサービスでは、バージョンを固定(あるいは最新を明示)しないと、実行時のデフォルト挙動に振り回されるリスクがあります。

④ 冪等性(べきとうせい)の確保:二重実行を許さない設計

自動実行(Cron)と手動実行(Workflow Dispatch)を併用する際、怖いのが「同じ議事録が二度プッシュされる」ことです。

  • 工夫: IsArchived フラグによる排他制御を行いました
    1. 抽出時に「未アーカイブ」のみを狙い撃つ
    2. 納品完了直後に「アーカイブ済み」へ更新する
  • メリット: この設計により、万が一17時の自動実行が失敗して手動で再実行(連打)したとしても、二重にPDFが生成されることはありません。この「何度叩いても結果が壊れない(冪等性)」が、運用者への安心感に繋がっています

5. おわりに

本パイプラインの導入で真の自動化が進んだと思います。もちろん、これが完成形ではありません。今回の基盤をベースに、以下のような拡張も視野に入れています。

  • Slack連携: PDF生成と同時に、要約内容をSlackへ自動投稿し、チーム内での即時共有を強化する。
  • 高度なタグ管理: 議事録内の「宿題事項」を自動でGitHub IssuesやNotionのタスクDBへ起票する。
  • マルチプロジェクト対応: 案件ごとに保存先リポジトリやディレクトリを自動で振り分ける。
  • 格納先をGoogleドライブやSharePointに変更する。

「面倒なことを自動化する」のは、楽しい瞬間の一つです。議事録作成という、泥臭い事務作業ですが、そこにAIと自動化をかけ合わせることで、効率化することができます。この記事が誰かの役に立ったら幸いです。

最後まで読んでいただいた方、ありがとうございました。

参考文献

notion公式ドキュメント(公開日不明)「AI ミーティングノート(ベータ版)」, https://www.notion.com/ja/help/ai-meeting-notes 2026年4月7日アクセス.

notion公式ドキュメント(公開日不明)「Working with page content」, https://developers.notion.com/guides/data-apis/working-with-page-content 2026年4月7日アクセス.

TEMP,円谷様、もい様(2025年9月24日)「【無料テンプレ】Notionで議事録を管理する方法|AIを使った効率化についても解説」, https://temp.co.jp/blog/2023-10-26-meeting-notes 2026年4月7日アクセス.

TEMP,円谷様(2025年5月4日)「Notionインテグレーショントークンの取得方法・コネクト設定方法」, https://temp.co.jp/blog/2024-01-21-notion-integration-connect 2026年4月7日アクセス.

「暮らしとnotion」Rei様(2026年3月7日)「Notionの「AIミーティングノート」で議事録を自動化しよう!使い方や活用法を徹底解説」 https://kurashi-notion.com/blogs/notion/notionai-meetingnotes?srsltid=AfmBOop60jECN0GZLDQ52oZ22n8JHKZ_fl5ccy0eF41FLBdr-WDv1UYa 2026年4月7日アクセス.

0
2
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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?