Bilibili Danmaku Translator

使用 Google Chrome 的翻译工具,自动翻译 bilibili 的用户评论(弹幕)。

目前为 2019-06-30 提交的版本。查看 最新版本

// ==UserScript==
// @name        Bilibili Danmaku Translator
// @name:ja     Bilibili Danmaku Translator
// @name:zh-CN  Bilibili Danmaku Translator
// @namespace   knoa.jp
// @description Add translations on streaming user comments(弾幕;danmaku) of bilibili, with the translation of Google Chrome.
// @description:ja Google Chrome の翻訳ツールを使って、ビリビリのユーザーコメント(弾幕)を自動翻訳します。
// @description:zh-CN 使用 Google Chrome 的翻译工具,自动翻译 bilibili 的用户评论(弹幕)。
// @include     /^https://www\.bilibili\.com/video/av[0-9]+/
// @include     /^https://live\.bilibili\.com/[0-9]+/
// @version     2.1.0
// @require     https://cdnjs.cloudflare.com/ajax/libs/pako/1.0.10/pako_inflate.min.js
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTNAME = 'BilibiliDanmakuTranslator';
  const DEBUG = false;/*
[update] 2.1.0
大量弾幕時の継続力を改善。一部の単語に固定訳を用意。ほか、不具合の修正。

[bug]

[to do]

[to research]
Chrome翻訳負荷制限
  キューはクリアしない方針?遅れた翻訳は意義薄い?
  文字列の長さの可能性?
  Chromeがサボるだけなら自家製クエリに手を出す手も?
  Chromeがどんどん反応を遅くしていった?
  新語に対する複数回クエリなど謎の挙動?
右の一覧内でも特殊案内は訳したいかも
主要UI要素を指定翻訳語として登録しておきたい
  動的に生成される要素の対応がめんどくさい
頻出コメントほかにもたくさん登録しとく?
  頻出だけなら人力や腾讯を使う手も。
  統計用には英語弾幕とかも含めたいけど。
自分のコメントの翻訳時も逆辞書で節約と蓄積?
日本語と英語は翻訳しない方針で問題ないよね?
Google翻訳の一般Webユーザーのフリをして各ユーザーにAPIを叩かせる手もあるようだが
  https://github.com/andy-portmen/Simple-Translate/blob/master/src/lib/common.js
  それが許されるならBaiduのAPIを叩かせることも可能?
翻訳文をただ置き換えてしまう設定項目は趣旨に反する?
翻訳辞書を共有サーバーに溜め込む仕組み?
iframe内で映像配信する放送に対応できていない。
  https://live.bilibili.com/76?visit_id=6kwlti59xlg0
pako.deflate + TextDecoder でdictionaryを無理やり圧縮して保存できる?
動画のタイトル下に翻訳を挿入したいね

[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 NOW = Date.now();
  const ISMAC = (window.navigator.userAgent.match(/Mac/) !== null);
  const VIDEOINFOAPI = 'https://api.bilibili.com/x/web-interface/view'
  const COMMENTLISTAPI = 'https://comment.bilibili.com/{cid}.xml';/*動画用*/
  const CHATSERVER = 'chat.bilibili.com';/*直播用*/
  const TRANSLATOR = 'https://script.google.com/macros/s/AKfycby29iFLZ742UEC6TlN8-b4Dxtlu_7XYbVeo2GgiYVWMtuzIcbA/exec?text={text}&source={source}&target={target}';
  const TRANSLATIONSATONCE = 4;/*同時最大翻訳リクエスト数(Chrome翻訳負荷の低減)*/
  const TRANSLATIONSINTERVAL = 1250;/*最短翻訳リクエスト間隔(ms)(Chrome翻訳負荷の低減)*/
  const HISTORYLENGTH = 50000;/*辞書の最大保持数(5万で5MB見込み)*/
  const TRANSLATIONEXPIRED = 90*24*60*60*1000;/*翻訳の有効期限(翻訳精度の改善に期待する)*/
  const BILIBILILANGUAGE = 'zh-CN';
  const USERLANGUAGE = window.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: {/* original: [translation, count, created] */
      '哔哩哔哩 (゜-゜)つロ 干杯~': ['ビリビリ (゜-゜)つロ 乾杯~', 0, NOW],
    },
    en: {
      '哔哩哔哩 (゜-゜)つロ 干杯~': ['bilibili (゜-゜)つロ cheers~', 0, NOW],
    },
  };
  const MODIFICATIONS = {
    /* '単語': ['誤訳(削除する)', '適訳(挿入する)'] */
    ja: {
      // 日本語
      '上手': ['はじめに', '上手'],
      '上手上手': ['手をつないで', '上手上手'],
      // スラング
      '单推': ['シングルプッシュ', '単推し'],
      '木大': [/大きな木|ウッドビッグ/g, '無駄'],
      '拜拜': ['さようなら', 'バイバイ'],
      '才八点': ['たった8時', 'まだ8時'],
      // awsl
      '奥维丝丽': ['オビスリ', 'awsl'],
      '阿伟少林': ['魏少林寺', 'awsl'],
      '阿伟爽了': ['魏がかっこいい', 'awsl'],
      '阿伟死了': ['魏は死んでいる', 'awsl'],
      '阿伟射了': ['ウェイショット', 'awsl'],
      '啊我睡了': ['ああ、私は寝ました。', 'awsl'],
      '爱我苏联': ['私を愛してソビエト連邦', 'awsl'],
      '阿伟乱葬岗': ['アウェイマスグレイブ', 'awsl墓地'],
      // 固有名詞
      '谷酱': ['谷', 'グーグルちゃん'],
      'goo酱': ['グーソース', 'Googleちゃん'],
      '油管': ['オイルパイプ', 'YouTube'],
      '爱酱': ['ラブソース', 'アイちゃん'],
      '诸葛孔明': [/Zhuge Kongming|ジュージュコミング/, '諸葛孔明'],
      '孔明': [/Kong Ming|コミング|コングミン/, '孔明'],
    },
    en: {
      '草': ['grass', 'lol'],
    },
  };
  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 RETRY = 10;
  let sites = {
    video: {
      targets: {
        danmakuSetting: () => $('.bilibili-player-video-danmaku-setting'),/*弾幕設定*/
        videoDanmaku: () => $('.bilibili-player-video-danmaku'),/* div or canvas */
      },
      translationTargets: [
        [false, () => $('title')],
        [false, () => $('body')],
      ],
      get: {
        commentlistApi: (videoInfo) => COMMENTLISTAPI.replace('{cid}', videoInfo.cid),
        danmakuTypeCSS: (danmakuSetting) => danmakuSetting.querySelector('li.bui-select-item[data-value="div"]'),
        danmakuInput: () => $('input.bilibili-player-video-danmaku-input'),
      },
    },
    live: {
      targets: {
        operableContainer: () => $('.bilibili-live-player-video-operable-container'),/*特殊弾幕枠*/
        videoDanmaku: () => $('.bilibili-live-player-video-danmaku'),
        chatHistoryList: () => $('#chat-history-list'),
        chatActions: () => $('#chat-control-panel-vm .bottom-actions'),
      },
      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[style*="height:"]'),
        danmakuInput: () => $('textarea.chat-input'),/*divからtextareaに置換される*/
      },
    },
  };
  let html, elements = {}, storages = {}, timers = {}, sizes = {}, site;
  let translator, translations = {}, videoInfo;
  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.TRANSLATIONSATONCE = TRANSLATIONSATONCE;
      Translator.TRANSLATIONSINTERVAL = TRANSLATIONSINTERVAL;
      Translator.HISTORYLENGTH = HISTORYLENGTH;
      Translator.TRANSLATIONEXPIRED = TRANSLATIONEXPIRED;
      Translator.DICTIONARY = DICTIONARIES[USERLANGUAGE] || DICTIONARIES[USERLANGUAGE.substring(0, 2)] || {};
      Translator.MODIFICATIONS = MODIFICATIONS[USERLANGUAGE] || MODIFICATIONS[USERLANGUAGE.substring(0, 2)] || {};
      Translator.MODIFICATIONSKEYS = Object.keys(Translator.MODIFICATIONS);
      Translator.PRIOR_WAITING_LIMIT = 10*1000;/* waiting limit for auto translation by browser */
      this.counters = {pushes: 0, registerTranslations: 0, fails: 0};
      this.readDictionary();
      this.updateDictionary();
      this.history = Storage.read('history') || [];
      this.priorDanmaku = this.createPriorDanmaku();
      this.priorDanmakuWaitings = {};/* waiting for getting translated */
      this.priorDanmakuRequested = 0;/* last requested time */
      this.priorDanmakuQueue = [];/* queue for preventing multiple request in TRANSLATIONSINTERVAL */
      this.timer = 0;/* timer to next TRANSLATIONSINTERVAL */
      this.danmakuWaitings = {};/* waiting for getting translation */
    }
    readDictionary(){
      /* use browser language dictionary */
      if(Storage.read('USERLANGUAGE') !== USERLANGUAGE) this.dictionary = Translator.DICTIONARY;
      else this.dictionary = Storage.read('dictionary') || Translator.DICTIONARY;
      Storage.save('USERLANGUAGE', USERLANGUAGE);
    }
    updateDictionary(){
      /* update structure (2019/6/11) */
      let keys = Object.keys(this.dictionary);
      if(typeof this.dictionary[keys[0]] === 'string') keys.forEach(key => {
        this.dictionary[key] = [this.dictionary[key], 1, NOW];
      });
      /* update key (2019/6/23) */
      let oldKey = 'BilibiliLiveCommentTranslator';
      let oldDictionary = localStorage[`${oldKey}-dictionary`], oldHistory = localStorage[`${oldKey}-history`];
      if(oldDictionary && oldHistory){
        this.dictionary = JSON.parse(oldDictionary).value;
        this.history = JSON.parse(oldHistory).value;
        localStorage.removeItem(`${oldKey}-dictionary`);
        localStorage.removeItem(`${oldKey}-history`);
      }
    }
    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;
    }
    pushAll(originals){
      originals.forEach(o => this.push(o));
      this.throttle();
    }
    push(original){
      this.counters.pushes++;
      if(this.dictionary[original] !== undefined) return this.dictionary[original][1]++;/* already exists in the dictionary */
      if(this.priorDanmakuQueue.includes(original) === true) return;/* already queued */
      if(this.priorDanmakuWaitings[original] !== undefined) return;/* already waiting for translation */
      if(this.shouldBeTranslated(original) === false) return;/* seems not to be Chinese */
      this.priorDanmakuQueue.push(original);
    }
    throttle(){
      if(this.priorDanmakuQueue.length === 0) return;
      /* throttle for single waiting query to Chrome Translation */
      if(this.priorDanmaku.children.length > 0) return;
      /* throttle for TRANSLATIONSINTERVAL */
      let now = Date.now(), elapsed = now - this.priorDanmakuRequested;
      clearTimeout(this.timer);
      if(elapsed <= Translator.TRANSLATIONSINTERVAL){
        this.timer = setTimeout(() => this.putOnPriorDanmaku(), Translator.TRANSLATIONSINTERVAL - elapsed);
      }else{
        this.putOnPriorDanmaku();
      }
    }
    putOnPriorDanmaku(){
      //log(this.priorDanmakuQueue);
      this.priorDanmakuRequested = Date.now();
      let putOnce = this.putOnPriorDanmaku.putOnce ? true : false;/* it can put more only on first time */
      let fragment = document.createDocumentFragment();
      for(let i = 0, original; (original = this.priorDanmakuQueue[i]) && i < (putOnce ? Translator.TRANSLATIONSATONCE : 10*1000); i++){
        let li = createElement(core.html.danmakuContent(original));
        this.priorDanmakuWaitings[original] = li;
        fragment.appendChild(li);
        /* Observe auto translation by browser */
        let observer = observe(li, (records) => {
          //log('Got translated:', original);
          this.registerTranslation(original, li.textContent);
          this.removeWaiting(original, li, observer);
          this.throttle();
        });
        /* Time to give up */
        setTimeout(() => {
          if(li && li.isConnected){
            log('Give up for waiting translated:', original);
            this.counters.fails++;
            this.removeWaiting(original, li, observer);
          }
        }, (putOnce) ? Translator.PRIOR_WAITING_LIMIT : 60*60*1000);
      }
      //log(Array.from(fragment.children).map(c => c.textContent));
      this.priorDanmaku.appendChild(fragment);
      this.priorDanmakuQueue = [];/* dropped */
      this.putOnPriorDanmaku.putOnce = true;
    }
    registerTranslation(original, translation){
      this.counters.registerTranslations++;
      this.dictionary[original] = [translation, 1, Date.now()];
      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(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{
        if(danmaku.textContent === this.dictionary[danmaku.textContent][0]) return;/* original and translation are the same */
        this.appendTranslation(danmaku, this.dictionary[danmaku.textContent][0]);
      }
    }
    appendTranslation(danmaku, translation){
      Translator.MODIFICATIONSKEYS.filter(key => {
        return danmaku.textContent.includes(key) && translation.includes(Translator.MODIFICATIONS[key][0]);
      }).forEach(key => {
        translation = translation.replace(Translator.MODIFICATIONS[key][0], Translator.MODIFICATIONS[key][1]);
      });
      danmaku.appendTranslation(translation);
    }
    shouldBeTranslated(textContent){
      switch(true){
        case(this.dictionary[textContent] !== undefined):/* has a 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.pushes - c.fails - c.registerTranslations)/((c.pushes - c.fails) || 1))*100).toFixed(0) + '%';
      log('Total danmaku:', c.pushes, 'Newly translated:', c.registerTranslations, 'Saved:', saved, 'Fails:', c.fails);
      /* save the dictionary and the history of latest HISTORYLENGTH pairs */
      let newDictionary = {}, newHistory = [];
      for(let i = this.history.length - 1, count = 0, now = Date.now(); 0 <= i; i--){
        if(this.dictionary[this.history[i]] === undefined){
          log('Unknown history', this.history[i]);
          continue;
        };
        if(this.dictionary[this.history[i]][2] < now - Translator.TRANSLATIONEXPIRED) continue;/* old data */
        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;
      }
      /* keep the default dictionary */
      Object.keys(Translator.DICTIONARY).forEach(key => {
        newDictionary[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 = parseInt(this.element.style.zIndex || 0) + Danmaku.zIndex++;/* newer comments have priority */
      /* Make space for appending translation text */
      this.element.style.top = (() => {
        if(this.element.style.top === '') return;
        let operableContainer = elements.operableContainer, operableSpace = operableContainer ? site.get.operableSpace(operableContainer) : null;
        if(this.element.style.top[0] === '-' || operableSpace === null || operableSpace.children.length === 0 || operableSpace.style.height === ''){
          return (parseFloat(this.element.style.top) * 2) + 'px';
        }else{
          let height = parseFloat(operableSpace.style.height), top = parseFloat(this.element.style.top);
          return (height + ((top - height) * 2)) + 'px';
        }
      })();
      /* Even if double long translation text added, keep streaming to completely go away */
      this.element.style.transitionDuration = ((transitionDuration) => {
        if(transitionDuration === '') return;
        let m = transitionDuration.match(/([0-9.]+)(m?s)/);
        if(m === null) return log('Unknown transitionDuration format:', transitionDuration), transitionDuration;
        return (parseFloat(m[1]) * 2) + m[2];
      })(this.element.style.transitionDuration);
      this.element.style.transform = ((transform) => {
        if(transform === '') return;
        let m = transform.match(/(translateX?)\(([-0-9.]+)(px)/);
        if(m === null) return log('Unknown transform format:', transform), transform;
        return transform.replace(m[0], `${m[1]}(${parseFloat(m[2]) * 2}${m[3]}`);
      })(this.element.style.transform);
    }
    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);
      switch(true){
        case(location.href.match(/^https:\/\/www\.bilibili\.com\/video\/av[0-9]+/) !== null):
          site = sites.video;
          translator = new Translator();
          core.listenXMLHttpRequests();
          core.targetTranslation();
          core.readyForVideo();
          break;
        case(location.href.match(/^https:\/\/live\.bilibili\.com\/[0-9]+/) !== null):
          site = sites.live;
          translator = new Translator();
          core.listenWebSockets();
          core.targetTranslation();
          core.readyForLive();
          break;
        default:
          log('Bye.');
          break;
      }
    },
    readyForVideo: function(){
      if(document.hidden) return setTimeout(core.readyForVideo, 1000);
      core.getTargets(site.targets, RETRY).then(() => {
        log("I'm ready for Video.");
        core.translateUserInterface();
        core.setDanmakuSettings();
        core.observeVideoDanmaku();
        core.modifyDanmakuInput();
        core.addStyle();
        core.readyForUnload();
        core.exportLog();
      });
    },
    readyForLive: function(){
      if(document.hidden) return setTimeout(core.readyForVideo, 1000);
      core.getTargets(site.targets, RETRY).then(() => {
        log("I'm ready for Live.");
        core.translateUserInterface();
        core.observeVideoDanmaku();
        core.modifyDanmakuInput();
        core.addStyle();
        core.readyForUnload();
        core.exportLog();
      });
    },
    targetTranslation: function(){
      const setTranslate = function(element){
        element.classList.add('translate');
        element.translate = true;
      };
      const setNoTranslate = function(element){
        element.classList.add('notranslate');
        element.translate = false;
      };
      for(let i = 0, target; target = site.translationTargets[i]; i++){
        if(target[1]() === null) return setTimeout(core.targetTranslation, 1000);
        if(target[0] === true) setTranslate(target[1]());
        else setNoTranslate(target[1]());
      }
    },
    translateUserInterface: function(){
      translations = TRANSLATIONS[USERLANGUAGE] || TRANSLATIONS[USERLANGUAGE.substring(0, 2)] || TRANSLATIONS.en;
      /*置換したりobserveしたりする・・・かもしれない*/
    },
    listenXMLHttpRequests: function(){
      /* 公式の通信内容を取得 */
      window.XMLHttpRequest = new Proxy(XMLHttpRequest, {
        construct(target, arguments){
          const xhr = new target(...arguments);
          //log(xhr, arguments);
          xhr.addEventListener('load', function(e){
            if(xhr.responseURL.startsWith(VIDEOINFOAPI) === false) return;
            if(xhr.response[0] !== '{') return;
            videoInfo = JSON.parse(xhr.response).data;
            //log(videoInfo);
            core.getDanmakuList();
          });
          return xhr;
        }
      });
    },
    getDanmakuList: function(){
      let api = site.get.commentlistApi(videoInfo);
      fetch(api, {credentials: 'include', mode: 'cors'})
      .then(response => response.text())
      .then(text => new DOMParser().parseFromString(text, 'text/xml'))
      .then(d => {
        let ds = d.querySelectorAll('d');
        if(ds.length === 0) return log('Unknown danmaku format:', d);
        let danmakuContents = Array.from(ds).map(d => d.textContent);
        translator.pushAll(danmakuContents);
      });
    },
    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.length, danmakuContents);
            translator.pushAll(danmakuContents);
          });
          return ws;
        }
      });
    },
    setDanmakuSettings: function(){
      if(elements.videoDanmaku.localName === 'canvas'){
        let danmakuSetting = elements.danmakuSetting;
        danmakuSetting.dispatchEvent(new MouseEvent('mouseover'));
        danmakuSetting.dispatchEvent(new MouseEvent('mouseout'));
        animate(function(){
          let danmakuTypeCSS = site.get.danmakuTypeCSS(danmakuSetting);
          if(danmakuTypeCSS) danmakuTypeCSS.click();
          else log('Can\'t find CSS3 setting.', danmakuSetting);
        });
      }
    },
    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);
        });
      };
    },
    modifyDanmakuInput: function(){
      /* 弾幕投稿内容を翻訳する機能を追加 */
      let danmakuInput = site.get.danmakuInput(), modifier = ISMAC ? 'metaKey' : 'ctrlKey';
      if(danmakuInput === null || danmakuInput.placeholder === undefined) return setTimeout(core.modifyDanmakuInput, 1000);/*属性付与が遅れる場合もあるので*/
      danmakuInput.placeholder += '\n' + translations.inputTranslationKey;
      observe(danmakuInput, function(record){
        if(danmakuInput.placeholder.endsWith(translations.inputTranslationKey)) return;
        danmakuInput.placeholder += '\n' + translations.inputTranslationKey;
      }, {attributes: true, attributeFilter: ['placeholder']});
      window.addEventListener('keydown', function(e){
        if(e.target !== danmakuInput) return;
        if(e.key === 'Enter' && e[modifier] === true){
          e.preventDefault();
          e.stopPropagation();
          danmakuInput.classList.add('translating');
          let api = TRANSLATOR.replace('{text}', danmakuInput.value).replace('{source}', USERLANGUAGE).replace('{target}', BILIBILILANGUAGE);
          fetch(api, {mode: 'cors'})
          .then(response => response.text())
          .then(text => {
            //log(text);
            danmakuInput.value = text;
            danmakuInput.dispatchEvent(new InputEvent('input'));/*実際の送信内容に反映させるために必要*/
            danmakuInput.classList.remove('translating');
          })
          .catch(error => {
            log('Error:', error);
            danmakuInput.classList.remove('translating');
          });
        }
      }, true);
    },
    readyForUnload: function(){
      window.addEventListener('unload', function(e){
        translator.save();
      });
    },
    exportLog: function(){
      if(DEBUG === false) return;
      window.translatorLog = translator.save.bind(translator);
    },
    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">
          /* bilibili color: #00A1D6 */
          ul#${SCRIPTNAME}-prior-danmaku{
            /* 画面内にないと自動翻訳されない */
            visibility: hidden;
            position: fixed;
            top: 0;
            padding: 0;
            margin: 0;
            white-space: nowrap;
            z-index: 9999;
          }
          ul#${SCRIPTNAME}-prior-danmaku li{
            position: absolute;
          }
          .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 \(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(SCRIPTNAME);
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址