загружаю

This commit is contained in:
2026-09-07 19:07:40 +03:00
commit 1485819556
16 changed files with 1526 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
# translate_util
Набор скриптов для перевода визуальной новеллы на движке CatSystem2.
Полный путь: игра → распаковка архивов → декомпиляция скриптов → перевод → сборка обратно → компиляция для игры.
## Требования
- Python 3.9+
- Windows (нужны `.exe`-утилиты в папке `tool/`)
- Ключ доступа к любому OpenAI-совместимому API (OpenAI, OpenRouter, локальный сервер и т.д.)
### Python-зависимости
Всё, кроме одного пакета, — стандартная библиотека Python (`json`, `re`, `csv`, `pathlib`, `subprocess`, `urllib`, `logging` и т.д.), ничего дополнительно ставить не нужно.
Единственная внешняя зависимость — официальная реализация формата NSV:
```
pip install nsv
```
Это готовый пакет с [PyPI](https://pypi.org/project/nsv/) (исходники: [nsv-format/nsv-python](https://github.com/nsv-format/nsv-python)), под Windows ставится как готовый wheel — компилятор не нужен. Без него `convert.py`, `names.py` и `translate.py` не запустятся (упадут с `ModuleNotFoundError: No module named 'nsv'`).
Наш файл `nsv_io.py` — тонкая обёртка над этим пакетом: сам он не парсит и не экранирует NSV, а только переводит между "строка формата NSV" и "запись перевода с полями line/speaker/pcm/text/translated_text", специфичными для этого проекта. `nsv_io.py` должен лежать рядом с `convert.py`, `names.py`, `translate.py` — они делают `from nsv_io import ...`.
Проверить, что Python установлен и виден из консоли:
```
python --version
```
Если команда не найдена — установите Python с [python.org](https://www.python.org/downloads/) (при установке на Windows отметьте галочку "Add python.exe to PATH").
### Сторонние утилиты (не Python-пакеты)
Эти `.exe` — не часть репозитория, их нужно положить в папку `tool/` самостоятельно (взять из готового тулкита для CatSystem2 или найти по названию):
| Файл | Для чего | Используется в |
|---|---|---|
| `exkifint_v3.exe` | распаковка `.int`-архивов игры | `extract.py` |
| `cs2_decompile.exe` | декомпиляция `.cst``.txt` | `decompile.py` |
| `mc.exe` | компиляция `.txt``.cst` обратно | `compile.py` |
Без них соответствующие шаги пайплайна работать не будут — остальные шаги (Txt to NSV, Names, Перевод, NSV to Txt) от них не зависят и работают на чистом Python.
## Структура папок
```
translate_util/
├── start.py запускать отсюда
├── extract.py
├── decompile.py
├── convert.py
├── names.py
├── translate.py
├── compile.py
├── nsv_io.py обёртка над пакетом nsv под схему записей проекта
├── config.json настройки API и перевода
├── glossary.md глоссарий (имена, термины, заметки по стилю)
├── prompt.txt системный промпт для LLM
├── namestable.csv создаётся скриптом names.py
├── tool/ exkifint_v3.exe, cs2_decompile.exe, mc.exe
├── txt/ декомпилированные .txt (шаг 2)
├── json/ .nsv файлы для перевода (шаг 3-5)
├── txt_translated/ собранные переведённые .txt (шаг 6)
├── update00/, update01/ оригинальные и переведённые архивы игры
└── _translate_data/ кэш переводов и логи (создаётся автоматически)
```
## Быстрый старт
```
python start.py
```
Покажет меню на 7 пунктов — можно проходить шаги по порядку, каждый раз возвращаясь в меню.
```
1. Extract — достать файлы из архива игры (.int → .cst/.cstl)
2. Decompile — .cst → .txt
3. Txt to NSV — .txt → .nsv (подготовка к переводу)
4. Names — собрать/применить перевод имён персонажей
5. Перевод — прогнать текст через LLM
6. NSV to Txt — .nsv → .txt (сборка перевода обратно)
7. Compile — .txt → .cst (готово для игры)
```
Каждый скрипт можно запускать и напрямую (`python decompile.py`, `python translate.py` и т.д.) — меню лишь избавляет от необходимости помнить порядок и имена файлов.
## Шаг за шагом
### 1. Extract — достать файлы из игры
```
python extract.py
```
Спросит путь к папке с игрой и `.exe` игры (для определения версии движка). Извлекает `.cst` (тексты) и `.cstl` (уже локализованные, если есть) файлы из всех архивов (`scene.int`, `fes.int`, `config.int`, `update00.int`...`update19.int`), учитывая, что апдейты перезаписывают базовые файлы. Результат кладётся в папку, которую вы укажете (по умолчанию `extracted_texts/text` и `extracted_texts/localized_text`).
### 2. Decompile — .cst → .txt
```
python decompile.py
```
Спросит папку с `.cst` (по умолчанию `update00`). Декомпилирует все файлы через `tool/cs2_decompile.exe` в папку `txt` (кодировка Shift-JIS/CP932).
### 3. Txt to NSV — подготовка к переводу
```
python convert.py --txt2nsv
```
Спросит папку с `.txt` (по умолчанию `txt`) и папку для результата (по умолчанию `json`). Разбирает каждый `.txt`, находит строки с текстом (отличает их от игровых команд), и сохраняет в компактном текстовом формате `.nsv` — по одной записи на реплику, с полями: номер строки, спикер, id войсовера (pcm), оригинальный текст, перевод (изначально пустой).
`.nsv` — обычный текстовый файл, его можно открыть и посмотреть в любом редакторе. Сам формат — [спецификация NSV](https://github.com/nsv-format/nsv), парсится пакетом `nsv`; маппинг на конкретные поля (line/speaker/pcm/text/translated_text) описан в `nsv_io.py`.
### 4. Names — перевод имён персонажей
```
python names.py
```
Меню на 2 пункта:
- **1 — собрать имена**: проходит по всем `.nsv` в указанной папке, собирает все уникальные значения поля "спикер" и складывает их в `namestable.csv` (в корне `translate_util`) с пустой колонкой `Translated`.
- **2 — применить перевод**: читает `namestable.csv`, и для каждой строки, где колонка `Translated` заполнена, заменяет спикера во всех `.nsv` файлах.
Откройте `namestable.csv` (Excel, Google Таблицы, любой редактор с поддержкой UTF-8) и заполните колонку `Translated` для нужных имён, затем запустите пункт 2.
Пример содержимого:
```csv
Original,Translated
Michiru,Мичиру
Sachi,Сачи
Fan|A,Фанат|А
```
Строки с пустым `Translated` игнорируются — можно переводить имена постепенно.
### 5. Перевод текста через LLM
Перед первым запуском настройте `config.json`:
```json
{
"api": {
"base_url": "https://api.openai.com/v1",
"api_key": "sk-ваш-ключ",
"model": "gpt-4o",
"temperature": 0.3,
"max_tokens": 4092,
"timeout_seconds": 120
},
"translation": {
"source_language": "English",
"target_language": "Russian",
"batch_size": 10,
"context_before": 2,
"context_after": 2,
"max_retries": 3,
"retry_delay_seconds": 5
},
"paths": {
"json_dir": "json",
"glossary_file": "glossary.md"
}
}
```
| Параметр | Что значит |
|---|---|
| `base_url` | адрес API — для OpenAI, OpenRouter или локального сервера (LM Studio, Ollama и т.д.) |
| `api_key` | ваш ключ |
| `model` | название модели у выбранного провайдера |
| `temperature` | ниже — переводит более предсказуемо и буквально, выше — вольнее |
| `max_tokens` | лимит длины ответа на один запрос |
| `reasoning_effort` | необязательное поле, если модель поддерживает режимы рассуждения (например `"low"`/`"medium"`/`"high"`) |
| `batch_size` | сколько реплик отправляется в LLM за один запрос |
| `context_before` / `context_after` | сколько соседних реплик показывать модели для контекста (до — уже переведённые, после — оригинал) |
| `max_retries` / `retry_delay_seconds` | сколько раз и с какой паузой повторять запрос при сбое |
Отредактируйте `glossary.md` — впишите имена персонажей, термины и заметки по стилю перевода. Формат:
```markdown
## Characters
- Michiru: Мичиру
## Terms
- Idol: Айдол
## Notes
- Michiru обращается к другим неформально.
```
Запуск:
```
python translate.py
```
Спросит, какие `.nsv` файлы переводить — имена файлов через запятую (без расширения) или `all` для всех. Дальше работает автоматически:
- бьёт текст на пачки и отправляет в LLM вместе с глоссарием и контекстом соседних реплик;
- пишет перевод сразу в файл после каждой пачки — можно прервать (Ctrl+C) и продолжить позже, ничего не потеряется;
- строки, у которых перевод уже есть, пропускаются — safe перезапускать сколько угодно раз;
- при сбое или странном ответе модели — повторяет запрос; если не получилось после всех попыток — идёт дальше, а в конце выводит список проблемных мест для повторного прогона;
- одинаковый исходный текст переводится один раз и берётся из кэша при повторных встречах (файл `_translate_data/translation_cache.json`) — экономит и деньги, и время.
Логи каждого запуска — в `_translate_data/translate_logs/`, сырые запросы/ответы API — в `_translate_data/raw_api_logs/` (полезно для отладки, если модель ведёт себя странно).
Если после прогона остались проблемные пачки — просто запустите `python translate.py` ещё раз для тех же файлов, уже переведённое не тронется.
### 6. NSV to Txt — сборка перевода обратно
```
python convert.py --nsv2txt
```
Спросит:
- папку с оригинальными `.txt` (по умолчанию `txt`) — используется как "скелет", все игровые команды и структура берутся отсюда;
- папку с переведёнными `.nsv` (по умолчанию `json`);
- папку для результата (по умолчанию `txt_translated`);
- какое поле использовать — `text` (оригинал, для проверки, что ничего не сломалось) или `translated_text` (реальный перевод, стандартный выбор).
Соберёт `.txt`-файлы, готовые к компиляции, подставив перевод только в текстовые строки и не тронув остальное.
### 7. Compile — .txt → .cst
```
python compile.py
```
Спросит папку с переведёнными `.txt` (по умолчанию `txt_translated`) и папку для результата (по умолчанию `update01`). Компилирует всё через `tool/mc.exe`.
После этого `update01` можно класть обратно в игру (или собирать в архив — зависит от того, как игра подхватывает патчи).
## Формат .nsv
Простой текстовый формат ([спецификация](https://github.com/nsv-format/nsv), реализация — пакет `nsv`): одна запись — 5 строк подряд (номер, спикер, pcm, оригинал, перевод), затем пустая строка-разделитель. Пустое значение поля — одиночный `\`. Внутри текста `\` экранируется как `\\`, настоящий перенос строки — как `\n`.
Файл можно открыть в любом текстовом редакторе. LLM никогда не видит это экранирование напрямую — при отправке в модель текст уже "распакован" в обычный читаемый вид, а при сохранении на диск запаковывается заново автоматически.
## Частые проблемы
**`translate.py` падает с ошибкой про кодировку/JSON при чтении файла** — убедитесь, что используете `.nsv` файлы, созданные текущей версией `convert.py --txt2nsv`, а не старые `.json` от прежних версий скриптов.
**В игре после компиляции вместо буквы стоит `?`** — где-то в переводе оказался символ, не входящий в Shift-JIS/CP932 (например длинное тире `—`, `™`, эмодзи). `convert.py` при сборке (`--nsv2txt`) старается автоматически заменить самые частые проблемные символы, но если что-то новое проскочило — `compile.py`/`convert.py` покажут ошибку кодировки в консоли, укажут на файл, надо будет поправить перевод вручную в `.nsv`.
**Перевод потерял `[слова] [в] [скобках]` или `\`-теги** — это значит модель их не сохранила, несмотря на инструкцию в `prompt.txt`. Найдите строку в `.nsv`, поправьте вручную или сотрите `translated_text` у этой записи и запустите `translate.py` заново — тогда LLM переведёт её ещё раз.
**Хочу начать перевод заново с нуля** — очистите поле `translated_text` в нужных `.nsv` файлах (или пересоздайте их через `convert.py --txt2nsv`), и при желании удалите `_translate_data/translation_cache.json`, чтобы не подтягивался старый перевод из кэша.
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Скрипт компиляции .txt файлов обратно в .cst.
Запускать из папки translate_util.
Спрашивает, из какой папки брать .txt файлы (по умолчанию txt_translated)
и в какую папку положить итоговые .cst (по умолчанию update01).
mc.exe запускается из папки txt и кладёт результат (.cst) туда же,
рядом с .txt. Скрипт копирует .txt файлы из исходной папки в txt,
компилирует их, а затем переносит получившиеся .cst в целевую папку
(перезаписывая существующие без вопросов).
"""
import shutil
import subprocess
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
TOOL_EXE = BASE_DIR / "tool" / "mc.exe"
TXT_DIR = BASE_DIR / "txt"
def main():
if not TOOL_EXE.exists():
print(f"Ошибка: не найден {TOOL_EXE}")
sys.exit(1)
src_input = input("Из какой папки брать .txt файлы? [txt_translated]: ").strip()
src_dir_name = src_input if src_input else "txt_translated"
src_dir = BASE_DIR / src_dir_name
dst_input = input("В какую папку положить скомпилированные .cst? [update01]: ").strip()
dst_dir_name = dst_input if dst_input else "update01"
dst_dir = BASE_DIR / dst_dir_name
if not src_dir.is_dir():
print(f"Ошибка: папка не найдена: {src_dir}")
sys.exit(1)
TXT_DIR.mkdir(exist_ok=True)
dst_dir.mkdir(exist_ok=True)
txt_files = sorted(src_dir.glob("*.txt"))
if not txt_files:
print(f"В папке {src_dir} не найдено .txt файлов.")
sys.exit(0)
print(f"Найдено файлов: {len(txt_files)}")
print(f"Источник .txt: {src_dir}")
print(f"Рабочая папка компиляции: {TXT_DIR}")
print(f"Итоговая папка .cst: {dst_dir}\n")
ok_count = 0
fail_count = 0
same_dir = src_dir.resolve().samefile(TXT_DIR.resolve()) if src_dir.exists() and TXT_DIR.exists() else False
for txt_file in txt_files:
working_txt = TXT_DIR / txt_file.name
# Копируем исходный .txt в рабочую папку txt (перезаписываем, если есть),
# если источник и есть txt — копировать не нужно
if not same_dir:
shutil.copy2(txt_file, working_txt)
print(f"-> {txt_file.name}")
try:
result = subprocess.run(
[str(TOOL_EXE), working_txt.name],
cwd=str(TXT_DIR),
capture_output=True,
text=True,
)
if result.stdout:
print(result.stdout.strip())
if result.stderr:
print(result.stderr.strip())
if result.returncode != 0:
fail_count += 1
print(f" [!] Код возврата: {result.returncode}")
continue
compiled_cst = TXT_DIR / (working_txt.stem + ".cst")
if not compiled_cst.exists():
fail_count += 1
print(f" [!] Ожидаемый файл не найден: {compiled_cst}")
continue
# Переносим результат в целевую папку, перезаписывая без вопросов
dst_cst = dst_dir / compiled_cst.name
shutil.move(str(compiled_cst), str(dst_cst))
ok_count += 1
except Exception as e:
fail_count += 1
print(f" [!] Ошибка запуска: {e}")
print(f"\nГотово. Успешно: {ok_count}, с ошибками: {fail_count}")
if __name__ == "__main__":
main()
+24
View File
@@ -0,0 +1,24 @@
{
"api": {
"base_url": "https://example.com/api/v1",
"api_key": "sk-example",
"model": "google/gemini-3.8-flash",
"temperature": 0.3,
"max_tokens": 4092,
"reasoning_effort": "medium",
"timeout_seconds": 120
},
"translation": {
"source_language": "English",
"target_language": "Russian",
"batch_size": 10,
"context_before": 2,
"context_after": 2,
"max_retries": 3,
"retry_delay_seconds": 5
},
"paths": {
"json_dir": "json",
"glossary_file": "glossary.md"
}
}
+256
View File
@@ -0,0 +1,256 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
Универсальный скрипт для локализации CatSystem2 (Shift-JIS).
2 в 1: Извлечение в .nsv и обратная сборка в .txt с поддержкой поля translated_text.
Поддерживает аргументы: --txt2nsv и --nsv2txt
Формат хранения — NSV (Newline-Separated Values, см. nsv.py) вместо JSON:
компактнее на диске, лучше диффы в git. LLM (в translate.py) никогда не видит
NSV-экранирование — оно разбирается и собирается заново только здесь и в
nsv.py; наружу и в промпт всегда идут обычные Python-строки.
"""
import re
import sys
import shutil
from pathlib import Path
from nsv_io import load_nsv_file, save_nsv_file
BASE_DIR = Path(__file__).resolve().parent
SRC_ENCODING = "cp932" # Shift-JIS
PURE_CONTROL_TOKENS = {"\\r", "\\p", "\\n", "\\@"}
def is_text_line(raw_line: str) -> bool:
stripped = raw_line.strip()
if not stripped or stripped in PURE_CONTROL_TOKENS:
return False
if "[" in raw_line or "" in raw_line or "" in raw_line:
return True
return False
def parse_speaker_and_text(raw_line: str):
"""Строгий разбор формата CatSystem2: 'Имя\\tТекст' или '\\tТекст' (без имени).
Разделитель между именем и текстом — ровно один таб, ничего больше.
Если строка начинается с таба — имени нет, текст идёт с отступом.
"""
if raw_line.startswith("\t"):
return None, raw_line[1:]
if "\t" in raw_line:
speaker, text = raw_line.split("\t", 1)
return speaker, text
# Строка без единого таба под критерии is_text_line не должна попадать,
# но на случай мусорных данных не теряем её молча.
return None, raw_line
def extract_entries(lines):
entries = []
pending_pcm = None
pcm_re = re.compile(r"^\s*pcm\s+(\S+)\s*$")
for idx, raw in enumerate(lines):
line_no = idx + 1
stripped = raw.strip()
pcm_match = pcm_re.match(stripped)
if pcm_match:
pending_pcm = pcm_match.group(1)
continue
if is_text_line(raw):
speaker, text = parse_speaker_and_text(raw)
entries.append({
"line": line_no,
"speaker": speaker,
"pcm": pending_pcm,
"text": text,
"translated_text": "",
})
pending_pcm = None
return entries
def txt_to_nsv():
print("\n--- РЕЖИМ 1: ИЗВЛЕЧЕНИЕ В .nsv ---")
src_dir = BASE_DIR / (input("Папка с исходными .txt [txt]: ").strip() or "txt")
dst_dir = BASE_DIR / (input("Папка для .nsv [translate]: ").strip() or "translate")
if not src_dir.is_dir():
print(f"Ошибка: папка не найдена: {src_dir}")
return
dst_dir.mkdir(exist_ok=True)
txt_files = sorted(src_dir.glob("*.txt"))
ok_count, fail_count = 0, 0
for txt_file in txt_files:
nsv_file = dst_dir / (txt_file.stem + ".nsv")
try:
# errors="replace" здесь осознанно: если в исходном декомпилированном
# .txt попадётся байт, нечитаемый в cp932, лучше получить "?" и продолжить,
# чем упасть на самом первом шаге пайплайна. Итоговый .nsv всё равно
# редактируется человеком/LLM дальше, ошибка будет заметна визуально.
with open(txt_file, "r", encoding=SRC_ENCODING, errors="replace", newline="") as f:
lines = [ln.rstrip("\r\n") for ln in f]
entries = extract_entries(lines)
save_nsv_file(nsv_file, entries)
ok_count += 1
print(f"-> {txt_file.name} (строк: {len(entries)})")
except Exception as e:
fail_count += 1
print(f"[!] Ошибка {txt_file.name}: {e}")
print(f"\nГотово. Успешно: {ok_count}, Ошибок: {fail_count}\n")
def fix_llm_symbols(text: str) -> str:
"""Заменяет символы от LLM, от которых падает Shift-JIS"""
replacements = {
'ё': 'е', 'Ё': 'Е',
'': '', '': '-',
'«': '"', '»': '"',
'': '"', '': '"',
'': "'", '': "'",
'': '...',
'\u200b': '',
'\u00a0': ' ',
'\u2014': '',
'\u2013': '-',
}
for bad, good in replacements.items():
text = text.replace(bad, good)
return text
def nsv_to_txt():
print("\n--- РЕЖИМ 2: СБОРКА ИЗ .nsv В TXT ---")
print("Какое поле с текстом использовать?")
print(" 1 - Использовать базовое поле 'text'")
print(" 2 - Использовать поле 'translated_text' (с откатом к 'text')")
while True:
mode_input = input("Ваш выбор [2]: ").strip()
if not mode_input:
mode = 2
break
elif mode_input in ("1", "2"):
mode = int(mode_input)
break
else:
print("Пожалуйста, введите 1 или 2.")
txt_input = input("Папка с ОРИГИНАЛЬНЫМИ .txt [txt]: ").strip() or "txt"
nsv_input = input("Папка с ПЕРЕВЕДЕННЫМИ .nsv [translate]: ").strip() or "translate"
out_input = input("Куда сохранить готовые .txt [txt_translated]: ").strip() or "txt_translated"
txt_dir = BASE_DIR / txt_input
nsv_dir = BASE_DIR / nsv_input
out_dir = BASE_DIR / out_input
if not nsv_dir.is_dir() or not txt_dir.is_dir():
print("Ошибка: папки с исходниками или переводами не найдены.")
return
out_dir.mkdir(exist_ok=True)
nsv_files = sorted(nsv_dir.glob("*.nsv"))
ok_count, fail_count = 0, 0
for nsv_file in nsv_files:
txt_file = txt_dir / (nsv_file.stem + ".txt")
out_file = out_dir / txt_file.name
temp_txt_file = out_dir / f"~temp_{txt_file.name}"
if not txt_file.exists():
print(f"[!] Нет оригинального txt для {nsv_file.name}, пропускаю...")
continue
try:
shutil.copy2(txt_file, temp_txt_file)
with open(temp_txt_file, "r", encoding=SRC_ENCODING, errors="replace", newline="") as f:
orig_lines = f.readlines()
entries = load_nsv_file(nsv_file)
for entry in entries:
idx = entry["line"] - 1
if idx >= len(orig_lines):
continue
if mode == 2:
text_raw = entry.get("translated_text") or entry.get("text", "")
else:
text_raw = entry.get("text", "")
text = fix_llm_symbols(text_raw)
speaker = entry.get("speaker")
orig_line = orig_lines[idx]
newline_chars = "\r\n" if orig_line.endswith("\r\n") else "\n"
clean_orig = orig_line.rstrip("\r\n")
if speaker:
orig_lines[idx] = f"{speaker}\t{text}{newline_chars}"
else:
m = re.match(r"^(\s+)", clean_orig)
sep = m.group(1) if m else " "
orig_lines[idx] = f"{sep}{text}{newline_chars}"
# errors="strict": если после fix_llm_symbols остался символ,
# не представимый в cp932, лучше упасть с понятной ошибкой сейчас,
# чем молча получить "?" в игре после компиляции.
with open(out_file, "w", encoding=SRC_ENCODING, errors="strict", newline="") as f:
f.writelines(orig_lines)
ok_count += 1
print(f"<- {nsv_file.name} собран успешно")
except Exception as e:
fail_count += 1
print(f"[!] Ошибка {nsv_file.name}: {e}")
finally:
if temp_txt_file.exists():
try:
temp_txt_file.unlink()
except Exception:
pass
print(f"\nГотово. Успешно: {ok_count}, Ошибок: {fail_count}\n")
def main():
if "--txt2nsv" in sys.argv or "--txt2json" in sys.argv:
txt_to_nsv()
return
elif "--nsv2txt" in sys.argv or "--json2txt" in sys.argv:
nsv_to_txt()
return
print("=== Утилита локализации CatSystem2 ===")
print("1. Разобрать игру (TXT -> NSV)")
print("2. Собрать перевод (NSV -> TXT)")
choice = input("Ваш выбор (1 или 2): ").strip()
if choice == '1':
txt_to_nsv()
elif choice == '2':
nsv_to_txt()
else:
print("Неверный выбор. До свидания!")
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Скрипт декомпиляции .cst файлов.
Запускать из папки translate_util.
Спрашивает, в какой папке лежат .cst файлы (по умолчанию update00/text),
и декомпилирует все .cst файлы из неё в папку txt с помощью
tool\\cs2_decompile.exe.
"""
import subprocess
import sys
from pathlib import Path
# Корень translate_util — папка, откуда запущен скрипт
BASE_DIR = Path(__file__).resolve().parent
TOOL_EXE = BASE_DIR / "tool" / "cs2_decompile.exe"
TXT_DIR = BASE_DIR / "txt"
def main():
if not TOOL_EXE.exists():
print(f"Ошибка: не найден {TOOL_EXE}")
sys.exit(1)
src_input = input("В какой папке лежат .cst файлы? [update00/text]: ").strip()
src_dir_name = src_input if src_input else "update00/text"
src_dir = BASE_DIR / src_dir_name
if not src_dir.is_dir():
print(f"Ошибка: папка не найдена: {src_dir}")
sys.exit(1)
TXT_DIR.mkdir(exist_ok=True)
cst_files = sorted(src_dir.glob("*.cst"))
if not cst_files:
print(f"В папке {src_dir} не найдено .cst файлов.")
sys.exit(0)
print(f"Найдено файлов: {len(cst_files)}")
print(f"Источник: {src_dir}")
print(f"Результат будет в: {TXT_DIR}\n")
ok_count = 0
fail_count = 0
for cst_file in cst_files:
print(f"-> {cst_file.name}")
try:
result = subprocess.run(
[str(TOOL_EXE), str(cst_file)],
cwd=str(TXT_DIR),
capture_output=True,
text=True,
)
if result.stdout:
print(result.stdout.strip())
if result.stderr:
print(result.stderr.strip())
if result.returncode == 0:
ok_count += 1
else:
fail_count += 1
print(f" [!] Код возврата: {result.returncode}")
except Exception as e:
fail_count += 1
print(f" [!] Ошибка запуска: {e}")
print(f"\nГотово. Успешно: {ok_count}, с ошибками: {fail_count}")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
Интерактивный скрипт распаковки текстов из игр на CatSystem2.
Извлекает только .cst и .cstl файлы, учитывая порядок файлов обновлений.
"""
import subprocess
import sys
import shutil
import tempfile
from pathlib import Path
# Путь к утилите exkifint_v3.exe
BASE_DIR = Path(__file__).resolve().parent
TOOL_EXE = BASE_DIR / "tool" / "exkifint_v3.exe"
def clean_path(user_input: str) -> Path:
return Path(user_input.strip().strip('"').strip("'"))
def main():
if not TOOL_EXE.exists():
print(f"Ошибка: Исполняемый файл утилиты не найден: {TOOL_EXE}")
sys.exit(1)
# 1. Запрос пути к папке с игрой
game_dir_input = input("Укажите путь к папке с игрой: ")
game_dir = clean_path(game_dir_input)
if not game_dir.is_dir():
print(f"Ошибка: Папка '{game_dir}' не существует.")
sys.exit(1)
# 2. Поиск exe-файлов и выбор
exe_files = list(game_dir.glob("*.exe"))
if not exe_files:
print("В указанной папке не найдено .exe файлов!")
sys.exit(1)
print("\nНайденные исполняемые файлы:")
for i, exe in enumerate(exe_files, 1):
print(f"[{i}] {exe.name}")
exe_choice = input("\nУкажите цифру нужного exe и нажмите Enter: ").strip()
try:
exe_index = int(exe_choice) - 1
if exe_index < 0 or exe_index >= len(exe_files):
raise ValueError
selected_exe = exe_files[exe_index]
except ValueError:
print("Ошибка: Неверный выбор.")
sys.exit(1)
# 3. Название папки вывода
out_name = input("\nНазвание папки куда складывать извлечённые файлы [update00 по умолчанию]: ").strip()
if not out_name:
out_name = "update00" # Значение по умолчанию
out_dir = BASE_DIR / out_name
text_dir = out_dir / "text"
loc_dir = out_dir / "localized_text"
# Создаем целевые папки
text_dir.mkdir(parents=True, exist_ok=True)
loc_dir.mkdir(parents=True, exist_ok=True)
# Список базовых архивов и апдейтов в порядке приоритета (как в unpack.py)
target_archives = ["scene.int", "fes.int", "config.int"]
for i in range(20): # Добавляем update00.int - update19.int
target_archives.append(f"update{i:02d}.int")
print(f"\nНачинаю извлечение текста. Папка вывода: {out_dir}")
cst_count = 0
cstl_count = 0
# 4. Распаковка во временную папку для фильтрации
# Используем tempfile, чтобы мусор автоматически удалился после завершения
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Распаковываем архивы по очереди. Файлы из апдейтов будут
# перезаписывать базовые во временной папке.
for archive_name in target_archives:
archive_path = game_dir / archive_name
if archive_path.exists():
print(f"Обработка архива: {archive_name}...")
cmd = [str(TOOL_EXE), str(archive_path), str(selected_exe)]
subprocess.run(
cmd,
cwd=str(temp_path),
capture_output=True,
text=True,
encoding="cp1251",
errors="replace"
)
# 5. Поиск и перенос только нужных текстовых форматов
print("Сортировка извлеченных файлов...")
for file in temp_path.rglob("*"):
if file.is_file():
if file.suffix.lower() == ".cst":
shutil.copy2(file, text_dir / file.name)
cst_count += 1
elif file.suffix.lower() == ".cstl":
shutil.copy2(file, loc_dir / file.name)
cstl_count += 1
# Временная папка со всем нетекстовым мусором автоматически удаляется здесь
print(f"\nГотово!")
print(f"Извлечено .cst файлов: {cst_count} -> {text_dir}")
print(f"Извлечено .cstl файлов: {cstl_count} -> {loc_dir}")
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
## Characters
- Michiru: Мичиру
- Sachi: Сачи
- Amane: Амане
- Asako: Асако
- Kazuki: Казуки
- Makina: Макина
- Nyanmel: Нянмел
## Terms
- Idol: Айдол
- Magical Girl: Девочка-волшебница
## Notes
- Michiru обращается к другим неформально, используй разговорный стиль речи.
- Сохраняй восклицательные интонации персонажа Michiru — она энергичная и громкая.
- НИКОГДА не использу систему поливанова. Не Си, а Ши, не Ти, а Чи и так далее.
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
names.py — сбор уникальных имён спикеров из .nsv файлов в CSV-таблицу
для перевода, и обратное применение переведённых имён в .nsv файлы.
Файлы .nsv всегда в UTF-8 (пишутся convert.py/translate.py) — здесь нет
нужды в автоопределении кодировки, в отличие от более ранней версии этого
скрипта, где угадывание cp932/shift_jis/utf-8 могло молча испортить текст
на символах вроде "", которых нет в cp932, но которые cp932 способен
"прочитать" без ошибки, просто дав кракозябры.
"""
import csv
from pathlib import Path
from nsv_io import load_nsv_file, save_nsv_file
def get_target_dir():
folder_input = input("Укажите путь к папке с .nsv файлами [по умолчанию: translate]: ").strip()
folder_path = folder_input.strip('"\'') if folder_input else "translate"
target_dir = Path(folder_path)
if not target_dir.is_dir():
print(f"Ошибка: папка '{target_dir}' не найдена.\n")
return None
return target_dir
def mode_extract():
target_dir = get_target_dir()
if not target_dir:
return
nsv_files = list(target_dir.rglob("*.nsv"))
if not nsv_files:
print(f"В папке '{target_dir}' не найдено файлов .nsv.\n")
return
print(f"Найдено .nsv файлов: {len(nsv_files)}. Сбор имён...")
unique_speakers = set()
for file_path in nsv_files:
try:
entries = load_nsv_file(file_path)
except Exception as e:
print(f"Пропуск файла {file_path.name} (не удалось прочитать: {e})")
continue
for e in entries:
speaker = e.get("speaker")
if speaker is not None and speaker.strip():
unique_speakers.add(speaker.strip())
output_csv = Path(__file__).resolve().parent / "namestable.csv"
sorted_speakers = sorted(unique_speakers)
with open(output_csv, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
writer.writerow(["Original", "Translated"])
for speaker in sorted_speakers:
writer.writerow([speaker, ""])
print(f"\nГотово! Уникальных имён собрано: {len(sorted_speakers)}")
print(f"Файл сохранён: {output_csv}\n")
def mode_apply():
csv_path = Path(__file__).resolve().parent / "namestable.csv"
if not csv_path.is_file():
print(f"Ошибка: файл '{csv_path.name}' рядом со скриптом не найден.\n")
return
translation_map = {}
with open(csv_path, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
orig = row.get("Original", "").strip()
trans = row.get("Translated", "").strip()
if orig and trans:
translation_map[orig] = trans
if not translation_map:
print("В таблице namestable.csv нет заполненных колонок 'Translated'. Нечего применять.\n")
return
print(f"Загружено переводов для замены: {len(translation_map)}")
target_dir = get_target_dir()
if not target_dir:
return
nsv_files = list(target_dir.rglob("*.nsv"))
if not nsv_files:
print(f"В папке '{target_dir}' не найдено файлов .nsv.\n")
return
print(f"Обработка {len(nsv_files)} файлов...")
total_replaced = 0
modified_files = 0
for file_path in nsv_files:
try:
entries = load_nsv_file(file_path)
except Exception as e:
print(f"Пропуск {file_path.name}: не удалось прочитать ({e}).")
continue
replaced_in_file = 0
for e in entries:
speaker = e.get("speaker")
if speaker is not None:
raw_val = speaker.strip()
if raw_val in translation_map:
e["speaker"] = translation_map[raw_val]
replaced_in_file += 1
if replaced_in_file > 0:
save_nsv_file(file_path, entries)
total_replaced += replaced_in_file
modified_files += 1
print(f"\nГотово!")
print(f"Изменено файлов: {modified_files}")
print(f"Всего произведено замен: {total_replaced}\n")
def main():
while True:
print("=== Меню ===")
print("1. Собрать уникальные имена в namestable.csv")
print("2. Применить перевод из namestable.csv обратно в .nsv")
print("0. Выход")
choice = input("Выберите действие [1/2/0]: ").strip()
print()
if choice == "1":
mode_extract()
elif choice == "2":
mode_apply()
elif choice == "0":
break
else:
print("Неверный ввод, попробуйте снова.\n")
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
"""nsv_io.py — чтение/запись .nsv диалоговых файлов через пакет nsv."""
from pathlib import Path
import nsv
FIELDS = ("line", "speaker", "pcm", "text", "translated_text")
def load_nsv_file(path: Path) -> list[dict]:
with open(path, "r", encoding="utf-8", newline="") as f:
it = iter(nsv.load(f))
next(it, None)
entries = []
for line, speaker, pcm, text, translated_text in it:
entries.append({
"line": int(line or 0),
"speaker": speaker or None,
"pcm": pcm or None,
"text": text,
"translated_text": translated_text,
})
return entries
def save_nsv_file(path: Path, entries: list[dict]) -> None:
rows = [list(FIELDS)]
for e in entries:
rows.append([
str(e.get("line", "")),
e.get("speaker") or "",
e.get("pcm") or "",
e.get("text", ""),
e.get("translated_text", ""),
])
with open(path, "w", encoding="utf-8", newline="") as f:
nsv.dump(rows, f)
+41
View File
@@ -0,0 +1,41 @@
You are a professional video game / visual novel localization translator.
Keep your reasoning effort medium-low.
Translate the given lines from {source_lang} to {target_lang}.
CRITICAL FORMATTING RULES — the game engine will break if you don't follow these exactly:
1. The text contains word-by-word bracket markup like [word] [word] [word,] — this
marks timing boundaries used to sync voice-over playback. You MUST preserve this
same bracket structure: wrap your translated text in the same number and style of
square brackets, splitting the translated sentence into a similar number of
bracketed chunks. Do not remove or merge the brackets.
2. The text may contain backslash control tags such as \f26;\pc, \n, \p, \@,
\fl...\fn, \w0; and similar — each backslash is a SINGLE character, exactly as
shown here. These are engine formatting/typewriter-effect codes, NOT part of the
visible text. You must keep every such tag exactly as-is (single backslash, same
spelling), in the same relative position (start/middle/end of the line), and never
translate, remove, duplicate, or alter them.
3. Japanese-style quotation marks 「 and 」 (if present) should be preserved as-is;
do not replace them with regular quotes.
4. Do not add, remove, or reorder any lines. Only translate the visible natural-language
words; leave all markup, punctuation-as-markup, and tags untouched.
5. Preserve line breaks and whitespace structure implied by the brackets.
You will receive:
- An optional glossary of character names, terms, and style notes. Follow it strictly.
- Some CONTEXT lines before and after the lines you need to translate, for tone and
continuity, formatted the same way as the lines to translate: "[<line number>] <text>".
Do NOT translate or return the context lines — they are for reference only.
- A list of lines to translate under "=== LINES TO TRANSLATE ===", one per line, each
formatted as: [<line number>] <text>
Example:
[84] [|\f26;\pc「Hello] [there.」]
[96] [Some] [more] [text.」]
Return ONLY a JSON array, with no extra commentary, no markdown code fences, in this
exact format:
[{{"line": <line_number>, "text": "<translated text with original markup preserved>"}}, ...]
The array must contain exactly one entry for every line number you were asked to
translate — no more, no less. This JSON array is the ONLY place you use JSON string
escaping (e.g. a literal backslash in the text becomes \\ inside the JSON string) —
the input lines above are plain text, not JSON, and do not use JSON escaping.
+1
View File
@@ -0,0 +1 @@
nsv==0.2.4
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
start.py — главное меню утилиты перевода визуальной новеллы.
Запускать из папки translate_util:
python start.py
Показывает список доступных шагов и запускает выбранный скрипт.
"""
import subprocess
import sys
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
MENU_ITEMS = [
{
"key": "1",
"title": "Extract — достать файлы из архива",
"desc": "Достаёт файлы из int архива и помещает их в одноимённую папку",
"script": "extract.py",
},
{
"key": "2",
"title": "Decompile — достать текст из .cst файлов",
"desc": "Берёт зашифрованные .cst файлы (например из update00) и превращает их в читаемые .txt файлы в папке txt.",
"script": "decompile.py",
},
{
"key": "3",
"title": "Txt to NSV — подготовить текст к переводу",
"desc": "Превращает .txt файлы из папки txt в .nsv файлы в папке json — компактный текстовый формат, удобный для перевода и для git-диффов.",
"script": "convert.py --txt2nsv",
},
{
"key": "4",
"title": "Names — скрап всех имён",
"desc": "Собирает все имена из .nsv файлов и помещает в CSV в формате original,translated",
"script": "names.py",
},
{
"key": "5",
"title": "Перевод",
"desc": "Перевод с помощью OpenAI-соместимой LLM (Gemini, Claude, ChatGPT и т.д.)",
"script": "translate.py",
},
{
"key": "6",
"title": "NSV to Txt — собрать текст после перевода",
"desc": "Берёт переведённые .nsv файлы и вставляет текст обратно в .txt (структура и команды не трогаются). Результат — в папке txt_translated.",
"script": "convert.py --nsv2txt",
},
{
"key": "7",
"title": "Compile — собрать .cst обратно для игры",
"desc": "Компилирует .txt файлы (по умолчанию из txt_translated) обратно в .cst и кладёт их в папку update01.",
"script": "compile.py",
},
]
def print_menu():
print("=" * 60)
print(" Утилита перевода визуальной новеллы — главное меню")
print("=" * 60)
print()
for item in MENU_ITEMS:
print(f" {item['key']}. {item['title']}")
print(f" {item['desc']}")
print()
print(" 0. Выход")
print()
print("=" * 60)
def run_script(command_str: str):
parts = command_str.split()
script_name = parts[0]
args = parts[1:]
script_path = BASE_DIR / script_name
if not script_path.exists():
print(f"Ошибка: не найден файл {script_path}")
return
print(f"\nЗапуск: {command_str}\n")
run_cmd = [sys.executable, str(script_path)] + args
subprocess.run(run_cmd)
def main():
while True:
print_menu()
choice = input("Выберите пункт (0-7): ").strip()
if choice == "0":
break
matched = next((item for item in MENU_ITEMS if item["key"] == choice), None)
if matched:
run_script(matched["script"])
input("\nНажмите Enter, чтобы вернуться в меню...")
else:
print("\nНеверный выбор, попробуйте ещё раз.")
input("Нажмите Enter, чтобы продолжить...")
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
translate.py — перевод .nsv файлов через OpenAI-совместимый API.
Включает глобальный кэш переводов, сырое логирование запросов,
все системные файлы аккуратно складываются в папку _translate_data.
"""
import json
import re
import sys
import time
import urllib.request
import urllib.error
import logging
from datetime import datetime
from pathlib import Path
from nsv_io import load_nsv_file, save_nsv_file
BASE_DIR = Path(__file__).resolve().parent
CONFIG_PATH = BASE_DIR / "config.json"
PROMPT_PATH = BASE_DIR / "prompt.txt"
# --------------------------------------------------------------------------
# Настройка системных папок (кэш, логи)
# --------------------------------------------------------------------------
SYS_DIR = BASE_DIR / "_translate_data"
SYS_DIR.mkdir(exist_ok=True)
CACHE_PATH = SYS_DIR / "translation_cache.json"
LOG_DIR = SYS_DIR / "translate_logs"
LOG_DIR.mkdir(exist_ok=True)
log_filename = LOG_DIR / f"translation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
RAW_LOG_DIR = SYS_DIR / "raw_api_logs"
RAW_LOG_DIR.mkdir(exist_ok=True)
raw_log_filename = RAW_LOG_DIR / f"raw_llm_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl"
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[
logging.FileHandler(log_filename, encoding="utf-8"),
logging.StreamHandler(sys.stdout)
]
)
def log_raw_api(request_body, raw_response_dict, error_msg=None):
"""Пишет сырой запрос и ответ в jsonl файл для дебага."""
entry = {
"timestamp": datetime.now().isoformat(),
"request": request_body,
"response": raw_response_dict,
"error": error_msg
}
with open(raw_log_filename, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# --------------------------------------------------------------------------
# Загрузка конфига, глоссария, промпта и кэша
# --------------------------------------------------------------------------
def load_config():
if not CONFIG_PATH.exists():
logging.error(f"Ошибка: не найден файл конфигурации {CONFIG_PATH}")
sys.exit(1)
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def load_glossary(glossary_path: Path) -> str:
if not glossary_path.exists():
return ""
with open(glossary_path, "r", encoding="utf-8") as f:
return f.read().strip()
def load_prompt() -> str:
if not PROMPT_PATH.exists():
logging.error(f"Ошибка: не найден файл промпта {PROMPT_PATH}. Создайте его.")
sys.exit(1)
with open(PROMPT_PATH, "r", encoding="utf-8") as f:
return f.read()
def load_cache() -> dict:
if CACHE_PATH.exists():
try:
with open(CACHE_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
logging.warning("Предупреждение: файл кэша поврежден, создаем новый.")
return {}
def save_cache(cache_data: dict):
with open(CACHE_PATH, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
# --------------------------------------------------------------------------
# Фильтр текста
# --------------------------------------------------------------------------
def sanitize_text(text: str) -> str:
"""Очищает текст от символов, несовместимых со стандартным Shift-JIS (cp932)."""
if not text:
return ""
replacements = {
'': '', '': '-', '': '-',
'\xa0': ' ', '\u200b': '', '\u200c': '', '\u200d': '', '\ufeff': '',
'': '...', '': '#', '́': ''
}
for bad_char, good_char in replacements.items():
text = text.replace(bad_char, good_char)
return text
# --------------------------------------------------------------------------
# Построение промпта
# --------------------------------------------------------------------------
def format_glossary_block(glossary_text: str) -> str:
if not glossary_text:
return ""
return f"\n=== GLOSSARY ===\n{glossary_text}\n=== END GLOSSARY ===\n"
def format_context_block(label: str, entries) -> str:
if not entries:
return ""
lines_str = "\n".join(f'[{e["line"]}] {e["text"]}' for e in entries)
return f"\n=== {label} (reference only, do not translate) ===\n{lines_str}\n"
def format_batch_block(entries) -> str:
lines_str = "\n".join(f'[{e["line"]}] {e["text"]}' for e in entries)
return f"\n=== LINES TO TRANSLATE ===\n{lines_str}\n"
def build_messages(cfg, glossary_text, context_before_entries, batch_entries, context_after_entries):
tr_cfg = cfg["translation"]
system_prompt = load_prompt().format(source_lang=tr_cfg["source_language"], target_lang=tr_cfg["target_language"])
system_prompt += format_glossary_block(glossary_text)
user_parts = [
format_context_block("CONTEXT BEFORE", context_before_entries),
format_context_block("CONTEXT AFTER", context_after_entries),
format_batch_block(batch_entries)
]
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "".join(p for p in user_parts if p)}
]
# --------------------------------------------------------------------------
# Вызов API
# --------------------------------------------------------------------------
def call_llm(cfg, messages):
api_cfg = cfg["api"]
url = api_cfg["base_url"].rstrip("/") + "/chat/completions"
body = {
"model": api_cfg["model"],
"messages": messages,
"max_tokens": api_cfg.get("max_tokens", 4000),
"temperature": api_cfg.get("temperature", 0.3),
}
if "reasoning_effort" in api_cfg:
body["reasoning_effort"] = api_cfg["reasoning_effort"]
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_cfg['api_key']}"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=api_cfg.get("timeout_seconds", 120)) as resp:
resp_body = resp.read().decode("utf-8")
resp_json = json.loads(resp_body)
log_raw_api(body, resp_json)
return resp_json["choices"][0]["message"]["content"]
except urllib.error.URLError as e:
log_raw_api(body, None, str(e))
raise
def extract_json_array(text: str):
original_text = text
text = text.strip()
fence_match = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
if fence_match:
text = fence_match.group(1).strip()
if not text.startswith("["):
start, end = text.find("["), text.rfind("]")
if start != -1 and end != -1 and end > start:
text = text[start:end + 1]
try:
return json.loads(text)
except json.decoder.JSONDecodeError as e:
logging.error(f"\n [!] Ошибка парсинга JSON: {e}")
logging.error(f" [!] Сырой ответ:\n{original_text}\n")
raise
# --------------------------------------------------------------------------
# Основная логика перевода
# --------------------------------------------------------------------------
def get_translated_map(entries):
return {e["line"]: e["translated_text"] for e in entries if e.get("translated_text")}
def build_context_before(entries, idx, n, translated_map):
return [{"line": e["line"], "text": translated_map.get(e["line"], e["text"])} for e in entries[max(0, idx - n):idx]]
def build_context_after(entries, idx_end, n):
return [{"line": e["line"], "text": e["text"]} for e in entries[idx_end:idx_end + n]]
def translate_batch(cfg, glossary_text, entries, batch_idx_list, translated_map):
tr_cfg = cfg["translation"]
batch_entries = [entries[idx] for idx in batch_idx_list]
context_before = build_context_before(entries, batch_idx_list[0], tr_cfg["context_before"], translated_map)
context_after = build_context_after(entries, batch_idx_list[-1] + 1, tr_cfg["context_after"])
messages = build_messages(cfg, glossary_text, context_before, batch_entries, context_after)
expected_lines = {e["line"] for e in batch_entries}
max_retries = tr_cfg.get("max_retries", 3)
retry_delay = tr_cfg.get("retry_delay_seconds", 5)
last_error = None
for attempt in range(1, max_retries + 1):
try:
raw_response = call_llm(cfg, messages)
parsed = extract_json_array(raw_response)
result_map = {}
for item in parsed:
if "line" not in item or "text" not in item:
raise ValueError(f"Элемент без line/text: {item}")
result_map[item["line"]] = sanitize_text(item["text"])
if set(result_map.keys()) != expected_lines:
raise ValueError("Несовпадение строк в ответе.")
return result_map, None
except Exception as e:
last_error = e
if attempt < max_retries:
logging.warning(f" [!] Попытка {attempt}/{max_retries} ошибка: {e}. Повтор через {retry_delay}с...")
time.sleep(retry_delay)
else:
logging.error(f" [!] Все попытки исчерпаны: {e}")
return None, last_error
def translate_file(cfg, glossary_text, nsv_path: Path, global_cache: dict):
entries = load_nsv_file(nsv_path)
cache_applied = 0
for e in entries:
if not e.get("translated_text"):
orig_text = e.get("text", "")
if orig_text in global_cache:
e["translated_text"] = global_cache[orig_text]
cache_applied += 1
if cache_applied > 0:
logging.info(f" Восстановлено из кэша: {cache_applied} строк.")
save_nsv_file(nsv_path, entries)
translated_map = get_translated_map(entries)
line_to_idx = {e["line"]: i for i, e in enumerate(entries)}
pending_indices = [i for i, e in enumerate(entries) if not e.get("translated_text")]
if not pending_indices:
logging.info(" Все строки переведены, пропускаем.")
return []
batch_size = cfg["translation"]["batch_size"]
total_pending = len(pending_indices)
done_count = 0
problem_batches = []
logging.info(f" К переводу через API: {total_pending} строк")
i = 0
while i < len(pending_indices):
batch_idx_list = pending_indices[i:i + batch_size]
result_map, error = translate_batch(cfg, glossary_text, entries, batch_idx_list, translated_map)
if result_map is not None:
for line_no, translated_text in result_map.items():
idx = line_to_idx.get(line_no)
if idx is not None:
entries[idx]["translated_text"] = translated_text
translated_map[line_no] = translated_text
global_cache[entries[idx]["text"]] = translated_text
done_count += len(batch_idx_list)
else:
problem_batches.append({"lines": [entries[idx]["line"] for idx in batch_idx_list], "error": str(error)})
logging.info(f" Прогресс API: {min(done_count + sum(len(b['lines']) for b in problem_batches), total_pending)}/{total_pending} строк")
save_nsv_file(nsv_path, entries)
save_cache(global_cache)
i += batch_size
return problem_batches
# --------------------------------------------------------------------------
# main
# --------------------------------------------------------------------------
def main():
cfg = load_config()
paths_cfg = cfg["paths"]
global_cache = load_cache()
nsv_dir = BASE_DIR / paths_cfg["json_dir"]
glossary_path = BASE_DIR / paths_cfg["glossary_file"]
if not nsv_dir.is_dir():
logging.error(f"Ошибка: папка не найдена: {nsv_dir}")
sys.exit(1)
glossary_text = load_glossary(glossary_path)
all_nsv_files = sorted(nsv_dir.glob("*.nsv"))
if not all_nsv_files:
logging.error(f"В {nsv_dir} нет .nsv файлов.")
sys.exit(0)
choice = input(f"\nНайдено {len(all_nsv_files)} файлов. Имена через запятую (или 'all'): ").strip()
selected_files = all_nsv_files if choice.lower() == "all" else [nsv_dir / f"{n.strip()}.nsv" for n in choice.split(",") if (nsv_dir / f"{n.strip()}.nsv").exists()]
all_problems = {}
for nsv_path in selected_files:
logging.info(f"\n=== {nsv_path.name} ===")
problems = translate_file(cfg, glossary_text, nsv_path, global_cache)
if problems:
all_problems[nsv_path.name] = problems
logging.info("\n" + "=" * 60)
if all_problems:
logging.warning("Перевод завершён с проблемами в некоторых пачках. Запустите скрипт повторно.")
else:
logging.info("Перевод завершён успешно.")
if __name__ == "__main__":
main()