Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Translation APIとAccessibility APIを使ってSlackやDiscordのメッセージを自動翻訳する

iOS 18とmacOS Sequoiaでは新しくTranslation APIが利用できるようになりました。それなりの品質の翻訳APIが無料で使い放題というのは非常にお得で、おそらく代替になるものは他に存在しません。ですので活用できるところがあれば積極的に使っていくといいでしょう。

今回はこのTranslation APIとAccessibility APIを組み合わせて、SlackやDiscordのメッセージを自動的に翻訳するアプリケーションを作成したので、その内容をご紹介します。

OSSライブラリのコミュニティなど外国語が主に使われるサーバーの話題を追いかけるのに便利です。Botではなくローカルで動作するので管理権限を持っていないサーバーでも利用できます。

このアプリケーションを作成するために必要な手順は、

  1. Accessibility APIでSlackやDiscordのメッセージテキストと座標を取得する
  2. Translation APIでメッセージを翻訳する
  3. メッセージにピッタリ重なるようなウインドウに翻訳したテキストを表示する

となります。

Translation APIはSwiftUI専用ですが、任意の位置にウインドウを作成するのはSwiftUIでは面倒なのでこのアプリケーションはAppKitで作られています。

翻訳の際に使用するTranslationSessionオブジェクトはわりと簡単にSwiftUIからAppKit(UIKitでも同様)の世界に持ち出せます。その方法についても後ほど解説します。

Accessibility API

Accessibility APIとはUIテストや自動化のために使われる、別のプロセスからアプリケーションの情報を読み取ったりボタンを押したりすることができるAPIです。

例えばCopilotForXcodeもXcodeのテキストを読み取り、独自のコード補完を提供するためにAccessibility APIを使用しています。AXUIElementAXValueなどAXで始まる型を検索するとどのようなところで使われているのかわかります。

AIが日常的に使われるようになってから、既存のアプリケーションでAIの支援を活用するためにAccessibility APIを機能拡張の手段にすることは非常に活発に行なわれるようになりました。やり方を知っておくと自分でもさまざまなアプリケーションをAIで拡張できて便利です。

Accessibility APIを活用して任意のアプリケーションの情報を読み取り、機能を拡張するという手法についてはtry! Swif 2024で話したので以下の記事や発表資料、講演のビデオをご覧ください。

blog.kishikawakatsumi.com

Accessibility APIでテキストを読み取る

アプリケーションのテキストを読み取るにはAccessibility APIを使って対象のアプリケーションのテキストが表示されているUI要素を取得します。

Accessibility APIから取得するUI要素はツリー構造になっているので、目的の要素を取得するためにはツリー構造を辿ります。

私が作成したAXUIElementInspectorはAccessibility APIを使ってマウスカーソルの位置にあるUI要素を取得できるアプリケーションです。

github.com

これを使うとアプリケーションのUI要素がツリー構造のどの位置にあってどのような属性を持っているかなど、ツリー構造を辿るためのヒントが簡単に得られます。

Xcodeに付属しているAccessibility Inspectorでも同様の解析ができますが、Accessibility InspectorはAppleによる特別な権限で動作するため、Accessibility APIで取得できる結果とまったく同じとは限らない可能性があります。

AXUIElementInspectorはAccessibility APIを使って動作しているので、このアプリケーションで取得できる要素であればAccessibility APIで間違いなく取得できます。

また、次のようなツリー構造をそのままダンプする処理を書いて出力すると全体のツリー構造の形がわかります。

private func dumpAXUIElements(in element: AXUIElement, indent: Int) {
  print("\(String(repeating: " ", count: indent * 2))\(element.role) \(element.value)")
  if element.role == kAXStaticTextRole {
    return
  }
  for child in element.children {
    dumpAXUIElements(in: child, indent: indent + 1)
  }
}

Slackに対して実行するとこのような構造が出力されます。

AXGroup 
  AXStaticText Bookmark folder
AXGroup 
  AXGroup 
    AXGroup 
      AXGroup 
      AXList 
        AXGroup 
          AXGroup 
            AXPopUpButton 
        AXGroup 
          AXGroup 
            AXGroup 
              AXButton 
              AXGroup 
                AXImage 
                AXGroup 
                  AXStaticText ​
              AXStaticText   
              AXLink 
                AXStaticText 2:52 AM
              AXGroup 
              AXGroup 
                AXStaticText I'm adding some unit tests, testing with a random integer in a range, and I have this as the given:
                AXGroup
              AXGroup 
                AXStaticText But this is crashing with
                ...

SlackのメッセージはAXListの子要素として並んでいることがわかるので、AXListが見つかるまで読み飛ばして、AXListの子要素でテキストを持つ要素に対して処理をすればいいということになります。

一方で、Discordは次の画像に示すように、中心的な要素のroleDescriptionプロパティにはそれぞれ名前がついています。AXUIElementInspectorで調べたい要素の位置にカーソルを合わせて、AXRoleDescriptionの値を見るとわかります。

つまりDiscordは各メッセージの要素にはmessage、その親要素のスクロールビューにはcontent listという名前がついているのでそれを当てにして構造を辿っていくと簡単に目当ての要素を取得できます。

そうするとDiscordのチャットメッセージ欄の座標と大きさ、およびテキストを取得するコードは下記のようになります。意外とシンプルなコードで書けます。なのですが、実はこの要素のつけられた名前はDiscordの言語設定によってそれぞれの言語で表示されるので、このようにハードコーディングすると特定の言語設定(例ではEnglish)でしか正しく動作しません。

