「
Mery 翻訳
」を編集中 (節単位)
ナビゲーションに移動
検索に移動
警告:
ログインしていません。編集を行うと、あなたの IP アドレスが公開されます。
ログイン
または
アカウントを作成
すれば、あなたの編集はその利用者名とともに表示されるほか、さまざまなメリットもあります。
スパム攻撃防止用のチェックです。 決して、ここには、値の入力は
しない
でください!
== ソースコード == <syntaxhighlight lang="javascript" copy> #language = "quickjs" #title = "Mery 翻訳" /** * Mery 翻訳マクロ * * Traduzir-paginas-web と同じ Google 翻訳クライアント API * (POST https://translate-pa.googleapis.com/v1/translateHtml) を、 * shell.Exec + curl.exe で呼び出して翻訳します。 * * API キーは Google 配信 JS から x-goog-api-key を抽出し、 * Window.Tag に最大 20 分キャッシュします。翻訳結果はキャッシュしません。 * * 使い方: * 1. 翻訳したい文字列を選択(未選択ならカーソル行を選択) * 2. このマクロを実行 * * 必要環境: * - Mery Ver 3.8.8 以降(左記バージョンにて動作確認) * - PATH 上の curl.exe(Windows 10 以降に標準搭載) * * 設定は下の CONFIG を編集してください。 * display は複数同時に true にできます(組み合わせ可)。 */ const CONFIG = { /** 翻訳元言語。空文字または "auto" で自動判定 */ sourceLanguage: "auto", /** 翻訳先言語 */ targetLanguage: "ja", /** 検出言語が targetLanguage と同じときの翻訳先 */ reverseLanguage: "en", /** curl のタイムアウト(秒) */ timeoutSec: 10, /** * 翻訳結果の表示方法(複数同時に有効化可) * - editor: エディターへ挿入(選択範囲の直下) * - outputBar: アウトプットバーへ出力 * - tip: ツールチップで表示 */ display: { editor: false, outputBar: true, tip: false, }, /** * アウトプットバー出力時に自動で表示するか * true で表示、false で表示しない */ outputBarVisible: true, /** * tip 表示時の位置フラグ * meShowTipPosCaret (キャレット位置) | meShowTipPosMouse (マウス位置) */ tipFlags: meShowTipPosCaret, }; const GOOGLE_TRANSLATE_ENDPOINT = "https://translate-pa.googleapis.com/v1/translateHtml"; const GOOGLE_AUTH_URL = "https://translate.googleapis.com/_/translate_http/_/js/k=translate_http.tr.en_US.YusFYy3P_ro.O/am=AAg/d=1/exm=el_conf/ed=1/rs=AN8SPfq1Hb8iJRleQqQc8zhdzXmF9E56eQ/m=el_main"; const GOOGLE_API_KEY_TTL_MS = 20 * 60 * 1000; const GOOGLE_API_KEY_PATTERN = /^\w{39}$/; const GOOGLE_API_KEY_TAG = "meryTranslate.apiKey"; const GOOGLE_API_KEY_FETCHED_AT_TAG = "meryTranslate.fetchedAt"; // Mery の shell.Exec は stdout の改行をスペースにすることがあるため、 // curl -w の区切りには改行を使わない。 const CURL_HTTP_CODE_MARKER = "__MERY_HTTP_CODE__"; /** 空白を除いた文字のうち、この割合以上が日本語なら API 判定前に逆翻訳する */ const JAPANESE_CHAR_RATIO_THRESHOLD = 0.3; const sel = document.selection; const console = { log: (message) => { outputBar.Writeln(message); }, }; main(); function main() { let keepResultTip = false; try { const target = resolveTranslateTarget(); if (!target) { Status = "翻訳するテキストがありません"; return; } if (!hasAnyDisplayTarget()) { throw new Error( "CONFIG.display のいずれか(editor / outputBar / tip)を true にしてください。" ); } let tipMessage = ""; if (sel.IsEmpty) { tipMessage = "この行を翻訳中..."; } else { tipMessage = "選択範囲を翻訳中..."; } ShowTip(tipMessage, meShowTipPosCaret); Status = "翻訳中…"; const outcome = runTranslation(target.text); keepResultTip = presentTranslation( outcome.translatedText, outcome.sourceLanguage, outcome.targetLanguage ); Status = "翻訳完了"; } catch (error) { const message = error && error.message ? error.message : String(error); Status = ""; alert("Translator: " + message); } finally { // tip 結果表示を残す場合は非表示にしない if (!keepResultTip) { ShowTip("", meShowTipHide); } } } function hasAnyDisplayTarget() { const display = CONFIG.display || {}; return !!(display.editor || display.outputBar || display.tip); } /** * CONFIG.display に従って翻訳結果を出力する。 * tip を使った場合は true を返し、進捗チップの非表示を抑止する。 */ function presentTranslation(translatedText, sourceLanguage, targetLanguage) { const display = CONFIG.display || {}; const block = formatTranslationBlock( translatedText, sourceLanguage, targetLanguage ); if (display.editor) { insertTranslationBelow(translatedText, sourceLanguage, targetLanguage); } if (display.outputBar) { writeTranslationToOutputBar(block); } if (display.tip) { const flags = CONFIG.tipFlags == null ? meShowTipPosCaret : CONFIG.tipFlags; ShowTip( formatTranslationTip(translatedText, sourceLanguage, targetLanguage), flags ); return true; } return false; } function formatTranslationBlock(translatedText, sourceLanguage, targetLanguage) { const header = "----- 翻訳: " + sourceLanguage + " -> " + targetLanguage + " -----"; const footer = repeatChar("-", header.length); return { header: header, footer: footer, translatedText: translatedText, full: header + "\n" + translatedText + "\n" + footer, }; } /** ツールチップ用の短い表示形式 */ function formatTranslationTip(translatedText, sourceLanguage, targetLanguage) { return ( "[翻訳: " + sourceLanguage + " -> " + targetLanguage + "]\n" + translatedText ); } function writeTranslationToOutputBar(block) { // Visible は CONFIG.outputBarVisible が true のときだけ立てる if (CONFIG.outputBarVisible) { OutputBar.Visible = true; } OutputBar.Writeln(block.full); } /** * 選択があればそれを使う。未選択ならカーソル行を選択してから返す。 */ function resolveTranslateTarget() { if (!sel.IsEmpty) { const text = normalizeSelectionText(sel.Text); if (!text.trim()) { return null; } return { text: text }; } const y = sel.GetActivePointY(mePosLogical); const line = document.GetLine(y, 0).trim(); if (!line || !String(line).trim()) { return null; } return { text: normalizeSelectionText(line) }; } function normalizeSelectionText(text) { return String(text).replace(/\r\n$/, "").replace(/\n$/, "").replace(/\r$/, ""); } function runTranslation(rawText) { const sourceLanguage = normalizeSourceLanguage(CONFIG.sourceLanguage); const text = prepareTextForTranslation(rawText); if (shouldSkipToReverseTranslation(text, sourceLanguage)) { const reversed = translate(text, "auto", CONFIG.reverseLanguage); return { translatedText: reversed.text, sourceLanguage: reversed.detectedLanguage || "ja", targetLanguage: CONFIG.reverseLanguage, }; } const first = translate(text, sourceLanguage, CONFIG.targetLanguage); let translatedText = first.text; let direction = resolveTranslationDirection(first.detectedLanguage); if (needsReverseTranslation(first.detectedLanguage)) { const second = translate(text, "auto", CONFIG.reverseLanguage); translatedText = second.text; direction = { sourceLanguage: second.detectedLanguage || first.detectedLanguage || "auto", targetLanguage: CONFIG.reverseLanguage, }; } return { translatedText: translatedText, sourceLanguage: direction.sourceLanguage, targetLanguage: direction.targetLanguage, }; } function translate(text, sourceLanguage, targetLanguage) { return translateOnce(text, sourceLanguage, targetLanguage, false); } function translateOnce(text, sourceLanguage, targetLanguage, isRetry) { const apiKey = getGoogleApiKey(isRetry); try { return executeGoogleTranslateRequest( text, sourceLanguage, targetLanguage, apiKey ); } catch (error) { if (!isRetry && error && error.isAuthError) { clearGoogleApiKeyCache(); return translateOnce(text, sourceLanguage, targetLanguage, true); } throw error; } } function getGoogleApiKey(forceRefresh) { if (!forceRefresh) { const cached = getCachedGoogleApiKey(); if (cached) { return cached; } } const fetched = fetchGoogleApiKey(); if (!isValidGoogleApiKey(fetched)) { throw new Error("Google Translate の認証情報を取得できませんでした。"); } cacheGoogleApiKey(fetched); return fetched; } function getCachedGoogleApiKey() { const store = getWindowTagStore(); if (!store) { return ""; } if ( !tagExists(store, GOOGLE_API_KEY_TAG) || !tagExists(store, GOOGLE_API_KEY_FETCHED_AT_TAG) ) { return ""; } const apiKey = String(store[GOOGLE_API_KEY_TAG] || ""); const fetchedAt = Number(store[GOOGLE_API_KEY_FETCHED_AT_TAG]); if (!isValidGoogleApiKey(apiKey) || !isFinite(fetchedAt)) { return ""; } if (Date.now() - fetchedAt >= GOOGLE_API_KEY_TTL_MS) { return ""; } return apiKey; } function cacheGoogleApiKey(apiKey) { const store = getWindowTagStore(); if (!store) { return; } store[GOOGLE_API_KEY_TAG] = apiKey; store[GOOGLE_API_KEY_FETCHED_AT_TAG] = String(Date.now()); } function clearGoogleApiKeyCache() { const store = getWindowTagStore(); if (!store) { return; } tagRemove(store, GOOGLE_API_KEY_TAG); tagRemove(store, GOOGLE_API_KEY_FETCHED_AT_TAG); } function fetchGoogleApiKey() { const fallbackKey = getGoogleFallbackApiKey(); try { const response = executeCurl([ "-sS", "-A", quoteWindowsArgument("Mozilla/5.0"), "--max-time", String(CONFIG.timeoutSec), quoteWindowsArgument(GOOGLE_AUTH_URL), ]); if (response.exitCode === 0 && response.stdout) { const extracted = extractGoogleApiKey(response.stdout); if (isValidGoogleApiKey(extracted)) { return extracted; } } } catch (error) { // ネットワークエラーでもフォールバックキーを使う } return fallbackKey; } function extractGoogleApiKey(jsText) { const match = String(jsText).match( /['"]x\-goog\-api\-key['"]\s*:\s*['"](\w{39})['"]/i ); return match && match[1] ? match[1] : ""; } function getGoogleFallbackApiKey() { const codes = [ 65, 73, 122, 97, 83, 121, 65, 84, 66, 88, 97, 106, 118, 122, 81, 76, 84, 68, 72, 69, 81, 98, 99, 112, 113, 48, 73, 104, 101, 48, 118, 87, 68, 72, 109, 79, 53, 50, 48, ]; let key = ""; for (let i = 0; i < codes.length; i++) { key += String.fromCharCode(codes[i]); } return key; } function isValidGoogleApiKey(apiKey) { return GOOGLE_API_KEY_PATTERN.test(String(apiKey || "")); } function buildGoogleRequestBody(text, sourceLanguage, targetLanguage) { const wrapped = "<pre>" + escapeHtml(text) + "</pre>"; return JSON.stringify([ [[wrapped], normalizeSourceLanguage(sourceLanguage), targetLanguage], "te", ]); } function executeGoogleTranslateRequest( text, sourceLanguage, targetLanguage, apiKey ) { const body = buildGoogleRequestBody(text, sourceLanguage, targetLanguage); const response = executeCurl([ "-sS", "-A", quoteWindowsArgument("Mozilla/5.0"), "--max-time", String(CONFIG.timeoutSec), "-X", "POST", "-H", quoteWindowsArgument("Content-Type: application/json+protobuf"), "-H", quoteWindowsArgument("X-goog-api-key: " + apiKey), "--data-binary", quoteWindowsArgument(body), "-w", quoteWindowsArgument(CURL_HTTP_CODE_MARKER + "%{http_code}"), quoteWindowsArgument(GOOGLE_TRANSLATE_ENDPOINT), ]); if (response.exitCode === 28) { throw new Error( "Google Translate への接続がタイムアウトしました(" + CONFIG.timeoutSec + " 秒)。" ); } if (response.exitCode !== 0) { throw new Error( "curl が失敗しました (exit " + response.exitCode + ")。" + (response.stderr ? "\n" + String(response.stderr).trim() : "") ); } const parsedHttp = splitCurlHttpResponse(response.stdout); const statusCode = parsedHttp.statusCode; const raw = parsedHttp.body; if (isGoogleAuthError(statusCode, raw)) { throw createAuthError("Google Translate の認証に失敗しました。"); } if (statusCode === 429 || raw.indexOf("429") === 0) { throw new Error("Google Translate のレート制限に達しました。"); } if (statusCode >= 500 && statusCode <= 599) { throw new Error("Google Translate で一時的なエラーが発生しました。"); } if (!raw) { throw new Error("Google Translate から空の応答が返されました。"); } if (raw.charAt(0) === "<") { console.log(raw.slice(0, 180)); throw new Error( "Google Translate が HTML エラーを返しました(リクエストが拒否された可能性があります)。" ); } if (statusCode && statusCode !== 200) { throwInvalidGoogleResponse(raw); } let data; try { data = JSON.parse(raw); } catch (error) { throwInvalidGoogleResponse(raw); } const result = parseGoogleTranslateHtmlResponse(data); if (!result.text.trim()) { throw new Error("翻訳結果が空でした。"); } return result; } function parseGoogleTranslateHtmlResponse(data) { if (!Array.isArray(data) || !Array.isArray(data[0])) { throwInvalidGoogleResponse(JSON.stringify(data)); } const html = data[0][0]; if (typeof html !== "string") { throwInvalidGoogleResponse(JSON.stringify(data)); } let detectedLanguage; if (Array.isArray(data[1]) && typeof data[1][0] === "string") { const detected = data[1][0].trim(); if (detected) { detectedLanguage = detected; } } return { text: unwrapGoogleTranslatedHtml(html), detectedLanguage: detectedLanguage, }; } function unwrapGoogleTranslatedHtml(html) { let result = String(html); if (result.indexOf("<pre") !== -1) { result = result.replace(/<\/pre>/i, ""); const gt = result.indexOf(">"); if (gt !== -1) { result = result.slice(gt + 1); } } const sentences = []; let idx = 0; while (true) { const sentenceStartIndex = result.indexOf("<b>", idx); if (sentenceStartIndex === -1) { break; } const sentenceFinalIndex = result.indexOf("<i>", sentenceStartIndex); if (sentenceFinalIndex === -1) { sentences.push(result.slice(sentenceStartIndex + 3)); break; } sentences.push(result.slice(sentenceStartIndex + 3, sentenceFinalIndex)); idx = sentenceFinalIndex; } if (sentences.length > 0) { result = sentences.join(" "); } result = result.replace(/<\/b>/g, ""); result = result.replace(/<a\s+i=\d+>/g, "").replace(/<\/a>/g, ""); return unescapeHtml(result); } function escapeHtml(text) { return String(text) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function unescapeHtml(text) { return String(text) .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, "&"); } function executeCurl(args) { const cmd = ["curl.exe"].concat(args).join(" "); const exec = shell.Exec(cmd, "", true); const exitCode = Number( exec.ExitCode != null ? exec.ExitCode : exec.exitCode ); const stdout = exec.StdOut != null ? exec.StdOut : exec.stdOut; const stderr = exec.StdErr != null ? exec.StdErr : exec.stdErr; return { exitCode: exitCode, stdout: String(stdout == null ? "" : stdout).replace(/^\uFEFF/, ""), stderr: String(stderr == null ? "" : stderr), }; } function splitCurlHttpResponse(raw) { const text = String(raw == null ? "" : raw).replace(/^\uFEFF/, ""); const markerIndex = text.lastIndexOf(CURL_HTTP_CODE_MARKER); if (markerIndex !== -1) { const codeStr = text.slice(markerIndex + CURL_HTTP_CODE_MARKER.length).trim(); const body = text.slice(0, markerIndex).trim(); if (/^\d{3}$/.test(codeStr)) { return { body: body, statusCode: Number(codeStr) }; } } // -w の改行がスペースになった場合の保険 const fallback = text.match(/^([\s\S]*?)(?:\s|\r|\n)+(\d{3})\s*$/); if (fallback && fallback[1] !== "") { return { body: fallback[1].trim(), statusCode: Number(fallback[2]) }; } const trimmed = text.trim(); if (/^\d{3}$/.test(trimmed)) { return { body: "", statusCode: Number(trimmed) }; } return { body: trimmed, statusCode: 0 }; } function isGoogleAuthError(statusCode, body) { if (statusCode === 401 || statusCode === 403) { return true; } if (statusCode !== 400) { return false; } const text = String(body || ""); return ( text.indexOf("API_KEY_INVALID") !== -1 || text.indexOf("API key not valid") !== -1 || text.indexOf("API key expired") !== -1 ); } function createAuthError(message) { const error = new Error(message); error.isAuthError = true; return error; } function throwInvalidGoogleResponse(raw) { const preview = String(raw == null ? "" : raw) .replace(/\s+/g, " ") .slice(0, 180); console.log(preview); throw new Error( "Google Translate が不正な JSON を返しました。\n" + preview ); } function getWindowTagStore() { try { if (Tag && typeof Tag.exists === "function") { return Tag; } } catch (error) { return null; } return null; } function tagExists(store, key) { try { return !!(store && store.exists(key)); } catch (error) { return false; } } function tagRemove(store, key) { if (!store || !tagExists(store, key)) { return; } try { if (typeof store.remove === "function") { store.remove(key); } } catch (error) { store[key] = ""; } } function resolveTranslationDirection(detectedLanguage) { if (normalizeSourceLanguage(CONFIG.sourceLanguage) !== "auto") { return { sourceLanguage: CONFIG.sourceLanguage, targetLanguage: CONFIG.targetLanguage, }; } const detected = detectedLanguage && detectedLanguage.trim(); if (detected && languagesEqual(detected, CONFIG.targetLanguage)) { return { sourceLanguage: detected, targetLanguage: CONFIG.reverseLanguage, }; } return { sourceLanguage: detected || "auto", targetLanguage: CONFIG.targetLanguage, }; } function needsReverseTranslation(detectedLanguage) { if (normalizeSourceLanguage(CONFIG.sourceLanguage) !== "auto") { return false; } const detected = detectedLanguage && detectedLanguage.trim(); if (!detected) { return false; } return languagesEqual(detected, CONFIG.targetLanguage); } /** * auto→ja のあと検出言語が ja なら en へ再翻訳している。 * ひらがな・カタカナ・漢字の割合が高ければ、1 回目の API を省略する。 */ function shouldSkipToReverseTranslation(text, sourceLanguage) { if (normalizeSourceLanguage(sourceLanguage) !== "auto") { return false; } if (!languagesEqual(CONFIG.targetLanguage, "ja")) { return false; } return looksLikeJapaneseText(text); } function looksLikeJapaneseText(text) { let japanese = 0; let total = 0; const s = String(text); for (let i = 0; i < s.length; i++) { const code = s.charCodeAt(i); if (code <= 32 || code === 127) { continue; } total += 1; if (isJapaneseCharCode(code)) { japanese += 1; } } if (total === 0) { return false; } return japanese / total >= JAPANESE_CHAR_RATIO_THRESHOLD; } function isJapaneseCharCode(code) { // ひらがな if (code >= 0x3040 && code <= 0x309f) { return true; } // カタカナ if (code >= 0x30a0 && code <= 0x30ff) { return true; } // CJK 統合漢字 if (code >= 0x4e00 && code <= 0x9fff) { return true; } // 半角カタカナ if (code >= 0xff66 && code <= 0xff9d) { return true; } // CJK 記号・句読点(、。・など) if (code >= 0x3000 && code <= 0x303f) { return true; } return false; } function languagesEqual(a, b) { return a.trim().toLowerCase() === b.trim().toLowerCase(); } function normalizeSourceLanguage(value) { const trimmed = String(value == null ? "" : value).trim(); return trimmed === "" ? "auto" : trimmed; } /** * 単独の変数風識別子(camelCase / snake_case 等)なら単語に分割する。 */ function prepareTextForTranslation(text) { const trimmed = text.trim(); if (!isVariableStyleIdentifier(trimmed)) { return text; } const words = splitVariableIdentifier(trimmed); return words.length > 0 ? words.join(" ") : text; } function isVariableStyleIdentifier(text) { if (!text || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(text)) { return false; } return ( text.includes("_") || text.includes("-") || /[a-z][A-Z]/.test(text) || /[A-Z]{2,}[a-z]/.test(text) ); } function splitVariableIdentifier(text) { return text .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") .split(/[\s_-]+/) .filter(Boolean); } /** * 選択範囲の最終行末の直後へ、vscode-translator と同じ形式で挿入する。 */ function insertTranslationBelow(translatedText, sourceLanguage, targetLanguage) { let endY = sel.GetBottomPointY(mePosLogical); const endX = sel.GetBottomPointX(mePosLogical); // 行全体選択で次行先頭 (x=1) に終わっている場合は前の行末へ if (endX === 1 && endY > sel.GetTopPointY(mePosLogical)) { endY = endY - 1; } sel.SetActivePoint(mePosLogical, 1, endY); sel.EndOfLine(false, mePosLogical); const block = formatTranslationBlock( translatedText, sourceLanguage, targetLanguage ); const body = "\n" + block.full; // 文書末尾なら、フッター直後に空行を残せるよう末尾改行を付ける const lineCount = document.GetLines(); const appendTrailingNewline = endY >= lineCount; sel.Text = appendTrailingNewline ? body + "\n" : body; // フッターの次の行先頭へカーソルを置く const translationLineCount = String(translatedText).split("\n").length; const cursorY = endY + 3 + translationLineCount; sel.SetActivePoint(mePosLogical, 1, cursorY, false); } function repeatChar(ch, count) { let out = ""; for (let i = 0; i < count; i++) { out += ch; } return out; } /** * Windows の C ランタイムが解釈するコマンドライン引数用に引用する。 * JSON の " や \ を含む --data-binary を安全に渡すために使う。 */ function quoteWindowsArgument(value) { const s = String(value); if (s === "") { return '""'; } if (!/[\s"]/.test(s)) { return s; } let result = '"'; let numBackslashes = 0; for (let i = 0; i < s.length; i++) { const ch = s.charAt(i); if (ch === "\\") { numBackslashes += 1; continue; } if (ch === '"') { result += repeatChar("\\", numBackslashes * 2 + 1) + '"'; numBackslashes = 0; continue; } result += repeatChar("\\", numBackslashes) + ch; numBackslashes = 0; } result += repeatChar("\\", numBackslashes * 2) + '"'; return result; } </syntaxhighlight>
編集内容の要約:
MeryWikiへの投稿はすべて、他の投稿者によって編集、変更、除去される場合があります。 自分が書いたものが他の人に容赦なく編集されるのを望まない場合は、ここに投稿しないでください。
また、投稿するのは、自分で書いたものか、パブリック ドメインまたはそれに類するフリーな資料からの複製であることを約束してください(詳細は
MeryWiki:著作権
を参照)。
著作権保護されている作品は、許諾なしに投稿しないでください!
このページを編集するには、下記の数式を計算してその答えを欄に入力してください (
ヘルプ
):
いちたすには =
キャンセル
編集ヘルプ
(新しいウィンドウで開きます)
スポンサーリンク
ナビゲーション メニュー
個人用ツール
ログインしていません
トーク
投稿記録
アカウント作成
ログイン
名前空間
ページ
議論
日本語
表示
閲覧
編集
履歴表示
その他
検索
スポンサーリンク
スポンサーリンク
案内
メインページ
ヘルプ
よくある質問
マクロリファレンス
マクロライブラリ
プラグインライブラリ
構文ファイル
テーマ
寄付・開発支援
練習用ページ
開発室
開発者のブログ
ツール
スポンサーリンク