// ==UserScript==
// @name Vecorder
// @namespace https://www.joi-club.cn/
// @version 0.53
// @description 直播间内容记录
// @author Xinrea
// @match https://live.bilibili.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @require https://cdn.staticfile.org/jquery/3.3.1/jquery.min.js
// @require https://gf.qytechs.cn/scripts/407985-ajax-hook/code/Ajax-hook.js?version=832614
// @require https://cdn.jsdelivr.net/npm/[email protected]/moment.min.js
// @run-at document-end
// ==/UserScript==
function vlog(msg) {
console.log('[Vecorder]['+getLiveStatus()+']'+msg)
}
function p(msg) {
return {
'time':(new Date()).getTime(),
'content':msg,
}
}
var dbname = 'vdb'+getRoomID()
var db = JSON.parse(GM_getValue(dbname, '[]'))
var Option = JSON.parse(GM_getValue('vop','{"reltime":false,"toffset":0}'))
function nindexOf(n) {
for (let i in db) {
if (!db[i].del && db[i].name == n) return i
}
return -1
}
function tindexOf(id,t) {
for (let i in db[id].lives) {
if (!db[id].lives[i].del && db[id].lives[i].title == t) return i
}
return -1
}
function gc() {
for (let i = db.length-1; i >= 0; i--) {
if (db[i].del) {
db.splice(i,1)
continue
}
for (let j = db[i].lives.length-1; j >= 0; j--) {
if (db[i].lives[j].del) {
db[i].lives.splice(j,1)
continue
}
}
}
GM_setValue(dbname,JSON.stringify(db))
}
function addPoint(msg) {
let ltime = getLiveStartTime()*1000
if (ltime == 0) return
let name = getName()
let title = getRoomTitle()
let id = nindexOf(name)
if (id == -1) {
db.push({
'name':name,
'link':getLink(),
'del':false,
'lives':[{
'title':title,
'time':ltime,
'del':false,
'points':[p(msg)]
}]
})
} else {
let lid = tindexOf(id,title)
if (lid == -1) {
db[id].lives.push({
'title':title,
'time':ltime,
'points':[p(msg)]
})
} else {
db[id].lives[lid].points.push(p(msg))
}
}
GM_setValue(dbname,JSON.stringify(db))
}
vlog('Init')
ah.proxy({
onRequest: (config, handler) => {
if (config.url === 'https://api.live.bilibili.com/msg/send') {
let danmu = getMsg(config.body)
if (danmu.charAt(danmu.length-1) == '】' && danmu.charAt(0) == '【') {
vlog('Add Point: '+danmu.substring(1,danmu.length-1))
addPoint(danmu.substring(1,danmu.length-1))
handler.resolve({
config: config,
status: 200,
headers: {'content-type': 'application/json; charset=utf-8'},
response: '{"code":0,"data":[],"message":"","msg":"recorded"}'
})
} else {
vlog('Normal Danmu')
handler.next(config);
}
} else {
handler.next(config);
}
},
onError: (err, handler) => {
handler.next(err)
},
onResponse: (response, handler) => {
handler.next(response)
}
})
function getLiveStatus() {
return $('#head-info-vm > div > div > div.room-info-upper-row.p-relative > div.normal-mode > div:nth-child(1) > h1 > span.live-status-label.live-skin-highlight-text.live-skin-highlight-border.v-middle.preparing').text()
}
function getMsg(body)
{
var vars = body.split("&");
for (var i=0;i<vars.length;i++) {
var pair = vars[i].split("=");
if(pair[0] == 'msg'){return decodeURI(pair[1]);}
}
return(false);
}
function getName(){
return $('#head-info-vm > div > div > div.room-info-down-row > a.room-owner-username.live-skin-normal-a-text.dp-i-block.v-middle').text()
}
function getLink() {
return $('#head-info-vm > div > div > div.room-info-down-row > a.room-owner-username.live-skin-normal-a-text.dp-i-block.v-middle').attr('href')
}
function getRoomTitle() {
return $('#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').text()
}
// 根据当前地址获取直播间ID
function getRoomID() {
var url_text = window.location.href+"";
var i = url_text.indexOf("roomid=")
var m = 0
if (i != -1) m = url_text.slice(i+7);
else m = url_text.slice(url_text.indexOf(".com/")+5);
return parseInt(m);//获取当前房间号
}
function getLiveStartTime() {
// https://api.live.bilibili.com/room/v1/Room/room_init?id={roomID}
let resp = JSON.parse($.ajax({
url: "https://api.live.bilibili.com/room/v1/Room/room_init?id="+getRoomID(),
async: false
}).responseText)
if (resp.data.live_status != 1) return 0
else return resp.data.live_time
}
let toggle = false
waitForKeyElements('#chat-control-panel-vm > div > div.bottom-actions.p-relative > div',(n)=>{
let recordBtn = $('<button><span class="txt">记录</span></button>')
recordBtn.attr('style','font-family: sans-serif;\
text-transform: none;\
position: relative;\
box-sizing: border-box;\
line-height: 1;\
margin: 0;\
margin-left: 3px;\
padding: 6px 12px;\
border: 0;\
cursor: pointer;\
outline: none;\
overflow: hidden;\
background-color: #23ade5;\
color: #fff;\
border-radius: 4px;\
min-width: 40px;\
height: 24px;\
font-size: 12px;')
recordBtn.hover(function(){
if (!toggle) recordBtn.css('background-color','#58bae2')
},
function() {
if (!toggle) recordBtn.css('background-color','#23ade5')
})
recordBtn.click(function(){
if (toggle) {
$('#vPanel').remove()
gc()
toggle = false
$(this).css('background-color','#58bae2')
return
}
let panel = $('<div id="vPanel"><div id="vArrow"></div><p style="font-size:20px;font-weight:bold;margin:0px;" class="vName">🍊直播笔记</p></div>')
let contentList = dbToListview()
panel.append(contentList)
let clearBtn = $('<button><span class="txt">清空</span></button>')
clearBtn.attr('style','font-family: sans-serif;\
text-transform: none;\
position: relative;\
box-sizing: border-box;\
line-height: 1;\
margin: 0;\
margin-left: 3px;\
padding: 6px 12px;\
border: 0;\
cursor: pointer;\
outline: none;\
overflow: hidden;\
background-color: #23ade5;\
color: #fff;\
border-radius: 4px;\
min-width: 40px;\
height: 24px;\
font-size: 12px;')
clearBtn.hover(function(){
clearBtn.css('background-color','#58bae2')
},
function() {
clearBtn.css('background-color','#23ade5')
})
clearBtn.click(function(){
contentList.empty()
db = []
GM_deleteValue(dbname)
})
panel.append(clearBtn)
let closeBtn = $('<a style="position:absolute;right:7px;top:5px;font-size:20px;" class="vName">×</a>')
closeBtn.click(function(){
panel.remove()
gc()
toggle = false
recordBtn.css('background-color','#23ade5')
})
panel.append(closeBtn)
let timeop = $(`<hr style="border:0;height:1px;background-color:#58bae2;margin-top:10px;margin-bottom:10px;"/><div id="timeop">\
<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>\
<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>\
</div>`)
panel.append(timeop)
$('#chat-control-panel-vm > div').append(panel)
if (Option.reltime) {
$('#reltime').attr('checked',true)
}
$('#reltime').change(function(){
Option.reltime = $(this).prop("checked")
GM_setValue('vop',JSON.stringify(Option))
})
$('#toffset').change(function(){
Option.toffset = $(this).val()
GM_setValue('vop',JSON.stringify(Option))
})
$(this).css('background-color','#0d749e')
toggle = true
})
n.append(recordBtn)
let styles = $('<style></style>')
styles.text(
'#vPanel {\
line-height: 1.15;\
font-size: 12px;\
font-family: Arial,Microsoft YaHei,Microsoft Sans Serif,Microsoft SanSerf,\\5FAE\8F6F\96C5\9ED1!important;\
display: block;\
box-sizing: border-box;\
background: #fff;\
border: 1px solid #e9eaec;\
border-radius: 8px;\
box-shadow: 0 6px 12px 0 rgba(106,115,133,.22);\
animation: scale-in-ease cubic-bezier(.22,.58,.12,.98) .4s;\
padding: 16px;\
position: absolute;\
right: 0px;\
bottom: 50px;\
z-index: 999;\
transform-origin: right bottom;\
}\
#vPanel ul {\
list-style-type: none;\
padding-inline-start: 0px;\
color: #666;\
}\
#vPanel li {\
margin-top: 10px;\
white-space: nowrap;\
}\
.vName {\
color: #23ade5;\
cursor: pointer;\
}\
#vArrow {\
line-height: 1.15;\
font-size: 12px;\
font-family: Arial,Microsoft YaHei,Microsoft Sans Serif,Microsoft SanSerf,\\5FAE\8F6F\96C5\9ED1!important;\
position: absolute;\
top: 100%;\
width: 0;\
height: 0;\
border-left: 4px solid transparent;\
border-right: 4px solid transparent;\
border-top: 8px solid #fff;\
right: 25px;\
}\
'
)
n.append(styles)
})
function dbToListview() {
let urlObject = window.URL || window.webkitURL || window;
let content = $('<ul></ul>')
for (let i in db) {
let list = $('<li></li>')
if (db[i].del) {
continue
}
let innerlist = $('<ul></ul>')
for (let j in db[i].lives) {
if (db[i].lives[j].del) continue
let item = $('<li>'+`[${moment(db[i].lives[j].time).format('YYYY/MM/DD')}]`+db[i].lives[j].title+'['+db[i].lives[j].points.length+']'+'</li>')
let ep = $('<a class="vName" style="font-weight:bold;">[导出]</a>')
let cx = $('<a class="vName" style="color:red;font-weight:bold;">[删除]</a>')
ep.click(function(){
exportRaw(db[i].lives[j],db[i].name,`[${db[i].name}][${db[i].lives[j].title}][${moment(db[i].lives[j].time).format('YYYY-MM-DD')}]`)
})
cx.click(function(){
if (db[i].lives.length == 1) {
db[i].del = true
item.remove()
list.remove()
} else {
db[i].lives[j].del = true
item.remove()
}
GM_setValue(dbname,JSON.stringify(db))
})
item.append(ep)
item.prepend(cx)
innerlist.append(item)
}
list.append(innerlist)
content.append(list)
}
return content
}
function exportRaw(live, v, fname) {
var urlObject = window.URL || window.webkitURL || window;
var export_blob = new Blob([rawToString(live,v)]);
var save_link = document.createElementNS("http://www.w3.org/1999/xhtml", "a")
save_link.href = urlObject.createObjectURL(export_blob);
save_link.download = fname;
save_link.click();
}
function rawToString(live,v){
let r = "# 由Vecorder自动生成,不妨关注下可爱的@轴伊Joi_Channel:https://space.bilibili.com/61639371/\n"
r += `# ${v} \n`
r += `# ${live.title} - 直播开始时间:${moment(live.time).format('YYYY-MM-DD HH:mm:ss')}\n\n`
for (let i in live.points) {
if (!Option.reltime) r += `[${moment(live.points[i].time).add(Option.toffset,'seconds').format('HH:mm:ss')}] ${live.points[i].content}\n`
else {
let seconds = moment(live.points[i].time).diff(moment(live.time),'second') + Number(Option.toffset)
let minutes = Math.floor(seconds/60)
let hours = Math.floor(minutes/60)
seconds = seconds%60
minutes = minutes%60
r += `[${f(hours)}:${f(minutes)}:${f(seconds)}] ${live.points[i].content}\n`
}
}
return r
}
function f(num) {
if(String(num).length > 2) return num;
return (Array(2).join(0) + num).slice(-2);
}
function waitForKeyElements (
selectorTxt, /* Required: The jQuery selector string that
specifies the desired element(s).
*/
actionFunction, /* Required: The code to run when elements are
found. It is passed a jNode to the matched
element.
*/
bWaitOnce, /* Optional: If false, will continue to scan for
new elements even after the first match is
found.
*/
iframeSelector /* Optional: If set, identifies the iframe to
search.
*/
) {
var targetNodes, btargetsFound;
if (typeof iframeSelector == "undefined")
targetNodes = $(selectorTxt);
else
targetNodes = $(iframeSelector).contents ()
.find (selectorTxt);
if (targetNodes && targetNodes.length > 0) {
btargetsFound = true;
/*--- Found target node(s). Go through each and act if they
are new.
*/
targetNodes.each ( function () {
var jThis = $(this);
var alreadyFound = jThis.data ('alreadyFound') || false;
if (!alreadyFound) {
//--- Call the payload function.
var cancelFound = actionFunction (jThis);
if (cancelFound)
btargetsFound = false;
else
jThis.data ('alreadyFound', true);
}
} );
}
else {
btargetsFound = false;
}
//--- Get the timer-control variable for this selector.
var controlObj = waitForKeyElements.controlObj || {};
var controlKey = selectorTxt.replace (/[^\w]/g, "_");
var timeControl = controlObj [controlKey];
//--- Now set or clear the timer as appropriate.
if (btargetsFound && bWaitOnce && timeControl) {
//--- The only condition where we need to clear the timer.
clearInterval (timeControl);
delete controlObj [controlKey]
}
else {
//--- Set a timer, if needed.
if ( ! timeControl) {
timeControl = setInterval ( function () {
waitForKeyElements ( selectorTxt,
actionFunction,
bWaitOnce,
iframeSelector
);
},
300
);
controlObj [controlKey] = timeControl;
}
}
waitForKeyElements.controlObj = controlObj;
}