3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Zoom AI Servicesで会議を録音するだけで、要約・英訳・Slack通知まで自動化してみた

3
Last updated at Posted at 2026-06-17

はじめに

会議の議事録って、書くのも、共有するのも、海外メンバー向けに英訳するのも、毎回地味にしんどくないですか。私はずっとこれが面倒で、「録音を放り込んだら全部終わる」状態にしたかった。

2026年3月に公開された Zoom AI Services(Scribe / Summarizer / Translator の各API)を使えばできそうだったので、実際に営業定例の録音1本から要約・アクションアイテム抽出・英訳・Slack通知までを自動で流すパイプラインを作りました。本記事はその記録です。
完成形はこれ。録音を渡すと、Slackにこういう1通が届きます。
image.png

対象読者

  • 議事録の作成・共有・英訳がだるいと思っている人
  • Zoom AI Services の実用例を、コードで具体的に知りたい人
  • Python が少し読める人(コードは全部で数十行です)

先に結論だけ言うと、コア部分は本当に短く書けました。ただし途中で Invalid Access token という一文字に半日溶かしたので、その顛末も正直に書きます(同じ轍を踏む人が絶対いるはず)。

つくったもの(全体像)

やっていることはシンプルで、こういう流れです。

image.png

使うのは Zoom AI Services の Summarizer と Translator。文字起こしは今回はZoomの純正機能で用意しました(このあたりの判断は後述します)。実装は Python で、HTTPは httpx、JWT生成に PyJWT、環境変数に python-dotenv、パッケージ管理は uv を使っています。

準備:Build PlatformでキーをとってJWTを作る

Zoom AI Services のAPIは、xoxb- のようなトークンではなく、API key と API secret から自分でJWT(HS256)を署名して Authorization: Bearer <JWT> で叩く方式です。

