commit 1485819556d2560917861869f060efee832669ab Author: poison-flower Date: Mon Sep 7 19:07:40 2026 +0300 загружаю diff --git a/README.md b/README.md new file mode 100644 index 0000000..5be45d2 --- /dev/null +++ b/README.md @@ -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`, чтобы не подтягивался старый перевод из кэша. diff --git a/compile.py b/compile.py new file mode 100644 index 0000000..e31c7a2 --- /dev/null +++ b/compile.py @@ -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() diff --git a/config.json b/config.json new file mode 100644 index 0000000..cec974f --- /dev/null +++ b/config.json @@ -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" + } +} \ No newline at end of file diff --git a/convert.py b/convert.py new file mode 100644 index 0000000..cde7db3 --- /dev/null +++ b/convert.py @@ -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() diff --git a/decompile.py b/decompile.py new file mode 100644 index 0000000..095707a --- /dev/null +++ b/decompile.py @@ -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() diff --git a/extract.py b/extract.py new file mode 100644 index 0000000..8ab6d48 --- /dev/null +++ b/extract.py @@ -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() \ No newline at end of file diff --git a/glossary.md b/glossary.md new file mode 100644 index 0000000..5c7fa17 --- /dev/null +++ b/glossary.md @@ -0,0 +1,15 @@ +## Characters +- Michiru: Мичиру +- Sachi: Сачи +- Amane: Амане +- Asako: Асако +- Kazuki: Казуки +- Makina: Макина +- Nyanmel: Нянмел +## Terms +- Idol: Айдол +- Magical Girl: Девочка-волшебница +## Notes +- Michiru обращается к другим неформально, используй разговорный стиль речи. +- Сохраняй восклицательные интонации персонажа Michiru — она энергичная и громкая. +- НИКОГДА не использу систему поливанова. Не Си, а Ши, не Ти, а Чи и так далее. \ No newline at end of file diff --git a/names.py b/names.py new file mode 100644 index 0000000..17384bb --- /dev/null +++ b/names.py @@ -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() diff --git a/nsv_io.py b/nsv_io.py new file mode 100644 index 0000000..3696ac3 --- /dev/null +++ b/nsv_io.py @@ -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) diff --git a/prompt.txt b/prompt.txt new file mode 100644 index 0000000..0d28d7d --- /dev/null +++ b/prompt.txt @@ -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: "[] ". + 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: [] + 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": , "text": ""}}, ...] + +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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bc982f8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +nsv==0.2.4 diff --git a/start.py b/start.py new file mode 100644 index 0000000..b7c1b9a --- /dev/null +++ b/start.py @@ -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() diff --git a/tool/cs2_decompile.exe b/tool/cs2_decompile.exe new file mode 100644 index 0000000..0c5655f Binary files /dev/null and b/tool/cs2_decompile.exe differ diff --git a/tool/exkifint_v3.exe b/tool/exkifint_v3.exe new file mode 100644 index 0000000..d22e7c7 Binary files /dev/null and b/tool/exkifint_v3.exe differ diff --git a/tool/mc.exe b/tool/mc.exe new file mode 100644 index 0000000..3cc4a17 Binary files /dev/null and b/tool/mc.exe differ diff --git a/translate.py b/translate.py new file mode 100644 index 0000000..0db76de --- /dev/null +++ b/translate.py @@ -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() \ No newline at end of file