Niconico Batch Commenter

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

当前为 2020-09-07 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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);
})();