Zoom会議Save transcript完全自動化 - MacOS Accessibility APIとOCRで実現
はじめに
"あ、また保存し忘れた..."
Zoom会議が終わった後、この後悔を何度経験したでしょうか。自動文字起こし機能は素晴らしいのに、毎回「Save Transcript」ボタンをクリックしなければ失われてしまう。これは明らかに自動化すべき作業です。
そこで、完全に放置しても自動保存されるシステムを作りました。開発過程では想定外の技術的制約に直面しましたが、最終的にmacOSの深い機能を活用して実現できました。
本記事では、単なる実装手順ではなく、なぜその選択をしたのか、何が失敗してどう修正したのかという開発の実態をお伝えします。
この記事の対象読者:
- macOS自動化に興味がある開発者
- Accessibility APIやOCRの実践例を探している方
- LaunchAgentで詰まった経験がある方
- 日常の小さな不便を技術で解決したい方
技術スタック
- 言語: Swift
- OCR: Vision Framework (VNRecognizeTextRequest)
- UI自動化: Accessibility API (AXUIElement)
- プロセス管理: macOS LaunchAgent + Watchdog
- ビルドツール: Swift Package Manager
開発の背景と目標
課題
- Zoom会議ごとに手動で「Save Transcript」ボタンをクリックする必要がある
- クリックし忘れると文字起こしが失われる
- 会議中は他の作業に集中したい
目標
- 完全自動化 - ユーザー操作不要
- バックグラウンド動作 - 作業を邪魔しない
- 高い信頼性 - クラッシュしても自動復旧
フェーズ1: 基本機能の実装
アーキテクチャ設計
システムは以下のコンポーネントで構成されています:
TranscriptMonitor (メインループ)
├── AccessibilityClicker (ボタンクリック)
├── ScreenCapturer (画面キャプチャ)
├── TextDetector (OCR検出)
└── MouseClicker (フォールバック用)
OCR検出の実装
Vision FrameworkのOCR機能を使用して「Save Transcript」ボタンを検出:
let request = VNRecognizeTextRequest { request, error in
guard let observations = request.results as? [VNRecognizedTextObservation] else {
return nil
}
for observation in observations {
guard let candidate = observation.topCandidates(1).first else { continue }
if searchTerms.contains(where: { candidate.string.contains($0) }) {
return DetectedText(
text: candidate.string,
confidence: candidate.confidence,
boundingBox: observation.boundingBox
)
}
}
}
Accessibility APIによるクリック
macOSのAccessibility APIを使用してボタンを直接操作:
func clickButtonByTitle(_ title: String, in bundleIdentifier: String) -> Bool {
guard let app = getApplication(bundleIdentifier: bundleIdentifier) else {
return false
}
if let button = findButton(withTitle: title, in: app) {
var actionError: Unmanaged<CFError>?
AXUIElementPerformAction(button, kAXPressAction as CFString)
return true
}
return false
}
結果: OCRで100%の精度でボタンを検出し、Accessibility APIで確実にクリックできることを確認。
フェーズ2: LaunchAgentによる自動起動 - 失敗からの学び
当初の計画
macOS LaunchAgentを使用してシステム起動時に自動実行:
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
致命的な問題の発見
LaunchAgentとして実行すると、OCR検出が完全に失敗:
[LaunchAgent実行]
Detected 0 text regions ❌
[手動実行]
Detected 93 text regions ✅
原因分析
macOSはセキュリティ上の理由からLaunchAgentプロセスによる画面キャプチャAPIアクセスを制限していました。これは:
- WindowServerへのアクセス制限
- Screen Recording APIの利用不可
- ユーザーコンテキスト外での実行による制約
解決策: 手動起動 + Watchdog監視
LaunchAgentでの自動起動を諦め、以下のアーキテクチャに変更:
-
手動起動スクリプト (
start.sh) - ユーザーコンテキストで実行 - Watchdog LaunchAgent - プロセス監視のみ(OCRなし)
# start.sh
export SKIP_WINDOW_ACTIVATION=1
nohup ./.build/release/ZoomTranscriptSaver > /dev/null 2>&1 &
# watchdog.sh (5分ごとに実行)
if ! pgrep -f "ZoomTranscriptSaver" > /dev/null; then
log "⚠️ Process not running, attempting to restart..."
cd "$SCRIPT_DIR"
./start.sh
fi
学び: セキュリティ制約を理解し、システムの設計思想に沿った実装が重要。回避策ではなく、正しいアーキテクチャを選択する。
フェーズ3: バックグラウンドクリックの実現
新たな課題
「Zoomウィンドウを前面に出さずにクリックできないか?」
従来の実装では、クリック前にZoomウィンドウをアクティブ化していました:
zoom.activate(options: [])
Thread.sleep(forTimeInterval: 0.2)
これはユーザーの作業を中断させる可能性がありました。
Accessibility APIの利点
実験の結果、Accessibility APIはウィンドウのアクティブ化なしでボタンをクリック可能であることが判明:
let skipActivation = ProcessInfo.processInfo.environment["SKIP_WINDOW_ACTIVATION"] == "1"
if !skipActivation {
zoom.activate(options: [])
Thread.sleep(forTimeInterval: 0.2)
} else {
Logger.shared.debug("Window activation skipped")
}
結果: Zoomが背景にあってもボタンクリックに成功。ユーザー体験が大幅に向上。
フェーズ4: 完全非表示対応 - Accessibility API優先アプローチ
最終課題
「ボタンが完全に隠れている(画面外、最小化など)状態でも保存できるか?」
従来のフロー(OCR優先)
1. 画面キャプチャ
2. OCR検出 ← ボタンが見えないと失敗
3. Accessibility APIクリック
改善後のフロー(Accessibility API優先)
1. Accessibility APIで検出・クリック ← ボタンの視覚的状態に依存しない
2. 失敗時のみOCRフォールバック
実装
private func checkForTranscriptDialog() {
// 1. Accessibility API優先
Logger.shared.info("🔍 Attempting to find button via Accessibility API...")
var clickSucceeded = accessibilityClicker.clickButtonByTitle("Save transcript", in: "us.zoom.xos")
if clickSucceeded {
Logger.shared.info("✅ Successfully clicked via Accessibility API!")
return
}
// 2. OCRフォールバック
Logger.shared.info("⚠️ Accessibility API failed, falling back to OCR...")
// ... OCR検出とマウスクリック処理
}
効果
- ✅ ボタンが画面外でも検出可能 - UI階層から直接アクセス
- ✅ 高速化 - 画面キャプチャとOCR処理をスキップ
- ✅ 信頼性向上 - 視覚的検出の制約からの解放
- ✅ リソース効率 - CPU使用率の削減
システムアーキテクチャ(最終版)
┌─────────────────────────────────────────┐
│ LaunchAgent (Watchdog) │
│ - 5分ごとに監視 │
│ - クラッシュ時に自動再起動 │
└─────────────────────────────────────────┘
↓ 監視
┌─────────────────────────────────────────┐
│ ZoomTranscriptSaver (Main Process) │
│ - 30秒ごとにポーリング │
│ - Accessibility API優先 │
│ - OCRフォールバック │
│ - バックグラウンドクリック │
└─────────────────────────────────────────┘
↓ ↓
┌──────────┐ ┌──────────────┐
│Accessibility│ │Screen Capture│
│ API │ │ + OCR │
└──────────┘ └──────────────┘
技術的な学び
1. macOSセキュリティモデルの理解
- LaunchAgentは強力だが、画面アクセスに制限がある
- ユーザーコンテキストでの実行が必要なケースがある
- 権限(Accessibility、Screen Recording)の適切な管理
2. Accessibility APIの威力
- UIの視覚的状態に依存しない
- ウィンドウのアクティブ化不要
- Electronアプリ(Zoom)でも正常に動作
3. OCRの適材適所
- 精度は非常に高い(100%信頼度を達成)
- しかし処理コストが高い
- フォールバックとして最適
4. 環境変数による機能切り替え
SKIP_PERMISSION_CHECK=1 // LaunchAgent用
SKIP_WINDOW_ACTIVATION=1 // バックグラウンドクリック
柔軟な動作制御が可能に。
パフォーマンス
従来(OCR優先)
- 画面キャプチャ: ~100ms
- OCR処理: ~500ms
- 合計: ~600ms/チェック
現在(Accessibility API優先)
- Accessibility API: ~50ms
- 合計: ~50ms/チェック
12倍の高速化を達成。
トラブルシューティング
権限エラー: "Operation not permitted"
症状:
Error: AXError -25205 (kAXErrorCannotComplete)
解決方法:
- システム設定 → プライバシーとセキュリティ → アクセシビリティ
- ZoomTranscriptSaverを追加
- Screen Recordingも同様に追加(OCRフォールバック用)
- アプリを再起動
OCR検出が不安定
症状: ボタンが表示されているのにクリックされない
原因: 画面解像度やRetinaディスプレイの影響
解決方法:
// 検出精度を調整
request.recognitionLevel = .accurate // .fast から .accurate へ
request.minimumTextHeight = 0.01 // より小さなテキストも検出
request.usesLanguageCorrection = true // 言語補正を有効化
Watchdogが再起動を繰り返す
症状: プロセスが頻繁に再起動される
原因: クラッシュループの可能性
デバッグ方法:
# ログを確認
tail -f ~/Library/Logs/ZoomTranscriptSaver.log
# LaunchAgentのログを確認
tail -f /tmp/zoom_transcript_watchdog.log
# プロセスの状態を確認
ps aux | grep ZoomTranscriptSaver
Accessibility APIが動作しない
症状: Accessibility APIでボタンを見つけられない
デバッグ方法:
// UI階層をデバッグ出力
func printUIHierarchy(_ element: AXUIElement, depth: Int = 0) {
let indent = String(repeating: " ", count: depth)
let role = getRole(element) ?? "unknown"
let title = getTitle(element) ?? ""
print("\(indent)\(role): \(title)")
for child in getChildren(element) {
printUIHierarchy(child, depth: depth + 1)
}
}
セキュリティとプライバシーの考慮事項
必要な権限
このアプリは以下の権限を必要とします:
-
Accessibility Access: ボタンのクリックに必要
- システム設定 → プライバシーとセキュリティ → アクセシビリティ
-
Screen Recording: OCRフォールバック時に必要
- システム設定 → プライバシーとセキュリティ → 画面収録
プライバシーポリシー
- ネットワークアクセス: 一切なし
- データ送信: ゼロ
- ローカル処理: すべての処理はローカルで完結
- データ保存: Zoomが保存するファイル以外は保存しない
- 監視範囲: Zoomアプリのみを監視
コードの透明性
すべてのコードはオープンソースで公開しており、監査可能です。以下の点を保証します:
- 画面キャプチャはボタン検出のみに使用
- 取得したデータは外部に送信されない
- ログファイルにも機密情報は含まれない
ロードマップ
短期(1-2週間)
-
エラー通知機能: 保存失敗時にmacOS通知を送信
let notification = UNMutableNotificationContent() notification.title = "Zoom Transcript Save Failed" notification.body = "Failed to save transcript. Please check manually." -
設定ファイル: 保存先ディレクトリのカスタマイズ
{ "save_directory": "~/Documents/ZoomTranscripts", "filename_pattern": "zoom_{date}_{time}.txt" } - 統計ダッシュボード: 保存成功率、会議数の可視化
中期(1-2ヶ月)
-
他のアプリ対応: Microsoft Teams、Google Meetへの拡張
// 汎用化されたTranscriptSaverプロトコル protocol TranscriptSaver { var appBundleId: String { get } var buttonTitle: String { get } func saveTranscript() -> Bool } - Swift Concurrency移行: async/awaitでコードを現代化
- GUIツール: 設定を簡単にするためのmacOSアプリ
長期(3ヶ月以上)
- AIサマリー機能: 保存したトランスクリプトを自動要約
- 多言語対応: 日本語、中国語の文字起こしにも対応
- クラウド同期: オプションでクラウドストレージと連携
コントリビューション歓迎!
このプロジェクトはオープンソースです。Issue報告やPull Requestをお待ちしています。
まとめ
Zoom会議録の自動保存という一見シンプルな課題でしたが、macOSのセキュリティモデル、Accessibility API、OCR技術、プロセス管理など、多くの技術要素を組み合わせることで実現できました。
特に重要だったのは:
- 技術的制約の正しい理解 - LaunchAgentの制限を理解し、適切なアーキテクチャを選択
- 段階的な改善 - OCR → バックグラウンドクリック → Accessibility API優先と進化
- ユーザー体験重視 - 自動化だけでなく、作業を邪魔しない設計
このプロジェクトを通じて、macOSアプリケーション開発の奥深さと、適切な技術選択の重要性を再認識しました。
開発期間: 2025年12月
技術: Swift, Vision Framework, Accessibility API, LaunchAgent
Discussion