ブログに戻る
A Contentful-style content entry card showing a filled English locale tab and an empty French locale tab with a warning icon.

なぜあなたのContentfulコンテンツはまだ実際には多言語対応ではないのか

By Robert

なぜあなたのContentfulコンテンツはまだ本当の意味で多言語対応ではないのか

Contentfulはロケールをうまく扱います。必要なだけ定義でき、フォールバックチェーンを設定し、エディターで切り替え、ルートごとに適切な言語を提供するフロントエンドを構築できます。多言語コンテンツのインフラは本当に優れています。

しかし、Contentfulがしないことは、あなたのコンテンツを翻訳することです。

これは直接言われると明白ですが、「ロケールフィールドを設定している」ことと「多言語対応である」ことを混同しやすいのは驚きです。この二つは同じではありません。ロケールフィールドはコンテナです。多言語対応とは、そのコンテナにコンテンツが入っていることを意味します。

もしあなたのContentfulスペースにフランス語とドイツ語のロケールが設定されていても、そのロケールのフィールドが空であったり、英語のコピーがプレースホルダーとして入っていたり、サイト公開以来誰も見直していない粗い初稿で埋められているなら、あなたは多言語対応ではありません。多言語対応の足場があるだけです。

この記事はそのギャップを埋めることについてです。


Contentfulが実際に提供するもの

Contentfulのロケールシステムはよく設計されています。各コンテンツエントリーはロケールごとにフィールド値を持つことができます。デフォルトのロケールを設定し、フォールバックを構成し、配信APIは要求されたときに適切なロケールを返します。コンテンツモデリングは、異なるコンテンツタイプにわたる複雑な多言語要件を処理するのに十分柔軟です。

しかし、このシステムは翻訳されたコンテンツがどのように入るかについては完全に中立です。Contentfulは、フランス語ロケールのフィールドに専門的な翻訳が含まれているのか、機械翻訳されたコピーなのか、誤って貼り付けられた英語のテキストなのか、あるいは全く何もないのかを知りません。単にあなたが入力したものを保存し配信するだけです。

翻訳の問題は完全にあなたが解決するものです。


チームが通常どのように対応しているか(そしてどこで破綻するか)

Contentfulの翻訳を扱うほとんどのチームは、いくつかのパターンのいずれかに当てはまります。

手動エクスポートパターン。 開発者がContentfulからコンテンツをエクスポートし、翻訳会社やフリーランサーに送信し、戻ってくるのを待ち、再フォーマットしてインポートします。これは一度きりのローンチには機能しますが、コンテンツが変わると維持できなくなります。ソースロケールの更新ごとにこのサイクルを繰り返す必要があります。実際には、翻訳されたコンテンツはすぐにソースに遅れを取り、誰も追いつく時間がありません。

エディター内翻訳パターン。 編集者が各エントリーを開き、ターゲットロケールに切り替え、フィールドごとに手動で、または翻訳ツールに内容を貼り付けて翻訳します。これは正確ですが遅いです。また、スケールしません — 何百ものエントリーが複数のコンテンツタイプにまたがる場合、手作業の量が膨大です。

「まあまあ」パターン。 翻訳されたコンテンツは存在しますが、最初に作成されて以来レビューされていません。ソースロケールはその後何度も更新されています。あなたの価格ページのフランス語版は、8ヶ月前に廃止したプランをまだ参照しています。ドイツ語のホームページは古いキャッチフレーズのままです。誰も指摘していません、なぜなら誰もチェックしていないからです。

これら3つのパターンはすべて同じ根本的な問題を共有しています:翻訳が一度きりのタスクとして扱われ、コンテンツワークフローの継続的な一部として扱われていないことです。


多言語対応に実際に必要なこと

Contentfulで本格的な多言語コンテンツを実現するには、3つの要素が連携して機能する必要があります。

正確な初期翻訳。 すべてのターゲットロケールのすべてのフィールドに対して、正確で適切にローカライズされ、その市場向けに書かれたかのように自然に読める翻訳コンテンツが必要です。単なる基本的な翻訳ツールを通しただけでレビューされていないものではありません。

