NicoNico Tsuu

ニコニコライフを快適に。

Från och med 2020-07-16. Se den senaste versionen.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        NicoNico Tsuu
// @namespace   knoa.jp
// @description ニコニコライフを快適に。
// @include     https://www.nicovideo.jp/watch/*
// @include     https://live2.nicovideo.jp/watch/*
// @version     0.11.1
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTNAME = 'NicoNicoTsuu';
  const DEBUG = false;/*
[update] 0.11.1
新着コメントの矢印がチラチラ表示されるのを回避。

[bug]
モニタサイズフルスクリーンからの復帰で崩れる
  シアターとの競合が原因。無理やりfullscreen属性を削除して回る手もあるが。

[to do]
英語と中国語をいちおう追加してみる?

上下で音量
  端数スタートでも10区切りに調整
  10以下の時は1単位に
全画面シアターStylishの廃止と統一
  アリーナ最前列、(コメント)(おすすめ生放送)のハイライトカラーを変えるか
  ウィンドウリサイズの不要処理を撤去
  マウスオーバーによる透明度は廃止かな?
    設定で固定値や比率やシフトなど選ばせる?
  cssで5秒くらいで消える案内を出す?
  いっそ拡張化のタイミングで。
  動画も投稿?動画と生の2種類?しんどい!
# 共通
  設定/Help
    流れるコメント
      縁取り太さ?
      マウスオーバー時の透過率比率
      フォント指定
    コメント一覧
      幅
      文字サイズ
      NGスコア表示
  当該ユーザーの発言一覧
  連投規制されそうなとき、投稿ボタンにびっくりマーク
  ログインボタン目立たないとか勝手にログアウトするとか
  sm/lv単位で既視聴管理(設定でサムネopacityとリンク色?)
# 生放送
  番組内容にタイムスケジュールがあればリンク化
  タイムシフト視聴するだのなんだのUI
  リアルタイム視聴でだんだんリアルタイムから遅れていく現象
    video.currentTime を監視してplaybackRateの調整で追いつきたい。
    低遅延なら自動で追いつく?一瞬で追いつくので途中切れるが。
  [←]によるバッファ移動時にコメ一覧のハイライトくらい機能させたいが
# 動画
  100%レイアウト([自動]時のみでよい)
    右に[動画説明文/コメント一覧]しかないのか?
  倍速再生にアクセスしやすく
  先頭と次の動画のボタンいらない気が
  NiconicoMyTheater継承のコメント・ショートカットキーUX
  ヒートマップ
  映像中央の再生マークに白い影
  コメ投稿アンドゥ
  ランキング改変
    スタイル
    集計期間の記憶
    新着ハイライト
ログインクッキー有効期限延長機能?
  アクセスのたびに30日延長とかなら許されるんじゃないの?
  2019/8/03現在
  2029/7/12期限 nicosid
  2029/8/31期限 user_session

[to research]
スクロールバーと100vw問題(全画面シアター側の課題か) => スクロールバー非表示で解決したけど
コメントの色は動画背景に対する明度加算なんてことはできないのかな?
同一コメ判定に「ひとつ前のコメのtextContent」が使えないか?偶然2連続する可能性は低いだろう。
TSでシークバーにフォーカス時、一度だけ[↑][↓]のpreventDefaultが間に合わない
  シークバーのマウスアップでデフォーカスすれば回避できるかも
一覧コメントマウスオーバー時のたまにスクロール止まらない件
一覧コメント右クリックでテキスト選択中は配慮してあげたい
TSで1.5倍速とかDLが追いつかない
NGスコアの可視化
生放送TSヒートマップ
横型番組表

[memo]
再生速度の変更アップデート!1.5以上はプレミアムのみ!
コメントJSONの例
  chat: {
    thread : 1649308673,
    vpos : 4256100,
    date : 1556109561,
    date_usec : 880193,
    mail : "184",
    content : "/hb ifseetno 686",
    premium : 3,
    user_id : "SaMpLe",
    anonymity : 1,
    yourpost : 1,
    locale : "ja-jp",
  }
公式ショートカットキー
  動画   https://qa.nicovideo.jp/faq/show/287?site_domain=default
  生放送 https://qa.nicovideo.jp/faq/show/14851?site_domain=default
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const API = {
    LIVEMSG: 'wss://msg.live2.nicovideo.jp/',
  };
  const PREMIUM = {
    USER:       1,/*プレミアム会員*/
    USERAD:     2,/*広告*/
    OPERATOR:   3,/*運営・システム*/
    GUEST:      7,/*公式ゲストコメント(?)*/
    CHANNEL8:   8,/*チャンネル会員(?)*/
    CHANNEL9:   9,/*チャンネル会員(?)*/
    CHANNEL24: 24,/*未入会(?)*/
    CHANNEL25: 25,/*未入会(?)*/
  };
  const STROKEALPHA = '1.0';/*流れるコメントの縁取りのアルファ値(公式: 0.4)*/
  const EASING = 'cubic-bezier(0,.75,.5,1)';/*主にナビゲーションのアニメーション用*/
  const RETRY = 10;
  let sites = {
    video: {
      targets: {
        videoTitle: () => $('.HeaderContainer-videoTitle'),
        searchBox: () => $('.HeaderContainer-searchBox'),
        videoDescription: () => $('.VideoDescription'),
        videoDescriptionExpanderSwitch: () => $('.VideoDescriptionExpander-switch'),
        commentRenderer: () => $('#CommentRenderer'),
      },
      get: {
        videoDescriptionHtml: (videoDescription) => $('.VideoDescription-html'),
      },
    },
    live: {
      targets: {
        leoPlayer: () => $('[class*="_leo-player_"]'),/*出没するplayer-statusの親*/
        playerDisplayHeader: () => $('[class*="_player-display-header_"]'),/*運営コメント*/
        playerDisplayScreen: () => $('[class*="_player-display-screen_"]'),
        interactionLayerContent: () => $('[class*="_interaction-layer_"] > [data-content-visibility]'),/*アンケート*/
        commentLayer: () => $('[class*="_comment-layer_"]'),
        telopLayer: () => $('[class*="_telop-layer_"]'),
        seekInformation: () => $('[class*="_seek-information_"]'),
        //playButton: () => $('[class*="_play-button_"]'),/*現在不使用;プレミアムにしか出現しない*/
        muteButton: () => $('[class*="_mute-button_"]'),
        timeStatusArea: () => $('[class*="_time-status-area_"]'),
        elapsedTime: () => $('span[class*="_elapsed-time_"] > span:first-child'),
        commentVisibilityButton: () => $('[class*="_comment-button_"]'),
        fullscreenButton: () => $('[class*="_fullscreen-button_"]'),
        reloadButton: () => $('[class*="_reload-button_"]'),
        commentTextBox: () => $('[class*="_comment-text-box_"]'),
        commentsTable: () => $('[class*="_comment-panel_"] [class*="_table_"]'),
        embeddedData: () => $('#embedded-data'),
      },
      get: {
        video: () => $('[class*="_video-layer_"] video[src]'),
        liveButton: () => $('[class*="_live-button_"]'),
        announcement: (playerDisplayHeader) => playerDisplayHeader.querySelector('[class*="_announcement-renderer_"]'),
        seekInformationTime: (seekInformation) => seekInformation.querySelector('span'),
        content: (comment) => comment.querySelector('[class*="_comment-text_"]'),
        time: (comment) => comment.querySelector('[class*="_comment-time_"]'),
        props: (embeddedData) => JSON.parse(embeddedData.dataset.props),
      },
      addedNodes: {
        comment: (node) => (node.dataset.commentType) ? node : null,
      },
      is: {
        realtime:  () => {let b = sites.live.get.liveButton(); return (b && b.dataset.liveStatus === 'live')  ? true : false},
        chasing:   () => {let b = sites.live.get.liveButton(); return (b && b.dataset.liveStatus === 'chase') ? true : false},
        timeshift: () => sites.live.get.liveButton() ? false : true,
      },
    },
  };
  let html, elements = {}, storages = {}, timers = {}, configs = {}, site;
  let props, chats = [], users = {}/*id検索用テーブル*/;
  let core = {
    initialize: function(){
      html = document.documentElement;
      html.classList.add(SCRIPTNAME);
      switch(true){
        case(location.href.match(/^https:\/\/www\.nicovideo\.jp\/watch\/[a-z]{2}[0-9]+/) !== null):
          site = sites.video;
          core.readyForVideo();
          break;
        case(location.href.match(/^https:\/\/live[0-9]?\.nicovideo\.jp\/watch\/lv[0-9]+/) !== null):
          site = sites.live;
          core.listenWebSockets();
          core.readyForLive();
          break;
        default:
          log('Bye.');
          break;
      }
      //core.panel.createPanels();
    },
    readyForVideo: function(){
      core.getTargets(site.targets, RETRY).then(() => {
        log("I'm ready for video.");
        core.addStyle('styleVideo');
      });
    },
    readyForLive: function(){
      core.getTargets(site.targets, RETRY).then(() => {
        log("I'm ready for live.");
        core.addStyle('styleLive');
        core.getProps();
        core.listenUserActions();
        core.listenCanvas();
        core.listenEnquete();
        core.observePlayerDisplayHeader();
        core.appendLocalTime();
        core.observeCommentTable();
        core.appendIndicator();
        core.indicateCommentOpacity(elements.commentLayer.dataset.opacity = Storage.read('opacity') || '1');
        core.appendCommentOpacitySelector();
      });
    },
    getProps: function(){
      props = site.get.props(elements.embeddedData);
      log(props);
    },
    appendIndicator: function(e){
      elements.indicator = createElement(core.html.indicator());
      elements.playerDisplayScreen.appendChild(elements.indicator);
    },
    indicate: function(indication, duration = 1000){
      let indicator = elements.indicator;
      if(typeof indication !== 'object') indication = document.createTextNode(indication);
      while(indicator.firstChild) indicator.removeChild(indicator.firstChild);
      indicator.appendChild(indication);
      indicator.classList.add('active');
      clearTimeout(timers.indicator);
      timers.indicator = setTimeout(function(){
        indicator.classList.remove('active');
      }, duration);
    },
    setCommentOpacity: function(key){
      elements.commentLayer.dataset.opacity = key;
      Storage.save('opacity', key);
      core.indicate(key);
      core.indicateCommentOpacity(key);
    },
    indicateCommentOpacity: function(key){
      let button = elements.commentVisibilityButton, indicator = elements.commentOpacityIndicator || createElement(core.html.commentOpacityIndicator(key));
      if(indicator.isConnected) button.replaceChild(elements.commentOpacityIndicator = createElement(core.html.commentOpacityIndicator(key)), indicator);
      else button.appendChild(elements.commentOpacityIndicator = indicator);
    },
    appendCommentOpacitySelector: function(){
      let button = elements.commentVisibilityButton, selector = createElement(core.html.commentOpacitySelector());
      button.parentNode.insertBefore(selector, button.nextElementSibling);
      selector.addEventListener('mouseover', function(e){
        if(e.target.dataset.opacity === undefined) return;
        /*マウスを動かすだけでプレビューさせる*/
        elements.commentLayer.dataset.opacity = e.target.dataset.opacity;
        core.indicate(e.target.dataset.opacity);
      });
      selector.addEventListener('click', function(e){
        if(e.target.dataset.opacity === undefined) return;
        core.setCommentOpacity(e.target.dataset.opacity);/*クリックしたら上書き保存*/
        elements.indicator.animate([ 
          {opacity: 1, transform: 'scale(1)'},
          {opacity: 0, transform: 'scale(2)'},
        ], {duration: 1250, easing: EASING});
      });
      selector.addEventListener('mouseout', function(e){
        core.setCommentOpacity(Storage.read('opacity'));/*クリックされていなければ初期値に戻る*/
      });
    },
    listenUserActions: function(){
      /* プレイヤーをアクティブに */
      const activatePlayer = function(){
        document.activeElement.blur();
        elements.playerDisplayScreen.click();
      };
      /* キーボード */
      window.addEventListener('keydown', function(e){
        let activeElement = document.activeElement;
        /* テキスト入力中は反応しない */
        if(['input', 'textarea'].includes(activeElement.localName) && activeElement.type !== 'range'){
          if(e.key === 'Escape'){/*Escapeは必ずアンフォーカス*/
            activeElement.blur();
            e.stopPropagation();
            return;
          }
          if(activeElement.value !== '') return;/*テキスト入力中*/
          else if([/*テキスト空欄なら以下のキーは有効*/
            'ArrowLeft',
            'ArrowUp',
            'ArrowDown',
            ' ',
          ].includes(e.key) === false) return;
        }
        switch(true){
          case(e.key === 'ArrowLeft'  && !e.altKey && e.shiftKey === true && !e.ctrlKey && !e.metaKey):
          case(e.key === 'ArrowRight' && !e.altKey && e.shiftKey === true && !e.ctrlKey && !e.metaKey):
            if(site.is.realtime()) return;
            else{
              let video = site.get.video();
              video.currentTime += (e.key === 'ArrowLeft') ? -10 : +10;
              e.stopPropagation();
              e.preventDefault();/*ブラウザによるテキスト選択を回避*/
            }
            return;
          /* 以下Alt/Shift/Ctrl/Metaキーが押されていたら反応しない */
          case(e.altKey || e.shiftKey || e.ctrlKey || e.metaKey):
            return;
          case(e.key === ' '):
            if(site.is.realtime()){
              elements.commentTextBox.focus();
              e.preventDefault();/*コメント欄にフォーカスさせるだけ*/
            }else activatePlayer();
            return;
          case(e.key === 'ArrowLeft'):
            if(site.is.chasing() || site.is.timeshift()){
              /* バッファ範囲内なら公式の重たい処理を回避する */
              let video = site.get.video();
              if(video.currentTime - video.buffered.start(0) >= 30){
                video.currentTime -= 30;
                e.stopPropagation();
              }else activatePlayer();/*プレイヤーにフォーカスさせて公式の30秒巻き戻しを実行させる*/
            }else{
              const REWIND = 10, CATCHUP = 1.5;
              let video = site.get.video(), rewinded = false, duration = 1000;
              if(!video.paused && !video.rewinded && video.currentTime > REWIND/*少しだけ戻すこともできるが通信が安定しなくなるので*/){
                duration = (REWIND / (CATCHUP - 1))*1000;
                video.rewinded = rewinded = true;
                video.currentTime = video.currentTime - REWIND;
                video.playbackRate = CATCHUP;
                elements.playerDisplayScreen.dataset.rewinded = 'true';
                setTimeout(function(){
                  video.rewinded = false;
                  video.playbackRate = 1;
                  delete elements.playerDisplayScreen.dataset.rewinded;
                }, duration);
              }
              core.indicate(createElement(core.html.rewind(rewinded)), duration);
              e.stopPropagation();
            }
            return;
          case(e.key === 'ArrowRight'):
            if(site.is.chasing() || site.is.timeshift()){
              /* バッファ範囲内なら公式の重たい処理を回避する */
              let video = site.get.video();
              if(video.buffered.end(0) - video.currentTime >= 30){
                video.currentTime += 30;
                e.stopPropagation();
              }else activatePlayer();/*プレイヤーにフォーカスさせて公式の30秒巻き戻しを実行させる*/
            }
            return;
          case(e.key === 'ArrowUp'):
          case(e.key === 'ArrowDown'):
            activatePlayer();/*プレイヤーにフォーカスさせて公式の音量調整を実行させる*/
            site.get.video().addEventListener('volumechange', function(e){
              core.indicate(parseInt(e.target.volume * 100));
            }, {once: true});
            e.preventDefault();
            /* 連続すると focus() がわずかに遅延を感じさせるので */
            if(activeElement === elements.commentTextBox || timers.focusBack){
              clearTimeout(timers.focusBack);
              timers.focusBack = setTimeout(() => {
                elements.commentTextBox.focus();
                delete timers.focusBack;
              }, 250);
            }
            return;
          case(e.key === '1'):
          case(e.key === '2'):
          case(e.key === '3'):
          case(e.key === '4'):
          case(e.key === '5'):
          case(e.key === '6'):
          case(e.key === '7'):
          case(e.key === '8'):
          case(e.key === '9'):
          case(e.key === '0'):
            core.setCommentOpacity(e.key);
            return;
          case(e.key === 'm'):
            elements.muteButton.click();
            site.get.video().addEventListener('volumechange', function(e){
              if(e.target.muted) core.indicate('mute');
              else core.indicate(parseInt(e.target.volume * 100));
            }, {once: true});
            return;
          case(e.key === 'f'):
            elements.fullscreenButton.click();
            return;
          case(e.key === 'r'):
            elements.reloadButton.click();
            return;
        }
      }, {capture: true});
      /* 再生・一時停止 */
      /* 勝手に再生を再開してしまうので保留 */
      //elements.playButton.addEventListener('click', function(e){
      //  /* 公式プレイヤによる再読み込みを回避して軽快に動作させる */
      //  let video = site.get.video();
      //  if(video.paused) video.play();
      //  else video.pause();
      //  e.stopPropagation();
      //}, true);
      /* 出没するplayer-statusを監視 */
      observe(elements.leoPlayer, function(records){
        let commentsTable = elements.commentsTable = site.targets.commentsTable();
        if(commentsTable === null) return;
        commentsTable.dataset.selector = 'commentsTable';
        core.observeCommentTable();/*commentsTableが復活するのでもう一度監視する*/
      }, {childList: true});
      /* フルスクリーン状態の変化 */
      observe(html, function(records){
        if(html.dataset.browserFullscreen) return;/*フルスクリーン化したときは何もしない*/
        animate(window.scrollTo.bind(window, 0, 0));/*スクロール位置がずれるのを即補正*/
      }, {attributes: true});
      /* ウィンドウリサイズ */
      window.addEventListener('resize', function(e){
        /* ニコ生コメント一覧付き全画面シアターとの連携(なめらかスクロールをこちらで引き受ければ本来不要な処理のはず) */
        clearTimeout(window.resizing), window.resizing = setTimeout(function(){
          if(document.fullscreenElement) return;/*モニタフルスクリーン時は何もしない*/
          elements.fullscreenButton.click();
          elements.fullscreenButton.click();
          window.resizing = null;
        }, 250);/*リサイズ中の連続起動を避ける*/
      });
      /* ウィンドウフォーカス */
      window.addEventListener('focus', function(e){
        elements.commentTextBox.focus();
      });
    },
    listenWebSockets: function(){
      /* 公式の通信内容を取得 */
      window.WebSocket = new Proxy(WebSocket, {
        construct(target, arguments){
          const ws = new target(...arguments);
          //log(ws, arguments);
          if(ws.url.startsWith(API.LIVEMSG)) ws.addEventListener('message', function(e){
            let json = JSON.parse(e.data);
            if(json.chat === undefined) return;
            //if(json.chat.premium === 3) log(json.chat);
            if(![1,2,3,undefined].includes(json.chat.premium)) log(json.chat);/*ユーザーと広告と運営以外のコメントログ*/
            chats.push(json.chat);
            /* ユーザー別コメント一覧 */
            //if(users[json.chat.user_id] === undefined) users[json.chat.user_id] = [];
            //users[json.chat.user_id].push(json.chat);
          });
          return ws;
        }
      });
    },
    listenCanvas: function(){
      /* 公式のキャンバスコンテキストメソッドを書き換えて縁取りを見やすく */
      let strokeText = CanvasRenderingContext2D.prototype.strokeText;
      CanvasRenderingContext2D.prototype.strokeText = function(text, x, y, maxWidth){
        //log(text, this.strokeStyle);
        this.strokeStyle = this.strokeStyle.replace(/rgba\(([0-9]+),\s?([0-9]+),\s?([0-9]+),\s?([0-9.]+)\)/, `rgba($1,$2,$3,${STROKEALPHA})`);
        return strokeText.call(this, text, x, y, maxWidth);
      };
    },
    listenEnquete: function(){
      /* アンケートの表示を捉える */
      Notification.requestPermission();
      let notification, title = props.program.title;
      observe(elements.interactionLayerContent, function(records){
        if(notification) notification.close();/*古い通知が出たままなら閉じる*/
        if(elements.interactionLayerContent.dataset.contentVisibility === 'false') return;/*閉じたときは何もしない*/
        notification = new Notification(title, {body: site.get.announcement(elements.playerDisplayHeader).textContent});
        notification.addEventListener('click', function(e){
          notification.close();
        });
      }, {attributes: true});
    },
    observePlayerDisplayHeader: function(){
      let playerDisplayHeader = elements.playerDisplayHeader, commentLayer = elements.commentLayer;
      observe(playerDisplayHeader, function(records){
        //log(records);
        if(playerDisplayHeader.children.length === 0){
          delete playerDisplayHeader.dataset.extraLayout;
          delete commentLayer.dataset.extraLayout;
        }else{
          let announcement = site.get.announcement(playerDisplayHeader);
          if(announcement) setTimeout(function(){
            playerDisplayHeader.dataset.fresh = 'true';
            observe(announcement, function(rs){
              playerDisplayHeader.dataset.fresh = announcement.dataset.fresh;/*同期させる*/
            }, {attributes: true});
          }, 250);/*フルスクリーン切り替えなどでラグが発生するので*/
          playerDisplayHeader.dataset.extraLayout = 'showOperatorComment';
          commentLayer.dataset.extraLayout = 'showOperatorComment';
        }
      });
    },
    appendLocalTime: function(){
      /* seek */
      let seekInformation = elements.seekInformation, seekInformationTime = site.get.seekInformationTime(seekInformation);
      let localTime = createElement(core.html.localTime()), beginTime = props.program.beginTime;
      localTime.textContent = seekInformationTime.textContent;
      seekInformationTime.parentNode.insertBefore(localTime, seekInformationTime);
      observe(seekInformationTime, function(records){
        //log(records);
        localTime.textContent = (new Date((beginTime + timeToSeconds(seekInformationTime.textContent))*1000)).toLocaleTimeString();
      }, {characterData: true, subtree: true});
      /* elapsed */
      let elapsedTime = elements.elapsedTime;
      let currentLocalTime = createElement(core.html.localTime());
      currentLocalTime.textContent = elapsedTime.textContent;
      elapsedTime.parentNode.insertBefore(currentLocalTime, elapsedTime);
      observe(elapsedTime, function(records){
        //log(records);
        currentLocalTime.textContent = (new Date((beginTime + timeToSeconds(elapsedTime.textContent))*1000)).toLocaleTimeString();
      }, {characterData: true, subtree: true});
    },
    observeCommentTable: function(){
      let commentsTable = elements.commentsTable;
      if(commentsTable.observing) return;/*起こりえないけど重複を避ける*/
      commentsTable.observing = true;
      core.listenMouseOnCommentsTable();
      /* 初期コメントに適用しつつ、追加コメントを監視する */
      const ADVANCE = 30;
      let elapsedTime = elements.elapsedTime, lastTime = timeToSeconds(elapsedTime.textContent), cutoffTime = -Infinity;
      Array.from(commentsTable.children).forEach(c => core.modifyComment(c));
      observe(commentsTable, function(records){
        //log(records);
        let isRealtime = site.is.realtime(), isChasing = site.is.chasing(), isTimeshift = site.is.timeshift();
        let removedComments = [], newComments = [], currentTime = timeToSeconds(elapsedTime.textContent);
        /* 30秒早送りなどで一覧がクリアされたら古いニセ新着を除外する */
        if(lastTime + ADVANCE <= currentTime) cutoffTime = lastTime;
        else if(currentTime < lastTime) cutoffTime = -Infinity;
        lastTime = currentTime;
        for(let i = 0, record; record = records[i]; i++){/*あらかじめ画面外へ消えて削除される要素を収集しておく*/
          if(record.removedNodes.length) removedComments.push(record.removedNodes[0]);
        }
        for(let i = records.length - 1, record; record = records[i]; i--){/*chatとのマッチングを逆順に行うのでこちらも逆順で*/
          if(record.addedNodes.length === 0) continue;
          if(site.addedNodes.comment(record.addedNodes[0]) === null) continue;
          let comment = record.addedNodes[0];
          core.modifyComment(comment);
          if(isChasing || isTimeshift){
            /* 30秒早送りなどで一覧がクリアされたら古いニセ新着を除外する */
            if(timeToSeconds(site.get.time(comment).textContent) < cutoffTime) break;
            /* タイムシフトではユーザーコメント以外は毎回置換されるので(バグ?)、置換要素は新着コメント扱いしない */
            if(['normal', 'trialWatch'].includes(comment.dataset.commentType) === false) continue;
            if(removedComments.find(c => comment.textContent === c.textContent)) continue;
            /*偶然一致するとnewCommentsから抜けてしまう!!*/
          }
          newComments.push(comment);
        }
        if(newComments.length) core.slideUpNewComments(newComments);
      });
    },
    listenMouseOnCommentsTable: function(){
      /* マウス操作中はスクロールでフォーカスが不意に外れてしまうのを抑制する */
      /* (マウスオーバーで新着スクロールを止めて、マウスアウトで一気に復帰させる) */
      let commentsTable = elements.commentsTable, parent = commentsTable.parentNode, scroll = 0;
      commentsTable.addEventListener('mouseenter', function(e){
        scroll = atMost(Math.round(parseFloat(getComputedStyle(commentsTable.lastElementChild).height)), parent.scrollTop);/*最初に少しスクロールさせると公式も空気を読んで新着コメントが来てもスクロールしなくなる*/
        commentsTable.dataset.mouseenter = 'true';
        parent.scrollTop -= scroll;
        commentsTable.style.transform = `translateY(-${scroll}px)`;
      });
      commentsTable.addEventListener('mouseleave', function(e){
        delete commentsTable.dataset.mouseenter;
        let scrollTopMax = parent.scrollHeight - parent.clientHeight, distance = scrollTopMax - parent.scrollTop - scroll;
        parent.scrollTop = scrollTopMax;
        animate(function(){parent.scrollTop = scrollTopMax + scroll});/*スクロールによって移動した分をさらに調整*/
        commentsTable.style.transform = ``;
        parent.animate([ 
          {transform: `translateY(${distance}px)`},
          {transform: `translateY(0)`},
        ], {duration: 125, easing: EASING});
        commentsTable.lastElementChild.dataset.new = 'true';/*すでに追加されている1件分*/
      });
    },
    modifyComment: function(commentNode){
      const additionalVpos = (props.program.beginTime - props.program.openTime) * 100;
      let contentNode = site.get.content(commentNode), timeNode = site.get.time(commentNode);
      let commentType = commentNode.dataset.commentType, content = contentNode.textContent, vpos = additionalVpos + timeToSeconds(timeNode.textContent);
      /* コメントに追加情報を与える */
      for(let i = chats.length - 1, chat; chat = chats[i]; i--){
        /* 時刻の一致を検証 */
        if(chat.vpos < vpos - 60*100) break;/*60秒以上古いログは追わずにあきらめる(TSではchatsの時系列がかなりばらけている)*/
        //if(!(vpos <= chat.vpos && chat.vpos <= vpos + 100)) continue;/*timeNodeの表示時刻とvposは必ずしも一致しない*/
        /* 既存の一致を検証 */
        if(chat.commentNode && chat.commentNode.isConnected) continue;
        /* 内容の一致を検証 */
        switch(commentType){
          case('normal'):/*通常コメント*/
          case('trialWatch'):/*有料番組のお試し視聴*/
            if(chat.content !== content) continue;
            break;
          case('operator'):/*運営コメント*/
            let operator = content.split(/\s/);
            if(!operator.every(o => chat.content.includes(o))) continue;
            break;
          case('nicoad'):/*ニコニ広告*/
            let nicoad = content.match(/(?:【.+?】)?(.+)さんが([0-9]+)pt/) || content.match(/(?:提供:)?(.+)さん(([0-9]+)pt)/);
            if(nicoad === null) log('Unknown nicoad format:', content);
            else if(!chat.content.includes(nicoad[1]) || !chat.content.includes(nicoad[2])) continue;/*厳密ではないけど十分*/
            break;
          case('programExtend'):/*放送枠の延長*/
          case('ranking'):/*ランキング入り通知*/
          case('cruise'):/*クルーズのお知らせ*/
          case('quote'):/*クルーズさんのコメント*/
          case('spi'):/*ニコニコ新市場*/
          case('gift'):/*ニコニコ新市場*/
            if(content.includes(chat.content)) continue;
            if(chat.content.includes(content)) continue;
            break;
          default:
            log('Unknown commentType found:', commentType, chats[i]);
            continue;/*複数吐かれる時間内のログから当該chatを見つける*/
        }
        /* 晴れてペアとなるchatを見つけられたので */
        chats[i].commentNode = commentNode;
        switch(commentType){
          case('normal'):
          case('trialWatch'):
            linkify(contentNode);/*URLをリンク化*/
            commentNode.dataset.score = chat.score || 0;
            commentNode.dataset.premium = chat.premium || 0;
            commentNode.dataset.user_id = chat.user_id || '';
            timeNode.parentNode.insertBefore(createElement(core.html.score(commentNode.dataset.score)), timeNode);/*NGスコア付与*/
            //commentNode.addEventListener('click', core.showUserHistory.bind(commentNode), {capture: true});
            break;
          case('operator'):
            let link = chat.content.match(/<a href="([^"]+)"/);
            if(link === null) linkify(contentNode);/*URLをリンク化*/
            else contentNode.innerHTML = `<a href="${link[1]}">${content}</a>`;
            break;
          case('nicoad'):
            linkify(contentNode);/*URLをリンク化*/
            break;
          case('programExtend'):
          case('ranking'):
          case('cruise'):
          case('quote'):
          case('spi'):
            break;
          default:
            break;
        }
        break;
      }
    },
    slideUpNewComments: function(newComments){
      if(elements.commentsTable.dataset.mouseenter) return;/*マウスオーバー中は処理しない*/
      //連続起動しうるけど125ms以内には起こらないだろう
      const DURATION = '125ms', EASING = 'ease';
      let commentsTable = elements.commentsTable, parent = commentsTable.parentNode;
      let scrollTopMax = parent.scrollHeight - parent.clientHeight;
      let height = parseFloat(getComputedStyle(newComments[0]).height) * newComments.length;/*高さは共通のはずなので*/
      for(let i = 0, comment; comment = newComments[i]; i++){
        comment.dataset.new = 'true';
      }
      if(scrollTopMax === 0) return;/*放送開始時などコメントが少なくてスクロール不要*/
      parent.scrollTop = scrollTopMax - 2;/* 本来は1でよいが、ブラウザのズーム倍率に対する保険 */
      commentsTable.style.transform = `translateY(${height - 2}px)`;
      animate(function(){
        commentsTable.style.transition = `transform ${DURATION} ${EASING}`;
        commentsTable.style.transform = `translateY(0)`;
        commentsTable.addEventListener('transitionend', function(e){
          commentsTable.style.transition = 'none';
          animate(function(){parent.scrollTop = scrollTopMax + 1});
        }, {once: true});
      });
    },
    showUserHistory: function(e){
      let commentNode = this, user_id = commentNode.dataset.user_id;
      log(this, user_id, users[user_id]);
    },
    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: {
      indicator: () => `<div id="${SCRIPTNAME}-indicator"></div>`,
      rewind: (rewinded) => `
        <svg id="rewind" ${rewinded ? 'class ="rewinded"' : ''} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.4" class="PlayerSeekBackwardButton-icon">
          <path d="M18.3 29A38 38 0 1 1 23 76.7a4 4 0 0 0-5.7 0l-2.8 2.8a4 4 0 0 0 0 5.7A50 50 0 1 0 8 22.8l-2-1.2a4 4 0 0 0-6 3.5v18.2a4 4 0 0 0 6 3.5L21.7 38a4 4 0 0 0 .2-7L18.3 29zM42 66a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V40h-2a2 2 0 0 1-2-2v-4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v32zm32 0a2 2 0 0 1-2 2H52a2 2 0 0 1-2-2V34c0-1.1.9-2 2-2h20a2 2 0 0 1 2 2v32zm-8-26h-8v20h8V40z"></path>
        </svg>
      `,
      localTime: () => `<span class="${SCRIPTNAME}-localTime"></span>`,
      commentOpacityIndicator: (key) => `<span id="comment-opacity-indicator" data-opacity="${key}">${key}</span>`,
      commentOpacitySelector: () => `
        <ul id="comment-opacity-selector" aria-label="コメント透明度">
          <li data-opacity="1">1</li>
          <li data-opacity="2">2</li>
          <li data-opacity="3">3</li>
          <li data-opacity="4">4</li>
          <li data-opacity="5">5</li>
          <li data-opacity="6">6</li>
          <li data-opacity="7">7</li>
          <li data-opacity="8">8</li>
          <li data-opacity="9">9</li>
          <li data-opacity="0">0</li>
        </ul>
      `,
      score: (score) => `<span class="___comment-score___${SCRIPTNAME}">${score}</span>`,
      styleVideo: () => `
        <style type="text/css">

        </style>
      `,
      styleLive: () => `
        <style type="text/css">
          /* nicoHighlightColor: ${configs.nicoHighlightColor     = 'rgba(0,128,255,1)'} */
          /* panel_zIndex:       ${configs.panel_zIndex           = 101} */
          /* 流れるコメント透明度 */
          [data-selector="commentLayer"],
          [data-selector="telopLayer"]{
            transition: opacity 125ms;
          }
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="1"]{opacity: ${9/36 + ((9*(9+1))/2)/60}}/*比例25:75三角数(max:45)の重みがベスト*/
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="2"]{opacity: ${8/36 + ((8*(8+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="3"]{opacity: ${7/36 + ((7*(7+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="4"]{opacity: ${6/36 + ((6*(6+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="5"]{opacity: ${5/36 + ((5*(5+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="6"]{opacity: ${4/36 + ((4*(4+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="7"]{opacity: ${3/36 + ((3*(3+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="8"]{opacity: ${2/36 + ((2*(2+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="9"]{opacity: ${1/36 + ((1*(1+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="0"]{opacity: ${0/36 + ((0*(0+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="1"] ~ [data-selector="telopLayer"]{opacity: ${9/36 + ((9*(9+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="2"] ~ [data-selector="telopLayer"]{opacity: ${8/36 + ((8*(8+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="3"] ~ [data-selector="telopLayer"]{opacity: ${7/36 + ((7*(7+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="4"] ~ [data-selector="telopLayer"]{opacity: ${6/36 + ((6*(6+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="5"] ~ [data-selector="telopLayer"]{opacity: ${5/36 + ((5*(5+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="6"] ~ [data-selector="telopLayer"]{opacity: ${4/36 + ((4*(4+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="7"] ~ [data-selector="telopLayer"]{opacity: ${3/36 + ((3*(3+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="8"] ~ [data-selector="telopLayer"]{opacity: ${2/36 + ((2*(2+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="9"] ~ [data-selector="telopLayer"]{opacity: ${1/36 + ((1*(1+1))/2)/60}}
          [data-selector="playerDisplayScreen"] [data-selector="commentLayer"][data-opacity="0"] ~ [data-selector="telopLayer"]{opacity: ${0/36 + ((0*(0+1))/2)/60}}
          /* 10秒戻り中の流れるコメント非表示 */
          [data-selector="playerDisplayScreen"] #comment-layer-container{
            transition: opacity 1000ms;
            opacity: 1;
          }
          [data-selector="playerDisplayScreen"][data-rewinded="true"] #comment-layer-container{
            opacity: 0;
          }
          /* インジケータ */
          #${SCRIPTNAME}-indicator{
            position: absolute;
            bottom: 0;
            right: 0;
            padding: 1vh 1vw;
            font-size: 25vh;
            color: ${configs.nicoHighlightColor};
            filter: drop-shadow(0 0 2.5px rgba(0,0,0,.75));
            opacity: 0;
            z-index: ${configs.panel_zIndex};
            pointer-events: none;
            transition: opacity 250ms;
          }
          #${SCRIPTNAME}-indicator.active{
            opacity: .75;
          }
          #${SCRIPTNAME}-indicator #rewind{
            fill: rgba(195,195,195,.5);
            width: 25vh;
            height: 25vh;
          }
          #${SCRIPTNAME}-indicator #rewind.rewinded{
            fill: ${configs.nicoHighlightColor};
          }
          #${SCRIPTNAME}-indicator.active #rewind{
            animation: ${SCRIPTNAME}-blink 2s step-end infinite;
          }
          @keyframes ${SCRIPTNAME}-blink{
            50%{opacity: 0}
          }
          /* コメント透明度インジケータ・セレクタ */
          #comment-opacity-indicator{
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            line-height: 32px;/*職人的調整*/
            font-weight: bold;
            color: black;
            z-index: 1;
          }
          #comment-opacity-indicator[data-opacity="1"], #comment-opacity-selector [data-opacity="1"]{opacity: ${9/9}}/*視認性を重視*/
          #comment-opacity-indicator[data-opacity="2"], #comment-opacity-selector [data-opacity="2"]{opacity: ${8/9}}
          #comment-opacity-indicator[data-opacity="3"], #comment-opacity-selector [data-opacity="3"]{opacity: ${7/9}}
          #comment-opacity-indicator[data-opacity="4"], #comment-opacity-selector [data-opacity="4"]{opacity: ${6/9}}
          #comment-opacity-indicator[data-opacity="5"], #comment-opacity-selector [data-opacity="5"]{opacity: ${5/9}}
          #comment-opacity-indicator[data-opacity="6"], #comment-opacity-selector [data-opacity="6"]{opacity: ${4/9}}
          #comment-opacity-indicator[data-opacity="7"], #comment-opacity-selector [data-opacity="7"]{opacity: ${3/9}}
          #comment-opacity-indicator[data-opacity="8"], #comment-opacity-selector [data-opacity="8"]{opacity: ${2/9}}
          #comment-opacity-indicator[data-opacity="9"], #comment-opacity-selector [data-opacity="9"]{opacity: ${1/9}}
          #comment-opacity-indicator[data-opacity="0"], #comment-opacity-selector [data-opacity="0"]{opacity: ${0/9}}
          [data-selector="commentVisibilityButton"][data-toggle-state="false"] #comment-opacity-indicator{visibility: hidden}
          [data-selector="commentVisibilityButton"]{
            z-index: 1;/*後のセレクタより上に*/
          }
          #comment-opacity-selector{
            color: white;
            padding: 0;
            margin: 0;
            list-style-type: none;
            display: flex;
            position: absolute;
            height: 100%;
            line-height: 32px;
            font-size: 12px;
            opacity: 0;
            pointer-events: none;
            transform: translate(calc(-100% + 32px), 0);
            transition: 250ms;
          }
          #comment-opacity-selector::before/*公式がクラス指定じゃないのでやむなくコピペ*/{
            content: attr(aria-label);
            display: block;
            position: absolute;
            bottom: 100%;
            left: 50%;
            padding: 6px 8px;
            letter-spacing: normal;
            box-sizing: border-box;
            text-align: center;
            white-space: nowrap;
            color: #fff;
            border-radius: 2px;
            background: rgba(0,0,0,.75);/*改変*/
            font-size: 12px;
            line-height: 1;
            pointer-events: none;
            transform: translate(-50%);
            z-index: 10000;
            box-shadow: 0 0 2px 0 rgba(0,0,0,.5);
            opacity: 0;
            transition: opacity .12s ease;
          }
          #comment-opacity-selector:hover::before{
            opacity: 1;
          }
          [data-selector="commentVisibilityButton"]:hover + #comment-opacity-selector,
          [data-selector="commentVisibilityButton"] + #comment-opacity-selector:hover{
            pointer-events: auto;
            opacity: 1;
            transform: translate(calc(-100% + 0px), 0);
          }
          #comment-opacity-selector li{
            padding: 0 .25em;
            cursor: pointer;
            transition: .12s ease;/*公式に合わせる*/
          }
          #comment-opacity-selector li:first-child{
            padding: 0 .25em 0 1.25em;
          }
          #comment-opacity-selector li:hover{
            opacity: 1;
            color: ${configs.nicoHighlightColor};
          }
          /* 当日時刻の追加 */
          [data-selector="seekInformation"]{
            display: flex;
            flex-direction: column;
          }
          [data-selector="seekInformation"] .${SCRIPTNAME}-localTime{
            color: #808080;
            font-size: 12px;
            margin-bottom: .25em;
          }
          [data-selector="timeStatusArea"] button[data-live-status="live"] ~ div .${SCRIPTNAME}-localTime{
            display: none;
          }
          [data-selector="timeStatusArea"] .${SCRIPTNAME}-localTime{
            color: #808080;
            font-size: 12px !important;
            margin: 0 .5em;
          }
          [data-browser-fullscreen] [data-selector="timeStatusArea"] .${SCRIPTNAME}-localTime{
            color: #c0c0c0;
          }
          [data-selector="timeStatusArea"] .${SCRIPTNAME}-localTime + span{
            font-size: 16px;/*少し大きく(公式12px)*/
            line-height: 24px;
          }
          [data-browser-fullscreen] [data-selector="timeStatusArea"] .${SCRIPTNAME}-localTime + span{
            font-size: 18px;/*少し大きく(公式12px)*/
          }
          [data-selector="timeStatusArea"] .${SCRIPTNAME}-localTime + span + span/*時刻の区切り*/{
            margin: 0 .5em;/*少し間隔を広げて見やすく*/
          }
          /* 新着コメント停止状態 */
          [class*="_comment-panel_"]:hover::after{
            content: " ";
            position: absolute;
            bottom: 0;
            width: 100%;
            height: 4px;
            animation: ${SCRIPTNAME}-stop 1s linear infinite alternate;
          }
          @keyframes ${SCRIPTNAME}-stop{
            0%{
              background: ${configs.nicoHighlightColor};
            }
            100%{
              background: transparent;
            }
          }
          /* ユーザー発言一覧 */
          dummy [class*="_comment-panel_"] [class*="_table-row_"][data-comment-type="normal"]{
            cursor: pointer;
          }
          /* CSSによる簡易なめらかスクロールの打ち消し */
          [class*="_comment-panel_"] [class*="_table-row_"]:first-child{
            margin-bottom: 0 !important;
            opacity: 1 !important;
            pointer-events: auto !important;
          }
          /* 新着コメントのハイライト */
          [class*="_comment-panel_"] [class*="_table-row_"][data-new="true"]{
            animation: ${SCRIPTNAME}-new 6s linear 1;
          }
          @keyframes ${SCRIPTNAME}-new{
            0%{
              background: rgba(255,255,255,.250);
            }
            100%{
              background: rgba(255,255,255,.000);
            }
          }
          [class*="_comment-panel_"] button[class*="_indicator_"]{
            opacity: 0 !important;
          }
          [class*="_comment-panel_"]:hover button[class*="_indicator_"]{
            opacity: 1 !important;
          }
          /* NGスコア */
          [class*="_comment-panel_"] [class*="_table-row_"] [class="___comment-score___${SCRIPTNAME}"]{
            visibility: hidden;
            margin: 0 .25em;
          }
          [class*="_comment-panel_"] [class*="_table-row_"]:hover [class="___comment-score___${SCRIPTNAME}"]{
            visibility: visible;
            color: #808080;
          }
        </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), requestIdleCallback = window.requestIdleCallback.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)}});
  if(!('fullscreenElement' in document)) Object.defineProperty(document, 'fullscreenElement', {get: function(){return document.mozFullScreenElement}});
  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 linkify = function(node){
    split(node);
    function split(n){
      if(['style', 'script', 'a'].includes(n.localName)) return;
      if(n.nodeType === Node.TEXT_NODE){
        let pos = n.data.search(linkify.RE);
        if(0 <= pos){
          let target = n.splitText(pos);/*pos直前までのnとpos以降のtargetに分割*/
          let rest = target.splitText(RegExp.lastMatch.length);/*targetと続くrestに分割*/
          /* この時点でn(処理済み),target(リンクテキスト),rest(次に処理)の3つに分割されている */
          let a = document.createElement('a');
          let match = target.data.match(linkify.RE);
          switch(true){
            case(match[1] !== undefined): a.href = (match[1][0] == 'h') ? match[1] : 'h' + match[1]; break;
            case(match[2] !== undefined): a.href = 'http://' + match[2]; break;
            case(match[3] !== undefined): a.href = 'mailto:' + match[4] + '@' + match[5]; break;
          }
          a.appendChild(target);/*textContent*/
          rest.parentNode.insertBefore(a, rest);
        }
      }else{
        for(let i = 0; n.childNodes[i]; i++) split(n.childNodes[i]);/*回しながらchildNodesは増えていく*/
      }
    }
  };
  linkify.RE = new RegExp([
    '(h?ttps?://[-\\w_./~*%$@:;,!?&=+#]+[-\\w_/~*%$@:;&=+#])',/*通常のURL*/
    '((?:\\w+\\.)+\\w+/[-\\w_./~*%$@:;,!?&=+#]*)',/*http://の省略形*/
    '((\\w[-\\w_.]+)(?:@|@)(\\w[-\\w_.]+\\w))',/*メールアドレス*/
  ].join('|'));
  const secondsToTime = function(seconds){
    let floor = Math.floor, zero = (s) => s.toString().padStart(2, '0');
    let h = floor(seconds/3600), m = floor(seconds/60)%60, s = floor(seconds%60);
    if(h) return h + '時間' + zero(m) + '分' + zero(s) + '秒';
    if(m) return m + '分' + zero(s) + '秒';
    if(s) return s + '秒';
  };
  const timeToSeconds = function(time){
    let sign = (time[0] === '-') ? -1 : +1, parts = time.replace(/^-/, '').split(':').map(p => parseFloat(p)), s = 1, m = 60*s, h = 60*m;
    switch(parts.length){
      case(1): return sign * (parts[0]*s);
      case(2): return sign * (parts[0]*m + parts[1]*s);
      case(3): return sign * (parts[0]*h + parts[1]*m + parts[2]*s);
      default: return 0;
    }
  };
  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 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;
        log('Total:');
        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);
})();