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?

Zoom Scribe API — 日本語対応 & Batch API × AWS S3 で音声を一括文字起こし

0
Last updated at Posted at 2026-05-22

8c035a_hFs1l6-lRKOudZ7y2ZdPsw_pic-5cc39eb3-1371-47c4-ab67-a16b582f69f0.png

はじめに

前回の記事では、Zoom AI Services の Scribe APIFast 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 のダッシュボードを開きます。

  1. 「バケットを作成」 をクリック
  2. 以下の設定で作成:
設定項目 備考
バケット名 zoom-scribe-handson-<yourname> グローバルで一意にする
リージョン ap-northeast-1(東京) 任意。音声データの所在に近いリージョン推奨
オブジェクト所有者 ACL 無効(推奨) デフォルトのまま
パブリックアクセス すべてブロック(推奨) デフォルトのまま
バージョニング 無効 ハンズオン用途なので不要
暗号化 SSE-S3(デフォルト) デフォルトのまま
  1. バケット作成後、以下の 2つのフォルダ(プレフィックス) を作成:

    • input/ — 文字起こし対象の音声ファイル置き場

    • output/ — Batch API の出力先

1.2 IAM ポリシーの作成

Scribe API の Batch 処理では、Zoom のサービスが S3 バケットにアクセスします。そのためのアクセス権を IAM ポリシーで定義します。

  1. IAM コンソール → ポリシーポリシーを作成
  2. 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/*"
      ]
    }
  ]
}
  1. ポリシー名を ZoomScribeS3Access として保存

ポリシーの構造:

1.3 IAM ユーザーの作成とアクセスキー取得

  1. IAM コンソール → ユーザーユーザーを作成
  2. ユーザー名:zoom-scribe-batch
  3. 「AWS マネジメントコンソールへのユーザーアクセスを提供する」 のチェックは外す(API アクセスのみ)
  4. 許可を設定 → 「ポリシーを直接アタッチする」→ ZoomScribeS3Access を選択
  5. ユーザー作成後、セキュリティ認証情報 タブ → アクセスキーを作成
  6. ユースケースは 「サードパーティのサービス」 を選択
  7. アクセスキー 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

  • jsrsasignKJUR.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.codef.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}`);
}

これを忘れると、ジョブ全体の stateQUEUED のまま遷移しないケースと相まって、「なぜ動かないのか」が画面から読み取れない状況に陥ります。

レスポンス例(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_idsecret_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 のファイルしか置いていない場合に発生します。

対処:

  1. input/ 配下に対象ファイルが実際にアップロードされているか確認
  2. 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 関連記事一覧

リソース

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?