🔄

YAML, JSONの翻訳ファイルとNotionの翻訳DBを同期させる翻訳システムを自作した話

に公開

1. はじめに

株式会社Sally CTO の @aitaro です。普段は uzu-app.commdms.jp といったサービスを開発しています。
本記事では、Goの自作システム、i18nextslang で使用する yamljson の翻訳ファイルをNotionと双方向に同期させるシステムをどのように構築したかを紹介します。

以前書いたウズ多言語化シリーズの記事はこちら→
https://zenn.dev/articles/879b388e184794

2. 翻訳管理の課題

これまで翻訳ファイルの管理は yamljson を手動で更新していました。しかし、以下のような問題が発生していました。

  • 翻訳業務の負担がエンジニアに集中していた
    • エンジニアが翻訳ファイルを直接編集することで、本来の開発業務に集中できない
    • 翻訳を行うたびにPull Requestを作成するのが面倒
  • 管理の煩雑さとミスのリスク
    • 手動更新によるフォーマット崩れや誤訳の発生
    • 変更履歴の管理が困難で、どの翻訳が最新なのかが分かりづらい

3. Notionを翻訳DBに採用した理由

翻訳業務の管理方法として、Notionを採用しました。その理由は以下の3つです。

  1. 非エンジニアが翻訳作業に関与しやすい
    • 複数人がリアルタイムで翻訳作業を進められる
    • フォーマット崩れを気にせず、シンプルに編集可能
  2. 社内の既存ナレッジがNotionに集約されていた
    • すでに社内の情報共有ツールとしてNotionが定着しており、使い慣れている
  3. GoogleスプレッドシートよりもAPIが扱いやすい
    • APIのレスポンスが直感的であり、データの取得・更新が容易

4. 既存の翻訳管理ソリューションではなく自作を選んだ理由

翻訳管理のSaaSとして Phrase, Lokalise, Crowdin などがありますが、以下の理由から自作を選択しました。

1. SaaSのコストが高い

多くの翻訳管理SaaSは高機能で便利ですが、ツールが提供している価値に対して、スタートアップのフェーズのチームにとってはコスト負担が大きすぎる問題がありました。

  • Phrase:月額$100以上のプランが必要で、コストパフォーマンスが悪い
  • Lokalise:無料枠は制限が厳しく、実用レベルで運用するには有料プランが前提
  • Crowdin:無料版は機能制限が多く、柔軟な運用が難しい

このように、SaaS製品は企業向けの価格設定が多く、コスト面から自作が現実的でした。

2. 言語サポートの貧弱さ

Flutterの arb ファイルや slang など、一部のSaaSやOSSツールでは対応していないことが多く、これが大きな制約となりました。
特にarbファイルはFlutter自体が新しい技術であることもあり、サポートされていないことが多かったです。

3. CLIの柔軟性が不足している

一部のSaaSやOSSにはCLIツールが提供されていますが、

  • tx (Transifex CLI):設定が複雑で、ユースケースに合わせたカスタマイズがしにくい
  • Weblate CLI:機能が限定的で、業務フローに完全に適合しない

結果として、CLIの柔軟性が不足しており、業務フローに沿ったカスタマイズができませんでした。

4. Notionを使うことでカスタマイズ性が高まる

最終的に、Notionを採用し、自作することで以下のメリットを得られると判断しました。

  • Notionの直感的なUIを活用し、非エンジニアも参加しやすい環境を構築
    • 複雑なツールを学ばなくても、既存の慣れ親しんだツールで簡単に翻訳の追加・編集が可能
  • Notionの柔軟なAPIを使い、プロダクトとの連携部分を自由にカスタマイズできる
    • 例えば、翻訳の自動レビュー機能や、開発環境への即時反映
  • 今後の拡張が容易
    • OpenAI APIを活用した自動翻訳の組み込みなど

このように、自作することで自社の要件に完全に適合した翻訳管理フローを構築できると判断しました。

5. 解決策: Notionと翻訳ファイルを自動同期するシステム

システムの概要

本システムは以下の3つの処理を実行します。

  1. YAML/JSONの翻訳ファイルを抽象化し、JS Object として扱う
  2. Notionのデータベースと差分を取得し、双方向で同期する
    • Notionに翻訳チェック済みのプロパティをもち、未翻訳の場合は、翻訳ファイルの内容を優先して同期、翻訳チェック済みの場合は、Notionの内容を優先して同期する。
  3. GitHub Actionsを用いたCI/CDにより、自動更新を実施

