// ==UserScript== // @name B站直播间蹲守 // @namespace https://scriptcat.org/bili-live-checker // @version 0.1.1 // @icon https://www.bilibili.com/favicon.ico // @description 按配置时间段定时检查 B 站 UP 主直播状态,开播后打开直播间。 // @author ZBpine // @background // @grant GM_xmlhttpRequest // @grant GM_openInTab // @grant GM_getValue // @grant GM_log // @grant GM_notification // @connect api.bilibili.com // @require https://cdn.jsdmirror.com/gh/ZBpine/bili-data-manager@26c45a54a832157dcdc623487102e16f5e043f56/dist/bili-data-manager.min.js // @license MIT // ==/UserScript== /* ==UserConfig== settings: upList: title: UP主列表 description: 格式:mid 时间段 打开方式 检测周期 type: textarea default: "" ==/UserConfig== */ const CONFIG_KEY = "settings.upList"; const ALL_DAY_SEGMENT = "ALL_DAY"; const openedSegments = Object.create(null); const openedTabs = Object.create(null); let runCycle = 0; return (async () => { const BDM = createBDM(); for (;;) { await runOnce(BDM); await sleep(randomInt(28000, 32000)); } })(); function createBDM() { if (typeof BiliDataManager === "undefined") { throw new Error("BiliDataManager 未加载,请检查 @require 地址是否可访问。"); } return BiliDataManager.create({ httpRequest: GM_xmlhttpRequest, name: "B站直播间蹲守", isLog: false, }); } async function runOnce(BDM) { runCycle += 1; const upListText = GM_getValue(CONFIG_KEY, ""); const targets = parseTargets(upListText); if (!targets.length) { GM_log("未配置有效的 UP 主。", "warn"); return; } pruneOpenedSegments(); const now = new Date(); for (const target of targets) { if (hasLiveTab(target.mid)) { GM_log(`UP ${target.mid} 已有未关闭的直播间标签页,跳过。`, "warn"); await sleep(randomInt(500, 1000)); continue; } if (!shouldCheckThisCycle(target.checkInterval)) { GM_log(`UP ${target.mid} 当前周期不检测,跳过。`, "debug"); await sleep(randomInt(500, 1000)); continue; } const matchedSegment = findMatchedSegment(target.segments, now); if (!matchedSegment) { GM_log(`UP ${target.mid} 当前不在配置时间段内,跳过。`, "warn"); await sleep(randomInt(500, 1000)); continue; } const segmentKey = buildSegmentKey(target.mid, matchedSegment, now); if (openedSegments[segmentKey]) { GM_log(`UP ${target.mid} 当前限制周期内已打开过,跳过。`, "warn"); await sleep(randomInt(500, 1000)); continue; } try { const info = await getUserInfo(BDM, target.mid); const liveRoom = info && info.live_room; if (liveRoom && Number(liveRoom.liveStatus) !== 0 && liveRoom.roomid) { handleLiveMatched(target, info); openedSegments[segmentKey] = { mid: String(target.mid), segment: matchedSegment.label, action: target.action, expiresAt: getOpenLimitExpiresAt(matchedSegment, now), roomid: liveRoom.roomid, openedAt: now.toISOString(), }; } else { GM_log(`UP ${target.mid} ${info.name} 未开播。`, "warn"); } } catch (error) { GM_log(`检查 UP ${target.mid} 失败:${formatError(error)}`, "error"); } await sleep(randomInt(500, 1000)); } } function shouldCheckThisCycle(checkInterval) { return (runCycle - 1) % checkInterval === 0; } function handleLiveMatched(target, info) { const liveRoom = info && info.live_room; const url = `https://live.bilibili.com/${liveRoom.roomid}`; const name = info && info.name ? info.name : target.mid; const image = info && info.face ? info.face : liveRoom.cover; if (target.action === "notify" || target.action === "both") { GM_log(`UP ${target.mid} ${name} 正在直播,发送通知:${url}`, "info"); notifyLive(target.mid, name, liveRoom, image, url); if (target.action === "notify") { return; } } GM_log(`UP ${target.mid} ${name} 正在直播,尝试打开直播间:${url}`, "info"); openLiveTab(target.mid, url); } function notifyLive(mid, name, liveRoom, image, url) { GM_notification({ title: `${name} 直播中...`, text: buildNotificationText(liveRoom), image, url, onclick(event) { if (event && typeof event.preventDefault === "function") { event.preventDefault(); } openLiveTab(mid, url); }, }); } function openLiveTab(mid, url) { const tab = GM_openInTab(url, { active: true }); openedTabs[mid] = tab; tab.onclose = () => { if (openedTabs[mid] === tab) { delete openedTabs[mid]; } GM_log(`UP ${mid} 的直播间标签页已关闭。`, "warn"); }; } function buildNotificationText(liveRoom) { const lines = []; if (liveRoom.title) { lines.push(liveRoom.title); } const watchedText = liveRoom.watched_show && liveRoom.watched_show.text_large; if (watchedText) { lines.push(watchedText); } lines.push("点击打开直播间"); return lines.join("\n") || "正在直播"; } async function getUserInfo(BDM, mid) { const user = new BDM.BiliUser(String(mid)); if (typeof user.getInfo === "function") { return await user.getInfo(); } if (user.api && typeof user.api.getInfo === "function") { return await user.api.getInfo(String(mid)); } throw new Error("当前 BiliUser 实例不支持 getInfo。"); } function hasLiveTab(mid) { const tab = openedTabs[mid]; if (!tab) { return false; } if (tab.closed) { delete openedTabs[mid]; return false; } return true; } function parseTargets(text) { const result = []; const lines = String(text || "").split(/\r?\n/); lines.forEach((rawLine, index) => { const line = normalizeText(rawLine).trim(); if (!line || line.startsWith("#") || line.startsWith("//")) { return; } const match = line.match(/^(\d+)(?:\s+(.+))?$/); if (!match) { GM_log(`第 ${index + 1} 行配置无效:${rawLine}`, "error"); return; } const mid = match[1]; const options = parseTargetOptions(match[2] || "", index + 1); const segments = options.segmentText ? parseSegments(options.segmentText, index + 1) : [createAllDaySegment()]; if (!segments.length) { GM_log(`第 ${index + 1} 行没有有效时间段,已跳过:${rawLine}`, "warn"); return; } result.push({ mid, segments, action: options.action, checkInterval: options.checkInterval, }); }); return result; } function parseTargetOptions(text, lineNumber) { const options = { segmentText: "", action: "both", checkInterval: 1, }; const tokens = normalizeText(text) .trim() .split(/\s+/) .filter(Boolean); const segmentParts = []; tokens.forEach((token) => { const action = parseAction(token); if (action) { options.action = action; return; } if (/^\d+$/.test(token)) { const checkInterval = Number(token); if (checkInterval >= 1) { options.checkInterval = checkInterval; } else { GM_log(`第 ${lineNumber} 行检测周期无效:${token}`, "warn"); } return; } segmentParts.push(token); }); options.segmentText = segmentParts.join(""); return options; } function parseAction(token) { const normalized = token.toLowerCase(); if (["open", "tab", "打开", "直接打开", "直播间"].includes(normalized)) { return "open"; } if (["notify", "notification", "通知", "提醒"].includes(normalized)) { return "notify"; } if (["both", "open+notify", "notify+open", "打开+通知", "通知+打开"].includes(normalized)) { return "both"; } return ""; } function parseSegments(text, lineNumber) { return text .split("|") .map((part) => part.trim()) .filter(Boolean) .map((part) => parseSegment(part, lineNumber)) .filter(Boolean); } function parseSegment(text, lineNumber) { const normalized = normalizeText(text).replace(/~/g, "~"); const match = normalized.match(/^(\d{1,2}):(\d{1,2})~(\d{1,2}):(\d{1,2})$/); if (!match) { GM_log(`第 ${lineNumber} 行时间段格式无效:${text}`, "warn"); return null; } const startHour = Number(match[1]); const startMinute = Number(match[2]); const endHour = Number(match[3]); const endMinute = Number(match[4]); if (!isValidTime(startHour, startMinute) || !isValidTime(endHour, endMinute)) { GM_log(`第 ${lineNumber} 行时间段数值无效:${text}`, "warn"); return null; } const start = startHour * 60 + startMinute; const end = endHour * 60 + endMinute; if (start === end) { GM_log(`第 ${lineNumber} 行时间段起止相同,已跳过:${text}`, "warn"); return null; } return { start, end, label: `${pad2(startHour)}:${pad2(startMinute)}~${pad2(endHour)}:${pad2(endMinute)}`, allDay: false, }; } function createAllDaySegment() { return { start: 0, end: 24 * 60, label: ALL_DAY_SEGMENT, allDay: true, }; } function findMatchedSegment(segments, date) { const minutes = date.getHours() * 60 + date.getMinutes(); return segments.find((segment) => { if (segment.allDay) { return true; } if (segment.start < segment.end) { return minutes >= segment.start && minutes < segment.end; } return minutes >= segment.start || minutes < segment.end; }); } function buildSegmentKey(mid, segment, date) { if (segment.allDay) { return `${formatHour(date)}|${mid}|${segment.label}`; } return `${getSegmentDate(segment, date)}|${mid}|${segment.label}`; } function getOpenLimitExpiresAt(segment, date) { if (segment.allDay) { const nextHour = new Date(date); nextHour.setMinutes(0, 0, 0); nextHour.setHours(nextHour.getHours() + 1); return nextHour.getTime(); } const expiresAt = new Date(date); expiresAt.setHours(0, 0, 0, 0); expiresAt.setDate(expiresAt.getDate() + 2); return expiresAt.getTime(); } function getSegmentDate(segment, date) { if (!segment.allDay && segment.start > segment.end) { const minutes = date.getHours() * 60 + date.getMinutes(); if (minutes < segment.end) { const previous = new Date(date); previous.setDate(previous.getDate() - 1); return formatDate(previous); } } return formatDate(date); } function pruneOpenedSegments() { const now = Date.now(); Object.keys(openedSegments).forEach((key) => { const record = openedSegments[key]; if (!record || record.expiresAt <= now) { delete openedSegments[key]; } }); } function normalizeText(text) { return String(text || "") .replace(/[0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0)) .replace(/:/g, ":") .replace(/\u3000/g, " "); } function isValidTime(hour, minute) { return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59; } function formatDate(date) { return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; } function formatHour(date) { return `${formatDate(date)}-${pad2(date.getHours())}`; } function pad2(value) { return String(value).padStart(2, "0"); } function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function formatError(error) { if (!error) { return "未知错误"; } if (error instanceof Error) { return error.stack || error.message; } return String(error); }