翻訳を最新の状態に保つためのプロセス。 ソースコンテンツが変更された場合、翻訳コンテンツも変更される必要があります。ここを多くのチームが過小評価しています。週に数回更新を公開し、4つのロケールと50のコンテンツタイプを持つContentfulスペースを運用するコンテンツチームは、手動で対応するとかなりの翻訳作業量になります。

翻訳が古くなっているかどうかを知る方法。 Contentfulのロケールシステムは古さを表示しません。英語のコピーを更新してフランス語のコピーを更新し忘れると、フランス語版は古いコンテンツを静かに提供し続けます。これを検知するためのプロセスかツールが必要です。


PolyLingoの位置づけ

PolyLingoのAPIは構造化されたコンテンツをその構造を保持したまま翻訳します。Contentfulにおいて関連するフォーマットはJSONです — Contentfulのエントリのフィールド値を抽出してAPIに送信すると、同じ構造を保ったまま翻訳されたものが返ってきます。

Node.jsでの基本的な流れ:

import PolyLingo from 'polylingo'
import { createClient } from 'contentful-management'

const poly = new PolyLingo({ apiKey: process.env.POLYLINGO_API_KEY })
const contentful = createClient({
  accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
})

const space = await contentful.getSpace(process.env.CONTENTFUL_SPACE_ID)
const environment = await space.getEnvironment('master')

// 翻訳したいエントリを取得
const entry = await environment.getEntry('your-entry-id')

// PolyLingoは地域なしのBCP-47コードを使用します(fr、de)。
// これらをContentfulのロケールIDにマッピングしてください(例:fr-FR、de-DE)。
const localeMap = { fr: 'fr', de: 'de' } // スペースのロケールIDに合わせて調整

// 英語の文字列フィールドのみ抽出 — 参照、リンク、ブール値、数字はスキップ
const sourceFields = Object.fromEntries(
  Object.entries(entry.fields)
    .filter(([, value]) => typeof value['en-US'] === 'string')
    .map(([key, value]) => [key, value['en-US']])
)

// フランス語とドイツ語に一度のリクエストで翻訳
const result = await poly.translate({
  content: JSON.stringify(sourceFields),
  format: 'json',
  targets: Object.keys(localeMap),
})

// 翻訳された値をContentfulのロケールIDを使ってエントリに書き戻す
for (const [polyLocale, translated] of Object.entries(result.translations)) {
  const contentfulLocale = localeMap[polyLocale]
  const parsed = JSON.parse(translated)
  for (const [field, value] of Object.entries(parsed)) {
    if (!entry.fields[field]) entry.fields[field] = {}
    entry.fields[field][contentfulLocale] = value
  }
}

await entry.update()
await entry.publish()

console.log('エントリが翻訳され、公開されました。')

1回のAPI呼び出しでフランス語とドイツ語の両方が返されます。エントリは同じスクリプト内で更新および公開されます。

ロケールIDについての注意:PolyLingoは地域サフィックスなしのBCP-47コード(frde)を使用します。Contentfulのスペースはしばしば地域付きID(fr-FRde-DE)を使用します。上記のlocaleMapオブジェクトでそれらを合わせてください — Contentfulスペースで使用しているロケールIDに合わせて更新してください。

同じパターンはPythonでも動作します:

import os, json, requests
from contentful_management import Client

poly_key = os.environ['POLYLINGO_API_KEY']
contentful = Client(os.environ['CONTENTFUL_MANAGEMENT_TOKEN'])

space = contentful.spaces().find(os.environ['CONTENTFUL_SPACE_ID'])
environment = space.environments().find('master')
entry = environment.entries().find('your-entry-id')

# 英語のフィールド値を抽出
source_fields = {
    key: value.get('en-US')
    for key, value in entry.fields().items()
    if value.get('en-US')
}

# フランス語とドイツ語に翻訳
r = requests.post(
    'https://api.usepolylingo.com/v1/translate',
    headers={'Authorization': f"Bearer {poly_key}"},
    json={
        'content': json.dumps(source_fields),
        'format': 'json',
        'targets': ['fr', 'de'],
    },
    timeout=120,
)
r.raise_for_status()
translations = r.json()['translations']