技術スタック

  • 言語・フレームワーク
    • TypeScript(Node.js)
  • データ管理
    • Notion API
    • eemeli/yaml (YAMLパーサー)
    • glob(ファイルパターン検索)
  • CI/CD
    • GitHub Actions

6. 実装詳細

翻訳ファイルの場所を記載するための設定ファイルを定義

弊社では、JavaScript/Flutter/Golangサーバ全てのシステムを一括で管理するモノレポでレポジトリを運用しており、システムに対して、どこに翻訳ファイルが存在するかを定義する必要があります。したがって、設定用のjsonを用意し、各プロダクトの開発者は翻訳ファイルを追加したときにここに設定を追記する運用にしました。
こうすることで翻訳システムとプロダクトコードを疎結合にすることを可能にしています。

また、特にWEBフロントエンドの翻訳ファイルについては、ページごとに必要な翻訳ファイルが異なり、ページフェッチ時にロードが必要な翻訳ファイルのjsonを最小限にするために、翻訳ファイルを細かく分割することが重要です。
一方でその全てを設定ファイルに記載するのは非現実的なので、globによる一括設定ファイル登録をサポートしています。

[
  {
    "name": "Example Flutter Service",
    "baseLocale": "ja",
    "paths": [
      {
        "path": "dart/apps/serviceA/assets/i18n/common/common_ja.i18n.yaml",
        "locale": "ja",
        "fileType": "yaml"
      },
      {
        "path": "dart/apps/serviceA/assets/i18n/common/common_en.i18n.yaml",
        "locale": "en",
        "fileType": "yaml"
      },
      {
        "path": "dart/apps/serviceA/assets/i18n/common/common_zh-TW.i18n.yaml",
        "locale": "zh_TW",
        "fileType": "yaml"
      },
      // 他の言語を追加
    ]
  },
  {
    "name": "Example JS Service",
    "baseLocale": "ja",
    "paths": [
      {
        "path": "javascript/packages/serviceB/src/i18n/ja/*.json",
        "locale": "ja",
        "fileType": "json"
      },
      // 他の言語を追加
    ]
  },
  {
    "name": "Example Backend Service",
    "baseLocale": "ja",
    "paths": [
      {
        "path": "go/serviceC/i18n/dict_ja.yaml",
        "locale": "ja",
        "fileType": "yaml"
      },
      // 他の言語を追加
    ]
  },
]

arbファイル, yamlファイル, jsonファイル等、各翻訳ツールで利用しているファイル形式を抽象化して JS Object として扱う

i18next, slang, flutter-i18n 等、技術スタックに応じて、それぞれの翻訳ライブラリの要求するフォーマットで翻訳ファイルを記述する必要があります。一方で、この翻訳システムではその差分を吸収して一つのNotionシステムで扱いたいため、差分吸収を行うロジックを実装する必要があります。
ファイルの種類に応じて、fileTypeを用意し、readFileDict及び、updateDictFileを通じて、各ファイルの操作を行います。

const getFilePath = (file: string) => resolve(__dirname, "../../../../", file);

type FileMeta = {
  path: string;
  fileType: "yaml" | "arb" | "json";
};

export const readDictFile = (fileMeta: FileMeta): any => {
  const content = readFileSync(getFilePath(fileMeta.path), "utf8");
  if (fileMeta.fileType === "yaml") {
    return parse(content);
  } else if (fileMeta.fileType === "arb") {
    return filterArbKeys(JSON.parse(content));
  } else if (fileMeta.fileType === "json") {
    return JSON.parse(content);
  } else {
    throw new Error("Unsupported file type");
  }
};

export const updateDictFile = (
  path: FileMeta,
  keyPath: string[],
  value: string | null
) => {
  if (path.fileType === "yaml") {
    updateDictFileYaml(getFilePath(path.path), keyPath, value);
  } else if (path.fileType === "arb") {
    updateDictFileJson(getFilePath(path.path), keyPath, value);
  } else if (path.fileType === "json") {
    updateDictFileJson(getFilePath(path.path), keyPath, value);
  }
};

特にupdateに関してはフォーマット崩れが発生したり、keyの順番が入れ替わったりしないように工夫し実装する必要があります。

最終的に、実装側の辞書データを以下の形で表現できるようにします。

