返回博客
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 导出内容,发送给翻译机构或自由译者,等待翻译完成后,再重新格式化并导入。这种方式适用于一次性发布,但随着内容的变化,变得难以维持。每次源语言内容更新都意味着要重新经历整个流程。实际上,翻译内容很快就会落后于源内容,而没人有时间去更新它。

编辑器内翻译模式。 编辑人员打开每个条目,切换到目标语言,然后逐字段手动翻译或将内容粘贴到翻译工具中。这种方式准确但速度慢。它也无法扩展——如果你有数百个条目,分布在十几种内容类型中,手动工作量非常大。

“凑合用”模式。 翻译内容存在,但自首次生成后未经过审核。源语言内容已经多次更新。你的定价页面的法语版本仍然引用了你八个月前废弃的方案。德语主页仍然使用旧的标语。没人指出这些问题,因为没人检查。

这三种模式的根本问题相同:翻译被视为一次性任务,而不是内容工作流程中的持续部分。


多语言实际上需要什么

Contentful 中真正的多语言内容需要三者协同工作。

准确的初始翻译。 每个目标语言的每个字段都需要准确、适当本地化的翻译内容,且读起来就像是为该市场专门撰写的,而不是通过基本翻译工具翻译后未经过审核的内容。

保持翻译内容更新的流程。 当源内容发生变化时,翻译内容也需要相应更改。这是大多数团队低估的部分。一个内容团队如果每周在一个包含四个语言环境和五十个内容类型的 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('条目已翻译并发布。')

一次 API 调用返回法语和德语。条目在同一脚本中更新并发布。

关于语言环境 ID 的说明:PolyLingo 使用不带区域后缀的 BCP-47 代码(fr, de)。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 空间,批量端点每次请求最多处理 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()
}

五十个条目,三种语言,一次批量请求。


保持翻译的最新状态

上述脚本可以按需运行,也可以集成到您的发布工作流程中。一个常见的模式是在源内容更改时触发的 CI 任务中运行翻译,或者按计划运行,以捕获过去 24 小时内更新的任何条目。

Contentful 还支持 webhook —— 您可以配置一个 webhook,当默认语言环境中的条目发布时触发,从而自动触发翻译任务。将 PolyLingo 集成到该 webhook 中意味着每次编辑者发布更新的英文内容时,翻译的语言环境都会自动更新,无需任何手动步骤。这是专门 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 密钥