Greasy Fork is available in English.

NicoNico Tsuu

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

Устаревшая версия на 25.04.2019. Перейти к последней версии.

// ==UserScript==
// @name        NicoNico Tsuu
// @namespace   knoa.jp
// @description ニコニコライフを快適に。
// @include     https://live2.nicovideo.jp/watch/*
// @version     0.1.3
// @grant       none
// ==/UserScript==
/*
anonymity : 1
content : "/hb ifseetno 686"
date : 1556109561
date_usec : 880193
mail : "184"
premium : 3
thread : 1649308673
user_id : "3TSWJ5NZfEYj9kaAWaDpxlLARJM"
vpos : 4256100
yourpost : 1
*/

(function(){
  const SCRIPTNAME = 'NicoNicoTsuu';
  const DEBUG = true;
  if(window === top && console.time) console.time(SCRIPTNAME);
  const API = {
    LIVEMSG: 'wss://msg.live2.nicovideo.jp/',
  };
  const PREMIUM = {
    USER: 1,
    OPERATOR: 3,
  };
  const RETRY = 10;
  let site = {
    targets: {
      playerDisplayHeader: () => $('[class*="_player-display-header_"]'),/*運営コメント*/
      playerDisplayScreen: () => $('[class*="_player-display-screen_"]'),
      interactionLayerContent: () => $('[class*="_interaction-layer_"] > [data-content-visibility]'),/*アンケート*/
      fullscreenButton: () => $('[class*="_fullscreen-button_"]'),
      commentTextBox: () => $('[class*="_comment-text-box_"]'),
      commentsTable: () => $('[class*="_comment-panel_"] [class*="_table_"]'),
      embeddedData: () => $('#embedded-data'),
    },
    get: {
      announcement: (playerDisplayHeader) => playerDisplayHeader.querySelector('[class*="_announcement-renderer_"]'),
      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 === "normal") ? node : null,
    },
  };
  let html, elements = {}, storages = {}, timers = {}, props, chats = [], users = {}/*id検索用テーブル*/;
  let core = {
    initialize: function(){
      html = document.documentElement;
      html.classList.add(SCRIPTNAME);
      core.ready();
      core.addStyle();
    },
    ready: function(){
      core.getTargets(site.targets, RETRY).then(() => {
        log("I'm ready.");
        core.getProps();
        core.listenUserActions();
        core.listenComments();
        core.listenEnquete();
        core.observeCommentTable();
      });
    },
    getProps: function(){
      props = site.get.props(elements.embeddedData);
      log(props);
    },
    listenUserActions: function(){
      /* 映像クリックで常にコメント入力欄にフォーカス */
      elements.playerDisplayScreen.addEventListener('click', function(e){
        elements.commentTextBox.focus();
      });
      /* フルスクリーン状態の変化 */
      observe(html, function(records){
        if(html.dataset.browserFullscreen) return;/*フルスクリーン化したときは何もしない*/
        setTimeout(window.scrollTo.bind(window, 0, 0), 0);/*スクロール位置がずれるのを即補正*/
        setTimeout(core.observeCommentTable, 1000);/*commentsTableが復活するのでもう一度監視する*/
      }, {attributes: true});
      /* ウィンドウリサイズ */
      window.addEventListener('resize', function(e){
        /* ニコ生コメント一覧付き全画面シアターとの連携(なめらかスクロールをこちらで引き受ければ本来不要な処理のはず) */
        clearTimeout(window.resizing), window.resizing = setTimeout(function(){
          elements.fullscreenButton.click();
          elements.fullscreenButton.click();
          window.resizing = null;
        }, 250);/*リサイズ中の連続起動を避ける*/
      });
    },
    listenComments: 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);
            //log(json);
            if(json.chat === undefined) return;
            if(json.chat.premium === PREMIUM.OPERATOR) return;
            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;
        }
      });
    },
    listenEnquete: function(){
      /* アンケートの表示を捉える */
      Notification.requestPermission();
      let notification;
      observe(elements.interactionLayerContent, function(records){
        if(elements.interactionLayerContent.dataset.contentVisibility === 'false'){
          if(notification) notification.close();/*通知が出たままなら閉じる*/
          return;/*閉じたときは何もしない*/
        }
        notification = new Notification(props.program.title, {body: site.get.announcement(elements.playerDisplayHeader).textContent});
        notification.addEventListener('click', function(e){
          notification.close();
        });
      }, {attributes: true});
    },
    observeCommentTable: function(){
      if(!elements.commentsTable || !elements.commentsTable.isConnected) elements.commentsTable = site.targets.commentsTable();
      if(elements.commentsTable.observing) return;/*起こりえないけど重複を避ける*/
      elements.commentsTable.observing = true;
      Array.from(elements.commentsTable.children).forEach(c => core.modifyComment(c));
      observe(elements.commentsTable, function(records){
        //log(records);
        records.forEach(r => {
          if(r.addedNodes.length === 0) return;
          if(site.addedNodes.comment(r.addedNodes[0]) === null) return;
          core.modifyComment(r.addedNodes[0]);
        });
      });
    },
    modifyComment: function(commentNode){
      const additionalVpos = props.program.beginTime - props.program.openTime;
      const toVpos = function(time){
        let sign = (time[0] === '-') ? -1 : +1;
        let p = time.split(':').map(d => parseFloat(d)), s = 100, m = 60*s, h = 60*m;
        if(p[2]) return additionalVpos + sign * (sign*p[0]*h + p[1]*m + p[2]*s);
        if(p[1]) return additionalVpos + sign * (sign*p[0]*m + p[1]*s);
        if(p[0]) return additionalVpos + sign * (sign*p[0]*s);
      };
      let contentNode = site.get.content(commentNode), timeNode = site.get.time(commentNode);
      let content = contentNode.textContent, vpos = toVpos(timeNode.textContent);
      linkify(contentNode);/*URLをリンク化*/
      for(let i = chats.length - 1, chat; chat = chats[i]; i--){
        if(!between(vpos, chat.vpos, vpos + 100)) continue;
        if(chat.content !== content) continue;
        if(chat.commentNode && chat.commentNode.isConnected) continue;
        /* 晴れてペアとなるchatを見つけられたので */
        chats[i].commentNode = commentNode;
        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);
        //commentNode.addEventListener('click', core.showUserHistory.bind(commentNode), {capture: true});
        break;
      }
    },
    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: {
      score: (score) => `<span class="___comment-score___${SCRIPTNAME}">${score}</span>`,
      style: () => `
        <style type="text/css">
          dummy [class*="_comment-panel_"] [class*="_table-row_"][data-comment-type="normal"]{
            cursor: pointer;/*ユーザー発言一覧用*/
          }
          [class*="_comment-panel_"] [class*="_table-row_"][data-comment-type="normal"] [class="___comment-score___${SCRIPTNAME}"]{
            display: none;
            margin: 0 .25em;
          }
          [class*="_comment-panel_"] [class*="_table-row_"][data-comment-type="normal"]:hover [class="___comment-score___${SCRIPTNAME}"]{
            display: inline;
            color: #808080;
          }
        </style>
      `,
    },
  };
  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}){
    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 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]+)\)$/)[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 + '\n' + new Error().stack);
    return true;
  });
  const time = function(label){
    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 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);
})();