「Mery 翻訳」の版間の差分

Yuko (トーク | 投稿記録)
編集の要約なし
Yuko (トーク | 投稿記録)
編集の要約なし
 
323行目: 323行目:
  * Mery 翻訳マクロ
  * Mery 翻訳マクロ
  *
  *
  * VSCode 拡張「Comment Translate」と同じ Google 翻訳の非公式エンドポイントを、
  * Traduzir-paginas-web と同じ Google 翻訳クライアント API
  * shell.Exec + curl で呼び出して翻訳します。
* (POST https://translate-pa.googleapis.com/v1/translateHtml) を、
  * (q は encodeURIComponent して渡します。stdin の q@- は Mery 経由だと欠落します)
  * shell.Exec + curl.exe で呼び出して翻訳します。
  *
* API キーは Google 配信 JS から x-goog-api-key を抽出し、
* Window.Tag に最大 20 分キャッシュします。翻訳結果はキャッシュしません。
  *
  *
  * 使い方:
  * 使い方:
356行目: 359行目:
   */
   */
   display: {
   display: {
     editor: true,
     editor: false,
     outputBar: false,
     outputBar: true,
     tip: false,
     tip: false,
   },
   },
374行目: 377行目:
};
};


const ENDPOINT = "https://translate.google.com/translate_a/single";
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) {
   // q@-(stdin)は Mery の shell.Exec 経由だと curl に届かず、
   return translateOnce(text, sourceLanguage, targetLanguage, false);
   // Google が HTML 400 を返して JSON パースに失敗するため使わない。
}
   // q は encodeURIComponent 済みで渡し、コマンドラインへの生テキスト埋め込みを避ける。
 
   const cmd = [
function translateOnce(text, sourceLanguage, targetLanguage, isRetry) {
     "curl.exe",
  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",
    "-G",
     "-A",
     "-A",
     quoteArg("Mozilla/5.0"),
     quoteWindowsArgument("Mozilla/5.0"),
     "--max-time",
     "--max-time",
     String(CONFIG.timeoutSec),
     String(CONFIG.timeoutSec),
    quoteArg(ENDPOINT),
     "-X",
     "--data-urlencode",
     "POST",
     quoteArg("client=gtx"),
     "-H",
     "--data-urlencode",
     quoteWindowsArgument("Content-Type: application/json+protobuf"),
     quoteArg("sl=" + normalizeSourceLanguage(sourceLanguage)),
     "-H",
     "--data-urlencode",
     quoteWindowsArgument("X-goog-api-key: " + apiKey),
     quoteArg("tl=" + targetLanguage),
     "--data-binary",
    "--data-urlencode",
     quoteWindowsArgument(body),
    quoteArg("dt=t"),
     "-w",
     "--data-urlencode",
     quoteWindowsArgument(CURL_HTTP_CODE_MARKER + "%{http_code}"),
     quoteArg("ie=UTF-8"),
     quoteWindowsArgument(GOOGLE_TRANSLATE_ENDPOINT),
     "--data-urlencode",
   ]);
     quoteArg("oe=UTF-8"),
     "--data",
    quoteArg("q=" + encodeURIComponent(text)),
   ].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;
  const raw = String(stdout == null ? "" : stdout)
    .replace(/^\uFEFF/, "")
    .trim();


   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) {
     const preview = raw.replace(/\s+/g, " ").slice(0, 180);
     throwInvalidGoogleResponse(raw);
    throw new Error(
      "Google Translate が不正な JSON を返しました。\n" + preview
    );
   }
   }


   const result = parseGoogleResponse(data);
   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])) {
* 形の概略: [[["訳文","原文",...], ...], null, "detectedLang", ...]
     throwInvalidGoogleResponse(JSON.stringify(data));
*/
function parseGoogleResponse(data) {
   if (!Array.isArray(data)) {
     throw new Error("Google Translate の応答が配列ではありません。");
   }
   }


   const segments = data[0];
   const html = data[0][0];
   if (!Array.isArray(segments)) {
   if (typeof html !== "string") {
     throw new Error("翻訳セグメントがありません。");
     throwInvalidGoogleResponse(JSON.stringify(data));
   }
   }


   const parts = [];
   let detectedLanguage;
   for (const segment of segments) {
   if (Array.isArray(data[1]) && typeof data[1][0] === "string") {
    if (!Array.isArray(segment) || typeof segment[0] !== "string") {
    const detected = data[1][0].trim();
      throw new Error("不正な翻訳セグメントが含まれています。");
    if (detected) {
      detectedLanguage = detected;
     }
     }
    parts.push(segment[0]);
   }
   }


   return {
   return {
     text: parts.join(""),
     text: unwrapGoogleTranslatedHtml(html),
     detectedLanguage: extractDetectedLanguage(data),
     detectedLanguage: detectedLanguage,
   };
   };
}
}


function extractDetectedLanguage(data) {
function unwrapGoogleTranslatedHtml(html) {
   const candidate = data[2];
   let result = String(html);
   if (typeof candidate === "string" && candidate.trim() !== "") {
   if (result.indexOf("<pre") !== -1) {
     return candidate;
    result = result.replace(/<\/pre>/i, "");
    const gt = result.indexOf(">");
    if (gt !== -1) {
      result = result.slice(gt + 1);
     }
   }
   }


   const nested = data[8];
   const sentences = [];
   if (Array.isArray(nested) && Array.isArray(nested[0])) {
   let idx = 0;
     const first = nested[0][0];
  while (true) {
     if (typeof first === "string" && first.trim() !== "") {
     const sentenceStartIndex = result.indexOf("<b>", idx);
       return first;
     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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}


   return undefined;
function unescapeHtml(text) {
   return String(text)
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&amp;/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 quoteArg(value) {
/**
   // Windows のコマンドライン向けにダブルクォートで囲む
* Windows の C ランタイムが解釈するコマンドライン引数用に引用する。
   return '"' + String(value).replace(/"/g, '\\"') + '"';
* 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 公開
スポンサーリンク