Vecorder

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

当前为 2021-04-25 提交的版本,查看 最新版本

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