Greasy Fork is available in English.

Bilibili Live Comment Translator

Add translation on streaming user comments.

נכון ליום 07-06-2019. ראה הגרסה האחרונה.

// ==UserScript==
// @name     Bilibili Live Comment Translator
// @name:ja     Bilibili Live Comment Translator
// @namespace   knoa.jp
// @description Add translation on streaming user comments.
// @description:ja ビリビリ生放送(直播)のユーザーコメント(弾幕)を自動翻訳します。
// @include     https://live.bilibili.com/*
// @exclude     https://live.bilibili.com/
// @version     1
// @require     https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.10/pako_inflate.min.js
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTNAME = 'BilibiliLiveCommentTranslator';
  const DEBUG = false;/*

[bug]
iframe内で映像配信する放送に対応できていない。
https://live.bilibili.com/76?visit_id=6kwlti59xlg0

[to do]
ページ遷移次第で不要要素まで翻訳しすぎる target_blank してなければセーフ?
target_blank 無効時の対策でURL変化を検出して再度処理する?

[to research]
主要UI要素を指定翻訳語として登録しておきたい
  動的に生成される要素の対応がめんどくさい
定型コメントほかにもたくさん登録しとく?
自分のコメントの翻訳時も逆辞書で節約と蓄積?
日本語と英語は翻訳しない方針で問題ないよね?
Google翻訳の一般Webユーザーのフリをして各ユーザーにAPIを叩かせる手もあるようだが
  https://github.com/andy-portmen/Simple-Translate/blob/master/src/lib/common.js
  それが許されるならBaiduのAPIを叩かせることも可能?
翻訳文をただ置き換えてしまう設定項目は趣旨に反する?
翻訳辞書を共有サーバーに溜め込む仕組み?
ブラウザの言語設定の変更に対応すべき?

[memo]
1. 翻訳辞書構築の流れ
1-1. core.listenWebSocketsで弾幕テキストを取得(要素出現より1秒ほど早く取得できる)
1-2. Translatorに弾幕テキストを登録
1-3. TranslatorがpriorDanmaku要素に弾幕テキスト要素を設置
1-4. Chromeが弾幕テキスト要素を自動翻訳してくれる
1-5. Translatorが察知して辞書として登録

2. 弾幕訳文追加の流れ
2-1. core.observeVideoDanmakuで弾幕要素を発見
2-2. Danmakuインスタンスを作成してTranslatorに登録
2-3. 弾幕テキストに一致する辞書がすでにあればすぐに訳文を追加
2-4. なければ1-5.のタイミングで訳文を追加

3. 自分の投稿コメント翻訳
Google Apps Script (推定1日7000回(=1回5文字で月100万文字相当)を超えたあたりで制限がかかる)
https://qiita.com/tanabee/items/c79c5c28ba0537112922
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const ISMAC = (window.navigator.userAgent.match(/Mac/) !== null);
  const CHATSERVER = 'chat.bilibili.com';
  const TRANSLATOR = 'https://script.google.com/macros/s/AKfycby29iFLZ742UEC6TlN8-b4Dxtlu_7XYbVeo2GgiYVWMtuzIcbA/exec?text={text}&source={source}&target={target}';
  const HISTORYLENGTH = 100000;/*辞書の最大保持数(10万で5MB見込み)*/
  const BILIBILILANGUAGE = 'zh-CN';
  const USERLANGUAGE = navigator.language;
  const TRANSLATIONS = {
    ja: {
      inputTranslationKey: ISMAC ? '(Command+Enterで翻訳)' : '(Ctrl+Enterで翻訳)',
    },
    en: {
      inputTranslationKey: ISMAC ? '(Command+Enter to translate)' : '(Ctrl+Enter to translate)',
    },
  };
  const DICTIONARIES = {
    ja: {
      '哔哩哔哩 (゜-゜)つロ 干杯~': 'ビリビリ (゜-゜)つロ 乾杯~',
    },
    en: {
      '哔哩哔哩 (゜-゜)つロ 干杯~': 'bilibili (゜-゜)つロ cheers~',
    },
  };
  const REGEXP = {
    hasKana: /[ぁ-んァ-ン]/,
    allAlphabet: /^[a-zA-Z0-9,.'"!?\s]+$/,
    allEmoji: /^(\ud83c[\udf00-\udfff]|\ud83d[\udc00-\ude4f]|\ud83d[\ude80-\udeff]|\ud7c9[\ude00-\udeff]|[\u2600-\u27BF])+$/,
  };
  const EASING = 'cubic-bezier(0,.75,.5,1)';/*主にナビゲーションのアニメーション用*/
  const RETRY = 10;
  let site = {
    targets: {
      operableContainer: () => $('.bilibili-live-player-video-operable-container'),/*特殊弾幕枠*/
      videoDanmaku: () => $('.bilibili-live-player-video-danmaku'),
      chatHistoryList: () => $('#chat-history-list'),
      chatInput: () => $('.chat-input'),
    },
    translationTargets: [
      [false, () => $('title')],
      [false, () => $('body')],
      [  true,  () => $('.bilibili-live-player-video-controller')],/*プレイヤ内コントローラ*/
      [    false, () => $('.bilibili-live-player-video-controller-duration-btn > div > span')],
      [  true,  () => $('#chat-control-panel-vm')],/*投稿欄内コントローラ*/
      [    false, () => $('#chat-control-panel-vm .bottom-actions')],
    ],
    get: {
      operableSpace: (operableContainer) => operableContainer.querySelector('#pk-vm + div'),
    }
  };
  let html, elements = {}, storages = {}, timers = {}, sizes = {};
  let translator, translations = {};
  class Packet{
    /* Bilibili Live WebSocket message packet */
    /* thanks to:
      https://segmentfault.com/a/1190000017328813
      https://blog.csdn.net/xuchen16/article/details/81064372
      https://github.com/shugen002/userscript/blob/master/BiliBili%20WebSocket%20Proxy%20Rebuild.user.js
    */
    constructor(buffer){
      Packet.VERSION_COMPRESSED = 2;/* protocol version for compressed body */
      Packet.OPERATION_COMMAND = 5;/* operation type for command */
      Packet.COMMAND_DANMAKU = 'DANMU_MSG';/* command code for 弾幕(danmaku/danmu) */
      this.buffer = buffer;
      this.dataView = new DataView(buffer);
      this.views = {
        package:   this.dataView.getUint32(0),/* packet length */
        header:    this.dataView.getUint16(4),/* header length = offset for body */
        version:   this.dataView.getUint16(6),/* protocol version */
        operation: this.dataView.getUint32(8),/* operation type */
      };
      try{
        this.array = this.getArray();
        this.messages = this.getMessages();
      }catch(e){
        log(e, this.views, new Uint8Array(this.buffer));
      }
    }
    getArray(){
      return (this.isCompressed)
        ? pako.inflate(new Uint8Array(this.buffer, this.views.header))
        : new Uint8Array(this.buffer)
      ;
    }
    getMessages(){
      let dataView = new DataView(this.array.buffer);
      let messages = [], headerLength = this.views.header, decoder = new TextDecoder();
      for(let pos = 0, packetLength = 0; pos < this.array.length; pos += packetLength){
        packetLength = dataView.getUint32(pos);
        let subarray = this.array.subarray(pos + headerLength, pos + packetLength);
        let string = decoder.decode(subarray);
        messages.push(string[0] === '{' ? JSON.parse(string) : string);
      }
      return messages;
    }
    getDanmakuContents(){
      return this.getDanmakus().map(d => {
        if(d.info === undefined) return log('Unexpected Danmaku JSON.', d), null;
        return d.info[1];
      });
    }
    getDanmakus(){
      if(this.isCommand === false) return [];
      return this.messages.filter(m => {
        if(m.cmd === undefined) return log('Unexpected Command JSON:', m), false;
        return m.cmd.startsWith(Packet.COMMAND_DANMAKU);
      });
    }
    get isCompressed(){
      return (this.views.version === Packet.VERSION_COMPRESSED);
    }
    get isCommand(){
      return (this.views.operation === Packet.OPERATION_COMMAND);
    }
  }
  class Translator{
    /* Danmaku translator using the browser's auto translation */
    constructor(){
      Translator.HISTORYLENGTH = HISTORYLENGTH;
      Translator.DICTIONARY = DICTIONARIES[USERLANGUAGE] || DICTIONARIES[USERLANGUAGE.substring(0, 2)] || DICTIONARIES.en;
      Translator.PRIOR_WAITING_LIMIT = 10*1000;/* waiting limit for auto translation by browser */
      this.counters = {push: 0, registerTranslation: 0};
      this.dictionary = Storage.read('dictionary') || Translator.DICTIONARY;
      this.history = Storage.read('history') || [];
      this.priorDanmaku = this.createPriorDanmaku();
      this.priorDanmakuWaitings = {};
      this.danmakuWaitings = {};
    }
    createPriorDanmaku(){
      /* Append danmaku comments from WebSocket for translating by browser as fast as possible */
      let priorDanmaku = elements.priorDanmaku = createElement(core.html.priorDanmaku());
      document.body.appendChild(priorDanmaku);
      return priorDanmaku;
    }
    push(original){
      this.counters.push++;
      if(this.dictionary[original] !== undefined) return;/* already exists in the dictionary */
      if(this.priorDanmakuWaitings[original] !== undefined) return;/* already waiting for translation */
      if(this.shouldBeTranslated(original) === false) return;/* seems not to be Chinese */
      let span = createElement(core.html.danmakuContent(original));
      this.priorDanmaku.appendChild(span);
      this.priorDanmakuWaitings[original] = span;
      /* Observe auto translation by browser */
      let observer = observe(span, (records) => {
        this.registerTranslation(original, span.textContent);
        this.removeWaiting(original, span, observer);
      });
      /* Time to give up */
      setTimeout(() => {
        if(span && span.isConnected){
          log('Give up for waiting translated:', original);
          this.removeWaiting(original, span, observer);
        }
      }, Translator.PRIOR_WAITING_LIMIT);
    }
    registerTranslation(original, translation){
      this.counters.registerTranslation++;
      this.dictionary[original] = translation;
      this.history.push(original);
      /* append the translation for each streaming danmakus */
      if(this.danmakuWaitings[original]){
        this.danmakuWaitings[original].forEach(d => this.appendTranslation(d, translation));
        delete this.danmakuWaitings[original];
      }
    }
    removeWaiting(original, span, observer){
      observer.disconnect();
      span.parentNode.removeChild(span);
      delete this.priorDanmakuWaitings[original];
    }
    requestTranslation(danmaku){
      if(danmaku.textContent === this.dictionary[danmaku.textContent]) return;/* the same in letters */
      if(this.shouldBeTranslated(danmaku.textContent) === false) return;/* seems not to be Chinese */
      if(this.dictionary[danmaku.textContent] === undefined){
        if(this.danmakuWaitings[danmaku.textContent] === undefined) this.danmakuWaitings[danmaku.textContent] = [];
        this.danmakuWaitings[danmaku.textContent].push(danmaku);
      }else{
        this.appendTranslation(danmaku, this.dictionary[danmaku.textContent]);
      }
    }
    appendTranslation(danmaku, translation){
      danmaku.appendTranslation(translation);
    }
    shouldBeTranslated(textContent){
      switch(true){
        case(this.dictionary[textContent] !== undefined):/* has translation */
          return true;
        case(textContent.match(REGEXP.hasKana) !== null):/* seems to be Japanese */
        case(textContent.match(REGEXP.allAlphabet) !== null):/* seems to be English */
        case(textContent.match(REGEXP.allEmoji) !== null):/* seems to be Emoji */
          return false;
        default:
          return true;
      }
    }
    save(){
      /*  log usage statistics */
      let c = this.counters, saved = (((c.push - c.registerTranslation)/(c.push || 1))*100).toFixed(0) + '%';
      log('Total danmaku:', c.push, 'Newly translated:', c.registerTranslation, 'Saved:', saved);
      /* save the dictionary and the history of latest HISTORYLENGTH pairs */
      let newDictionary = {}, newHistory = [];
      for(let i = this.history.length - 1, count = 0; 0 <= i; i--){
        if(newDictionary[this.history[i]] !== undefined) continue;
        newDictionary[this.history[i]] = this.dictionary[this.history[i]];
        newHistory[count] = this.history[i];
        if(count++ === Translator.HISTORYLENGTH) break;
      }
      Object.keys(Translator.DICTIONARY).forEach(key => {
        newDictionary[key] = Translator.DICTIONARY[key];
      });
      log('Dictionary length:', newHistory.length, 'Stored size:', toMetric(JSON.stringify(newDictionary).length * 2) + 'bytes');
      Storage.save('dictionary', newDictionary);
      Storage.save('history', newHistory.reverse());
    }
  }
  class Danmaku{
    constructor(danmaku){
      Danmaku.zIndex = Danmaku.zIndex || 1;
      this.element = danmaku;
      this.textContent = danmaku.textContent;
      this.modify();
    }
    modify(){
      this.element.style.zIndex = Danmaku.zIndex++;/* newer comments have priority */
      /* Make space for appending translation text */
      this.element.style.top = (() => {
        let operableContainer = elements.operableContainer, operableSpace = site.get.operableSpace(operableContainer);
        if(operableSpace === null || operableSpace.children.length === 0){
          return (parseInt(this.element.style.top) * 2) + 'px';
        }else{
          let height = parseInt(operableSpace.style.height), top = parseInt(this.element.style.top);
          return (height + ((top - height) * 2)) + 'px';
        }
      })();
      /* Even if double long translation text added, keep streaming to fully go away */
      this.element.style.transitionDuration = (() => {
        let m = this.element.style.transitionDuration.match(/([0-9.]+)(m?s)/);
        return (parseFloat(m[1]) * 2) + m[2];
      })();
      this.element.style.transform = (() => {
        let m = this.element.style.transform.match(/(translateX?)\(([-0-9.]+)(px)/);
        return this.element.style.transform.replace(m[0], `${m[1]}(${parseFloat(m[2]) * 2}${m[3]}`);
      })();
    }
    appendTranslation(translation){
      let span = createElement(core.html.translation(translation));
      this.element.appendChild(span);
      span.animate([{opacity: `0`},{opacity: `1`}], {duration: 500, fill: 'forwards'});
      this.element.addEventListener('transitionend', (e) => {
        span.animate([{opacity: `1`},{opacity: `0`}], {duration: 500, fill: 'forwards'});
      }, {once: true});
    }
    get hasTranslation(){
      /* bilibili removes previous translation element when the danmaku element has reused */
      return (this.element.querySelector('.translation') === null) ? false : true;
    }
  }
  let core = {
    initialize: function(){
      html = document.documentElement;
      html.classList.add(SCRIPTNAME);
      core.listenWebSockets();
      core.ready();
    },
    ready: function(){
      core.getTargets(site.targets, RETRY).then(() => {
        log("I'm ready.");
        translator = new Translator();
        core.translateUserInterface();
        core.targetTranslation();
        core.observeVideoDanmaku();
        core.modifyChatInput();
        core.addStyle();
        core.readyForUnload();
      });
    },
    translateUserInterface: function(){
      translations = TRANSLATIONS[USERLANGUAGE] || TRANSLATIONS[USERLANGUAGE.substring(0, 2)] || TRANSLATIONS.en;
    },
    targetTranslation: function(){
      const setTranslate = function(element){
        element.classList.add('translate');
        element.translate = true;
      };
      const setNoTranslate = function(element){
        element.classList.add('notranslate');
        element.translate = false;
      };
      site.translationTargets.forEach(target => {
        if(target[0] === true) setTranslate(target[1]());
        else setNoTranslate(target[1]());
      });
    },
    listenWebSockets: function(){
      /* 公式の通信内容を取得 */
      window.WebSocket = new Proxy(WebSocket, {
        construct(target, arguments){
          const ws = new target(...arguments);
          //log(ws, arguments);
          if(ws.url.includes(CHATSERVER)) ws.addEventListener('message', function(e){
            let packet = new Packet(e.data);
            //log(packet.views, packet.messages);
            if(packet.isCommand === false) return;
            let danmakuContents = packet.getDanmakuContents();
            if(danmakuContents.length === 0) return;
            //log(danmakuContents);
            danmakuContents.forEach(c => translator.push(c));
          });
          return ws;
        }
      });
    },
    observeVideoDanmaku: function(){
      let videoDanmaku = elements.videoDanmaku;
      let observer = observe(videoDanmaku, function(records){
        //log(records);
        for(let i = 0; records[i]; i++){
          if(records[i].addedNodes.length === 0) continue;
          if(records[i].addedNodes[0].classList.contains('bilibili-danmaku') === false) continue;
          let danmaku = new Danmaku(records[i].addedNodes[0]);
          translator.requestTranslation(danmaku);
          observeDanmaku(danmaku);/*danmakuは再利用される!*/
        }
      });
      const observeDanmaku = function(danmaku){
        /* 再利用(新規弾幕としての生まれ変わり)を検知したい */
        let observer = observe(danmaku.element, function(records){
          if(danmaku.hasTranslation) return;/*再利用ではなく翻訳文追加だった*/
          danmaku = new Danmaku(danmaku.element);/*上書き*/
          translator.requestTranslation(danmaku);
        });
      };
    },
    modifyChatInput: function(){
      /* 弾幕投稿内容を翻訳する機能を追加 */
      let chatInput = elements.chatInput, modifier = ISMAC ? 'metaKey' : 'ctrlKey';
      if(chatInput.placeholder === undefined) return setTimeout(core.modifyChatInput, 1000);/*属性付与が遅れる場合もあるので*/
      chatInput.placeholder += '\n' + translations.inputTranslationKey;
      window.addEventListener('keydown', function(e){
        if(e.target !== chatInput) return;
        if(e.key === 'Enter' && e[modifier] === true){
          e.preventDefault();
          e.stopPropagation();
          chatInput.classList.add('translating');
          let api = TRANSLATOR.replace('{text}', chatInput.value).replace('{source}', USERLANGUAGE).replace('{target}', BILIBILILANGUAGE);
          fetch(api, {mode: 'cors'})
          .then(response => response.text())
          .then(text => {
            //log(text);
            chatInput.value = text;
            chatInput.dispatchEvent(new InputEvent('input'));/*実際の送信内容に反映させるために必要*/
            chatInput.classList.remove('translating');
          })
          .catch(error => {
            log('Error:', error);
            chatInput.classList.remove('translating');
          });
        }
      }, true);
    },
    readyForUnload: function(){
      window.addEventListener('unload', function(e){
        translator.save();
      });
    },
    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'){
      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: {
      priorDanmaku: () => `<ul id="${SCRIPTNAME}-prior-danmaku" class="translate" translate="yes"></ul>`,
      danmakuContent: (content) => `<li>${content}</li>`,
      translation: (translation) => `<span class="translation">${translation}</span>`,
      style: () => `
        <style type="text/css">
          ul#${SCRIPTNAME}-prior-danmaku{
            /* 画面内にないと自動翻訳されない */
            visibility: hidden;
            position: fixed;
            top: 0;
          }
          ul#${SCRIPTNAME}-prior-danmaku li{
            display: inline;
          }
          .translation{
            font-size: 75%;
            display: block;
          }
          .translating{
            opacity: .25;
            animation: ${SCRIPTNAME}-blink 250ms step-end infinite;
          }
          @keyframes ${SCRIPTNAME}-blink{
            50%{opacity: .5}
          }
        </style>
      `,
    },
  };
  const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
  const 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 (SCRIPTNAME) ? (SCRIPTNAME + '-' + 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){return document.querySelector(s)};
  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 atLeast = function(min, b){
    return Math.max(min, b);
  };
  const atMost = function(a, max){
    return Math.min(a, max);
  };
  const between = function(min, b, max){
    return Math.min(Math.max(min, b), max);
  };
  const toMetric = function(number, decimal = 1){
    switch(true){
      case(number < 1e3 ): return (number);
      case(number < 1e6 ): return (number/1e3 ).toFixed(decimal) + 'K';
      case(number < 1e9 ): return (number/1e6 ).toFixed(decimal) + 'M';
      case(number < 1e12): return (number/1e9 ).toFixed(decimal) + 'G';
      default:             return (number/1e12).toFixed(decimal) + 'T';
    }
  };
  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(
      SCRIPTNAME + ':',
      /* 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 \((userscript\.html|chrome-extension:)/,
      getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 6,
      getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|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', 85, '\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(SCRIPTNAME);
})();