您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Translates Sekai Viewer story assets from Japanese to Korean using Gemini API.
// ==UserScript== // @name Sekai Viewer Story Translator (KR) - Tampermonkey // @namespace http://tampermonkey.net/ // @version 0.5.5 // @description Translates Sekai Viewer story assets from Japanese to Korean using Gemini API. // @author You // @match https://sekai.best/storyreader-live2d/* // @match https://sekai.best/storyreader/* // @icon https://www.google.com/s2/favicons?sz=64&domain=sekai.best // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_addStyle // @run-at document-start // @license MIT // ==/UserScript== (async function() { 'use strict'; console.log("Sekai Translator Script: Initializing..."); // --- 설정값 관리 (비동기 로드) --- const config = { apiKey: await GM_getValue('geminiApiKey', ''), isEnabled: await GM_getValue('isTranslationEnabled', true) }; // --- UI 생성 및 관리 함수 --- function createSettingsUI() { if (document.getElementById('sekai-translator-settings-ui')) { const ui = document.getElementById('sekai-translator-settings-ui'); if(ui) ui.style.display = 'flex'; return; } console.log("Sekai Translator Script: Creating settings UI elements..."); GM_addStyle(` /* --- 기존 스타일 시작 (변경 없음) --- */ #sekai-translator-settings-ui { position: fixed; top: 0; left: 0; width: 100%; background-color: rgba(40, 40, 40, 0.95); color: white; padding: 10px 20px; z-index: 99999; font-family: sans-serif; font-size: 14px; display: flex; align-items: center; justify-content: space-between; /* 기본은 양쪽 정렬 */ box-shadow: 0 2px 5px rgba(0,0,0,0.3); box-sizing: border-box; border-bottom: 1px solid #555; flex-wrap: wrap; /* !!!! 추가: 기본적으로 줄바꿈 허용 !!!! */ gap: 10px; /* 요소들 사이 기본 간격 */ } #sekai-translator-settings-ui .panel { display: flex; align-items: center; gap: 10px; /* 간격 약간 줄임 */ flex-wrap: wrap; /* 패널 내부 요소도 줄바꿈 허용 */ } /* API 키 입력 필드의 최소 너비 제거 또는 조정 */ #sekai-translator-settings-ui input[type="text"] { padding: 6px 10px; border: 1px solid #555; background-color: #333; color: white; /* min-width: 350px; */ /* !!!! 제거 또는 줄임 !!!! */ flex-grow: 1; /* 가능한 공간 차지하도록 */ min-width: 200px; /* 최소 너비는 유지 (선택 사항) */ border-radius: 4px; box-sizing: border-box; } #sekai-translator-settings-ui button { padding: 6px 12px; cursor: pointer; background-color: #4CAF50; color: white; border: none; border-radius: 4px; white-space: nowrap; flex-shrink: 0; /* 버튼 크기 줄어들지 않도록 */ } #sekai-translator-settings-ui button:hover { opacity: 0.9; } #sekai-translator-settings-ui .status-message { font-size: 0.9em; margin-left: 5px; min-width: 100px; text-align: left; flex-basis: 100%; order: 3; /* 상태 메시지는 줄바꿈 시 아래로 */ } #sekai-translator-settings-ui a { color: #87CEEB; text-decoration: none; white-space: nowrap; } #sekai-translator-settings-ui a:hover { text-decoration: underline; } #sekai-translator-settings-ui .switch-container { display: flex; align-items: center; margin-left: auto; /* 스위치는 오른쪽으로 밀기 (기본) */ } #sekai-translator-settings-ui .switch { position: relative; display: inline-block; width: 40px; height: 20px; margin-left: 8px; } #sekai-translator-settings-ui .switch input { opacity: 0; width: 0; height: 0; } #sekai-translator-settings-ui .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; border-radius: 20px; transition: .4s; } #sekai-translator-settings-ui .slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; background-color: white; border-radius: 50%; transition: .4s; } #sekai-translator-settings-ui input:checked + .slider { background-color: #2196F3; } #sekai-translator-settings-ui input:checked + .slider:before { transform: translateX(20px); } #sekai-translator-settings-ui .close-button { background: none; border: none; color: #aaa; font-size: 20px; cursor: pointer; padding: 0 5px; line-height: 1; margin-left: 10px; } #sekai-translator-settings-ui .close-button:hover { color: white; } /* 키 발급 링크 스타일 */ #sekai-translator-settings-ui span > a { flex-shrink: 0; margin-left: 5px;} /* --- 기존 스타일 끝 --- */ /* !!!! --- 미디어 쿼리 시작 --- !!!! */ /* 화면 너비가 768px 이하일 때 적용될 스타일 */ @media (max-width: 768px) { #sekai-translator-settings-ui { flex-direction: column; /* 세로 쌓기 */ align-items: stretch; /* 항목들 너비 채우기 */ padding: 10px; /* 패딩 줄이기 */ gap: 8px; /* 세로 간격 */ } #sekai-translator-settings-ui .panel { width: 100%; /* 패널 너비 100% */ gap: 8px; /* 내부 요소 간격 줄이기 */ justify-content: flex-start; /* 왼쪽 정렬 */ } #sekai-translator-settings-ui input[type="text"] { min-width: 150px; /* 모바일 최소 너비 */ flex-basis: auto; /* 너비 자동 조절 */ } #sekai-translator-settings-ui .status-message { margin-left: 0; /* 왼쪽 여백 제거 */ margin-top: 5px; /* 위쪽 여백 추가 */ order: 0; /* 순서 원래대로 (필요시 조절) */ flex-basis: auto; /* 너비 자동 */ } #sekai-translator-settings-ui .switch-container { margin-left: 0; /* 자동 마진 제거 */ /* 필요하다면 오른쪽 패널 내 다른 요소들과의 정렬 조정 */ justify-content: flex-end; /* 오른쪽 정렬 시도 */ width: 100%; } #sekai-translator-settings-ui .close-button { order: 1; /* 닫기 버튼을 스위치보다 앞으로 (선택적) */ } } /* !!!! --- 미디어 쿼리 끝 --- !!!! */ `); const settingsDiv = document.createElement('div'); settingsDiv.id = 'sekai-translator-settings-ui'; const leftPanel = document.createElement('div'); leftPanel.className = 'panel'; leftPanel.innerHTML = ` <label for="gmApiKeyInput">Gemini API Key:</label> <input type="text" id="gmApiKeyInput" placeholder="API 키 입력 후 Enter 또는 저장 버튼 클릭"> <button id="gmSaveApiKeyBtn">저장</button> <span id="gmApiStatus" class="status-message"></span> <span><a href="https://aistudio.google.com/app/apikey?hl=ko" target="_blank" title="Get API Key">키 발급</a></span> `; settingsDiv.appendChild(leftPanel); const rightPanel = document.createElement('div'); rightPanel.className = 'panel'; rightPanel.innerHTML = ` <div class="switch-container"> <label for="gmEnableSwitch">번역 활성화</label> <label class="switch"> <input type="checkbox" id="gmEnableSwitch"> <span class="slider"></span> </label> </div> <button id="gmCloseSettingsBtn" class="close-button" title="설정 창 닫기">×</button> `; settingsDiv.appendChild(rightPanel); if (document.body) { document.body.appendChild(settingsDiv); } else { document.addEventListener('DOMContentLoaded', () => { if (!document.getElementById('sekai-translator-settings-ui')) { document.body.appendChild(settingsDiv); } }); } const apiKeyInput = document.getElementById('gmApiKeyInput'); const saveButton = document.getElementById('gmSaveApiKeyBtn'); const statusSpan = document.getElementById('gmApiStatus'); const enableSwitch = document.getElementById('gmEnableSwitch'); const closeButton = document.getElementById('gmCloseSettingsBtn'); apiKeyInput.value = config.apiKey; enableSwitch.checked = config.isEnabled; if (!config.apiKey) { statusSpan.textContent = 'API 키를 입력하세요.'; statusSpan.style.color = 'orange'; } const saveApiKey = async () => { /* ... 이전과 동일 ... */ const newApiKey = apiKeyInput.value.trim(); if (!newApiKey) { statusSpan.textContent = 'API 키를 입력하세요.'; statusSpan.style.color = 'orange'; return; } await GM_setValue('geminiApiKey', newApiKey); config.apiKey = newApiKey; console.log('Sekai Translator Script: API Key saved.'); statusSpan.textContent = 'API 키 저장됨!'; statusSpan.style.color = 'lightgreen'; setTimeout(() => { statusSpan.textContent = ''; }, 2000); }; const handleEnableSwitchChange = async () => { /* ... 이전과 동일 ... */ const isEnabled = enableSwitch.checked; await GM_setValue('isTranslationEnabled', isEnabled); config.isEnabled = isEnabled; console.log(`Sekai Translator Script: Translation ${isEnabled ? 'ENABLED' : 'DISABLED'}. Refresh page to apply changes.`); statusSpan.textContent = `번역 ${isEnabled ? '활성화' : '비활성화'}됨 (페이지 새로고침 필요)`; statusSpan.style.color = 'lightblue'; setTimeout(() => { statusSpan.textContent = ''; }, 3000); }; saveButton.onclick = saveApiKey; apiKeyInput.onkeydown = (e) => { if (e.key === 'Enter') { saveApiKey(); } }; enableSwitch.onchange = handleEnableSwitchChange; closeButton.onclick = () => { const ui = document.getElementById('sekai-translator-settings-ui'); if(ui) ui.style.display = 'none'; }; } // --- 설정 UI 토글 버튼 생성 함수 --- function createSettingsToggleButton() { if (document.getElementById('sekai-translator-toggle-button')) return; console.log("Sekai Translator Script: Creating settings toggle button..."); GM_addStyle(` #sekai-translator-toggle-button { position: fixed; bottom: 20px; right: 20px; z-index: 99998; padding: 8px 12px; background-color: rgba(0, 0, 0, 0.7); color: white; border: 1px solid #666; border-radius: 5px; cursor: pointer; font-size: 13px; opacity: 0.8; transition: opacity 0.3s, background-color 0.3s; } #sekai-translator-toggle-button:hover { opacity: 1.0; background-color: rgba(0, 0, 0, 0.9); } `); const toggleButton = document.createElement('button'); toggleButton.id = 'sekai-translator-toggle-button'; toggleButton.textContent = '⚙️ 번역 설정'; toggleButton.onclick = () => { console.log("Sekai Translator Script: Toggle button clicked."); let settingsUI = document.getElementById('sekai-translator-settings-ui'); if (!settingsUI) { console.log("Sekai Translator Script: Settings UI not found, creating..."); createSettingsUI(); settingsUI = document.getElementById('sekai-translator-settings-ui'); if (settingsUI) { console.log("Sekai Translator Script: Settings UI created and showing."); settingsUI.style.display = 'flex'; } else { console.error("Sekai Translator Script: Failed to create settings UI!"); } } else { if (settingsUI.style.display === 'none') { console.log("Sekai Translator Script: Showing existing settings UI."); settingsUI.style.display = 'flex'; } else { console.log("Sekai Translator Script: Hiding existing settings UI."); settingsUI.style.display = 'none'; } } }; if (document.body) { document.body.appendChild(toggleButton); } else { document.addEventListener('DOMContentLoaded', () => { if (!document.getElementById('sekai-translator-toggle-button')) { document.body.appendChild(toggleButton); } }); } } // --- DOM 로드 완료 후 UI 초기화 --- function initializeUIAndButton() { if (window.self === window.top) { createSettingsToggleButton(); // 버튼은 항상 생성 if (!config.apiKey) { // API 키 없으면 UI도 바로 생성 및 표시 createSettingsUI(); const settingsUI = document.getElementById('sekai-translator-settings-ui'); if(settingsUI) settingsUI.style.display = 'flex'; } } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeUIAndButton); } else { initializeUIAndButton(); } // --- 번역 기능 활성화 및 API 키 확인 --- if (!config.isEnabled) { console.log("Sekai Translator Script: Translation is DISABLED."); return; } if (!config.apiKey) { console.warn("Sekai Translator Script: API Key is missing. Translation inactive."); return; } console.log("Sekai Translator Script: Translation is ENABLED and API Key found. Hooking XHR..."); // --- XHR 가로채기 로직 --- const originalXhrOpen = XMLHttpRequest.prototype.open; const originalXhrSend = XMLHttpRequest.prototype.send; const xhrStates = new WeakMap(); const targetAssetUrlPattern = /storage\.sekai\.best\/sekai-jp-assets\/(scenario\/|character\/(member\/res\d+_no\d+\/|card_story\/scenario\/)|event_story\/.*\/scenario\/|virtual_live\/.*\/scenario\/).*\.asset$/i; XMLHttpRequest.prototype.open = function(method, url, ...rest) { /* ... 이전과 동일 ... */ let isTarget = false; if (typeof url === 'string' && targetAssetUrlPattern.test(url)) { isTarget = true; } xhrStates.set(this, { url: url, method: method, isTarget: isTarget, async: rest.length > 0 ? rest[0] !== false : true }); return originalXhrOpen.apply(this, [method, url, ...rest]); }; XMLHttpRequest.prototype.send = function(...args) { /* ... 이전과 동일 ... */ const state = xhrStates.get(this); if (state && state.isTarget && config.isEnabled && config.apiKey) { console.log('Sekai Translator [XHR]: Intercepted send() for target asset pattern:', state.url); console.log('Sekai Translator [XHR]: Blocking original request and re-fetching manually...'); processManually(this, state.url, state.method); return; } else { return originalXhrSend.apply(this, args); } }; // --- 수동 처리 함수 --- async function processManually(xhrInstance, url, method) { /* ... 시작 부분 확인 로직 동일 ... */ if (!config.isEnabled || !config.apiKey) { /* ... 비활성화 또는 API 키 없음 처리 ... */ return; } try { console.log(`Sekai Translator [Manual Fetch]: Fetching ${url} via GM_xmlhttpRequest...`); const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ /* ... GM_xmlhttpRequest 설정 ... */ method: method.toUpperCase(), url: url, responseType: 'text', onload: res => (res.status >= 200 && res.status < 300) ? resolve(res) : reject(new Error(`GM_xmlhttpRequest failed: ${res.status}`)), onerror: res => reject(new Error(`GM_xmlhttpRequest error: ${res.error}`)), ontimeout: () => reject(new Error('GM_xmlhttpRequest timed out.')) }); }); const originalTextData = response.responseText; console.log("Sekai Translator [Manual Fetch]: Original data fetched."); const textsToTranslate = extractJapaneseTextsFromAsset(originalTextData, url); // !!!! 수정 필요 !!!! let modifiedTextData = originalTextData; if (textsToTranslate && textsToTranslate.length > 0) { console.log(`Sekai Translator [Manual Fetch]: Found ${textsToTranslate.length} texts to translate.`); try { const translatedTexts = await requestTranslationViaGmXhr(textsToTranslate); // API 호출 함수 사용 modifiedTextData = replaceJapaneseWithKorean(originalTextData, textsToTranslate, translatedTexts, url); // !!!! 수정 필요 !!!! console.log("Sekai Translator [Manual Fetch]: Text replaced with translation."); } catch (translationError) { console.error('Sekai Translator [Manual Fetch]: Translation/replacement failed:', translationError); } } else { console.log('Sekai Translator [Manual Fetch]: No Japanese text found to translate.'); } // XHR 상태 업데이트 및 이벤트 디스패치 console.log("Sekai Translator [Manual Fetch]: Manually setting XHR properties and dispatching events..."); Object.defineProperties(xhrInstance, { /* ... 상태 설정 ... */ readyState: { value: 4, writable: true, configurable: true }, status: { value: response.status, writable: true, configurable: true }, statusText: { value: response.statusText, writable: true, configurable: true }, response: { value: modifiedTextData, writable: true, configurable: true }, responseText: { value: modifiedTextData, writable: true, configurable: true }, responseURL: { value: response.finalUrl || url, writable: true, configurable: true } }); // 이벤트 디스패치 if (typeof xhrInstance.onreadystatechange === 'function') { try { xhrInstance.onreadystatechange(); } catch (e) {}} if (typeof xhrInstance.onload === 'function') { try { xhrInstance.onload(); } catch (e) {}} if (typeof xhrInstance.onloadend === 'function') { try { xhrInstance.onloadend(); } catch (e) {}} console.log("Sekai Translator [Manual Fetch]: Manual processing complete."); } catch (error) { /* ... 오류 처리 ... */ console.error('Sekai Translator [Manual Fetch]: Error during manual processing:', error); Object.defineProperties(xhrInstance, { /* ... 오류 상태 설정 ... */ }); if (typeof xhrInstance.onerror === 'function') { try { xhrInstance.onerror(); } catch (e) {}} if (typeof xhrInstance.onloadend === 'function') { try { xhrInstance.onloadend(); } catch (e) {}} } } // --- Gemini API 호출 함수 (GM_xmlhttpRequest 사용) --- async function requestTranslationViaGmXhr(texts) { if (!config.apiKey) throw new Error("API Key is missing."); if (!texts || texts.length === 0) return []; const modelName = "gemini-2.0-flash"; const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:generateContent?key=${config.apiKey}`; const delimiter = "<--SEP-->"; const combinedTextPrompt = texts.join(delimiter); const prompt = `This is Project Sekai's script. Please translate each of the following Japanese texts (separated by "${delimiter}") into Korean. Respond with only the translated Korean texts, also separated by "${delimiter}. Please remain all ${delimiter}". Include no other explanatory text or markdown formatting.\n\n--- Japanese Texts Start ---\n${combinedTextPrompt}\n--- Japanese Texts End ---`; // !!!! 요청 본문 형식 "그대로" 사용 !!!! const requestBody = { contents: [{ parts: [{ text: prompt }] }] // generationConfig, safetySettings 등은 API 기본값 사용 (필요시 추가) }; console.log(`Sekai Translator [GM_XHR]: Sending request to Gemini API (${modelName})...`); // console.log("Sekai Translator [GM_XHR]: Request Body:", JSON.stringify(requestBody, null, 2)); // 디버깅 필요시 주석 해제 return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: apiUrl, headers: { "Content-Type": "application/json" }, data: JSON.stringify(requestBody), responseType: 'json', timeout: 60000, onload: function(response) { // ... (응답 처리 및 개수 검증 로직은 이전과 동일) ... if (response.status === 200 && response.response) { console.log("Sekai Translator [GM_XHR]: Received response from Gemini API."); const resultData = response.response; let combinedTranslatedText = ""; try { combinedTranslatedText = resultData?.candidates?.[0]?.content?.parts?.[0]?.text?.trim(); if (!combinedTranslatedText) { throw new Error('Translation text not found in API response.'); } const translatedTexts = combinedTranslatedText.split(delimiter).map(t => t.trim().replace(/^["|]*(.*?)["]|]*$/g, '$1')); if (translatedTexts.length !== texts.length) { console.warn(`Count mismatch: ${translatedTexts.length}/${texts.length}.`); reject(new Error(`Translation count mismatch: ${translatedTexts.length}/${texts.length}`)); } else { console.log("Sekai Translator [GM_XHR]: Translation successful."); resolve(translatedTexts); } } catch (parseError) { reject(new Error('Failed to parse translation result.')); } } else { reject(new Error(`Gemini API error: ${response.status} - ${response.responseText}`)); } }, onerror: res => reject(new Error(`Network error: ${res.error}`)), ontimeout: () => reject(new Error('Gemini API request timed out.')) }); }); } function extractJapaneseTextsFromAsset(textData, url) { /* ... 이전 로직 또는 수정된 로직 ... */ console.log(`Sekai Translator: Extracting Japanese text from ${url} (Robust approach needed!)`); if (!textData) return []; let japaneseTexts = []; try { const regex = /^\s*"(WindowDisplayName|Body|Name|Thought|serif|Message)"\s*:\s*"(.*?)",?$/gm; let match; while ((match = regex.exec(textData)) !== null) { let extracted = match[2]; if (extracted && extracted.length > 0 && /\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Han}/u.test(extracted)) { extracted = extracted.replace(/\\n/g, '\n'); japaneseTexts.push(extracted); } } // console.log(`Sekai Translator: Extracted ${japaneseTexts.length} texts using regex.`); } catch (e) { console.error("Sekai Translator: Error during text extraction:", e); } console.log(`Sekai Translator: Finished extraction. Found ${japaneseTexts.length} texts.`); return japaneseTexts; } function replaceJapaneseWithKorean(originalTextData, originalJapanese, translatedKorean, url) { /* ... 이전 로직 또는 수정된 로직 ... */ console.log(`Sekai Translator: Replacing text in ${url} (Robust approach needed!)`); let modifiedText = originalTextData; if (!originalJapanese || !translatedKorean || originalJapanese.length !== translatedKorean.length) { console.warn(`Sekai Translator: Mismatch text count (${originalJapanese?.length}/${translatedKorean?.length}). Returning original data.`); return originalTextData; } try { let k_idx = 0; const regex = /^(\s*"(?:WindowDisplayName|Body|Name|Thought|serif|Message)"\s*:\s*")(.*?)("?,?)$/gm; modifiedText = originalTextData.replace(regex, (match, prefix, originalValue, suffix) => { if (k_idx < originalJapanese.length && originalValue === originalJapanese[k_idx].replace(/\n/g, '\\n')) { if (k_idx < translatedKorean.length) { const translatedEscaped = translatedKorean[k_idx].replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); k_idx++; return prefix + translatedEscaped + suffix; } else { return match; } } return match; }); if (k_idx < originalJapanese.length) { console.warn(`Replacement warning: ${originalJapanese.length - k_idx} items were not replaced.`); } } catch (e) { console.error("Sekai Translator: Error during text replacement:", e); return originalTextData; } console.log("Sekai Translator: Text replacement finished."); return modifiedText; } })(); // 즉시 실행 함수 끝