first commit

This commit is contained in:
Langley
2025-08-18 21:36:58 +09:00
commit 50f8710c6e
7 changed files with 454 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/additional/
/fix*/
/locales*/
/node_modules/
/logs/
*.po
*.json
*.csv

86
src/embedding.js Normal file
View File

@ -0,0 +1,86 @@
import * as fs from "fs"
import { getEmbedding } from "./openai.js"
import PO from "pofile";
async function generateEmbeddingsFromPo(poPath, embeddingPath) {
const embeddingMap = fs.existsSync(embeddingPath)
? JSON.parse(fs.readFileSync(embeddingPath, 'utf8'))
: {};
return new Promise((resolve, reject) => {
PO.load(poPath, async (err, po) => {
if (err) return reject(err);
const items = po.items;
let updated = false;
for (const item of items) {
const msgid = item.msgid.trim();
if (embeddingMap[msgid] && !embeddingMap[msgid].vec) {
const dummy = {};
dummy.vec = embeddingMap[msgid];
embeddingMap[msgid] = dummy;
updated = true;
continue;
}
if (!msgid || embeddingMap[msgid]) continue;
embeddingMap[msgid] = {};
try {
console.log(`🔍 Embedding: ${msgid}`);
const embedding = await getEmbedding(msgid);
embeddingMap[msgid].vec = embedding;
updated = true;
} catch (err) {
console.error(`❌ Failed to embed: "${msgid}"`, err.message);
}
}
if (updated) {
// embeddingMap 에 simiality 리스트를 작성한다
console.log('✅ 높은 유사도를 가진 텍스트를 연결합니다');
for (const item of items) {
const msgid = item.msgid.trim();
const similars = searchSimilarItems(embeddingMap, msgid)
console.log(`🔍 Add Similars: ${msgid} => ${similars}`);
embeddingMap[msgid].similars = similars;
}
fs.writeFileSync(embeddingPath, JSON.stringify(embeddingMap, null, 2), 'utf8');
console.log(`✅ embedding.json 저장 완료 (${Object.keys(embeddingMap).length}개)`);
} else {
console.log('✅ 모든 임베딩이 이미 존재합니다.');
}
resolve(embeddingMap);
});
});
}
function cosineSimilarity(vecA, vecB) {
const dot = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0);
const normA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0));
const normB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0));
return dot / (normA * normB);
}
function searchSimilarItems(embeddingMap, queryText, topN = 5) {
const queryVec = embeddingMap[queryText].vec;
const scored = Object.entries(embeddingMap)
.map(([msgid, emb]) => ({
msgid,
score: cosineSimilarity(queryVec, emb.vec)
}))
.filter(x => x.score > 0.5) // 자기 자신은 제외하고 일정 이상의 스코어만을 추가
.sort((a, b) => b.score - a.score)
.slice(0, topN);
return scored.map(x => x.msgid);// msgid 만 반환
}
export {
generateEmbeddingsFromPo
}

12
src/log.js Normal file
View File

@ -0,0 +1,12 @@
import * as fs from "fs"
function logToFile(prompt, result, logFile = 'conversion.log') {
const timestamp = new Date().toISOString();
const log = `[${timestamp}]\nInput: ${prompt}\nOutput: ${result}\n\n`;
fs.appendFileSync(logFile, log, 'utf8');
}
export {
logToFile
}

152
src/main.js Normal file
View File

