「Mery 翻訳」の版間の差分
編集の要約なし |
編集の要約なし |
||
| 323行目: | 323行目: | ||
* Mery 翻訳マクロ | * Mery 翻訳マクロ | ||
* | * | ||
* | * Traduzir-paginas-web と同じ Google 翻訳クライアント API | ||
* shell.Exec + curl で呼び出して翻訳します。 | * (POST https://translate-pa.googleapis.com/v1/translateHtml) を、 | ||
* | * shell.Exec + curl.exe で呼び出して翻訳します。 | ||
* | |||
* API キーは Google 配信 JS から x-goog-api-key を抽出し、 | |||
* Window.Tag に最大 20 分キャッシュします。翻訳結果はキャッシュしません。 | |||
* | * | ||
* 使い方: | * 使い方: | ||
| 356行目: | 359行目: | ||
*/ | */ | ||
display: { | display: { | ||
editor: | editor: false, | ||
outputBar: | outputBar: true, | ||
tip: false, | tip: false, | ||
}, | }, | ||
| 374行目: | 377行目: | ||
}; | }; | ||
const | 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 sel = document.selection; | ||
const console = { | |||
log: (message) => { | |||
outputBar.Writeln(message); | |||
}, | |||
}; | |||
main(); | main(); | ||
| 528行目: | 551行目: | ||
const sourceLanguage = normalizeSourceLanguage(CONFIG.sourceLanguage); | const sourceLanguage = normalizeSourceLanguage(CONFIG.sourceLanguage); | ||
const text = prepareTextForTranslation(rawText); | 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); | const first = translate(text, sourceLanguage, CONFIG.targetLanguage); | ||
| 551行目: | 583行目: | ||
function translate(text, sourceLanguage, targetLanguage) { | function translate(text, sourceLanguage, targetLanguage) { | ||
/ | return translateOnce(text, sourceLanguage, targetLanguage, false); | ||
// | } | ||
// | |||
const | 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", | "-sS", | ||
"-A", | "-A", | ||
quoteWindowsArgument("Mozilla/5.0"), | |||
"--max-time", | "--max-time", | ||
String(CONFIG.timeoutSec), | String(CONFIG.timeoutSec), | ||
"-X", | |||
"- | "POST", | ||
"-H", | |||
"- | quoteWindowsArgument("Content-Type: application/json+protobuf"), | ||
"-H", | |||
"- | quoteWindowsArgument("X-goog-api-key: " + apiKey), | ||
"--data-binary", | |||
quoteWindowsArgument(body), | |||
"-w", | |||
"--data- | quoteWindowsArgument(CURL_HTTP_CODE_MARKER + "%{http_code}"), | ||
quoteWindowsArgument(GOOGLE_TRANSLATE_ENDPOINT), | |||
"- | ]); | ||
] | |||
if (exitCode === 28) { | if (response.exitCode === 28) { | ||
throw new Error( | throw new Error( | ||
"Google Translate への接続がタイムアウトしました(" + | "Google Translate への接続がタイムアウトしました(" + | ||
| 597行目: | 750行目: | ||
} | } | ||
if (exitCode !== 0) { | if (response.exitCode !== 0) { | ||
throw new Error( | throw new Error( | ||
"curl が失敗しました (exit " + | "curl が失敗しました (exit " + | ||
exitCode + | response.exitCode + | ||
")。" + | ")。" + | ||
(stderr ? "\n" + String(stderr).trim() : "") | (response.stderr ? "\n" + String(response.stderr).trim() : "") | ||
); | ); | ||
} | } | ||
if (raw.indexOf("429") === 0) { | 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 のレート制限に達しました。"); | throw new Error("Google Translate のレート制限に達しました。"); | ||
} | |||
if (statusCode >= 500 && statusCode <= 599) { | |||
throw new Error("Google Translate で一時的なエラーが発生しました。"); | |||
} | } | ||
| 615行目: | 780行目: | ||
if (raw.charAt(0) === "<") { | if (raw.charAt(0) === "<") { | ||
console.log(raw.slice(0, 180)); | |||
throw new Error( | throw new Error( | ||
"Google Translate が HTML エラーを返しました(リクエストが拒否された可能性があります)。" | "Google Translate が HTML エラーを返しました(リクエストが拒否された可能性があります)。" | ||
); | ); | ||
} | |||
if (statusCode && statusCode !== 200) { | |||
throwInvalidGoogleResponse(raw); | |||
} | } | ||
| 624行目: | 794行目: | ||
data = JSON.parse(raw); | data = JSON.parse(raw); | ||
} catch (error) { | } catch (error) { | ||
throwInvalidGoogleResponse(raw); | |||
} | } | ||
const result = | const result = parseGoogleTranslateHtmlResponse(data); | ||
if (!result.text.trim()) { | if (!result.text.trim()) { | ||
throw new Error("翻訳結果が空でした。"); | throw new Error("翻訳結果が空でした。"); | ||
| 637行目: | 804行目: | ||
} | } | ||
function parseGoogleTranslateHtmlResponse(data) { | |||
if (!Array.isArray(data) || !Array.isArray(data[0])) { | |||
throwInvalidGoogleResponse(JSON.stringify(data)); | |||
function | |||
if (!Array.isArray(data)) { | |||
} | } | ||
const | const html = data[0][0]; | ||
if (! | 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 { | return { | ||
text: | text: unwrapGoogleTranslatedHtml(html), | ||
detectedLanguage: | detectedLanguage: detectedLanguage, | ||
}; | }; | ||
} | } | ||
function | function unwrapGoogleTranslatedHtml(html) { | ||
let result = String(html); | |||
if ( | if (result.indexOf("<pre") !== -1) { | ||
result = result.replace(/<\/pre>/i, ""); | |||
const gt = result.indexOf(">"); | |||
if (gt !== -1) { | |||
result = result.slice(gt + 1); | |||
} | |||
} | } | ||
const | const sentences = []; | ||
let idx = 0; | |||
const | while (true) { | ||
if ( | 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, "'"); | |||
} | |||
return | 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] = ""; | |||
} | |||
} | } | ||
| 713行目: | 1,013行目: | ||
} | } | ||
return languagesEqual(detected, CONFIG.targetLanguage); | 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; | |||
} | } | ||
| 799行目: | 1,157行目: | ||
} | } | ||
function | /** | ||
// | * 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> | </syntaxhighlight> | ||
| 807行目: | 1,193行目: | ||
== 更新履歴 == | == 更新履歴 == | ||
* 2026-08-28 翻訳APIのエンドポイントおよび実行方式を変更しました | |||
* 2026-08-12 出力方法を設定できるようにしました | * 2026-08-12 出力方法を設定できるようにしました | ||
* 2026-08-11 公開 | * 2026-08-11 公開 | ||
スポンサーリンク