Tencent Translator Enhancer

在腾讯翻译君中添加往返翻译等功能。

当前为 2019-12-24 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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);
})();