@ -0,0 +1,152 @@
import fs from 'fs-extra'
import * as path from 'path'
import PO from "pofile";
import { loadVocaDict, extractWordsList } from './voca.js'
import { translateText } from "./openai.js"
import { generateEmbeddingsFromPo } from "./embedding.js";
import { logToFile } from './log.js';
// ✅ 입력 PO 파일 경로
const ROOT_NAME = "fix001"
const INPUT_FILE = `./${ROOT_NAME}.po`;
const HINT_FILE = `./${ROOT_NAME}/en-US/TextExport.po`;
const EMBEDDING_FILE = './embedding.json';
// ✅ 번역 대상 언어
const languages = {
// 'en-US': 'English',
// 'ja-JP': 'Japanese',
// 'zh-Hans': 'Simplified Chinese',
// 'zh-Hant': 'Traditional Chinese',
'es-ES': 'Spanish (Spain)',
'es-419': 'Spanish (Latin America)',
'fr-FR': 'French',
'de-DE': 'German',
'ru-RU': 'Russian',
'pt-BR': 'Portuguese (Brazil)',
'pt-PT': 'Portuguese (Portugal)',
'it-IT': 'Italian',
'pl-PL': 'Polish',
'tr-TR': 'Turkish',
'uk-UA': 'Ukrainian',
'vi-VN': 'Vietnamese',
//-----------------------------
// 'th': 'Thai',
// 'id-ID': 'Indonesian',
// 'ar': 'Arabic',
// 'sv-SE': 'Swedish',
// 'fi-FI': 'Finnish',
// 'da-DK': 'Danish',
// 'nl-NL': 'Dutch',
// 'hu': 'Hungarian',
// 'cs': 'Czech',
// 'el-GR': 'Greek',
// 'no': 'Norwegian',
// 'ro-RO': 'Romanian',
// 'bg-GB': 'Bulgarian',
};
function loadPoAsync(filePath) {
return new Promise((resolve, reject) => {
// 파일이 존재하는지 체크
if(!fs.existsSync(filePath))
{
return resolve(undefined);
}
PO.load(filePath, (err, po) => {
if (err) {
reject(err);
} else {
resolve(po);
}
});
});
}
function savePoAsync(po, filePath) {
return new Promise((resolve, reject) => {
po.save(filePath, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
// ✅ PO 파일 번역
async function run() {
const embeddings = await generateEmbeddingsFromPo(INPUT_FILE, EMBEDDING_FILE);
const vocaDict = loadVocaDict(path.join(".", 'voca.csv'));
for (const [langCode, langName] of Object.entries(languages)) {
const targetDir = path.join(`./${ROOT_NAME}`, langCode);
const outputFile = path.join(targetDir, 'TextExport.po');
const logfile = path.join('./logs', langCode+".log");
await fs.ensureDir(targetDir);
const po = (await loadPoAsync(outputFile)) || (await loadPoAsync(INPUT_FILE));
const referencess = {};
const hint_po = await loadPoAsync(HINT_FILE)
for (const item of po.items) {
if (!item.msgid || !/[가-힣]/.test(item.msgid)) continue;
// 힌트에서 동일한 key (msgctxt) 를 가지는 언어를 찾는다
const words = extractWordsList(vocaDict, langName, langCode, item.msgid);
const hintItem = hint_po.items.find(x => x.msgctxt === item.msgctxt);
const hintText = hintItem && hintItem.msgstr && hintItem.msgstr[0] && hintItem.msgid != hintItem.msgstr[0] ? hintItem.msgstr[0] : undefined;
const comment = item.references[0];
const similars = embeddings[item.msgid]?.similars || [];
const history = [];
for (const sim of similars) {
if (referencess[sim]) {
history.push(sim + " => " + referencess[sim]);
}
}
// 동일한 단어를 포함한 경우에도 추가한다
const hWords = item.msgid.split(/\s+/);
for(const refId in referencess) {
if (referencess[refId]) {
if (hWords.some(word => refId.includes(word))) {
history.push(refId + " => " + referencess[refId]);
}
}
}
try {
const [prompt, translatedText] = await translateText(item.msgid, hintText, comment, langName, words, history);
item.msgstr = translatedText;
console.log(prompt, translatedText);
if (!translatedText.includes("\n")) {
referencess[item.msgid] = translatedText;
}
console.log(`[${langCode}] ${item.msgid}${translatedText}`);
// 로그를 기록한다
logToFile(prompt, translatedText, logfile);
} catch (err) {
console.error(`❌ [${langCode}] 번역 실패 (${item.msgid}):`, err.message);
}
}
await savePoAsync(po, outputFile);
console.log(`✅ [${langCode}] 저장 완료`);
}
}
run().catch(console.error);

37
src/openai.js Normal file
View File

@ -0,0 +1,37 @@
import OpenAI from 'openai'
import {getSystemPrompt, getUserPrompt } from "./prompt.js"
process.env.OPENAI_API_KEY = "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"
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// ✅ GPT 번역 함수
async function translateText(text, hint_text, comment, targetLangName, words, history) {
const systemPrompt = getSystemPrompt();
const userPrompt = getUserPrompt(text, hint_text, targetLangName, comment, words, history);
const res = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{
role: "system", content: systemPrompt,
role: 'user', content: userPrompt
}],
});
return [userPrompt, res.choices[0].message.content.trim()];
}
async function getEmbedding(text) {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small', // 또는 'text-embedding-ada-002'
input: text
});
return response.data[0].embedding;
}
export {
translateText,
getEmbedding,
}

