Niconico Batch Commenter

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

目前为 2019-03-16 提交的版本。查看 最新版本

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

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址