func perform() {
  var messages = [Message]()

  guard let focusedWindow = application.focusedWindow else { return }
  guard let messageList = findMessageListElement(in: focusedWindow) else { return }

  let rowContainers = messageList.children
    .filter { $0.role == kAXGroupRole }
  for rowContainer in rowContainers {
    for row in rowContainer.children {
      guard row.roleDescription == "message" else {
        continue
      }

      let messageContainer = row
      let messageGroup = messageContainer.children
        .filter { $0.roleDescription != "heading" && $0.roleDescription != "time" }
        .filter { $0.children.allSatisfy { $0.roleDescription != "article" } }
      for message in messageGroup {
        guard let frame = message.frame, frame.height > 1.0 else {
          continue
        }
        var text = ""
        var minX = CGFloat.greatestFiniteMagnitude
        concatMessageText(in: message, text: &text, minX: &minX)
        var textFrame = frame
        textFrame.origin.x = minX
        messages.append(
          Message(
            frame: frame, textFrame: textFrame, text: text, axElement: message
          )
        )
      }
    }
  }

  self.messages = messages
}

private func findMessageListElement(in element: AXUIElement) -> AXUIElement? {
  for child in element.children {
    if child.role == kAXGroupRole || child.role == kAXListRole || child.role == "AXWebArea" {
      if let messageList = findMessageListElement(in: child) {
        return messageList
      }
    }
    if child.roleDescription == "content list" && child.description.starts(with: "Messages") {
      return child
    }
  }
  return nil
}

とはいえ、名前で要素を見つけられるのはとても楽で、今さら構造を辿って上からn個目の要素を、というアプローチに変えるのも大変なので、このアプリケーションではローカライズされた名前をすべて列挙する形で条件に使用して、どんな言語設定でも同様に動作するようにしました。 実際のコードはこちらをご覧ください。

また、テキストを含む要素はリンクやスタンプなどが混ざると細切れになるので、翻訳APIにはあるまとまった文章を渡せるように共通の親要素を持つ要素でまとめるなどの工夫をするときれいな訳文が得られます。

このように取得した要素と同じサイズと位置のウインドウをピッタリ重ねた様子が下記です。わかりやすいようにウインドウに青い境界線を描いています。メッセージのテキストにピッタリ重なっています。ここまでできれば、あとはメッセージのテキストを翻訳してこのウインドウに表示するだけです。

Translation API

Translation APIを使用するには翻訳対象の言語モデルがインストールされている必要があります。Translation APIがSwiftUI専用なのは、この言語モデルをダウンロードするためのビューを表示するためのアンカーとなるビューがSwiftUIのビューを指定するインターフェースしか用意されていないためです。

つまり、アンカーとなるSwiftUIのビューがどこかに存在すればいいので、翻訳自体の処理はSwiftUIがないところでも実行できます。

このアプリケーションでは設定画面をSwiftUIで作成してそれをTranslation APIの言語モデルをダウンロードするビューのアンカーとすることにしました。

言語モデルのダウンロードのUIは設定画面からこのような形で表示されます。

言語モデルのダウンロードは最初の一回だけでいいので、通常の場合は見えないウインドウをSwiftUIでひとつ作成して、そこからTranslation APIを利用します。

翻訳の処理に必要なTranslationSessionオブジェクトは.translationTask()モディファイアからしか作成できませんが、スコープの外でも使えるのでTranslationSessionオブジェクトをAppDelegateのプロパティで保持してAppKitの世界に持ち出します。

@main
struct TextChatTranslatorApp: App {
  @AppStorage("targetLanguage") private var targetLanguage = "ja"

  @State private var translationContext = TranslationContext()
  @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

  var body: some Scene {
    WindowGroup {
      VStack {}
      .translationTask(translationContext.configuration) { (session) in
        appDelegate.translationSession = session
      }
    }
    Settings {
      SettingsView()
        .environment(\.translationContext, translationContext)
    }
  }
}

持ち出したTranslationSessionオブジェクトは、翻訳に使用する言語ペアが変わらない限りずっと使用できます。次のような関数で渡された文字列をTranslationSessionを使って翻訳します。Translation APIはおそらくレートリミットなどの利用制限はありませんが、同じテキストを何度も翻訳することは無駄なので同じテキストなら翻訳済みの結果を返すように簡単なキャッシュを持っています。

class TranslationService {
  private let cache = NSCache<NSString, NSString>()

  func translate(session: TranslationSession?, text: String) async throws -> String {
    if let translation = cache.object(forKey: NSString(string: text)) {
      return String(translation)
    }
    guard let session = session else {
      return text
    }

    do {
      let availability = LanguageAvailability()
      let status = try await availability.status(
        for: text,
        to: session.targetLanguage
      )
      if status == .installed {
        let response = try await session.translate(text)
        cache.setObject(NSString(string: response.targetText), forKey: NSString(string: text))
        return response.targetText
      }
    } catch {
      return text
    }
  }
}

TextChatTranslator

作成したアプリケーションはGitHubで公開しています。ビルド済みのバイナリもアップロードしてあるので、 Releasesページからダウンロードしてそのまま使えます。

Translation APIを使っているのでmacOS 15以上が必要です。

github.com

Accessibility APIでテキストが取得できればどんなアプリケーションでも同様の方法で翻訳できるので、自分用に改造しても便利だと思います。いろいろ応用を考えてみてください。