105
src/prompt.js Normal file
View File

@ -0,0 +1,105 @@
function getSystemPrompt() {
return "You are an assistant that helps to translate";
}
const baseRule = `다음 규칙에 따라 번역해 주세요.
────────────────────────────
1. 변수 보호:
- {VariableName} 형태의 변수는 절대 변경하거나 번역하지 마세요.
- 예: {Value}, {PlayerName} → 원문 그대로 유지
2. 리치 텍스트 태그 보존:
- <TagName>...</> 또는 <TagName> 형식의 태그는 번역하지 마세요.
- 태그 내부 텍스트는 번역하지만, 태그 구조와 위치는 그대로 유지하세요.
3. 줄바꿈 및 특수 문자 보존:
- 줄바꿈과 특수문자는 원문 그대로 유지하세요.
4. 번역된 문장 이외에 어떤 것도 포함하지 않는다.
- "### 수정 결과" 라는 문장은 응답에 포함하지 않는다.
- 원문에 존재하지 않는 태그나 특수기호를 결과에 추가하지 않는다.
`
function getCategoryGuide(category) {
category = category.toLowerCase()
if (category.includes("character")) {
return "캐릭터와 스탯의 이름은 원문에 가깝게 하되 용어집을 적극 참조해야 됩니다."
} else if (category.includes("skill") || category.includes("combat")) {
return "스킬 이름은 **캐릭터의 직업 및 특성과 일관성**을 가져야 합니다. 스킬 설명에 사용되는 '피해량', '지속시간', '범위' 등의 용어는 용어집을 적극 참조한다."
} else if (category.includes("item") || category.includes("equip")) {
return "아이템의 판타지 세계관과 특성을 잘 나타내도록 번역해야 하며, 아이템 등급(일반, 고급, 희귀, 전설)에 따라 이름의 무게감을 조절해야 합니다. 아이템의 고유 이름은 **원본의 느낌을 살리는 창의적인 번역**이 매우 중요합니다."
} else if (category.includes("world") || category.includes("interaction")) {
return "원본의 독특한 어투('~할지어다')는 마녀의 정체성이 나타나므로 각 언어별 문화에 맞게 톤을 통일해줘야 합니다."
} else if (category.includes("ui") || category.includes("system")) {
return "사용자에게 직접 노출되는 UI와 설정이 대부분이므로 **간결하고 명확한 번역**이 필수입니다. 특히 '확인', '취소'와 같은 표준 UI 용어와 'Anti-Aliasing' 같은 기술 용어는 **완벽한 일관성**을 유지해야 합니다."
} else if (category.includes("store") || category.includes("event")) {
return "상품의 정보를 **명확하고 오해의 소지가 없도록** 번역해야 합니다."
} else {
return "- **문맥 파악이 가장 중요**합니다. 텍스트의 정확한 용도를 이해하도록 합니다"
}
}
const removeDuplicates = (array) => [...new Set(array)];
function getUserPrompt(koText, enText, targetLangName, category, wordDic, history) {
let prompt = baseRule;
// 카테고리 가이드를 적용
prompt += `5. ${getCategoryGuide(category)}
`
// 용어집 있는 경우 용어집을 추가
if (wordDic.length > 0) {
prompt += `6. 용어집 사용:
- 지정된 번역 용어가 있는 경우 반드시 해당 언어로 대체하세요.
- 용어집과 비교시 대소문자에 의한 차이를 두지 않는다.
- 용어집에는 {"full":풀네임, "short":약어} 형식으로 병기되었을때, 둘중에 반드시 하나만 표기한다
- 예: {"full":"Armor Points", "short":"AP"} => "Armor Points" 혹은 "AP" 중에 하나를 선택한다. **이 규칙은 반드시 지킨다**
${enText ? "- 풀네임과 약어는 #영어 문장의 형태를 우선하여 선택한다. " : "- 문맥에 가장 자연스러운 표현을 선택한다"}
────────────────────────────
### 용어집
${wordDic.join("\r\n")}
────────────────────────────
`
}
// 과거 번역 기록이 있는 경우 번역기록읅 추가
if (history.length > 0) {
prompt += `7. 과거 번역 기록을 이용하여 일관성 유지한다
────────────────────────────
### 번역기록
${removeDuplicates(history).join("\r\n")}
────────────────────────────
`
}
if (enText) {
// 영어 원문이 있는 경우는 두가짖 언어를 모두 보고 결과를 도출
prompt += `────────────────────────────
위의 규칙을 지키며 다음의 문장을 [${targetLangName}]로 번역한다.
- 한국어와 영어는 동일한 의미를 가진다. 이를 참고래서 번역 대상 언어로 번역을 한다.
- 하나의 번역 결과만을 생성하여야 한다
- 응답에는 번역된 문장 이외에 어떤 것도 포함하지 않는다
### 한국어
${koText}
### 영어
${enText}
────────────────────────────
`
} else {
prompt += `────────────────────────────
위의 규칙을 지키며 다음의 문장을 [${targetLangName}]로 번역한다.
- 응답에는 번역된 문장 이외에 어떤 것도 포함하지 않는다
### 한국어
${koText}
────────────────────────────
`
}
prompt += `### 번역 결과
`
return prompt;
}
export {
getSystemPrompt,
getUserPrompt,
}

