Tencent Translator Enhancer

It brings back-and-forth translation to Tencent Translator (腾讯翻译君).

目前為 2019-12-24 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Tencent Translator Enhancer
// @name:ja     Tencent Translator Enhancer
// @name:zh-CN  Tencent Translator Enhancer
// @namespace   knoa.jp
// @description It brings back-and-forth translation to Tencent Translator (腾讯翻译君).
// @description:ja 騰訊翻訳君(腾讯翻译君)に往復翻訳などの機能を追加します。
// @description:zh-CN 在腾讯翻译君中添加往返翻译等功能。
// @include     https://fanyi.qq.com/
// @version     1
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTID = 'TencentTranslatorEnhancer';
  const SCRIPTNAME = 'Tencent Translator Enhancer';
  const DEBUG = false;/*
[update]

[bug]
たまに失敗するね observe検知のタイミング?

[todo]

[possible]

[memo]
  */
  if(window === top && console.time) console.time(SCRIPTID);
  const CORRECTIONS = [
    (s) => s.replace(/htt?p(s?)[::]\/\/([^\s。]+)([。. ]*)/ig, 'http$1://$2'),
    (s) => s.replace(/([0-9]+):([0-9]+)/g, '$1:$2'),
  ];
  const SEPARATORS = ['\n:\n', '\n:\n', ':'];/*翻訳元, 翻訳先, 翻訳先span.textContent */
  const RETRY = 10;
  let site = {
    targets: {
      textpanelSource: () => $('.textpanel-source'),
      sourceTextarea: () => $('[node-type="source-textarea"]'),
      textpanelTargetTextblock: () => $('[node-type="textpanel-target-textblock"]'),
      exchangeLanguageButton: () => $('[node-type="exchange_language_button"]'),
    },
    get: {
      textSrcs: (textpanelTargetTextblock) => textpanelTargetTextblock.querySelectorAll('.text-src'),
      textDsts: (textpanelTargetTextblock) => textpanelTargetTextblock.querySelectorAll('.text-dst'),
      textMatrix: (textpanelTargetTextblock) => {
        return {
          srcs: Array.from(site.get.textSrcs(textpanelTargetTextblock)).map(e => e.textContent),
          dsts: Array.from(site.get.textDsts(textpanelTargetTextblock)).map(e => e.textContent),
        };
      },
    },
  };
  let html, elements = {}, timers = {}, sizes = {};
  let core = {
    initialize: function(){
      html = document.documentElement;
      html.classList.add(SCRIPTID);
      core.ready();
      core.addStyle();
    },
    ready: function(){
      core.getTargets(site.targets, RETRY).then(() => {
        log("I'm ready.");
        core.listenUserActions();
        core.expandClickableArea();
      });
    },
    listenUserActions: function(){
      window.addEventListener('keypress', function(e){
        switch(true){
          case(e.key === 'Enter' && e.shiftKey === true):
            core.translateSwitch();
            return e.preventDefault();
          case(e.key === 'Enter' && e.ctrlKey === true):
            core.translateBackSwitch();
            return e.preventDefault();
        }
      });
    },
    translateSwitch: function(){
      /* 翻訳言語の向きを入れ替える */
      let exchangeLanguageButton = elements.exchangeLanguageButton, sourceTextarea = elements.sourceTextarea;
      exchangeLanguageButton.click();
      sourceTextarea.focus();
    },
    translateBackSwitch: function(){
      /* 往復翻訳の有効無効を切り替える */
      let exchangeLanguageButton = elements.exchangeLanguageButton;
      if(exchangeLanguageButton.dataset.translateBack === 'true'){
        exchangeLanguageButton.dataset.translateBack = 'false';
      }else{
        exchangeLanguageButton.dataset.translateBack = 'true';
        core.translateBack();
      }
    },
    translateBack: function(){
      /* 往復翻訳する */
      let exchangeLanguageButton = elements.exchangeLanguageButton;
      let sourceTextarea = elements.sourceTextarea, textpanelTargetTextblock = elements.textpanelTargetTextblock;
      let source = sourceTextarea.value, target = textpanelTargetTextblock.innerText, result = '';
      /* まだ往復翻訳してなければ */
      let selectionStart = sourceTextarea.selectionStart, selectionEnd = sourceTextarea.selectionEnd;/*カーソル位置を記憶*/
      if(sourceTextarea.value.includes(SEPARATORS[0]) === false){
        result = source + SEPARATORS[0] + target;
      /* すでに往復翻訳済みなら */
      }else{
        source = source.slice(0, source.indexOf(SEPARATORS[0]));
        target = target.slice(0, target.indexOf(SEPARATORS[1]));
        result = source + SEPARATORS[0] + target;
      }
      /* 左辺の表示を完成させる */
      CORRECTIONS.forEach(c => result = c(result));
      sourceTextarea.value = result;
      sourceTextarea.dispatchEvent(new Event('input'));
      sourceTextarea.setSelectionRange(selectionStart, selectionEnd);
      /* 右辺の表示を追従させる */
      core.translateSwitch();
      if(textpanelTargetTextblock.dataset.status !== undefined) return;
      let compositing = false, innerText = textpanelTargetTextblock.innerText;
      let observer = observe(textpanelTargetTextblock, function(records){
        if(sourceTextarea.value.includes(SEPARATORS[0]) === false){
          exchangeLanguageButton.dataset.translateBack = 'false';
          delete(textpanelTargetTextblock.dataset.status);
          observer.disconnect();
          return;
        }
        if(textpanelTargetTextblock.innerText === innerText) return;
        innerText = textpanelTargetTextblock.innerText;
        switch(textpanelTargetTextblock.dataset.status){
          /* 往復を終えた最終翻訳が取得できたタイミング */
          case(undefined):
          case('back'):
            textpanelTargetTextblock.textMatrix = site.get.textMatrix(textpanelTargetTextblock);
            core.translateSwitch();
            textpanelTargetTextblock.dataset.status = 'go';
            break;
          /* 往路スタンバイに戻ったタイミング */
          case('go'):
            setTimeout(function(){
              let textDsts = site.get.textDsts(textpanelTargetTextblock);
              for(let i = Array.from(textDsts).findIndex(t => t.textContent === SEPARATORS[2]) + 1; textDsts[i]; i++){
                textDsts[i].textContent = textpanelTargetTextblock.textMatrix.dsts[i];
                let once = observe(textDsts[i], function(r){
                  log(r);
                });
              }
              textpanelTargetTextblock.dataset.status = 'done';
            }, 1000);/*再度更新される場合があるので*/
            break;
          /* テキスト変更を検知して自動翻訳されたタイミング */
          case('done'):
            if(compositing === true) return;
            core.translateBack();
            textpanelTargetTextblock.dataset.status = 'back';
            break;
        }
      });
      sourceTextarea.addEventListener('compositionstart', function(e){
        compositing = true;
      });
      sourceTextarea.addEventListener('compositionend', function(e){
        compositing = false;
      });
    },
    expandClickableArea: function(){
      let textpanelSource = elements.textpanelSource, sourceTextarea = elements.sourceTextarea;
      textpanelSource.addEventListener('click', function(e){
        sourceTextarea.focus();
      }, true);
    },
    getTargets: function(targets, retry = 0){
      const get = function(resolve, reject, retry){
        for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
          let selected = targets[key]();
          if(selected){
            if(selected.length) selected.forEach((s) => s.dataset.selector = key);
            else selected.dataset.selector = key;
            elements[key] = selected;
          }else{
            if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
            log(`Not found: ${key}, retrying... (left ${retry})`);
            return setTimeout(get, 1000, resolve, reject, retry);
          }
        }
        resolve();
      };
      return new Promise(function(resolve, reject){
        get(resolve, reject, retry);
      });
    },
    addStyle: function(name = 'style'){
      if(core.html[name] === undefined) return;
      let style = createElement(core.html[name]());
      document.head.appendChild(style);
      if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
      elements[name] = style;
    },
    html: {
      style: () => `
        <style type="text/css">
          /* 翻訳方向スイッチボタン */
          [data-selector="exchangeLanguageButton"]{
            border: 1px solid transparent;
            border-radius: 100%;
            width: 36px;
            height: 36px;
          }
          [data-selector="exchangeLanguageButton"][data-translate-back="true"]{
            border: 1px solid rgb(160, 76, 247);
          }
          /* クリッカブル領域を広げる */
          [data-selector="textpanelSource"]{
            cursor: text;
          }
          dummy [data-selector="sourceTextarea"]{
            height: 100% !important;
          }
          /* 往復翻訳処理中 */
          [data-selector="textpanelTargetTextblock"]{
            transition: opacity 125ms;
          }
          [data-selector="textpanelTargetTextblock"][data-status="back"],
          [data-selector="textpanelTargetTextblock"][data-status="go"]{
            animation: ${SCRIPTID}-blink 500ms ease infinite;
          }
          @keyframes ${SCRIPTID}-blink{
              0%{opacity: .250}
            100%{opacity: .125}
          }
        </style>
      `,
    },
  };
  const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
  const alert = window.alert, confirm = window.confirm, getComputedStyle = window.getComputedStyle, fetch = window.fetch;
  if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
  class Storage{
    static key(key){
      return (SCRIPTID) ? (SCRIPTID + '-' + key) : key;
    }
    static save(key, value, expire = null){
      key = Storage.key(key);
      localStorage[key] = JSON.stringify({
        value: value,
        saved: Date.now(),
        expire: expire,
      });
    }
    static read(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.value === undefined) return data;
      if(data.expire === undefined) return data;
      if(data.expire === null) return data.value;
      if(data.expire < Date.now()) return localStorage.removeItem(key);
      return data.value;
    }
    static delete(key){
      key = Storage.key(key);
      delete localStorage.removeItem(key);
    }
    static saved(key){
      key = Storage.key(key);
      if(localStorage[key] === undefined) return undefined;
      let data = JSON.parse(localStorage[key]);
      if(data.saved) return data.saved;
      else return undefined;
    }
  }
  const $ = function(s, f){
    let target = document.querySelector(s);
    if(target === null) return null;
    return f ? f(target) : target;
  };
  const $$ = function(s){return document.querySelectorAll(s)};
  const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
  const createElement = function(html = '<span></span>'){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){
    let observer = new MutationObserver(callback.bind(element));
    observer.observe(element, options);
    return observer;
  };
  const normalize = function(string){
    return string.replace(/[!-~]/g, function(s){
      return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
    }).replace(normalize.RE, function(s){
      return normalize.KANA[s];
    }).replace(/ /g, ' ').replace(/~/g, '〜');
  };
  normalize.KANA = {
    ガ:'ガ', ギ:'ギ', グ:'グ', ゲ:'ゲ', ゴ: 'ゴ',
    ザ:'ザ', ジ:'ジ', ズ:'ズ', ゼ:'ゼ', ゾ: 'ゾ',
    ダ:'ダ', ヂ:'ヂ', ヅ:'ヅ', デ:'デ', ド: 'ド',
    バ:'バ', ビ:'ビ', ブ:'ブ', ベ:'ベ', ボ: 'ボ',
    パ:'パ', ピ:'ピ', プ:'プ', ペ:'ペ', ポ: 'ポ',
    ヷ:'ヷ', ヺ:'ヺ', ヴ:'ヴ',
    ア:'ア', イ:'イ', ウ:'ウ', エ:'エ', オ:'オ',
    カ:'カ', キ:'キ', ク:'ク', ケ:'ケ', コ:'コ',
    サ:'サ', シ:'シ', ス:'ス', セ:'セ', ソ:'ソ',
    タ:'タ', チ:'チ', ツ:'ツ', テ:'テ', ト:'ト',
    ナ:'ナ', ニ:'ニ', ヌ:'ヌ', ネ:'ネ', ノ:'ノ',
    ハ:'ハ', ヒ:'ヒ', フ:'フ', ヘ:'ヘ', ホ:'ホ',
    マ:'マ', ミ:'ミ', ム:'ム', メ:'メ', モ:'モ',
    ヤ:'ヤ', ユ:'ユ', ヨ:'ヨ',
    ラ:'ラ', リ:'リ', ル:'ル', レ:'レ', ロ:'ロ',
    ワ:'ワ', ヲ:'ヲ', ン:'ン',
    ァ:'ァ', ィ:'ィ', ゥ:'ゥ', ェ:'ェ', ォ:'ォ',
    ッ:'ッ', ャ:'ャ', ュ:'ュ', ョ:'ョ',
    "。":'。', "、":'、', "ー":'ー', "「":'「', "」":'」', "・":'・',
  };
  normalize.RE = new RegExp('(' + Object.keys(normalize.KANA).join('|') + ')', 'g');
  const log = function(){
    if(!DEBUG) return;
    let l = log.last = log.now || new Date(), n = log.now = new Date();
    let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
    //console.log(error.stack);
    console.log(
      (SCRIPTID || '') + ':',
      /* 00:00:00.000  */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
      /* +0.000s       */ '+' + ((n-l)/1000).toFixed(3) + 's',
      /* :00           */ ':' + line,
      /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
      /* caller        */ (callers[1] || '') + '()',
      ...arguments
    );
  };
  log.formats = [{
      name: 'Firefox Scratchpad',
      detector: /MARKER@Scratchpad/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Console',
      detector: /MARKER@debugger/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 3',
      detector: /\/gm_scripts\//,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Greasemonkey 4+',
      detector: /MARKER@user-script:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Firefox Tampermonkey',
      detector: /MARKER@moz-extension:/,
      getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
      getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
    }, {
      name: 'Chrome Console',
      detector: /at MARKER \(<anonymous>/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
    }, {
      name: 'Chrome Tampermonkey',
      detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?id=/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 6,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
    }, {
      name: 'Chrome Extension',
      detector: /at MARKER \(chrome-extension:/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
    }, {
      name: 'Edge Console',
      detector: /at MARKER \(eval/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
    }, {
      name: 'Edge Tampermonkey',
      detector: /at MARKER \(Function/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
    }, {
      name: 'Safari',
      detector: /^MARKER$/m,
      getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
      getCallers: (e) => e.stack.split('\n'),
    }, {
      name: 'Default',
      detector: /./,
      getLine: (e) => 0,
      getCallers: (e) => [],
    }];
  log.format = log.formats.find(function MARKER(f){
    if(!f.detector.test(new Error().stack)) return false;
    //console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
    return true;
  });
  const time = function(label){
    if(!DEBUG) return;
    const BAR = '|', TOTAL = 100;
    switch(true){
      case(label === undefined):/* time() to output total */
        let total = 0;
        Object.keys(time.records).forEach((label) => total += time.records[label].total);
        Object.keys(time.records).forEach((label) => {
          console.log(
            BAR.repeat((time.records[label].total / total) * TOTAL),
            label + ':',
            (time.records[label].total).toFixed(3) + 'ms',
            '(' + time.records[label].count + ')',
          );
        });
        time.records = {};
        break;
      case(!time.records[label]):/* time('label') to create and start the record */
        time.records[label] = {count: 0, from: performance.now(), total: 0};
        break;
      case(time.records[label].from === null):/* time('label') to re-start the lap */
        time.records[label].from = performance.now();
        break;
      case(0 < time.records[label].from):/* time('label') to add lap time to the record */
        time.records[label].total += performance.now() - time.records[label].from;
        time.records[label].from = null;
        time.records[label].count += 1;
        break;
    }
  };
  time.records = {};
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
})();