export type DictionaryData = {
  name: string;
  baseLocale: Locale;
  paths: {
    path: string;
    prefix?: string;
    data: Record<string, string>;
    locale: Locale;
    fileType: "yaml" | "arb" | "json";
  }[];
};

Notionからのデータを事前フェッチしてキャッシュを構築

翻訳ファイルから取得できた各翻訳キーに関して、都度Notionに存在するかAPIリクエストで確認し、更新するという形にすると、翻訳キーの増加に伴いNotionへのAPIアクセスが増加することになります。
これを避けるため、事前にNotionから全翻訳データを事前フェッチし、そのキャッシュに対して、各単語を更新すべきかどうかを判断し、必要な場合のみ更新のAPIをリクエストするようにします。

let notionCache: Record<string, NotionEntry> = {};

export const buildCache = async () => {
  let hasMore = true;
  let startCursor: string | undefined;

  notionCache = {};

  while (hasMore) {
    const response = await notion.databases.query({
      database_id: NOTION_TRANSLATE_DATABASE_ID,
      start_cursor: startCursor,
    });

    for (const result of response.results) {
      notionCache[result.id] = resultToEntry(result);
    }

    hasMore = response.has_more;
    startCursor = response.next_cursor ?? undefined;
  }
};

翻訳ファイルを抽象化したJS ObjectとNotionのCacheを比べて同期処理を行う。

JS Objectの翻訳キーを正のデータとして、各翻訳キーに対して、以下の操作を行います。

  1. 翻訳キーがNotionに存在しない場合
    • Notionに新しい翻訳キーの列を追加する。
  2. 翻訳キーがNotionに存在し、文字列が一致している場合
    • 何もしない。
  3. 翻訳キーがNotionに存在し、文字列が一致せず、翻訳チェック済みになっていない場合。
    • 翻訳ファイル側の文字列を優先し、Notionの該当プロパティを更新する。
  4. 翻訳キーがNotionに存在し、文字列が一致せず、翻訳チェック済みになっている場合。
    • Notion側の文字列を優先し、翻訳ファイルの該当プロパティを更新する。

また、Notionのキーのリストを最後に見て、翻訳キーに存在しないキーが最後にあれば、NotionにisDeletedという論理削除のカラムをONにします。

翻訳ファイル側に変更があった場合、PRを自動で作成する。

同期処理の結果、翻訳ファイル側に変更があった場合、GitHub Actions 上で PRを自動で作成する。ファイル変更時、及びcronで一日一回定期実行しています。

jobs:
  sync-translations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - id: sync
        uses: ./.github/actions/translations/sync-notion
      - id: cpr
        name: Create Pull Request
        uses: peter-evans/create-pull-request@v6
        with:
          // ...
      - name: Enable Pull Request Automerge
        if: steps.cpr.outputs.pull-request-operation == 'created'
        uses: peter-evans/enable-pull-request-automerge@v3
        with:
          // ...

7. 運用と効果

非エンジニアが翻訳作業を担当できるようになった

  • Notionで翻訳作業を分離し、非エンジニアが翻訳管理を担当できるようになりました。
  • エンジニアはレビューと最終確認に専念し、本来の業務に集中できます。

ネイティブチェックを効率化

  • 翻訳者がNotion上で作業し、エンジニアは git diff を確認しながらレビューが可能になりました。

CI/CDによる自動化でエンジニアの負担軽減

  • Notionの変更が自動で yaml/json に反映され、PRが自動生成されます。
  • エンジニアはPRをレビューし、マージするだけで運用可能に。

9. 今後の改善

データ量増大への対応

現在key数は数千件に達しており、最初のNotionの翻訳データを取得する段階でNotion API の RateLimitに引っかかることが多くなってきている。また CI/CD 時間の増加、不安定性の増加にもつながり、よりスマートな同期ロジックの導入が求められている。

OpenAI API を活用した自動翻訳の導入

エンジニアが各新機能を開発した時に、即座に翻訳されるように自動翻訳のシステムを導入する。

  • 日本語以外の翻訳を自動生成し、翻訳チェック前の単語でも他の言語でレビュー作業を可能にする。
  • 手動翻訳を校正のみにし、翻訳の負荷を下げる
  • 機械翻訳と手動翻訳のプロセスを分離することによって、より効率的な開発フローを実現する

本システムにより、翻訳業務の分業が明確になり、エンジニアの負担を大幅に削減できました。今後はさらに自動翻訳の導入を進め、翻訳プロセス全体を最適化していく予定です。

UZU テックブログ

Discussion