Tencent Translator Enhancer

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

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

(function(){
  const SCRIPTID = 'TencentTranslatorEnhancer';
  const SCRIPTNAME = 'Tencent Translator Enhancer';
  const DEBUG = false;/*
[update]
Focus on the textarea when the tab got focused. And minor fix.

[bug]

[todo]
ウィンドウフォーカスでテキストエリアにフォーカスだよね
#...を使って外部からテキストの受け渡しができるAPIとか?
しかし現行の自分のChromeアプリに渡せる手段がないような?

[possible]

[research]
効かなくなったら data-selector が付いてるか確認

[memo]
  */
  if(window === top && console.time) console.time(SCRIPTID);
  const MS = 1, SECOND = 1000*MS, MINUTE = 60*SECOND, HOUR = 60*MINUTE, DAY = 24*HOUR, WEEK = 7*DAY, MONTH = 30*DAY, YEAR = 365*DAY;
  const LANGUAGES = [/^en/, /^zh/, /^ja/];/* [0] がデフォルト */
  const LABELS = {
    '自动识别':         ['Auto-Detect',          '自动识别',         '自動認識',           ],
    '自动检测':         ['Auto-Detect',          '自动检测',         '自動認識',           ],
    '检测到中文':       ['Chinese detected',     '检测到中文',       '中国語 検出',        ],
    '检测到英语':       ['English detected',     '检测到英语',       '英語 検出',          ],
    '检测到日语':       ['Japanese detected',    '检测到日语',       '日本語 検出',        ],
    '检测到韩语':       ['Korean detected',      '检测到韩语',       '韓国語 検出',        ],
    '检测到法语':       ['French detected',      '检测到法语',       'フランス語 検出',    ],
    '检测到西班牙语':   ['Spanish detected',     '检测到西班牙语',   'スペイン語 検出',    ],
    '检测到意大利语':   ['Italian detected',     '检测到意大利语',   'イタリア語 検出',    ],
    '检测到德语':       ['German detected',      '检测到德语',       'ドイツ語 検出',      ],
    '检测到土耳其语':   ['Turkish detected',     '检测到土耳其语',   'トルコ語 検出',      ],
    '检测到俄语':       ['Russian detected',     '检测到俄语',       'ロシア語 検出',      ],
    '检测到葡萄牙语':   ['Portuguese detected',  '检测到葡萄牙语',   'ポルトガル語 検出',  ],
    '检测到越南语':     ['Vietnamese detected',  '检测到越南语',     'ベトナム語 検出',    ],
    '检测到印尼语':     ['Indonesian detected',  '检测到印尼语',     'インドネシア語 検出',],
    '检测到泰语':       ['Thai detected',        '检测到泰语',       'タイ語 検出',        ],
    '检测到马来西亚语': ['Malaysian detected',   '检测到马来西亚语', 'マレーシア語 検出',  ],
    '检测到阿拉伯语':   ['Arabic detected',      '检测到阿拉伯语',   'アラビア語 検出',    ],
    '检测到印地语':     ['Hindi detected',       '检测到印地语',     'ヒンディー語 検出',  ],
    '中文':       ['Chinese',     '中文',       '中国語',        ],
    '英语':       ['English',     '英语',       '英語',          ],
    '日语':       ['Japanese',    '日语',       '日本語',        ],
    '韩语':       ['Korean',      '韩语',       '韓国語',        ],
    '法语':       ['French',      '法语',       'フランス語',    ],
    '西班牙语':   ['Spanish',     '西班牙语',   'スペイン語',    ],
    '意大利语':   ['Italian',     '意大利语',   'イタリア語',    ],
    '德语':       ['German',      '德语',       'ドイツ語',      ],
    '土耳其语':   ['Turkish',     '土耳其语',   'トルコ語',      ],
    '俄语':       ['Russian',     '俄语',       'ロシア語',      ],
    '葡萄牙语':   ['Portuguese',  '葡萄牙语',   'ポルトガル語',  ],
    '越南语':     ['Vietnamese',  '越南语',     'ベトナム語',    ],
    '印尼语':     ['Indonesian',  '印尼语',     'インドネシア語',],
    '泰语':       ['Thai',        '泰语',       'タイ語',        ],
    '马来西亚语': ['Malaysian',   '马来西亚语', 'マレーシア語',  ],
    '阿拉伯语':   ['Arabic',      '阿拉伯语',   'アラビア語',    ],
    '印地语':     ['Hindi',       '印地语',     'ヒンディー語',  ],
    '翻译':       ['Translate',   '翻译',       '翻訳',          ],
    '人工翻译':   ['by Human',    '人工翻译',   '翻訳家に依頼',  ],
  };
  const CORRECTIONS = [
    (s) => s.replace(/h?tt?p(s?)[::]\/\/([^\s。]+)([。. ]*)/ig, 'http$1://$2'),/* for URL */
    (s) => s.replace(/([0-9]+):([0-9]+)/g, '$1:$2'),/* for 12:30 format */
    (s) => s.replace(/,([0-9]{3})/g, ',$1'),/* for 1,000,000 format */
    (s) => s.replace(/?/g, '?'),/* for URL */
    (s) => s.replace(/:/g, ':'),/* : */
    (s) => s.replace(/\.。/g, '。'),/* may be a bug */
  ];
  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"]'),
      sourceLanguageButton: () => $('[node-type="source_language_button"]'),
      exchangeLanguageButton: () => $('[node-type="exchange_language_button"]'),
      targetLanguageButton: () => $('[node-type="target_language_button"]'),
      sourceLanguageList: () => $('[node-type="source_language_list"]'),
      targetLanguageList: () => $('[node-type="target_language_list"]'),
      translateButton: () => $('[node-type="translate_button"]'),
      humanTranslation: () => $('[node-type="human-translation"]'),
    },
    get: {
      labels: () => {
        let index = LANGUAGES.findIndex(regexp => regexp.test(window.navigator.language)) || 0;
        let labels = LABELS;
        Object.keys(labels).forEach(key => labels[key] = labels[key][index]);
        return labels;
      },
      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),
        };
      },
    },
    set: {
      languageLabel: (node, labels) => {
        let span = node.querySelector('span'), label = span.textContent.replace(/\s/g, '');
        let replaced = createElement(core.html.languageLabel(labels[label] || span.textContent))
        span.parentNode.insertBefore(replaced, span);
      },
      languageButtonLabel: (button, labels) => {
        let label = button.textContent.replace(/\s/g, '');
        let buttonTextSpan = button.querySelector('.language-button-text');
        if(buttonTextSpan) buttonTextSpan.textContent = labels[label] || buttonTextSpan.textContent;
        else button.firstChild.data = labels[label] || button.firstChild.data;
      },
      translateButtonLabel: (button, labels) => {
        let label = button.textContent.replace(/\s/g, '');
        button.textContent = labels[label] || button.textContent;
      },
      humanTranslationLabel: (button, labels) => {
        let label = button.textContent.replace(/\s/g, '');
        button.lastChild.data = labels[label] || button.lastChild.data;
      },
    },
  };
  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.restoreMode();
        core.listenUserActions();
        core.replaceLabels();
        core.expandClickableArea();
        core.reloadOnWakeUp();
      });
    },
    restoreMode: function(){
      /* ページ読み込んだ時点で往復翻訳を有効に */
      let sourceTextarea = elements.sourceTextarea, translateButton = elements.translateButton;
      if(sourceTextarea.value.includes(SEPARATORS[0]) === true){
        translateButton.click();
        setTimeout(core.translateBackSwitch, 1000);
      }
    },
    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();
        }
      });
      window.addEventListener('focus', function(e){
        elements.sourceTextarea.focus();
      });
    },
    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 sourceText = sourceTextarea.value, targetText = textpanelTargetTextblock.innerText, result = '';
      /* まだ往復翻訳してなければ */
      let selectionStart = sourceTextarea.selectionStart, selectionEnd = sourceTextarea.selectionEnd;/*カーソル位置を記憶*/
      if(sourceTextarea.value.includes(SEPARATORS[0]) === false){
        result = sourceText + SEPARATORS[0] + targetText;
      /* すでに往復翻訳済みなら */
      }else{
        sourceText = sourceText.slice(0, sourceText.indexOf(SEPARATORS[0]));
        targetText = targetText.slice(0, targetText.indexOf(SEPARATORS[1]));
        result = sourceText + SEPARATORS[0] + targetText;
      }
      /* 左辺の表示を完成させる */
      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;
      let observer = observe(textpanelTargetTextblock, function(records){
        log(textpanelTargetTextblock.dataset.status, compositing, sourceTextarea.value.replace(/\n/g, ' '), textpanelTargetTextblock.innerText.replace(/\n/g, ' '));
        /* セパレータが消されたら往復翻訳モードを終了する */
        if(sourceTextarea.value.includes(SEPARATORS[0]) === false){
          exchangeLanguageButton.dataset.translateBack = 'false';
          delete(textpanelTargetTextblock.dataset.status);
          observer.disconnect();
          return;
        }
        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];
              }
              textpanelTargetTextblock.dataset.status = 'done';
            }, 1000);/*再度更新される場合があるので*/
            break;
          /* テキスト変更を検知して自動翻訳されたタイミング */
          case('done'):
            /* 原文も訳文も変化していなければ何も処理しない */
            if(sourceTextarea.value === sourceText && textpanelTargetTextblock.innerText === targetText) return;
            if(compositing === true) return;/*sourceTextとtargetTextは更新させない!*/
            sourceText = sourceTextarea.value, targetText = textpanelTargetTextblock.innerText;
            core.translateBack();
            textpanelTargetTextblock.dataset.status = 'back';
            break;
        }
      });
      sourceTextarea.addEventListener('compositionstart', function(e){
        compositing = true;
      });
      sourceTextarea.addEventListener('compositionend', function(e){
        compositing = false;
      });
    },
    replaceLabels: function(){
      let labels = site.get.labels();
      /* 翻訳言語リスト */
      let sourceLanguageList = elements.sourceLanguageList, targetLanguageList = elements.targetLanguageList;
      [sourceLanguageList, targetLanguageList].forEach(list => {
        Array.from(list.children).forEach(li => site.set.languageLabel(li, labels));
      });
      observe(targetLanguageList, function(records){
        Array.from(targetLanguageList.children).forEach(li => site.set.languageLabel(li, labels));
      });
      /* 翻訳言語 */
      let sourceLanguageButton = elements.sourceLanguageButton, targetLanguageButton = elements.targetLanguageButton;
      [sourceLanguageButton, targetLanguageButton].forEach(button => {
        site.set.languageButtonLabel(button, labels);
        observe(button, function(records){
          site.set.languageButtonLabel(button, labels);
        });
      });
      /* 翻訳ボタン */
      site.set.translateButtonLabel(elements.translateButton, labels);
      site.set.humanTranslationLabel(elements.humanTranslation, labels);
    },
    expandClickableArea: function(){
      let textpanelSource = elements.textpanelSource, sourceTextarea = elements.sourceTextarea;
      textpanelSource.addEventListener('click', function(e){
        sourceTextarea.focus();
      }, true);
    },
    reloadOnWakeUp: function(){
      let lastTime = Date.now();
      setInterval(function(){
        let now = Date.now();
        if(now - lastTime < 3*MINUTE) lastTime = now;
        else setTimeout(() => location.reload(), 1*MINUTE);/*ネットワークの復帰を待つ*/
      }, 1*MINUTE);
    },
    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: {
      languageLabel: (label) => `<span class="replaced">${label}</span>`,
      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/*core.expandClickableAreaでやる*/ [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}
          }
          /* 翻訳言語リスト */
          [data-selector="sourceLanguageList"] > li > span.replaced,
          [data-selector="targetLanguageList"] > li > span.replaced{
            display: block;
            padding: 0 !important;
            margin: 0 1px !important;
          }
          [data-selector="sourceLanguageList"] > li > span.replaced + span,
          [data-selector="targetLanguageList"] > li > span.replaced + span{
            display: none;
          }
          /* 翻訳ボタン */
          [data-selector="humanTranslation"]{
            text-align: center;
          }
          [data-selector="humanTranslation"] .human-translation{
            display: inline-block;
            margin-left: auto;
            float: none;
          }
        </style>
      `,
    },
  };
  const setTimeout = window.setTimeout.bind(window), clearTimeout = window.clearTimeout.bind(window), setInterval = window.setInterval.bind(window), clearInterval = window.clearInterval.bind(window), requestAnimationFrame = window.requestAnimationFrame.bind(window);
  const alert = window.alert.bind(window), confirm = window.confirm.bind(window), prompt = window.prompt.bind(window), getComputedStyle = window.getComputedStyle.bind(window), fetch = window.fetch.bind(window);
  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);
})();