# エントリに書き戻す
for locale, translated in translations.items():
    parsed = json.loads(translated)
    for field, value in parsed.items():
        if field in entry.fields():
            entry.fields()[field][locale] = value

entry.save()
entry.publish()
print('エントリが翻訳され、公開されました。')

大規模な翻訳

上記の例は単一のエントリーを扱っています。複数のコンテンツタイプにまたがる多数のエントリーがあるContentfulスペースの場合、バッチエンドポイントは1回のリクエストで最大100件のアイテムを処理します:

// 複数のエントリーを取得し、一度のバッチ呼び出しで翻訳する
const entries = await environment.getEntries({
  content_type: 'blogPost',
  limit: 50,
})
 
// PolyLingoのロケールコードをContentfulのロケールIDにマッピング
const localeMap = { fr: 'fr', de: 'de', es: 'es' } // スペースに合わせて調整してください
 
const items = entries.items.map(entry => ({
  id: entry.sys.id,
  content: JSON.stringify(
    Object.fromEntries(
      Object.entries(entry.fields)
        .filter(([, value]) => typeof value['en-US'] === 'string')
        .map(([k, v]) => [k, v['en-US']])
    )
  ),
  format: 'json',
}))
 
const batch = await poly.batch({
  items,
  targets: Object.keys(localeMap),
})
 
for (const result of batch.results) {
  const entry = entries.items.find(e => e.sys.id === result.id)
  for (const [polyLocale, translated] of Object.entries(result.translations)) {
    const contentfulLocale = localeMap[polyLocale]
    const parsed = JSON.parse(translated)
    for (const [field, value] of Object.entries(parsed)) {
      if (!entry.fields[field]) entry.fields[field] = {}
      entry.fields[field][contentfulLocale] = value
    }
  }
  await entry.update()
  await entry.publish()
}

50件のエントリー、3言語、1回のバッチリクエスト。


翻訳を最新の状態に保つ

上記のスクリプトは、必要に応じて実行することも、公開ワークフローに組み込むこともできます。一般的なパターンは、ソースコンテンツが変更されたときにトリガーされるCIジョブの一部として翻訳を実行するか、過去24時間に更新されたエントリをキャッチするスケジュールで実行することです。

ContentfulはWebhookもサポートしています — デフォルトロケールでエントリが公開されたときに発火するWebhookを設定でき、それが自動的に翻訳ジョブをトリガーすることができます。そのWebhookにPolyLingoを接続すると、編集者が更新された英語コンテンツを公開するたびに、翻訳されたロケールが手動のステップなしで更新されます。これは専用のPolyLingo統合のロードマップにありますが、それまではAPIがそのフローを自分で構築するために必要なすべてを提供します。


フィールドタイプについての注意

上記の方法は、短いテキスト、長いテキスト、および文字列として保存されたリッチテキストに適しています。Contentfulのいくつかのフィールドタイプは別途処理が必要です:

リッチテキストフィールド はContentfulのドキュメント形式(プレーンな文字列ではない)で保存されているため、PolyLingoに送信する前にプレーンテキストまたはHTMLにシリアライズし、その後デシリアライズする必要があります。@contentful/rich-text-html-rendererパッケージがシリアライズのステップを処理し、PolyLingo側のformat: "html"がマークアップを正しく保持します。

参照フィールド(他のエントリーやアセットへのリンク)は翻訳すべきではありません — それらはテキストではなくIDです。APIに送信するフィールドから除外してください。

数値、ブール値、日付フィールドは本質的に翻訳できません。ソースオブジェクトを構築する前にこれらを除外してください。


はじめに

PolyLingoの無料プランには月間50,000トークンが含まれています。適度な量のコンテンツがあるContentfulスペースの場合、複数のロケールにまたがるいくつかのエントリの初回翻訳をカバーし、継続的な更新のための余裕もあります。

完全なAPIドキュメントはusepolylingo.com/docsにあります。SDKパッケージはNode.js、Python、Ruby、PHP、Java、Go向けに利用可能です。

npm install polylingo

APIキーを取得する