Vecorder

直播间内容记录 https://github.com/Xinrea/Vecorder

当前为 2024-08-28 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Vecorder
  3. // @namespace https://www.joi-club.cn/
  4. // @version 0.70
  5. // @description 直播间内容记录 https://github.com/Xinrea/Vecorder
  6. // @author Xinrea
  7. // @license MIT
  8. // @match https://live.bilibili.com/*
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_deleteValue
  12. // @require https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
  13. // @require https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js
  14. // @run-at document-end
  15. // ==/UserScript==
  16.  
  17. function vlog(msg) {
  18. console.log("[Vecorder]" + msg);
  19. }
  20.  
  21. function p(msg) {
  22. return {
  23. time: new Date().getTime(),
  24. content: msg,
  25. };
  26. }
  27.  
  28. var dbname = "vdb" + getRoomID();
  29.  
  30. var db = JSON.parse(GM_getValue(dbname, "[]"));
  31. var Option = JSON.parse(GM_getValue("vop", '{"reltime":false,"toffset":0}'));
  32.  
  33. function nindexOf(n) {
  34. for (let i in db) {
  35. if (!db[i].del && db[i].name == n) return i;
  36. }
  37. return -1;
  38. }
  39.  
  40. function tindexOf(id, t) {
  41. for (let i in db[id].lives) {
  42. if (!db[id].lives[i].del && db[id].lives[i].title == t) return i;
  43. }
  44. return -1;
  45. }
  46.  
  47. function gc() {
  48. for (let i = db.length - 1; i >= 0; i--) {
  49. if (db[i].del) {
  50. db.splice(i, 1);
  51. continue;
  52. }
  53. for (let j = db[i].lives.length - 1; j >= 0; j--) {
  54. if (db[i].lives[j].del) {
  55. db[i].lives.splice(j, 1);
  56. continue;
  57. }
  58. }
  59. }
  60. GM_setValue(dbname, JSON.stringify(db));
  61. }
  62.  
  63. function addPoint(t, msg) {
  64. console.log("addPoint", t, msg);
  65. let ltime = t * 1000;
  66. if (ltime == 0) return;
  67. let [name, link, title] = getRoomInfo();
  68. console.log("CurrentRoom:", name, link, title);
  69. let id = nindexOf(name);
  70. if (id == -1) {
  71. db.push({
  72. name: name,
  73. link: link,
  74. del: false,
  75. lives: [
  76. {
  77. title: title,
  78. time: ltime,
  79. del: false,
  80. points: [p(msg)],
  81. },
  82. ],
  83. });
  84. } else {
  85. let lid = tindexOf(id, title);
  86. if (lid == -1) {
  87. db[id].lives.push({
  88. title: title,
  89. time: ltime,
  90. points: [p(msg)],
  91. });
  92. } else {
  93. db[id].lives[lid].points.push(p(msg));
  94. }
  95. }
  96. GM_setValue(dbname, JSON.stringify(db));
  97. $(`#vecorder-list`).replaceWith(dbToListview());
  98. }
  99.  
  100. function getMsg(body) {
  101. var vars = body.split("&");
  102. for (var i = 0; i < vars.length; i++) {
  103. var pair = vars[i].split("=");
  104. if (pair[0] == "msg") {
  105. return decodeURI(pair[1]);
  106. }
  107. }
  108. return false;
  109. }
  110.  
  111. function getRoomInfo() {
  112. let resp = $.ajax({
  113. url: "https://api.live.bilibili.com/xlive/web-room/v1/index/getH5InfoByRoom?room_id="+getRoomID(),
  114. async: false}).responseJSON.data;
  115. console.log("RoomInfo:",resp);
  116. return [resp.anchor_info.base_info.uname, "https://space.bilibili.com/" + resp.room_info.uid, resp.room_info.title]
  117. }
  118.  
  119. // 根据当前地址获取直播间ID
  120. function getRoomID() {
  121. let roomid = window.location.pathname.substring(1);
  122. return roomid; //获取当前房间号
  123. }
  124.  
  125. function tryAddPoint(msg) {
  126. // https://api.live.bilibili.com/room/v1/Room/room_init?id={roomID}
  127. console.log(msg, getRoomID());
  128. $.ajax({
  129. url:
  130. "https://api.live.bilibili.com/room/v1/Room/room_init?id=" + getRoomID(),
  131. async: true,
  132. success: function (resp) {
  133. let t = 0;
  134. if (resp.data.live_status != 1) t = 0;
  135. else t = resp.data.live_time;
  136. addPoint(t, msg);
  137. },
  138. });
  139. }
  140.  
  141. let toggle = false;
  142.  
  143. waitForKeyElements(
  144. "#control-panel-ctnr-box > div.chat-input-ctnr-new.p-relative > div.right-actions.border-box > button",
  145. (n) => {
  146. const input = $(`<div id="point-input"><textarea placeholder="输入内容并回车添加时间点"></textarea></div>`);
  147. input.bind("keypress", function (event) {
  148. if (event.keyCode == "13") {
  149. window.event.returnValue = false;
  150. console.log("Enter detected");
  151. tryAddPoint($("#point-input > textarea").val());
  152. $("#point-input > textarea").val("");
  153. }
  154. });
  155. $(`#control-panel-ctnr-box`).append(input);
  156. }
  157. );
  158.  
  159. waitForKeyElements(
  160. "#control-panel-ctnr-box > div.control-panel-icon-row-new.f-clear.p-relative.superChat > div.icon-left-part-new",
  161. (n) => {
  162. // Resize superchat/like buttons
  163. n[0].style = "width: 70%; align-items: center;";
  164. n[0].children[0].style = "margin: 0; width: 54px; height: 28px;";
  165. n[0].children[0].children[0].remove();
  166. n[0].children[0].children[0].innerText = "留言";
  167. n[0].children[1].style = "margin: 0; width: 54px; height: 28px; margin-left: 3px;";
  168. n[0].children[1].children[0].remove();
  169.  
  170. $("#control-panel-ctnr-box > div.chat-input-ctnr-new.p-relative > div.chat-input-new.border-box.p-relative").css("height", "30px");
  171. $("#control-panel-ctnr-box > div.chat-input-ctnr-new.p-relative > div.chat-input-new.border-box.p-relative > textarea").css({"height": "30px", "line-height": "13px", "white-space": "nowrap"});
  172. // create panel
  173. let panel = $(
  174. '<div id="vPanel"><p style="font-size:20px;font-weight:bold;margin:0px;" class="vName">🍊直播笔记</p></div>'
  175. );
  176. let contentList = dbToListview();
  177. panel.append(contentList);
  178. let clearBtn = $('<button><span class="txt">清空</span></button>');
  179. clearBtn.attr(
  180. "style",
  181. "font-family: sans-serif;\
  182. text-transform: none;\
  183. position: relative;\
  184. box-sizing: border-box;\
  185. line-height: 1;\
  186. margin: 0;\
  187. margin-left: 3px;\
  188. padding: 6px 12px;\
  189. border: 0;\
  190. cursor: pointer;\
  191. outline: none;\
  192. overflow: hidden;\
  193. background-color: #23ade5;\
  194. color: #fff;\
  195. border-radius: 4px;\
  196. min-width: 40px;\
  197. height: 24px;\
  198. font-size: 12px;"
  199. );
  200. clearBtn.hover(
  201. function () {
  202. clearBtn.css("background-color", "#58bae2");
  203. },
  204. function () {
  205. clearBtn.css("background-color", "#23ade5");
  206. }
  207. );
  208. clearBtn.click(function () {
  209. contentList.empty();
  210. db = [];
  211. GM_deleteValue(dbname);
  212. });
  213. panel.append(clearBtn);
  214. let closeBtn = $(
  215. '<a style="position:absolute;right:7px;top:5px;font-size:20px;" class="vName">&times;</a>'
  216. );
  217. closeBtn.click(function () {
  218. console.log("Close clicked");
  219. $("#vPanel").hide()
  220. gc();
  221. toggle = false;
  222. recordBtn.css("background-color", "#23ade5");
  223. });
  224. panel.append(closeBtn);
  225. let timeop = $(`<hr style="border:0;height:1px;background-color:#58bae2;margin-top:10px;margin-bottom:10px;"/><div id="timeop">\
  226. <div><input type="checkbox" id="reltime" value="false" style="vertical-align:middle;margin-right:5px;"/><label for="reltime" class="vName" style="vertical-align:middle;">按相对时间导出</label></div>\
  227. <div style="margin-top:10px;"><label for="toffset" class="vName" style="vertical-align:middle;">时间偏移(秒):</label><input type="number" id="toffset" value="${Option.toffset}" style="vertical-align:middle;width:35px;outline-color:#23ade5;"/></div>\
  228. </div>`);
  229. panel.append(timeop);
  230. // Setup recordButton
  231. let recordBtn = $('<div><span class="txt">记录</span></div>');
  232. recordBtn.attr(
  233. "style",
  234. "font-family: sans-serif;\
  235. display: flex;\
  236. align-items: center;\
  237. position: relative;\
  238. box-sizing: border-box;\
  239. margin-left: 3px;\
  240. padding: 6px 12px;\
  241. cursor: pointer;\
  242. outline: none;\
  243. overflow: hidden;\
  244. background-color: #23ade5;\
  245. color: #fff;\
  246. border-radius: 36px;\
  247. width: 54px;\
  248. height: 28px;\
  249. font-size: 14px;"
  250. );
  251. $("#chat-control-panel-vm > div").append(panel);
  252. $("#vPanel").hide();
  253. recordBtn.hover(
  254. function () {
  255. if (!toggle) recordBtn.css("background-color", "#58bae2");
  256. },
  257. function () {
  258. if (!toggle) recordBtn.css("background-color", "#23ade5");
  259. }
  260. );
  261. recordBtn.click(function () {
  262. if (toggle) {
  263. $("#vPanel").hide();
  264. gc();
  265. toggle = false;
  266. $(this).css("background-color", "#58bae2");
  267. return;
  268. }
  269. console.log("Toggle panel");
  270. $("#vPanel").show();
  271. if (Option.reltime) {
  272. $("#reltime").attr("checked", true);
  273. }
  274. $("#reltime").change(function () {
  275. Option.reltime = $(this).prop("checked");
  276. GM_setValue("vop", JSON.stringify(Option));
  277. });
  278. $("#toffset").change(function () {
  279. Option.toffset = $(this).val();
  280. GM_setValue("vop", JSON.stringify(Option));
  281. });
  282. $(this).css("background-color", "#0d749e");
  283. toggle = true;
  284. });
  285. n.append(recordBtn);
  286.  
  287. let styles = $(`<style type="text/css"></style>`);
  288. styles.text(
  289. "#vPanel {\
  290. line-height: 1.15;\
  291. font-size: 12px;\
  292. font-family: Arial,Microsoft YaHei,Microsoft Sans Serif,Microsoft SanSerf,\\5FAE8F6F96C59ED1!important;\
  293. display: block;\
  294. box-sizing: border-box;\
  295. background: #fff;\
  296. border: 1px solid #e9eaec;\
  297. border-radius: 8px;\
  298. box-shadow: 0 6px 12px 0 rgba(106,115,133,.22);\
  299. animation: scale-in-ease cubic-bezier(.22,.58,.12,.98) .4s;\
  300. padding: 16px;\
  301. position: absolute;\
  302. right: 4px;\
  303. bottom: 150px;\
  304. z-index: 999;\
  305. transform-origin: right bottom;\
  306. }\
  307. #vPanel ul {\
  308. list-style-type: none;\
  309. padding-inline-start: 0px;\
  310. color: #666;\
  311. }\
  312. #vPanel li {\
  313. margin-top: 10px;\
  314. white-space: nowrap;\
  315. }\
  316. .vName {\
  317. color: #23ade5;\
  318. cursor: pointer;\
  319. }\
  320. #control-panel-ctnr-box > div.chat-input-ctnr-new.p-relative > div.medal-section {\
  321. height: 30px;\
  322. line-height: 13px;\
  323. }\
  324. #point-input {\
  325. padding: 4px 8px;\
  326. background-color: var(--bg2);\
  327. border-radius: 6px;\
  328. border: 1px solid transparent;\
  329. margin-top: 6px;\
  330. }\
  331. #point-input > textarea {\
  332. width: 100%;\
  333. border: 0;\
  334. outline: 0;\
  335. resize: none;\
  336. background-color: var(--bg2);\
  337. color: var(--text2);\
  338. font-size: 13px;\
  339. height: 24px;\
  340. }\
  341. "
  342. );
  343. $("head").prepend(styles);
  344. }
  345. );
  346.  
  347. function dbToListview() {
  348. let urlObject = window.URL || window.webkitURL || window;
  349. let content = $(`<ul id="vecorder-list"></ul>`);
  350. for (let i in db) {
  351. let list = $("<li></li>");
  352. if (db[i].del) {
  353. continue;
  354. }
  355. let innerlist = $("<ul></ul>");
  356. for (let j in db[i].lives) {
  357. if (db[i].lives[j].del) continue;
  358. let item = $(
  359. "<li>" +
  360. `[${moment(db[i].lives[j].time).format("YYYY/MM/DD")}]` +
  361. db[i].lives[j].title +
  362. "[" +
  363. db[i].lives[j].points.length +
  364. "]" +
  365. "</li>"
  366. );
  367. let ep = $('<a class="vName" style="font-weight:bold;">[导出]</a>');
  368. let cx = $(
  369. '<a class="vName" style="color:red;font-weight:bold;">[删除]</a>'
  370. );
  371. ep.click(function () {
  372. exportRaw(
  373. db[i].lives[j],
  374. db[i].name,
  375. `[${db[i].name}][${db[i].lives[j].title}][${moment(
  376. db[i].lives[j].time
  377. ).format("YYYY-MM-DD")}]`
  378. );
  379. });
  380. cx.click(function () {
  381. if (db[i].lives.length == 1) {
  382. db[i].del = true;
  383. item.remove();
  384. list.remove();
  385. } else {
  386. db[i].lives[j].del = true;
  387. item.remove();
  388. }
  389. GM_setValue(dbname, JSON.stringify(db));
  390. });
  391. item.append(ep);
  392. item.prepend(cx);
  393. innerlist.append(item);
  394. }
  395. list.append(innerlist);
  396. content.append(list);
  397. }
  398. return content;
  399. }
  400.  
  401. function exportRaw(live, v, fname) {
  402. var urlObject = window.URL || window.webkitURL || window;
  403. var export_blob = new Blob([rawToString(live, v)]);
  404. var save_link = document.createElementNS("http://www.w3.org/1999/xhtml", "a");
  405. save_link.href = urlObject.createObjectURL(export_blob);
  406. save_link.download = fname;
  407. save_link.click();
  408. }
  409.  
  410. function rawToString(live, v) {
  411. let r =
  412. "# 由Vecorder自动生成,不妨关注下可爱的@轴伊Joi_Channel:https://space.bilibili.com/61639371/\n";
  413. r += `# ${v} \n`;
  414. r += `# ${live.title} - 直播开始时间:${moment(live.time).format(
  415. "YYYY-MM-DD HH:mm:ss"
  416. )}\n\n`;
  417. for (let i in live.points) {
  418. if (!Option.reltime)
  419. r += `[${moment(live.points[i].time)
  420. .add(Option.toffset, "seconds")
  421. .format("HH:mm:ss")}] ${live.points[i].content}\n`;
  422. else {
  423. let seconds =
  424. moment(live.points[i].time).diff(moment(live.time), "second") +
  425. Number(Option.toffset);
  426. let minutes = Math.floor(seconds / 60);
  427. let hours = Math.floor(minutes / 60);
  428. seconds = seconds % 60;
  429. minutes = minutes % 60;
  430. r += `[${f(hours)}:${f(minutes)}:${f(seconds)}] ${
  431. live.points[i].content
  432. }\n`;
  433. }
  434. }
  435. return r;
  436. }
  437.  
  438. function f(num) {
  439. if (String(num).length > 2) return num;
  440. return (Array(2).join(0) + num).slice(-2);
  441. }
  442.  
  443. function waitForKeyElements(
  444. selectorTxt /* Required: The jQuery selector string that
  445. specifies the desired element(s).
  446. */,
  447. actionFunction /* Required: The code to run when elements are
  448. found. It is passed a jNode to the matched
  449. element.
  450. */,
  451. bWaitOnce /* Optional: If false, will continue to scan for
  452. new elements even after the first match is
  453. found.
  454. */,
  455. iframeSelector /* Optional: If set, identifies the iframe to
  456. search.
  457. */
  458. ) {
  459. var targetNodes, btargetsFound;
  460.  
  461. if (typeof iframeSelector == "undefined") targetNodes = $(selectorTxt);
  462. else targetNodes = $(iframeSelector).contents().find(selectorTxt);
  463.  
  464. if (targetNodes && targetNodes.length > 0) {
  465. btargetsFound = true;
  466. /*--- Found target node(s). Go through each and act if they
  467. are new.
  468. */
  469. targetNodes.each(function () {
  470. var jThis = $(this);
  471. var alreadyFound = jThis.data("alreadyFound") || false;
  472.  
  473. if (!alreadyFound) {
  474. //--- Call the payload function.
  475. var cancelFound = actionFunction(jThis);
  476. if (cancelFound) btargetsFound = false;
  477. else jThis.data("alreadyFound", true);
  478. }
  479. });
  480. } else {
  481. btargetsFound = false;
  482. }
  483.  
  484. //--- Get the timer-control variable for this selector.
  485. var controlObj = waitForKeyElements.controlObj || {};
  486. var controlKey = selectorTxt.replace(/[^\w]/g, "_");
  487. var timeControl = controlObj[controlKey];
  488.  
  489. //--- Now set or clear the timer as appropriate.
  490. if (btargetsFound && bWaitOnce && timeControl) {
  491. //--- The only condition where we need to clear the timer.
  492. clearInterval(timeControl);
  493. delete controlObj[controlKey];
  494. } else {
  495. //--- Set a timer, if needed.
  496. if (!timeControl) {
  497. timeControl = setInterval(function () {
  498. waitForKeyElements(
  499. selectorTxt,
  500. actionFunction,
  501. bWaitOnce,
  502. iframeSelector
  503. );
  504. }, 300);
  505. controlObj[controlKey] = timeControl;
  506. }
  507. }
  508. waitForKeyElements.controlObj = controlObj;
  509. }