54
src/voca.js Normal file
View File

@ -0,0 +1,54 @@
import * as fs from 'fs'
import { parse } from 'csv-parse/sync';
// 용어집 로드
function loadVocaDict(csvPath) {
const csv = fs.readFileSync(csvPath, 'utf8');
const records = parse(csv, {
columns: true,
skip_empty_lines: true,
});
return records.map(row => {
const keys = Object.keys(row).map(x => x.trim());
const values = Object.values(row);
const obj = {}
for(let i = 0; i < keys.length; i++)
{
obj[keys[i]] = values[i];
}
return obj;
});
}
function extractWordsList(voca, contry, langCode, inputKorean) {
const words = inputKorean.split(/\s+/); // 공백 기준
const targetLang = `${contry} (${langCode})`;
const rows = voca
.filter(row =>
row['원본 용어 (ko)'] &&
row[targetLang] &&
row['대분류'] &&
row['중분류']
)
.filter(row => words.every(word => row['원본 용어 (ko)'].includes(word) || words.some(word => row['원본 용어 (ko)'] == word)))
.map(row => `{'${row['원본 용어 (ko)']}' => '${replaceFullNameShortName(row[targetLang])}'}`);
return rows;
}
function replaceFullNameShortName(text) {
const pattern = /^\s*([\p{L}\p{M}\p{Zs}'\-]+?)\s*\(\s*([\p{L}\p{N}]+)\s*\)\s*$/u;
const match = text.match(pattern);
if (!match) return text;
const [, fullName, shortName] = match;
return `{"full":"${fullName}", "short":"${shortName}"}`;
}
export {
loadVocaDict,
extractWordsList,
}