Bilibili Live Comment Translator

自动翻译bilibili直播的用户评语(弹幕)。

当前为 2019-06-07 提交的版本,查看 最新版本

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

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

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

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Bilibili Live Comment Translator
// @name:ja     Bilibili Live Comment Translator
// @name:zh-CN  Bilibili Live Comment Translator
// @namespace   knoa.jp
// @description Add translation on streaming user comments(弾幕) on bilibili live(直播).
// @description:ja ビリビリ生放送(直播)のユーザーコメント(弾幕)を自動翻訳します。
// @description:zh-CN 自动翻译bilibili直播的用户评语(弹幕)。 
// @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);
})();