Vecorder

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

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

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