Niconico Batch Commenter

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

目前為 2019-05-20 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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

(function(){
  const SCRIPTNAME = 'NiconicoBatchCommenter';
  const DEBUG = false;
  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) 時刻にカッコを続けるとコマンド指定もできます。
  `.replace(/^\s+/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/), 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);
          location.reload();
          return;
        }
        let index = i++, c = comments[index].match(/^#([0-9:.]+)(?:\(([a-z0-9_#@:\s]+)\))?\s(.+)$/);
        if(c === null) return errors.push(comments[index]) && core.flagLine(textarea, comments[index], false);
        let time = c[1], command = c[2] || '', content = c[3];
        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, c[0], success);
            if(!success) errors.push(c[0]);
          });
      }, INTERVAL);
    },
    flagLine: function(textarea, string, success){
      textarea.value = textarea.value.replace(new RegExp('^' + escapeRegExp(string) + '$', 'm'), (success ? 'OK ' : 'NG ') + 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);
})();