はじめに
前回の記事では、Zoom AI Services の Scribe API を Fast API(同期モード) を中心にご紹介しました。
あれから数ヶ月、Scribe API に待望のアップデートが入りました。
- 🇯🇵 日本語を含む多言語対応が正式リリース
- 🆕 処理済みファイルの個別取得 API(
GET /jobs/{jobId}/files/{fileId})
本記事では、日本語の音声ファイルを AWS S3 にアップロードし、Batch API で一括文字起こしするところまでを、ゼロからハンズオン形式で進めます。S3 バケットの作成からスタートするので、AWS アカウントさえあれば OK です。
前回からの変更点
前回の記事で「今後予定」としていた部分を含め、以下がアップデートされています。
| 項目 | 前回(2026年3月時点) | 今回 |
|---|---|---|
| 対応言語 | 英語のみ(en-US) |
日本語(ja-JP)を含む多言語対応 |
| Batch API | 概要紹介のみ | S3 連携で実用可能に |
| ファイル取得 | ジョブ単位の一覧取得のみ | 個別ファイルの取得 API 追加 |
| Translator/Summarizer API | 未リリース | 2026年5月リリース(別記事にて紹介予定) |
全体アーキテクチャ
今回のハンズオンで構築する処理フローの全体像です。
1. AWS S3 バケットのセットアップ
AWS アカウントはある前提で、S3 バケットの作成から始めます。
1.1 S3 バケットの作成
AWS マネジメントコンソールにログインし、S3 のダッシュボードを開きます。
- 「バケットを作成」 をクリック
- 以下の設定で作成:
| 設定項目 | 値 | 備考 |
|---|---|---|
| バケット名 | zoom-scribe-handson-<yourname> |
グローバルで一意にする |
| リージョン |
ap-northeast-1(東京) |
任意。音声データの所在に近いリージョン推奨 |
| オブジェクト所有者 | ACL 無効(推奨) | デフォルトのまま |
| パブリックアクセス | すべてブロック(推奨) | デフォルトのまま |
| バージョニング | 無効 | ハンズオン用途なので不要 |
| 暗号化 | SSE-S3(デフォルト) | デフォルトのまま |
-
バケット作成後、以下の 2つのフォルダ(プレフィックス) を作成:
-
input/— 文字起こし対象の音声ファイル置き場 -
output/— Batch API の出力先
-
1.2 IAM ポリシーの作成
Scribe API の Batch 処理では、Zoom のサービスが S3 バケットにアクセスします。そのためのアクセス権を IAM ポリシーで定義します。
- IAM コンソール → ポリシー → ポリシーを作成
- JSON エディタに切り替え、以下を入力:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ScribeInputRead",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::zoom-scribe-handson-<yourname>",
"arn:aws:s3:::zoom-scribe-handson-<yourname>/input/*"
]
},
{
"Sid": "ScribeOutputWrite",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::zoom-scribe-handson-<yourname>/output/*"
]
}
]
}
- ポリシー名を
ZoomScribeS3Accessとして保存
ポリシーの構造:
1.3 IAM ユーザーの作成とアクセスキー取得
- IAM コンソール → ユーザー → ユーザーを作成
- ユーザー名:
zoom-scribe-batch - 「AWS マネジメントコンソールへのユーザーアクセスを提供する」 のチェックは外す(API アクセスのみ)
-
許可を設定 → 「ポリシーを直接アタッチする」→
ZoomScribeS3Accessを選択 - ユーザー作成後、セキュリティ認証情報 タブ → アクセスキーを作成
- ユースケースは 「サードパーティのサービス」 を選択
- アクセスキー ID と シークレットアクセスキー を控える
シークレットアクセスキーはこの画面でしか確認できません。必ずこの時点で安全な場所に保存してください。
1.4 STS で一時クレデンシャル(SessionToken)を発行する
ここが Scribe Batch API 特有のポイントです。
Scribe Batch API は、AWS の長期 IAM キー(AKIA... で始まる Access Key + Secret のペア)を直接受け付けません。 長期キーを使ってリクエストすると、以下のような 400 エラーが返されます。
{
"code": 400,
"reason": "VALIDATION_INVALID_FORMAT",
"message": "missing SessionToken — this looks like a long-term IAM key, not STS credentials",
"metadata": { "field": "session_token" }
}
Scribe Batch API の認証ブロックは access_key_id / secret_access_key / session_token の 3 点セットを要求します。これは「サードパーティに永続キーを渡すのを避け、短時間で失効する一時クレデンシャルを使う」というセキュリティ設計です。
そこで AWS STS(Security Token Service)の GetSessionToken API を使って、長期キーから一時クレデンシャルを発行します。
AWS CLI ローカル実行の場合
# 1.3 で取得したアクセスキーで AWS CLI のプロファイルを設定
aws configure --profile zoom-scribe
# 一時クレデンシャル(最大 36 時間 = 129600 秒)を発行
aws sts get-session-token --duration-seconds 43200 --profile zoom-scribe
CloudShell を使う場合
AWS CLI をローカルに入れたくない場合は、AWS マネジメントコンソール右上の CloudShell アイコンからブラウザ内ターミナルを起動できます。
ただし CloudShell 自体は一時クレデンシャルで動いているため、そのまま aws sts get-session-token を叩くと「Cannot call GetSessionToken with session credentials」エラーになります。CloudShell に既にセットされている AWS_SESSION_TOKEN を明示的に空にしてから呼び出してください。
# 1.3 で発行した長期キー(AKIA...)を環境変数で渡しつつ、
# CloudShell 由来の AWS_SESSION_TOKEN を空にして実行
AWS_ACCESS_KEY_ID=AKIA... \
AWS_SECRET_ACCESS_KEY=... \
AWS_SESSION_TOKEN= \
aws sts get-session-token --duration-seconds 43200
なお、専用 IAM ユーザで CloudShell に入りたい場合は、そのユーザに AWSCloudShellFullAccess ポリシーをアタッチする必要があります(デフォルトでは CloudShell を開けません)。
返ってくるレスポンス
{
"Credentials": {
"AccessKeyId": "ASIA....",
"SecretAccessKey": "....",
"SessionToken": "FwoGZXIvYX....(長い文字列)",
"Expiration": "2026-05-22T12:00:00+00:00"
}
}
ポイントは AccessKeyId が ASIA... で始まること。これが一時クレデンシャルの目印です。Expiration を過ぎると失効するので、ハンズオン中は十分な --duration-seconds を指定しておきましょう(最大 36 時間)。
SessionToken は数百文字に及ぶ長い文字列です。.env に貼り付ける際、改行や空白が混ざらないように注意してください。
1.5 .env への保存
取得した認証情報は .env ファイルに保存します。Access Key / Secret / SessionToken の 3 点セットを必ず揃えてください。
# .env
# Zoom Build Platform
ZOOM_API_KEY=your_zoom_api_key
ZOOM_API_SECRET=your_zoom_api_secret
# AWS S3 — STS 一時クレデンシャル(ASIA... で始まる)
AWS_ACCESS_KEY_ID=ASIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_SESSION_TOKEN=FwoGZXIvYXdzE...(長いトークン)
S3_BUCKET_NAME=zoom-scribe-handson-<yourname>
S3_REGION=ap-northeast-1
有効期限が切れたら、再度 aws sts get-session-token を叩いて 3 つの値を更新します。長期キー(AKIA...)を .env に書き直さないように注意してください。
2. MP3 ファイルを S3 にアップロード
NotebookLM の音声ファイルを S3 バケットの input/ にアップロードします。
AWS CLI を使う場合
# AWS CLI のインストール(未インストールの場合)
# https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html
# プロファイルの設定
aws configure --profile zoom-scribe
# → Access Key ID、Secret Access Key、Region(ap-northeast-1)を入力
# 音声ファイルが入ったディレクトリごとアップロード
aws s3 cp ./notebooklm-audio/ s3://zoom-scribe-handson-<yourname>/input/ \
--recursive \
--include "*.mp3" \
--profile zoom-scribe
# アップロード確認
aws s3 ls s3://zoom-scribe-handson-<yourname>/input/ --profile zoom-scribe
AWS コンソールを使う場合
S3 ダッシュボードで input/ フォルダを開き、「アップロード」 ボタンから MP3 ファイルをドラッグ&ドロップでもOKです。
アップロードが完了したら、input/ 配下にファイルが並んでいることを確認しましょう。
s3://zoom-scribe-handson-<yourname>/
├── input/
│ ├── episode-01-ai-overview.mp3
│ ├── episode-02-llm-basics.mp3
│ ├── episode-03-prompt-engineering.mp3
│ ├── episode-04-rag-architecture.mp3
│ ├── episode-05-agent-patterns.mp3
│ ├── episode-06-fine-tuning.mp3
│ └── episode-07-evaluation.mp3
└── output/
└── (Batch API が結果を書き込む)
3. Zoom Build Platform の認証
Scribe API の認証は、Zoom Build Platform の API Key / API Secret を使った JWT 方式です。前回の記事でも解説しましたが、改めておさらいします。
Scribe API は Zoom Build Platform 上で提供されるため、通常の Zoom Apps Marketplace の Server-to-Server OAuth ではなく、Build Platform の API Key / Secret による JWT 認証を使用します。スコープの個別設定は不要(常に All)です。
4. Node.js 実装
ここからは Node.js(ESM)で実装していきます。機能ごとにファイルを分け、export/import で接続する構成です。
4.1 環境セットアップ
# プロジェクトディレクトリの作成
mkdir scribe-batch-handson && cd scribe-batch-handson
# package.json の作成
npm init -y
# ESM を有効化(package.json に追記)
# "type": "module" を追加
# 依存パッケージのインストール
npm install jsrsasign dotenv commander
依存パッケージの役割:
| パッケージ | 用途 |
|---|---|
jsrsasign |
Zoom Build Platform の JWT トークン生成(HS256 署名) |
dotenv |
.env ファイルからの環境変数読み込み |
commander |
CLI サブコマンドの定義 |
4.2 ファイル構成と関数の関係
本プロジェクトは 4ファイル に分割しています。
scribe-batch-handson/
├── .env # 認証情報(.gitignore 対象)
├── .env.example # テンプレート
├── package.json
├── auth.js # JWT 認証(generateJwt)
├── fast.js # Fast API(transcribeFast)
├── batch.js # Batch API(submit / status / list / poll)
└── index.js # CLI エントリポイント
4.3 auth.js — JWT トークンの生成
全ての API コールで使う基盤モジュールです。前回の記事と同様、jsrsasign を使って HS256 署名で JWT を組み立てます。
// auth.js
import dotenv from "dotenv";
dotenv.config();
import KJUR from "jsrsasign";
export function generateJwt() {
const iat = Math.round(new Date().getTime() / 1000) - 30;
const exp = iat + 60 * 60 * 2;
const oHeader = { alg: "HS256", typ: "JWT" };
const oPayload = {
iss: process.env.ZOOM_API_KEY,
iat: iat,
exp: exp,
};
const sHeader = JSON.stringify(oHeader);
const sPayload = JSON.stringify(oPayload);
const API_JWT = KJUR.jws.JWS.sign(
"HS256",
sHeader,
sPayload,
process.env.ZOOM_API_SECRET,
);
return API_JWT;
}
ポイント:
-
iat(issued at)を30秒前にセットしているのは、クライアントとサーバー間の時刻ズレを吸収するためです -
expは最大2時間まで設定可能です(ここではiat + 2h) -
jsrsasignのKJUR.jws.JWS.signは、ヘッダー・ペイロード(共に JSON 文字列)と秘密鍵を渡して署名付き JWT を返します -
ハンズオン中にトークンの中身を確認したい場合、
return直前にconsole.log(API_JWT)を入れて jwt.io で覗くと挙動を理解しやすくなります(本番では外してください)
4.4 fast.js — 日本語で文字起こし(同期モード)
Fast API で 1ファイルの日本語文字起こし を行うモジュールです。前回は en-US でしたが、今回は ja-JP(日本語)を指定します。
// fast.js
import { generateJwt } from "./auth.js";
const SCRIBE_FAST_URL = "https://api.zoom.us/v2/aiservices/scribe/transcribe";
export async function transcribeFast({
url,
language = "ja-JP",
timestamps = true,
wordTimeOffsets = false,
channelSeparation = true,
diarization = false,
profanityFilter = false,
outputFormat = "json",
} = {}) {
if (!url) {
throw new Error("transcribeFast requires { url } in URL mode");
}
const token = generateJwt();
const body = {
config: {
language,
timestamps,
word_time_offsets: wordTimeOffsets,
channel_separation: channelSeparation,
diarization,
profanity_filter: profanityFilter,
output_format: outputFormat,
},
file: url,
};
const start = Date.now();
const response = await fetch(SCRIBE_FAST_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const elapsedMs = Date.now() - start;
const contentType = response.headers.get("content-type") ?? "";
const text = await response.text();
if (!response.ok) {
throw new Error(
`Fast API error ${response.status}: ${text.slice(0, 500)}`
);
}
const data = contentType.includes("application/json")
? JSON.parse(text)
: text;
return { status: response.status, contentType, elapsedMs, data };
}
注目パラメータ:
| パラメータ | 設定値 | 説明 |
|---|---|---|
language |
"ja-JP" |
日本語を指定。前回は "en-US" だった |
diarization |
true |
話者分離。2人以上の会話の場合、有効にすると発話者ごとに分割が可能 |
timestamps |
true |
セグメント単位のタイムスタンプを付与 |
4.5 batch.js — ジョブの投入
ここからが今回のメイン、Batch API です。S3 上の複数ファイルを一括で文字起こしします。
submitBatchJob() — ジョブ投入
// batch.js(抜粋)
import { generateJwt } from "./auth.js";
const BASE_URL = "https://api.zoom.us/v2/aiservices/scribe";
export async function submitBatchJob({
inputUri,
outputUri,
config = {},
includeGlobs = ["**/*.mp3", "**/*.m4a", "**/*.wav"],
referenceId,
webhookUrl,
webhookSecret,
}) {
const token = generateJwt();
// STS 一時クレデンシャル(ASIA... + SessionToken)
// 長期キー(AKIA...)は API 側で拒否されます
const awsAuth = {
access_key_id: process.env.AWS_ACCESS_KEY_ID,
secret_access_key: process.env.AWS_SECRET_ACCESS_KEY,
session_token: process.env.AWS_SESSION_TOKEN,
};
const body = {
input: {
source: "S3",
mode: "PREFIX",
uri: inputUri,
filters: { include_globs: includeGlobs },
auth: { aws: awsAuth },
},
output: {
destination: "S3",
uri: outputUri,
layout: "PREFIX",
auth: { aws: awsAuth },
},
config: {
language: config.language ?? "ja-JP",
word_time_offsets: config.wordTimeOffsets ?? false,
channel_separation: config.channelSeparation ?? false,
diarization: config.diarization ?? true,
profanity_filter: config.profanityFilter ?? false,
output_format: config.outputFormat ?? "json",
},
};
if (referenceId) body.reference_id = referenceId;
if (webhookUrl) {
body.notifications = { webhook_url: webhookUrl };
if (webhookSecret) body.notifications.secret = webhookSecret;
}
const response = await fetch(`${BASE_URL}/jobs`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Batch submit error ${response.status}: ${errorBody}`);
}
return response.json();
}
Batch API のリクエスト構造を図解:
3つの入力モード:
| モード | 指定方法 | ユースケース |
|---|---|---|
| SINGLE |
uri に1ファイルを指定 |
単体ファイルの非同期処理 |
| PREFIX |
uri にディレクトリ(プレフィックス)を指定 |
フォルダ内の全ファイルを対象 |
| MANIFEST |
manifest に URI リストを指定(最大1000件) |
異なるパスのファイルを明示的に列挙 |
今回は PREFIX モード を使用し、input/ 配下の全音声ファイルを一括処理します。
input.mode は PREFIX / MANIFEST を使う場合は明示しましょう。 公式ガイドでは 「SINGLE モードは uri が単一ファイルを指す場合に推論される」 と書かれていますが、PREFIX と MANIFEST は明示が必要です。明示しないと 400 INVALID_INPUT: "Invalid input mode" で弾かれるケースがあります。安全策として、ハンズオンでは mode: "PREFIX" を明示する方針にしています。
include_globs で拾える拡張子は明示的に列挙を推奨します。 例えば ["**/*.mp3"] だけを指定すると .m4a や .wav は対象外になります。M4Aを扱う場合など、["**/*.mp3", "**/*.m4a", "**/*.wav"] のように対象拡張子を列挙してください。マッチするファイルが 0 件だと 404 RESOURCE_NOT_FOUND: "No files found matching the prefix and filters" が返ります。
また、Scribe Batch がサポートする音声フォーマットは WAV / MP3 / M4A / MP4 の4種類です。MP4は動画でも問題ありません(音声トラックがあれば抽出して処理します)。
checkJobStatus() — ステータス確認
// batch.js(続き)
export async function checkJobStatus(jobId) {
const token = generateJwt();
const response = await fetch(`${BASE_URL}/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Status error ${response.status}: ${errorBody}`);
}
return response.json();
}
レスポンス例:
{
"job_id": "job_abc123",
"state": "PROCESSING",
"submitted_at": "2026-05-21T10:00:00Z",
"summary": {
"total_files": 7,
"queued": 0,
"processing": 3,
"succeeded": 4,
"failed": 0,
"skipped": 0
}
}
ジョブの状態遷移:
listJobFiles() — ファイル一覧取得
ジョブ完了後、ファイルごとの処理結果を確認します。ページネーションにも自動対応しています。
// batch.js(続き)
export async function listJobFiles(jobId, pageSize = 200) {
const token = generateJwt();
const allFiles = [];
let nextPageToken = null;
do {
const url = new URL(`${BASE_URL}/jobs/${jobId}/files`);
url.searchParams.set("page_size", String(pageSize));
if (nextPageToken) {
url.searchParams.set("next_page_token", nextPageToken);
}
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`List files error ${response.status}: ${errorBody}`);
}
const data = await response.json();
allFiles.push(...(data.files ?? []));
nextPageToken = data.next_page_token || null;
} while (nextPageToken);
return allFiles;
}
個別ファイルが FILE_FAILED / FILE_SKIPPED になった場合、原因は f.error.code と f.error.message に入っています。 表示ループでは必ず error フィールドを出力するようにしましょう。
for (const f of files) {
const icon = f.state === "FILE_SUCCEEDED" ? "✅" : "❌";
console.log(` ${icon} ${f.input_uri} → ${f.output_uri ?? "N/A"}`);
if (f.error) console.log(` ↳ ${f.error.code}: ${f.error.message}`);
}
これを忘れると、ジョブ全体の state が QUEUED のまま遷移しないケースと相まって、「なぜ動かないのか」が画面から読み取れない状況に陥ります。
レスポンス例(1ファイル分):
{
"file_id": "f_001",
"input_uri": "s3://zoom-scribe-handson-michitaka/input/episode-01-ai-overview.mp3",
"state": "FILE_SUCCEEDED",
"output_uri": "s3://zoom-scribe-handson-michitaka/output/episode-01-ai-overview.json",
"duration_sec": 482.5
}
pollUntilComplete() — 完了までポーリング
ジョブの完了を待つヘルパー関数です。onProgress コールバックで進捗の通知先をカスタマイズできます。もちろん、Webhookで待ち受けてもOKです。
// batch.js(続き)
export async function pollUntilComplete(jobId, options = {}) {
const { intervalMs = 10_000, timeoutMs = 600_000, onProgress } = options;
const terminalStates = new Set(["SUCCEEDED", "PARTIAL", "FAILED", "CANCELED"]);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const status = await checkJobStatus(jobId);
const { state, summary = {} } = status;
if (onProgress) {
onProgress(status);
} else {
console.log(
` [${state}]` +
` total=${summary.total_files ?? "?"}` +
` succeeded=${summary.succeeded ?? 0}` +
` processing=${summary.processing ?? 0}` +
` failed=${summary.failed ?? 0}`
);
}
if (terminalStates.has(state)) return status;
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Job ${jobId} did not complete within ${timeoutMs}ms`);
}
4.6 index.js — CLI エントリポイント
各モジュールを import し、commander でサブコマンドを定義します。
// index.js
import "dotenv/config";
import { Command } from "commander";
import { transcribeFast } from "./fast.js";
import {
submitBatchJob,
checkJobStatus,
listJobFiles,
pollUntilComplete,
} from "./batch.js";
const program = new Command();
program
.name("scribe-handson")
.description("Zoom Scribe API Hands-on: Japanese transcription with S3")
.version("1.0.0");
// --- fast ---
const DEFAULT_FAST_URL = "https://some-url";
program
.command("fast")
.description("Transcribe a single file (URL mode) using Fast API")
.option("--url <url>", "HTTPS URL of the audio/video file", DEFAULT_FAST_URL)
.option("--lang <code>", "Language code", "ja-JP")
.option("--format <fmt>", "Output format (json)", "json")
.option("--no-diarization", "Disable speaker diarization")
.action(async (opts) => {
console.log("=== Fast API (URL mode) ===");
console.log(` URL: ${opts.url}`);
console.log(` Lang: ${opts.lang}`);
console.log(` Format: ${opts.format}\n`);
try {
const { status, elapsedMs, data } = await transcribeFast({
url: opts.url,
language: opts.lang,
diarization: opts.diarization,
outputFormat: opts.format,
});
console.log(`Status: ${status}`);
console.log(`Elapsed: ${(elapsedMs / 1000).toFixed(2)}s\n`);
console.log("--- Response ---");
if (typeof data === "string") {
console.log(data);
} else {
console.log(JSON.stringify(data, null, 2));
}
} catch (err) {
console.error("Error:", err.message);
process.exit(1);
}
});
// --- batch ---
program
.command("batch")
.description("Submit a batch job and poll until complete")
.option("--ref <id>", "Reference ID for tracking")
.option("--lang <code>", "Language code", "ja-JP")
.option("--input <uri>", "Override input S3 URI")
.option("--output <uri>", "Override output S3 URI")
.option("--no-poll", "Submit only, do not wait for completion")
.action(async (opts) => {
const bucket = process.env.S3_BUCKET_NAME;
if (!bucket && !opts.input) {
console.error("Error: S3_BUCKET_NAME must be set in .env");
process.exit(1);
}
const inputUri = opts.input ?? `s3://${bucket}/input/`;
const outputUri = opts.output ?? `s3://${bucket}/output/`;
console.log("=== Batch API ===");
console.log(` Input: ${inputUri}`);
console.log(` Output: ${outputUri}`);
console.log();
try {
// 1. Submit job
const job = await submitBatchJob({
inputUri,
outputUri,
config: { language: opts.lang },
referenceId: opts.ref,
});
const jobId = job.job_id;
console.log(` Job ID: ${jobId}`);
console.log(` State: ${job.state}`);
console.log();
if (!opts.poll) {
console.log("Submitted (--no-poll). Check status with:");
console.log(` node index.js status ${jobId}`);
return;
}
// 2. Poll until complete
console.log("Polling for completion...");
await pollUntilComplete(jobId);
console.log();
// 3. List results
console.log("=== Results ===");
const files = await listJobFiles(jobId);
for (const f of files) {
const icon = f.state === "FILE_SUCCEEDED" ? "✅" : "❌";
const duration = (f.duration_sec ?? 0).toFixed(1);
const name = f.input_uri.split("/").pop();
const out = f.output_uri ?? "N/A";
console.log(` ${icon} ${name} (${duration}s) → ${out}`);
}
// Summary
const succeeded = files.filter(
(f) => f.state === "FILE_SUCCEEDED"
).length;
const failed = files.length - succeeded;
console.log(
`\n Total: ${files.length} files, ${succeeded} succeeded, ${failed} failed`
);
} catch (err) {
console.error("Error:", err.message);
process.exit(1);
}
});
// --- status ---
program
.command("status <jobId>")
.description("Check the status of a batch job")
.option("--files", "Also list per-file results")
.action(async (jobId, opts) => {
try {
const status = await checkJobStatus(jobId);
console.log(JSON.stringify(status, null, 2));
if (opts.files) {
console.log("\n=== Files ===");
const files = await listJobFiles(jobId);
for (const f of files) {
const icon = f.state === "FILE_SUCCEEDED" ? "✅" : "❌";
const name = f.input_uri.split("/").pop();
console.log(` ${icon} ${name} → ${f.state}`);
}
}
} catch (err) {
console.error("Error:", err.message);
process.exit(1);
}
});
program.parse();
5. 実行してみる
5.1 Fast API — 日本語1ファイルを即時文字起こし
まずは Fast API で1ファイルだけ試して、日本語の文字起こし精度を確認します。
node index.js fast
想定される出力イメージ:
=== Fast API (URL mode) ===
URL: https://some-url
Lang: ja-JP
Format: json
Status: 200 OK
Elapsed: 31.08s
Content-Type: application/json
--- Response ---
{
"request_id": "8eb27636-9146-4605-b219-33d95b0ed808",
"duration_sec": 201.571,
"result": {
"text_display": "本日はお時間いただきありがとうございます。 株式会社エーアイの佐藤です...
"segments": [
{
"id": "1",
"start": 0,
"end": 2.705,
"channel": 0,
"speaker": "Speaker 1L",
"text": "本日はお時間いただきありがとうございます。",
"words": []
},
{
"id": "2",
"start": 2.705,
"end": 5.28,
"channel": 0,
"speaker": "Speaker 1L",
"text": "株式会社エーアイの佐藤です。",
"words": []
},...
{
"id": "52",
"start": 199.2,
"end": 201.571,
"channel": 0,
"speaker": "Speaker 1R",
"text": "引き続きどうぞよろしくお願いいたします。",
"words": []
}
]
},
"model": "zoom-asr-en-v1",
"metrics": {},
"usage": {
"input_units": 201.571,
"output_units": 0,
"unit_type": "seconds",
"details": {
"scribe": {
"language": "ja-JP"
}
}
}
}
発話者が複数いる場合、diarization: true を有効にしておくとこのように speaker_1 / speaker_2 に自動分離されます。
5.2 Batch API — 7本のMP3を一括文字起こし
S3 にアップロードした全ファイルを一括処理します。
node index.js batch --ref notebooklm-ja-2026
想定される出力イメージ:
=== Batch API ===
Input: s3://zoom-scribe-handson-xxxx/input/
Output: s3://zoom-scribe-handson-xxxx/output/
Job ID: job_7f8a9b2c
State: QUEUED
Polling for completion...
[QUEUED] total=7 succeeded=0 processing=0 failed=0
[PROCESSING] total=7 succeeded=0 processing=4 failed=0
[PROCESSING] total=7 succeeded=3 processing=4 failed=0
[PROCESSING] total=7 succeeded=5 processing=2 failed=0
[SUCCEEDED] total=7 succeeded=7 processing=0 failed=0
=== Results ===
✅ episode-01-ai-overview.mp3 (482.5s)
✅ episode-02-llm-basics.mp3 (521.3s)
✅ episode-03-prompt-engineering.mp3 (445.8s)
✅ episode-04-rag-architecture.mp3 (498.2s)
✅ episode-05-agent-patterns.mp3 (510.7s)
✅ episode-06-fine-tuning.mp3 (467.1s)
✅ episode-07-evaluation.mp3 (438.9s)
Total: 7 files, 7 succeeded, 0 failed
5.3 結果の確認
Batch API の出力は S3 の output/ に JSON ファイルとして保存されます。
# S3 から結果をダウンロード
aws s3 cp s3://zoom-scribe-handson-<yourname>/output/ ./results/ \
--recursive \
--profile zoom-scribe
# 結果を確認(jq がある場合)
cat ./results/episode-01-ai-overview.json | jq '.result.text_display'
6. トラブルシューティング — ハンズオン中に遭遇したエラー集
実際にこのハンズオンを書きながら踏み抜いたエラーをまとめておきます。同じ場所で詰まった人の助けになれば幸いです。
6.1 400 INVALID_INPUT: "Invalid input mode"
{"code":400, "reason":"INVALID_INPUT", "message":"Invalid input mode", "metadata":{}}
原因: リクエストボディの input.mode フィールドが未指定。ドキュメント上は省略時に auto-infer されることになっていますが、実際には弾かれます。
対処: input.mode を "SINGLE" / "PREFIX" / "MANIFEST" のいずれかで明示的に指定する。
6.2 400 VALIDATION_INVALID_FORMAT: "missing SessionToken"
{
"code":400,
"reason":"VALIDATION_INVALID_FORMAT",
"message":"missing SessionToken — this looks like a long-term IAM key, not STS credentials",
"metadata":{"field":"session_token"}
}
原因: 長期 IAM キー(AKIA... で始まる)を access_key_id に渡している。Scribe Batch API は STS 一時クレデンシャル(ASIA... + SessionToken)のみ受け付けます。
対処: §1.4 の手順で aws sts get-session-token を実行し、.env の 3 つの値(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN)を更新する。
6.3 400 ERROR_CLIENT_AWS_CREDENTIAL: "Invalid AWS security token"
{"code":400, "reason":"ERROR_CLIENT_AWS_CREDENTIAL", "message":"Invalid AWS security token", "metadata":{}}
原因: session_token の値が壊れている、もしくは既に期限切れ。.env への貼り付けで改行が混じった、あるいは Expiration を過ぎているケースが多いです。
対処: STS で再発行して .env を更新する。1 行で貼り付けられているか確認する。
6.4 400 ERROR_CLIENT_AWS_CREDENTIAL: "AWS signature verification failed"
{"code":400, "reason":"ERROR_CLIENT_AWS_CREDENTIAL", "message":"AWS signature verification failed", "metadata":{}}
原因: access_key_id と secret_access_key の組み合わせが不一致。新旧の STS 発行で片方だけコピーし直したケースで起きやすいです。
対処: STS のレスポンスから 3 つの値を 同時に コピーし直す。Access Key / Secret / SessionToken はセットで使うものなので、必ず 1 回の get-session-token 出力から揃えること。
6.5 404 RESOURCE_NOT_FOUND: "No files found matching the prefix and filters"
{"code":404, "reason":"RESOURCE_NOT_FOUND", "message":"No files found matching the prefix and filters", "metadata":{}}
原因: 指定した S3 prefix の配下に、include_globs パターンにマッチするファイルが存在しない。デフォルトの ["**/*.mp3"] で .m4a や .wav のファイルしか置いていない場合に発生します。
対処:
-
input/配下に対象ファイルが実際にアップロードされているか確認 -
include_globsに対象拡張子を列挙する(例:["**/*.mp3", "**/*.m4a", "**/*.wav"])
7. Fast API vs Batch API — 使い分けガイド
| 観点 | Fast API | Batch API |
|---|---|---|
| 処理方式 | 同期(即レスポンス) | 非同期(ポーリング or Webhook) |
| 入力 | multipart でファイル直接送信 or URL モード | S3 URI を指定 |
| 入力フォーマット | WAV / MP3 / M4A / MP4 | WAV / MP3 / M4A / MP4 |
| 出力 | JSON レスポンス (将来的にVTTなども対応予定) |
S3 に JSON ファイルを出力 |
| 最大ファイルサイズ | 100MB | S3 の制限に準ずる |
| ジョブ上限 | - | MANIFEST モード: 1,000 ファイル |
| AWS認証 | 不要 | 必須(STS 一時クレデンシャル) |
| 言語指定 | リクエストごと | ジョブ単位 |
| 向いている用途 | テスト、短い録音、即時処理 | 大量処理、定期バッチ、アーカイブ |
Zoom AI Services API 関連記事一覧
リソース
