commit 50f8710c6e94f503120bd35654332dff7faa06d3 Author: Langley Date: Mon Aug 18 21:36:58 2025 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec01fcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/additional/ +/fix*/ +/locales*/ +/node_modules/ +/logs/ +*.po +*.json +*.csv \ No newline at end of file diff --git a/src/embedding.js b/src/embedding.js new file mode 100644 index 0000000..29c6e2a --- /dev/null +++ b/src/embedding.js @@ -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 +} \ No newline at end of file diff --git a/src/log.js b/src/log.js new file mode 100644 index 0000000..e042794 --- /dev/null +++ b/src/log.js @@ -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 +} \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..365faab --- /dev/null +++ b/src/main.js @@ -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); diff --git a/src/openai.js b/src/openai.js new file mode 100644 index 0000000..d8dabd7 --- /dev/null +++ b/src/openai.js @@ -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, +} \ No newline at end of file diff --git a/src/prompt.js b/src/prompt.js new file mode 100644 index 0000000..bb7fe70 --- /dev/null +++ b/src/prompt.js @@ -0,0 +1,105 @@ + +function getSystemPrompt() { + return "You are an assistant that helps to translate"; +} + +const baseRule = `λ‹€μŒ κ·œμΉ™μ— 따라 λ²ˆμ—­ν•΄ μ£Όμ„Έμš”. +──────────────────────────── +1. λ³€μˆ˜ 보호: +- {VariableName} ν˜•νƒœμ˜ λ³€μˆ˜λŠ” μ ˆλŒ€ λ³€κ²½ν•˜κ±°λ‚˜ λ²ˆμ—­ν•˜μ§€ λ§ˆμ„Έμš”. +- 예: {Value}, {PlayerName} β†’ 원문 κ·ΈλŒ€λ‘œ μœ μ§€ +2. 리치 ν…μŠ€νŠΈ νƒœκ·Έ 보쑴: +- ... λ˜λŠ” ν˜•μ‹μ˜ νƒœκ·ΈλŠ” λ²ˆμ—­ν•˜μ§€ λ§ˆμ„Έμš”. +- νƒœκ·Έ λ‚΄λΆ€ ν…μŠ€νŠΈλŠ” λ²ˆμ—­ν•˜μ§€λ§Œ, νƒœκ·Έ ꡬ쑰와 μœ„μΉ˜λŠ” κ·ΈλŒ€λ‘œ μœ μ§€ν•˜μ„Έμš”. +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, +} \ No newline at end of file diff --git a/src/voca.js b/src/voca.js new file mode 100644 index 0000000..6803956 --- /dev/null +++ b/src/voca.js @@ -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, +} \ No newline at end of file