jscodeshift + OpenAI API でソースコード内の日本語文字列を一括で変換する

こんにちは、ROUTE06 でソフトウェアエンジニアをしている@MH4GFです。
私が関わるリポジトリでの共通言語を日本語から英語に変えることになり、既存のコードベースに散在する日本語文字列を一括で変換する方法を模索しました。
最終的に jscodeshift と OpenAI API を組み合わせて一括置換することで解決できました。今回はその手法について紹介します。

スクリプトの概要

早速、主要なコード部分と詳細な説明を示します。

#!/usr/bin/env zx
import OpenAI from "openai";
import jscodeshift from "jscodeshift";

// コマンドライン引数の解析
const argv = minimist(process.argv.slice(2), {
  boolean: ["dry-run"],
  alias: { d: "dry-run" },
});

// OpenAI APIを使用して日本語を英語に翻訳する関数
async function translateToEnglish(text, isDryRun = false, fileName = "") {
  if (isDryRun) {
    console.log(`File: ${fileName}, Would translate: "${text}"`);
    return `[DRYRUN] Would translate: "${text}"`;
  }

  const openai = new OpenAI();
  try {
    const response = await openai.chat.completions.create({
      model: "gpt-4o-mini", // コスト効率の高いモデルを使用
      messages: [
        {
          role: "system",
          content:
            "You are a translator. Translate the given Japanese text to English.",
        },
        { role: "user", content: text },
      ],
    });
    return response.choices[0].message.content.trim();
  } catch (error) {
    console.error(
      `Translation failed for text: "${text}" in file: "${fileName}"`,
      error
    );
    return text; // エラー時は元のテキストを返す
  }
}

// 日本語文字列を検出する関数
function containsJapanese(text) {
  return /[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf]/.test(
    text
  );
}

// jscodeshift変換関数
async function transform(fileInfo, api, options) {
  const j = api.jscodeshift;
  const { dryRun } = options;
  const filePath = fileInfo.path;

  try {
    const root = j(fileInfo.source, {
      tsx: true,
      jsx: true,
      tolerant: true,
      allowImportExportEverywhere: true,
      allowAwaitOutsideFunction: true,
    });

    const translationPromises = [];

    // 文字列リテラルの処理
    root.find(j.StringLiteral).forEach((path) => {
      if (containsJapanese(path.node.value)) {
        translationPromises.push(
          translateToEnglish(path.node.value, dryRun, filePath).then(
            (translatedText) => {
              j(path).replaceWith(j.stringLiteral(translatedText));
            }
          )
        );
      }
    });

    // JSXテキストの処理
    root.find(j.JSXText).forEach((path) => {
      const text = path.node.value.trim();
      if (containsJapanese(text)) {
        translationPromises.push(
          translateToEnglish(text, dryRun, filePath).then((translatedText) => {
            j(path).replaceWith(j.jsxText(` ${translatedText} `));
          })
        );
      }
    });

    // コメントの処理
    root.find(j.Comment).forEach((path) => {
      const commentText = path.value.value.trim();
      if (containsJapanese(commentText)) {
        translationPromises.push(
          translateToEnglish(commentText, dryRun, filePath).then(
            (translatedText) => {
              path.value.value = ` ${translatedText} `;
            }
          )
        );
      }
    });

    // すべての翻訳を並行して実行
    await Promise.all(translationPromises);

    return root.toSource();
  } catch (error) {
    console.error(`Failed to process ${filePath}: ${error.message}`);
    return fileInfo.source; // エラー時は元のソースを返す
  }
}

// メイン処理関数
async function main() {
  const isDryRun = argv["dry-run"];
  const paths = argv._.length > 1 ? argv._.slice(1) : [process.cwd()];

  for (const p of paths) {
    const files = await glob(`${p}/**/*.{js,jsx,ts,tsx}`);
    for (const file of files) {
      await processFile(file, { dryRun: isDryRun });
    }
  }
}

main().catch(console.error);

以下のように実行します。

$ OPENAI_API_KEY="API key" zx translate-japanese-strings.ts

このスクリプトは、以下のステップで変換処理を行います:

  1. 文字列リテラル・JSX テキスト・コードコメントを探索し、正規表現を利用して日本語文字列を検出します。
  2. 検出した日本語文字列を OpenAI API を使用して英語に翻訳します。
  3. 翻訳結果を元のソースコードに反映し、日本語文字列を英語に置き換えます。

今回は Open AI API で利用するモデルとして gpt-4o-mini を使用しました。費用も小さく、十分な翻訳品質が得られました。
また最終的に人間がレビューするタイミングで調整を行えば良く、今回は jscodeshift の実装や LLM に渡すプロンプトの精度はかなり適当となっています。実際、テンプレートリテラルで変数埋め込みがある場合は変換できていません。

コード自体はこちらのリポジトリにあります。

https://github.com/MH4GF/scripts/blob/93966ab3713d6aae50a3a1b3c5e918a2676c18b7/bin/translate-strings.mjs

終わりに

今回は少々特殊なユースケースでしたが、jscodeshift と OpenAI API を組み合わせることでリファクタリングの可能性は大きく広がると感じました。この記事が参考になれば幸いです。

Appendix - 上記スクリプトを出力するためのプロンプト

上記のスクリプトは Claude 3.5 Sonnet を使用し生成してもらったものです。以下のプロンプトを使用しました。

タスク: JavaScript/TypeScript ファイルの日本語文字列を英語に翻訳するコードモッドを作成してください。

要件:

1. 使用技術: zx, OpenAI API, jscodeshift
2. 入力: ディレクトリパスまたは個別のファイルパス
3. 出力: 翻訳された文字列を含む修正されたファイル

主要機能:

1. ディレクトリ内の JS/TS/JSX/TSX ファイルの再帰的検索
2. StringLiteral, JSXText ノード, コードコメント内の日本語テキスト検出
3. OpenAI API を使用した日本語から英語への翻訳
4. 翻訳されたテキストでの元のテキストの置換
5. ドライラン機能(--dry-run または-d オプション)

追加考慮事項:

1. エラーハンドリング(ファイル読み取り/書き込み、API 呼び出し)
2. 非同期処理の適切な管理
3. コマンドライン引数の解析(minimist を使用)
4. TypeScript/JSX の適切な解析(jscodeshift のパーサーオプション)
5. 翻訳失敗時の元のテキストへのフォールバック
6. 処理の進捗状況の表示

出力形式:
完全な実行可能な JavaScript ファイル(.mjs)として、上記の要件を満たすコードを提供してください。コードにはコメントを含め、主要な機能や重要な決定事項を説明してください。