Niconico Batch Commenter

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

目前为 2020-12-31 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Niconico Batch Commenter
  3. // @namespace knoa.jp
  4. // @description ニコニコ動画のコメントをまとめて投稿します。
  5. // @include https://www.nicovideo.jp/watch/*
  6. // @version 1.1.3
  7. // @grant none
  8. // ==/UserScript==
  9.  
  10. (function(){
  11. const SCRIPTNAME = 'NiconicoBatchCommenter';
  12. const DEBUG = false;/*
  13. [update] 1.1.3
  14. 正常動作を確認しました。
  15.  
  16. [to do]
  17.  
  18. [possible to do]
  19. ニコニコの仕様変更を検知したらお知らせと共にこのページを案内するなど
  20. 75文字制限「*75文字を超えるコメントがあります」(投稿できない)
  21. 時間制限「*動画時間を超える時刻指定があります」(投稿は可能)
  22. ログイン確認
  23. */
  24. if(window === top && console.time) console.time(SCRIPTNAME);
  25. const NMSG = 'https://nmsg.nicovideo.jp/api.json/thread?version=20090904&thread={thread}';
  26. const FLAPI = 'https://flapi.nicovideo.jp/api/getpostkey?thread={thread}&block_no={block_no}&device=1&version=1&version_sub=6';
  27. const POST = 'https://nmsg.nicovideo.jp/api.json/';
  28. const INTERVAL = 6000;
  29. const MAXLENGTH = 75;/*未使用*/
  30. let site = {
  31. targets: {
  32. CommentPanelContainer: () => $('.CommentPanelContainer'),
  33. },
  34. get: {
  35. apiData: () => JSON.parse(document.querySelector('#js-initial-watch-data').dataset.apiData),
  36. thread: (apiData) => apiData.thread.ids.default,
  37. user_id: (apiData) => apiData.viewer.id,
  38. premium: (apiData) => apiData.viewer.isPremium ? "1" : "0",
  39. },
  40. getChat: (vpos, command, content, parameters) => [
  41. {ping: {content: "rs:1"}},
  42. {ping: {content: "ps:8"}},
  43. {chat: {
  44. thread: parameters.thread,
  45. user_id: parameters.user_id,
  46. premium: parameters.premium,
  47. mail: command + " 184",
  48. vpos: vpos,
  49. content: content,
  50. ticket: parameters.ticket,
  51. postkey: parameters.postkey,
  52. }},
  53. {ping: {content: "pf:8"}},
  54. {ping: {content: "rf:1"}},
  55. ],
  56. toVpos: (time) => {
  57. let t = time.split(':'), h = 60*60*100, m = 60*100, s = 100;
  58. switch(t.length){
  59. case(3): return t[0]*h + t[1]*m + t[2]*s;
  60. case(2): return t[0]*m + t[1]*s;
  61. case(1): return t[0]*s;
  62. }
  63. },
  64. };
  65. let comment = `
  66. #0:00 うp乙
  67. #1:23 wwwww
  68. #1:23.45 コンマ秒単位ずらすwwwww
  69. #60:00.0 時刻表記は 1:00:00 でも 60:00 でも 3600 でもおk
  70. #1:25:25(shita small) 時刻にカッコを続けるとコマンド指定もできます。
  71.  
  72. <chat vpos="360000" mail="shita small">XML形式の貼り付けもできます。時刻(vpos)とコマンド(mail)以外の属性は無視します。184コマンドは自動で付与されます。</chat>
  73. `.trim().replace(/^ +/mg, '');
  74. let retry = 10, elements = {}, storages = {}, timers = {};
  75. let core = {
  76. initialize: function(){
  77. core.ready();
  78. core.addStyle();
  79. },
  80. ready: function(){
  81. for(let i = 0, keys = Object.keys(site.targets); keys[i]; i++){
  82. let element = site.targets[keys[i]]();
  83. if(element){
  84. element.dataset.selector = keys[i];
  85. elements[keys[i]] = element;
  86. }else{
  87. if(--retry < 0) return log(`Not found: ${keys[i]}, I give up.`);
  88. log(`Not found: ${keys[i]}, retrying... (left ${retry})`);
  89. return setTimeout(core.ready, 1000);
  90. }
  91. }
  92. log("I'm ready.");
  93. core.addButton();
  94. },
  95. addButton: function(){
  96. let button = createElement(core.html.button()), html = document.documentElement;
  97. button.addEventListener('click', function(e){
  98. if(html.classList.contains(SCRIPTNAME)) return;/*二重に開かない*/
  99. html.classList.add(SCRIPTNAME);
  100. let form = createElement(core.html.form(comment)), textarea = form.querySelector('textarea'), postButton = form.querySelector('button');
  101. postButton.addEventListener('click', core.post.bind(null, textarea, postButton));
  102. /* フォーム背景をクリックすると消える */
  103. form.addEventListener('click', function(e){
  104. if(e.target !== form) return;/*フォーム内の部品をクリックした場合は何もしない*/
  105. if(textarea.disabled) return;/*コメント送信処理中は何もしない*/
  106. comment = textarea.value;/* 保存 */
  107. form.parentNode.removeChild(form);
  108. html.classList.remove(SCRIPTNAME);
  109. });
  110. document.body.appendChild(form);
  111. });
  112. elements.CommentPanelContainer.appendChild(button);
  113. },
  114. post: function(textarea, button, e){
  115. e.preventDefault();
  116. let i = 0, comments = textarea.value.trim().split(/\n/).map(c => c.trimLeft()).filter(c => c.match(/^#[0-9]|^<chat /)), errors = [];
  117. if(!confirm(`${comments.length}件のコメントを${INTERVAL/1000}秒ごとに計${secondsToTime(comments.length * INTERVAL/1000)}かけて投稿します。`)) return;
  118. textarea.disabled = button.disabled = true;
  119. let timer = setInterval(function(){
  120. if(comments[i] === undefined){
  121. let message = `${comments.length}コメントの送信を完了しました。リロードで反映されます。`;
  122. if(errors.length) message += `以下のコメントは投稿に失敗しました:\n\n${errors.join(`\n`)}`;
  123. clearInterval(timer);
  124. alert(message);
  125. textarea.disabled = button.disabled = false;
  126. return;
  127. }
  128. let comment = comments[i++], line, time, command, content, fail = function(comment){errors.push(comment) && core.flagLine(textarea, comment, false)};
  129. switch(true){
  130. case(comment.startsWith('#')):
  131. let m = comment.match(/^#([0-9:.]+)(?:\(([a-z0-9_#@:\s]+)\))?\s(.+)$/);
  132. if(m === null) return fail(comment);
  133. line = m[0], time = m[1], command = m[2] || '', content = m[3];
  134. break;
  135. case(comment.startsWith('<chat ')):
  136. let lm = comment.match(/<chat[^>]+>([^<>]+)<\/chat>/), vm = comment.match(/ vpos="([0-9]+)"/), mm = comment.match(/ mail="([^"]+)"/);
  137. if(lm === null || vm === null) return fail(comment);
  138. 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, '&');
  139. break;
  140. default:
  141. return fail(comment);
  142. break;
  143. }
  144. let apiData = site.get.apiData(), parameters = {
  145. thread: site.get.thread(apiData),
  146. user_id: site.get.user_id(apiData),
  147. premium: site.get.premium(apiData),
  148. };
  149. fetch(NMSG.replace('{thread}', parameters.thread))
  150. .then(response => response.json())
  151. .then(json => {parameters.block_no = Math.floor(((json[0].thread.last_res || 0) + 1) / 100); parameters.ticket = json[0].thread.ticket;})
  152. .then(() => fetch(FLAPI.replace('{thread}', parameters.thread).replace('{block_no}', parameters.block_no), {credentials: 'include'}))
  153. .then(response => response.text())
  154. .then(text => {parameters.postkey = text.replace(/^postkey=/, '')})
  155. .then(() => fetch(POST, {method: 'POST', body: JSON.stringify(site.getChat(site.toVpos(time), command, content, parameters))}))
  156. .then(response => response.json())
  157. .then(json => json[2].chat_result.status === 0)
  158. .then(success => {
  159. core.flagLine(textarea, line, success);
  160. if(!success) errors.push(line);
  161. });
  162. }, INTERVAL);
  163. },
  164. flagLine: function(textarea, string, success){
  165. textarea.value = textarea.value.replace(new RegExp('^(.*?)' + escapeRegExp(string) + '$', 'm'), (success ? 'OK ' : 'NG ') + '$1' + string);
  166. },
  167. addStyle: function(name = 'style'){
  168. let style = createElement(core.html[name]());
  169. document.head.appendChild(style);
  170. if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
  171. elements[name] = style;
  172. },
  173. html: {
  174. button: () => `
  175. <button id="${SCRIPTNAME}-button" title="${SCRIPTNAME} コメントをまとめて投稿する">+</button>
  176. `,
  177. form: (comment) => `
  178. <form id="${SCRIPTNAME}-form">
  179. <textarea placeholder="#1:23 wwwww">${comment}</textarea>
  180. <button>まとめてコメントする</button>
  181. </form>
  182. `,
  183. style: () => `
  184. <style type="text/css">
  185. html.${SCRIPTNAME}{
  186. overflow: hidden;/*背後のコンテンツをスクロールさせない*/
  187. }
  188. #${SCRIPTNAME}-button{
  189. font-size: 2em;
  190. line-height: 1em;
  191. text-align: center;
  192. color: rgba(0,0,0,.5);
  193. background: white;
  194. border: none;
  195. border-radius: 1em;
  196. filter: drop-shadow(0 0 .1em rgba(0,0,0,.5));
  197. opacity: .25;
  198. width: 1em;
  199. height: 1em;
  200. padding: 0;
  201. margin: .25em;
  202. position: absolute;
  203. right: 0;
  204. bottom: 0;
  205. cursor: pointer;
  206. transition: opacity 250ms;
  207. }
  208. #${SCRIPTNAME}-button:hover{
  209. opacity: .75;
  210. }
  211. #${SCRIPTNAME}-form{
  212. background: rgba(0,0,0,.75);
  213. position: fixed;
  214. top: 0;
  215. left: 0;
  216. width: 100%;
  217. height: 100%;
  218. z-index: 1000;
  219. }
  220. #${SCRIPTNAME}-form textarea{
  221. font-family: monospace;
  222. border: none;
  223. width: 80vw;
  224. height: calc(80vh - 3em);
  225. padding: .5em;
  226. margin: 10vh 10vw 0;
  227. }
  228. #${SCRIPTNAME}-form button{
  229. color: white;
  230. background: rgb(0, 124, 255);
  231. border: none;
  232. width: 80vw;
  233. height: 3em;
  234. margin: 0 10vw;
  235. cursor: pointer;
  236. }
  237. #${SCRIPTNAME}-form button:hover{
  238. background: rgb(0, 96, 210);
  239. }
  240. #${SCRIPTNAME}-form button[disabled]{
  241. filter: brightness(.5);
  242. pointer-events: none;
  243. }
  244. </style>
  245. `,
  246. },
  247. };
  248. class Storage{
  249. static key(key){
  250. return (SCRIPTNAME) ? (SCRIPTNAME + '-' + key) : key;
  251. }
  252. static save(key, value, expire = null){
  253. key = Storage.key(key);
  254. localStorage[key] = JSON.stringify({
  255. value: value,
  256. saved: Date.now(),
  257. expire: expire,
  258. });
  259. }
  260. static read(key){
  261. key = Storage.key(key);
  262. if(localStorage[key] === undefined) return undefined;
  263. let data = JSON.parse(localStorage[key]);
  264. if(data.value === undefined) return data;
  265. if(data.expire === undefined) return data;
  266. if(data.expire === null) return data.value;
  267. if(data.expire < Date.now()) return localStorage.removeItem(key);
  268. return data.value;
  269. }
  270. static delete(key){
  271. key = Storage.key(key);
  272. delete localStorage.removeItem(key);
  273. }
  274. static saved(key){
  275. key = Storage.key(key);
  276. if(localStorage[key] === undefined) return undefined;
  277. let data = JSON.parse(localStorage[key]);
  278. if(data.saved) return data.saved;
  279. else return undefined;
  280. }
  281. }
  282. const $ = function(s){return document.querySelector(s)};
  283. const $$ = function(s){return document.querySelectorAll(s)};
  284. const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
  285. const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
  286. const createElement = function(html){
  287. let outer = document.createElement('div');
  288. outer.innerHTML = html;
  289. return outer.firstElementChild;
  290. };
  291. const escapeRegExp = function(string){
  292. return string.replace(/[.*+?^=!:${}()|[\]\/\\]/g, '\\$&'); // $&はマッチした部分文字列全体を意味します
  293. };
  294. const secondsToTime = function(seconds){
  295. let floor = Math.floor, zero = (s) => s.toString().padStart(2, '0');
  296. let h = floor(seconds/3600), m = floor(seconds/60)%60, s = floor(seconds%60);
  297. if(h) return h + '時間' + zero(m) + '分' + zero(s) + '秒';
  298. if(m) return m + '分' + zero(s) + '秒';
  299. if(s) return s + '秒';
  300. };
  301. const log = function(){
  302. if(!DEBUG) return;
  303. let l = log.last = log.now || new Date(), n = log.now = new Date();
  304. let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
  305. //console.log(error.stack);
  306. console.log(
  307. SCRIPTNAME + ':',
  308. /* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
  309. /* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
  310. /* :00 */ ':' + line,
  311. /* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
  312. /* caller */ (callers[1] || '') + '()',
  313. ...arguments
  314. );
  315. };
  316. log.formats = [{
  317. name: 'Firefox Scratchpad',
  318. detector: /MARKER@Scratchpad/,
  319. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  320. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  321. }, {
  322. name: 'Firefox Console',
  323. detector: /MARKER@debugger/,
  324. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  325. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  326. }, {
  327. name: 'Firefox Greasemonkey 3',
  328. detector: /\/gm_scripts\//,
  329. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
  330. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  331. }, {
  332. name: 'Firefox Greasemonkey 4+',
  333. detector: /MARKER@user-script:/,
  334. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
  335. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  336. }, {
  337. name: 'Firefox Tampermonkey',
  338. detector: /MARKER@moz-extension:/,
  339. getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
  340. getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
  341. }, {
  342. name: 'Chrome Console',
  343. detector: /at MARKER \(<anonymous>/,
  344. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  345. getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
  346. }, {
  347. name: 'Chrome Tampermonkey',
  348. detector: /at MARKER \((userscript\.html|chrome-extension:)/,
  349. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+)\)$/)[1] - 6,
  350. getCallers: (e) => e.stack.match(/[^ ]+(?= \((userscript\.html|chrome-extension:))/gm),
  351. }, {
  352. name: 'Edge Console',
  353. detector: /at MARKER \(eval/,
  354. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
  355. getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
  356. }, {
  357. name: 'Edge Tampermonkey',
  358. detector: /at MARKER \(Function/,
  359. getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
  360. getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
  361. }, {
  362. name: 'Safari',
  363. detector: /^MARKER$/m,
  364. getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
  365. getCallers: (e) => e.stack.split('\n'),
  366. }, {
  367. name: 'Default',
  368. detector: /./,
  369. getLine: (e) => 0,
  370. getCallers: (e) => [],
  371. }];
  372. log.format = log.formats.find(function MARKER(f){
  373. if(!f.detector.test(new Error().stack)) return false;
  374. //console.log('//// ' + f.name + '\n' + new Error().stack);
  375. return true;
  376. });
  377. core.initialize();
  378. if(window === top && console.timeEnd) console.timeEnd(SCRIPTNAME);
  379. })();