Перевод i18n файлов нейросетью без сломанных плейсхолдеров

Скрипт, который переводит i18n-файлы через API, сохраняет {name}, %s и ICU-формы и откладывает сомнительные строки на ручную проверку.

6 мин · · разработка · автоматизация

Перевод i18n файлов нейросетью экономит часы, пока модель не «переведёт» {name} в {имя} или не сломает ICU-форму множественного числа — и экран приложения упадёт с ошибкой форматирования. Ниже рабочий скрипт: батчи, глоссарий, автоматическая проверка плейсхолдеров и расчёт стоимости по ценам Zukko.

Чем API лучше копипаста в переводчик

Обычный машинный переводчик видит строку без контекста и не знает правил вашего проекта. Через API вы управляете процессом сами:

  • в каждый запрос уходит глоссарий и правила (не трогать плейсхолдеры, держать стиль интерфейса);
  • ответ приходит в JSON с теми же ключами, его можно разобрать программой;
  • каждую строку можно проверить кодом и отклонить, если структура изменилась;
  • при обновлении исходника переводятся только изменённые ключи.

Человеческая вычитка всё равно нужна — особенно для маркетинговых текстов и юридических формулировок. Скрипт снимает рутину, а не ответственность.

Схема: разложить, перевести, проверить, собрать

  1. Прочитать исходный JSON и разложить вложенную структуру в плоский словарь ключ → строка.
  2. Отправлять строки батчами по 20 штук в виде JSON-объекта.
  3. Сравнить плейсхолдеры в оригинале и переводе; при расхождении оставить оригинал и вывести ключ в список на ручную проверку.
  4. Собрать вложенную структуру обратно и сохранить.

Скрипт на Python

Нужны Python 3.10+ и pip install openai. Ключ fetch_... лежит в переменной OPENAI_API_KEY.

bash
import json
import os
import re
from openai import OpenAI

client = OpenAI(
    base_url="https://api.zukko.pro/v1",
    api_key=os.environ["OPENAI_API_KEY"],
)
MODEL = "glm-5.3-flash"

PLACEHOLDER_PATTERNS = [
    r"\{\{\s*[A-Za-z_]\w*\s*\}\}",  # {{name}}
    r"\{\s*[A-Za-z_]\w*\s*[,}]",    # {name} и начало ICU: {count,

Что здесь важно:

  • Ответ как JSON-объект с ключами, а не нумерованный список. Если модель пропустит строку, это видно по ключу, а не по сдвигу всего батча.
  • Проверка нарочно строгая. Если в ветке ICU стоит одно латинское слово (one {item}), проверка сработает ложно и вернёт оригинал. Такие строки попадут в список «Проверить вручную» — это дешевле, чем пропустить сломанную.
  • Ответ в обёртке ```json разбирается функцией parse_json.
  • YAML. Для файлов .yml замените json.load/json.dump на yaml.safe_load и yaml.safe_dump(data, allow_unicode=True) из PyYAML — остальной код не меняется.

Подробнее о клиенте — в руководстве по Python SDK и справке по Chat Completions.

ICU plural: самое хрупкое место

В английском у множественного числа две категории (one, other), в русском — четыре: one, few, many, other. Если модель механически перенесёт английскую структуру, «21 элементов» и «2 элементов» гарантированы.

Исходник:

bash
{
  "items": "You have {count, plural, =0 {no items} one {# item} other {# items}}"
}

Правильный русский перевод:

bash
{
  "items": "У вас {count, plural, =0 {нет элементов} one {# элемент} few {# элемента} many {# элементов} other {# элемента}}"
}

one в русском покрывает не только 1, но и 21, 31, 101; other нужен для дробных чисел («1,5 элемента»). Поэтому в правилах промпта явно перечислены категории целевого языка. После перевода стоит прогнать строки через вашу ICU-библиотеку (например, отрендерить каждую для 0, 1, 2, 5, 21) — это ловит ошибки, которые регулярное выражение не видит.

Сколько стоит перевод 10 тысяч строк

Допущения: средняя строка — 15 токенов, батч — 20 строк, значит 10 000 / 20 = 500 запросов.

  • Вход на запрос: 300 токенов строк + около 100 на ключи JSON + около 200 на правила и глоссарий ≈ 600 токенов. Всего 500 × 600 = 300 тыс.
  • Выход на запрос: строки и ключи ≈ 500 токенов (русский текст обычно занимает больше токенов, чем английский). Всего 500 × 500 = 250 тыс.
МодельЦена вход/выход, $ за 1MРасчётИтого на один язык
glm-5.3-flash0.04 / 0.04(300 000 × 0.04 + 250 000 × 0.04) / 1 000 000$0.022
claude-sonnet-50.3 / 0.3(300 000 × 0.3 + 250 000 × 0.3) / 1 000 000$0.165
gpt-5.6-sol0.3 / 0.3то же$0.165

Десять языков — умножьте на 10. Модель выбирайте не по цене, а по результату: переведите одни и те же 50–100 строк на glm-5.3-flash и на claude-sonnet-5, сравните, сколько строк отбраковала проверка и сколько правок внёс носитель языка. Актуальный список моделей — GET https://api.zukko.pro/v1/models или страница «Модели» в кабинете.

Про кэш: у вендоров чтение повторяющегося префикса из кэша стоит около 10% входной цены, а в Zukko кэшированные токены оплачиваются по обычной входной цене. Здесь повторяется только блок правил и глоссария — примерно 200 × 500 = 100 тыс. токенов, на glm-5.3-flash это $0.004, так что на итог это почти не влияет.

Чтобы запустить скрипт, создайте ключ в кабинете и положите его в OPENAI_API_KEY; общий порядок подключения описан в быстром старте.

Частые вопросы

Как добиться одинакового перевода терминов?

Передавайте глоссарий в каждом запросе — словарь из 20–30 пар добавит около 200 токенов на батч. Для 500 батчей это 100 тыс. токенов, на glm-5.3-flash — $0.004. Термины, которые нельзя переводить вовсе (название продукта), тоже внесите в глоссарий: "Zukko" -> "Zukko".

Модель всё равно ломает плейсхолдеры. Что делать?

Скрипт уже не пропустит такие строки в файл: они останутся на исходном языке и попадут в список «Проверить вручную». Если таких много — уменьшите батч до 5–10 строк, добавьте в правила пример «Hello {name} → Привет, {name}» и сравните результат на другой модели.

Можно ли переводить строки с Markdown и HTML?

Можно. Добавьте в правила «Keep Markdown and HTML tags unchanged, translate only visible text» и дополните PLACEHOLDER_PATTERNS шаблоном для тегов, например r"</?[a-z]+[^>]*>", чтобы проверка сравнивала и их.

Как переводить только изменённые строки?

Сравните старую и новую версии исходника в плоском виде и переведите только разницу, затем вмерджите её в существующий перевод:

bash
def update_translation(old_src: dict, new_src: dict, old_tr: dict, language: str) -> dict:
    old_flat, new_flat = flatten(old_src), flatten(new_src)
    changed = {k: v for k, v in new_flat.items() if old_flat.get(k) != v}
    merged = {k: v for k, v in flatten(old_tr).items() if k in new_flat}
    merged.update(flatten(translate_dict(unflatten(changed), language)))
    return unflatten(merged)

Удалённые из исходника ключи при этом уходят и из перевода.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.