ニコニコ動画のコメントをまとめて投稿します。
// ==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(/</g, '<').replace(/>/g, '>').replace(/&/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_r###lt.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); })();