キーは Zoom の Build Platform から取ります。

  1. Webポータル(https://zoom.us/profile)にログイン
  2. ADMIN → Plans and Billing → Plan Management
  3. Universal Credit の Manage → Build App
  4. 「API keys」セクションの API Key / API Secret をコピー(同じ場所に View JWT Token もあります)

JWTの生成はこれだけ。iss にAPI key、iatexp(1時間以内が推奨)を入れてHMAC-SHA256で署名します。

src/zoom_ai.py
import os, time
import httpx, jwt
from dotenv import load_dotenv

load_dotenv()
API_KEY = os.environ["ZOOM_API_KEY"]
API_SECRET = os.environ["ZOOM_API_SECRET"]
BASE_URL = "https://api.zoom.us/v2/aiservices"

def generate_jwt() -> str:
    now = int(time.time())
    payload = {"iss": API_KEY, "iat": now - 30, "exp": now + 3600}
    return jwt.encode(payload, API_SECRET, algorithm="HS256")

API key / secret は .env に置いて、絶対にGitにコミットしないでください(私は .gitignore.env を入れています)。記事に載せるスクショでもマスク必須です。

文字起こしは「Zoom純正」で用意した(Scribe APIとの違い)

今回の文字起こしは、Scribe APIではなくZoomの会議のネイティブ文字起こし(AI Companion)をエクスポートして使いました。2人で実際に喋った会議なので、発言者(佐藤 / 田中)が最初から分かれているのが良いところです。

Scribe APIとの役割の違いはこう整理できます。

  • Scribe API … 音声ファイルを投げて文字起こし。diarization: true を付けると speaker_1 speaker_2 … と声で話者を分離してくれる
  • Zoom純正 … 会議の参加者ごとに最初から分離済み。エクスポートすればテキストがすぐ手に入る

「とりあえず議事録テキストが欲しい」なら純正が速い。一方で精度を比べたくなる場面が後で出てきます(最終章で触れます)。

ちなみに、この純正文字起こしがわりと盛大に誤変換していて、それが逆に面白い検証材料になりました。たとえば「営業定例」が「営業手入れ」になっていたり。これは後半の精度の話で詳しく見ます。私の滑舌の問題もあるかもしれませんが:sweat_smile:

image.png

Summarizerで要約とアクションアイテムを抜く

ここからが本番。Summarizer APIに文字起こしを渡すと要約してくれます。日本語は languageja-jp、会議なら summary_typeconversationtask を変えると出力が変わります。

src/zoom_ai.py
def summarize(text: str, task: str = "summary", language: str = "ja-jp") -> dict:
    return _post("/summarizer/summarize", {
        "input": {"text": text},
        "config": {"task": task, "language": language, "summary_type": "conversation"},
    })

taskrecap / action_items / summary / full_summary から選べます。私は「要約」と「アクションアイテム」が欲しかったので、summaryaction_items の2回叩きました。

ここで1つ引っかかったのが、summary の結果が summary_text の中に複数バージョン(概要から詳細まで、実行ごとに3〜4本)まとめて返ってくること。全部Slackに流すとうるさいので、## で分割して一番詳しい1本だけ採用しました。

src/run.py
import re

def pick_one_summary(text: str) -> str:
    secs = [s.strip() for s in re.split(r"(?m)^##\s+", text) if s.strip()]
    return "## " + max(secs, key=len) if secs else text.strip()

実際の出力(アクションアイテム)はこんな感じ。担当ごとに、期限つきできれいに割れてくれました。

**佐藤**
- A社案件の15%値引きについて、部長に承認を確認する(明日まで)。
- 来月の予算配分を見直し、6月19日までに共有する(新規リード対策も含む)。

**田中**
- A社案件の構成を見直し、再提出する(6月27日(水)まで)。
- B社の競合敗戦理由をヘアリングし、次への活用策をまとめる(今週中)。
- C社のクロージングに集中し、今月の目標達成に努める。

Translatorで英訳して海外メンバーにも渡す

要約ができたら、Translatorで英訳します。日本語から英語は source_languageja-JPtarget_languages["en-US"]

src/zoom_ai.py
def translate(text: str, source="ja-JP", target="en-US") -> dict:
    return _post("/translator/translate", {
        "text": text[:4000],
        "config": {"source_language": source, "target_languages": [target]},
    })

Translatorは1回につき1言語・最大4000文字です。なので「文字起こし全文」ではなく「要約」を訳すのが正解。さらにSummarizerのsummaryは複数バージョンをまとめて返すので、当初は全部訳していました。1本に絞ってから訳すようにして、英訳の入力を280文字まで抑えました(下の実測表を参照)。

Slackに1通でまとめて自動通知

最後に、要約・アクションアイテム・英訳を1通にまとめてSlackへ。Incoming Webhook に {"text": ...} をPOSTするだけです。

src/slack_notify.py
import os, httpx
from dotenv import load_dotenv
load_dotenv()

def post_to_slack(summary: str, action_items: str, english: str) -> None:
    url = os.environ["SLACK_WEBHOOK_URL"]
    text = (
        "*:memo: 会議要約*\n" + summary + "\n\n"
        "*:white_check_mark: アクションアイテム*\n" + action_items + "\n\n"
        "*:globe_with_meridians: English summary*\n" + english
    )
    httpx.post(url, json={"text": text}, timeout=30).raise_for_status()

実は最初、SlackのユーザートークンをもらってWeb APIで送ろうとしたのですが、そのトークンが xoxe.xoxp- 形式で12時間で失効するローテーション付きでした。記事の再現性も考えると、チャンネルが内包されていてスコープ設定もいらないIncoming Webhookのほうが圧倒的に楽です。

Webhook URL は「知っていれば誰でもそのチャンネルに投稿できる」実質シークレットです。.env 管理+スクショではマスクを。

全部つなぐ:録音から通知までを1コマンドで

あとはオーケストレーション。文字起こしテキストを渡すと、要約 → アクション → 英訳 → Slack を順に実行して output/ に保存します。

uv run python src/run.py meeting.md --slack
[1/4] transcript: 1036 文字
[2/4] 要約 完了 (9.4s)
[3/4] アクションアイテム 完了
[4/4] 英訳 完了
Slackへ投稿しました ✓

ここまでで「録音を渡すと議事録がSlackに流れる」が完成しました。コア部分は本当に短い。…なんですが、ここに辿り着くまでが一番しんどかったので、その話をします。

ハマった話:「Invalid Access token」の犯人は l と I だった

最初に summarize を叩いたとき、返ってきたのはこれでした。

{"code": 124, "message": "Invalid Access token"}

キーは画面からコピー(したつもり)。JWTもローカルでちゃんと生成できている。なのに弾かれる。ここから切り分けが始まりました。

  • .env から読んだキーを repr() で確認 → 不可視文字や空白なし、長さもOK
  • マシンの時計 → 正常(iat / exp がおかしいわけではない)
  • 署名方式を疑い、公式が使う jsrsasign(Node)と PyJWT で同じ秘密鍵・同じメッセージのHMACを計算して突合 → 1バイトも違わず一致。つまり自分のコードは無罪

ここまでで「キーも時計も署名も正しい」。じゃあ何が、と詰まったので、最後の手として Build Platform の「View JWT Token」(Zoom自身が発行する出来合いトークン)をデコードして、iss(=API key)を自分の .env と1文字ずつ比べました。

(キーは例示用のダミー値。実際は20文字超のランダム文字列です)
Zoom発行 : Kb3Qz7lTfeRR4ph_91vC   ← 7文字目は 小文字のエル (l)
自分の.env: Kb3Qz7ITfeRR4ph_91vC   ← 7文字目は 大文字のアイ (I)

犯人は小文字のエル(l)を大文字のアイ(I)と読み違えていたこと。スクショから目で写したときにやられました。1文字直したら、何事もなかったように通りました。

ランダム文字列のAPIキーは、目で写さず必ず「Copy」ボタンで。lI1O0 は本当に見分けがつきません。怪しいときは、サービス側が発行する正規トークンをデコードして突き合わせるのが一番速い切り分けでした。

正直、署名まで疑い始めたあたりが一番つらかった。でも「自分のコードは正しい」と数字で確定させてから外側を疑えたので、遠回りに見えて結局これが近道でした。

どこまで正確? 純正ASRの誤変換を要約AIは直せるのか

ここが個人的に一番おもしろかったところ。検証用に台本へ正解(ground truth)を仕込んでおき、純正文字起こし → 要約の流れで、どこまで正しく残るかを見ました。

まず、純正の文字起こしがなかなかカオスでした。

本来 純正ASRの結果
営業定例 営業手入れ
確定が980万 確定ガス9,800,000
フォーキャスト 方。キャスト
ナーチャリング ネアンチャリング
失注したB社 出張したB社
C社 支持者 / ししゃゆ
6月17日 6月27日

ところが、Summarizerに通すと文脈で勝手に直してくれたものが結構ありました。

  • 「確定ガス9,800,000」→ 要約では「確定が9,800,000」
  • 「支持者」「ししゃゆ」→ ちゃんと「C社」
  • 「終戦を出します」→ アクションでは「再提出する」

LLMベースの要約は、多少の音声認識ミスならノイズとして吸収してくれる。これは素直にすごい。

一方で、直せなかったものもはっきりありました。

  • 日付「6月17日」→ ASRが「6月27日」と誤認 → 要約・アクションでもそのまま「6月27日」
  • 「ヒアリング」→「ヘアリング」も残存

正解と突き合わせると、アクションアイテムは正解4件中3件が完全一致、外したのは唯一この日付(ASR起因)でした。

ここから言えるのは、要約AIは意味の通る誤りは救えるが、固有の数字や日付みたいな「文脈から推測できないハードな情報」は救えないということ。つまり最終品質は文字起こしの精度で決まる。だからこそ、次はScribe APIと精度を比べたくなりました(後述)。

実測:1会議でどれだけ処理したか(usageで見える)

Zoom AI Services はレスポンスの usage に処理文字数(input_units / output_units)が入るので、何文字処理したかをそのまま実測できます。この営業定例(文字起こし1036文字)1本での内訳がこれです。

呼び出し 入力(文字) 出力(文字)
要約(summary) 1,036 1,008
アクション(action_items) 1,036 189
英訳(translate) 280 781
合計 2,352 1,978

処理量がAPIから数値で返るので、「どの呼び出しが重いか」がそのまま見えます。前述のとおり、要約は複数バージョンが返るのを1本に絞ってから英訳したので、英訳の入力(280文字)は小さく抑えられています。料金体系は変動するため本記事では触れませんが、この usage を見れば処理量ベースで見積もりや最適化の判断ができます。

まとめと、次にやりたいこと

録音1本から、要約・アクションアイテム・英訳・Slack通知までを数十行で自動化できました。やってみて効いたのは2つ。usage で処理量を文字単位で実測できること、そしてLLM要約が音声認識のノイズをかなり吸収してくれること。逆に、日付のようなハードな情報は文字起こし精度がそのまま効くので、入力の質がボトルネックだと分かりました。

なので次は、文字起こしを Zoom純正 vs Scribe API(diarization: true)で精度の両面で比べてみたい。同じ会議音声で取ったら、また追記します。

あなたのチームの議事録、まだ手で書いていませんか。もし試したら、どのくらい楽になったか教えてもらえると嬉しいです。コード一式はリポジトリに置いておきます。

参考リンク

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?