Niconico Batch Commenter

ニコニコ動画のコメントをまとめて投稿します。

// ==UserScript==
// @name        Niconico Batch Commenter
// @namespace   knoa.jp
// @description ニコニコ動画のコメントをまとめて投稿します。
// @include     https://www.nicovideo.jp/watch/*
// @version     1.1.3
// @grant       none
// ==/UserScript==

(function(){
  const SCRIPTNAME = 'NiconicoBatchCommenter';
  const DEBUG = false;/*
[update] 1.1.3
正常動作を確認しました。

[to do]

[possible to do]
ニコニコの仕様変更を検知したらお知らせと共にこのページを案内するなど
75文字制限「*75文字を超えるコメントがあります」(投稿できない)
時間制限「*動画時間を超える時刻指定があります」(投稿は可能)
ログイン確認
  */
  if(window === top && console.time) console.time(SCRIPTNAME);
  const NMSG = 'https://nmsg.nicovideo.jp/api.json/thread?version=20090904&thread={thread}';
  const FLAPI = 'https://flapi.nicovideo.jp/api/getpostkey?thread={thread}&block_no={block_no}&device=1&version=1&version_sub=6';
  const POST = 'https://nmsg.nicovideo.jp/api.json/';
  const INTERVAL = 6000;
  const MAXLENGTH = 75;/*未使用*/
  let site = {
    targets: {
      CommentPanelContainer: () => $('.CommentPanelContainer'),
    },
    get: {
      apiData: () => JSON.parse(document.querySelector('#js-initial-watch-data').dataset.apiData),
      thread: (apiData) => apiData.thread.ids.default,
      user_id: (apiData) => apiData.viewer.id,
      premium: (apiData) => apiData.viewer.isPremium ? "1" : "0",
    },
    getChat: (vpos, command, content, parameters) => [
      {ping: {content: "rs:1"}},
      {ping: {content: "ps:8"}},
      {chat: {
        thread: parameters.thread,
        user_id: parameters.user_id,
        premium: parameters.premium,
        mail: command + " 184",
        vpos: vpos,
        content: content,
        ticket: parameters.ticket,
        postkey: parameters.postkey,
      }},
      {ping: {content: "pf:8"}},
      {ping: {content: "rf:1"}},
    ],
    toVpos: (time) => {
      let t = time.split(':'), h = 60*60*100, m = 60*100, s = 100;
      switch(t.length){
        case(3): return t[0]*h + t[1]*m + t[2]*s;
        case(2): return t[0]*m + t[1]*s;
        case(1): return t[0]*s;
      }
    },
  };
  let comment = `
    #0:00 うp乙
    #1:23 wwwww
    #1:23.45 コンマ秒単位ずらすwwwww
    #60:00.0 時刻表記は 1:00:00 でも 60:00 でも 3600 でもおk
    #1:25:25(shita small) 時刻にカッコを続けるとコマンド指定もできます。

    <chat vpos="360000" mail="shita small">XML形式の貼り付けもできます。時刻(vpos)とコマンド(mail)以外の属性は無視します。184コマンドは自動で付与されます。</chat>
  `.trim().replace(/^ +/mg, '');
  let retry = 10, elements = {}, storages = {}, timers = {};
  let core = {
    initialize: function(){
      core.ready();
      core.addStyle();
    },
    ready: function(){
      for(let i = 0, keys = Object.keys(site.targets); keys[i]; i++){
        let element = site.targets[keys[i]]();
        if(element){
          element.dataset.selector = keys[i];
          elements[keys[i]] = element;
        }else{
          if(--retry < 0) return log(`Not found: ${keys[i]}, I give up.`);
          log(`Not found: ${keys[i]}, retrying... (left ${retry})`);
          return setTimeout(core.ready, 1000);
        }
      }
      log("I'm ready.");
      core.addButton();
    },
    addButton: function(){
      let button = createElement(core.html.button()), html = document.documentElement;
      button.addEventListener('click', function(e){
        if(html.classList.contains(SCRIPTNAME)) return;/*二重に開かない*/
        html.classList.add(SCRIPTNAME);
        let form = createElement(core.html.form(comment)), textarea = form.querySelector('textarea'), postButton = form.querySelector('button');
        postButton.addEventListener('click', core.post.bind(null, textarea, postButton));
        /* フォーム背景をクリックすると消える */
        form.addEventListener('click', function(e){
          if(e.target !== form) return;/*フォーム内の部品をクリックした場合は何もしない*/
          if(textarea.disabled) return;/*コメント送信処理中は何もしない*/
          comment = textarea.value;/* 保存 */
          form.parentNode.removeChild(form);
          html.classList.remove(SCRIPTNAME);
        });
        document.body.appendChild(form);
      });
      elements.CommentPanelContainer.appendChild(button);
    },
    post: function(textarea, button, e){
      e.preventDefault();
      let i = 0, comments = textarea.value.trim().split(/\n/).map(c => c.trimLeft()).filter(c => c.match(/^#[0-9]|^<chat /)), errors = [];
      if(!confirm(`${comments.length}件のコメントを${INTERVAL/1000}秒ごとに計${secondsToTime(comments.length * INTERVAL/1000)}かけて投稿します。`)) return;
      textarea.disabled = button.disabled = true;
      let timer = setInterval(function(){
        if(comments[i] === undefined){
          let message = `${comments.length}コメントの送信を完了しました。リロードで反映されます。`;
          if(errors.length) message += `以下のコメントは投稿に失敗しました:\n\n${errors.join(`\n`)}`;
          clearInterval(timer);
          alert(message);
          textarea.disabled = button.disabled = false;
          return;
        }
        let comment = comments[i++], line, time, command, content, fail = function(comment){errors.push(comment) && core.flagLine(textarea, comment, false)};
        switch(true){
          case(comment.startsWith('#')):
            let m = comment.match(/^#([0-9:.]+)(?:\(([a-z0-9_#@:\s]+)\))?\s(.+)$/);
            if(m === null) return fail(comment);
            line = m[0], time = m[1], command = m[2] || '', content = m[3];
            break;
          case(comment.startsWith('<chat ')):
            let lm = comment.match(/<chat[^>]+>([^<>]+)<\/chat>/), vm = comment.match(/ vpos="([0-9]+)"/), mm = comment.match(/ mail="([^"]+)"/);
            if(lm === null || vm === null) return fail(comment);
            line = lm[0], time = String(parseFloat(vm[1])/100), command = mm ? mm[1] : '', content = lm[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
            break;
          default:
            return fail(comment);
            break;
        }
         let apiData = site.get.apiData(), parameters = {
          thread:  site.get.thread(apiData),
          user_id: site.get.user_id(apiData),
          premium: site.get.premium(apiData),
        };
        fetch(NMSG.replace('{thread}', parameters.thread))
        .then(response => response.json())
        .then(json => {parameters.block_no = Math.floor(((json[0].thread.last_res || 0) + 1) / 100); parameters.ticket = json[0].thread.ticket;})
        .then(() => fetch(FLAPI.replace('{thread}', parameters.thread).replace('{block_no}', parameters.block_no), {credentials: 'include'}))
        .then(response => response.text())
        .then(text => {parameters.postkey = text.replace(/^postkey=/, '')})
        .then(() => fetch(POST, {method: 'POST', body: JSON.stringify(site.getChat(site.toVpos(time), command, content, parameters))}))
        .then(response => response.json())
        .then(json => json[2].chat_result.status === 0)
        .then(success => {
          core.flagLine(textarea, line, success);
          if(!success) errors.push(line);
        });
      }, INTERVAL);
    },
    flagLine: function(textarea, string, success){
      textarea.value = textarea.value.replace(new RegExp('^(.*?)' + escapeRegExp(string) + '$', 'm'), (success ? 'OK ' : 'NG ') + '$1' + string);
    },
    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: {
      button: () => `
        <button id="${SCRIPTNAME}-button" title="${SCRIPTNAME} コメントをまとめて投稿する">+</button>
      `,
      form: (comment) => `
        <form id="${SCRIPTNAME}-form">
          <textarea placeholder="#1:23 wwwww">${comment}</textarea>
          <button>まとめてコメントする</button>
        </form>
      `,
      style: () => `
        <style type="text/css">
          html.${SCRIPTNAME}{
            overflow: hidden;/*背後のコンテンツをスクロールさせない*/
          }
          #${SCRIPTNAME}-button{
            font-size: 2em;
            line-height: 1em;
            text-align: center;
            color: rgba(0,0,0,.5);
            background: white;
            border: none;
            border-radius: 1em;
            filter: drop-shadow(0 0 .1em rgba(0,0,0,.5));
            opacity: .25;
            width: 1em;
            height: 1em;
            padding: 0;
            margin: .25em;
            position: absolute;
            right: 0;
            bottom: 0;
            cursor: pointer;
            transition: opacity 250ms;
          }
          #${SCRIPTNAME}-button:hover{
            opacity: .75;
          }
          #${SCRIPTNAME}-form{
            background: rgba(0,0,0,.75);
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            z-index: 1000;
          }
          #${SCRIPTNAME}-form textarea{
            font-family: monospace;
            border: none;
            width: 80vw;
            height: calc(80vh - 3em);
            padding: .5em;
            margin: 10vh 10vw 0;
          }
          #${SCRIPTNAME}-form button{
            color: white;
            background: rgb(0, 124, 255);
            border: none;
            width: 80vw;
            height: 3em;
            margin: 0 10vw;
            cursor: pointer;
          }
          #${SCRIPTNAME}-form button:hover{
            background: rgb(0, 96, 210);
          }
          #${SCRIPTNAME}-form button[disabled]{
            filter: brightness(.5);
            pointer-events: none;
          }
        </style>
      `,
    },
  };
  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){
    let outer = document.createElement('div');
    outer.innerHTML = html;
    return outer.firstElementChild;
  };
  const escapeRegExp = function(string){
    return string.replace(/[.*+?^=!:${}()|[\]\/\\]/g, '\\$&'); // $&はマッチした部分文字列全体を意味します
  };
  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 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;
  });
  core.initialize();
  if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
})();