B站脚本

B站收藏夹备份

---js
// UserScript
// @name B站收藏夹备份
// @namespace http://tampermonkey.net/
// @version 1.0.2
// @description 备份B站个人所有收藏夹,导出XML(文件名:B站ID_日期_收藏夹.xml)
// @author You
// @match https://space.bilibili.com/*
// @grant GM_xmlhttpRequest
// @grant GM_download
// @connect api.bilibili.com
// /UserScript

(function () {
'use strict';

const PREFIX = 'bfav';
const CONFIG = {
REQUEST_INTERVAL: 1500,
RISK_COOLDOWN: 30 * 60 * 1000,
PAGE_SIZE: 20,
RETRY_MAX: 3,
};

const State = {
running: false, paused: false, cancelled: false,
currentTask: '', logs: [], folders: [],
};

// ============ 工具 ============
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function $(id) { return document.getElementById(PREFIX + '-' + id); }

function log(msg, type = 'info') {
const time = new Date().toLocaleTimeString();
const entry = [${time}] [${type.toUpperCase()}] ${msg};
State.logs.push(entry);
if (State.logs.length > 500) State.logs.shift();
updateLogUI(entry, type);
console.log([B站收藏夹备份] ${entry});
}
function sanitizeFilename(name) {
return String(name || 'unknown').replace(/[\/😗?"<>|]/g, '_').trim() || 'unknown';
}
function getDateStr() {
const d = new Date();
return ${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')};
}

// ============ WBI ============
const MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
20, 34, 44, 52
];
let wbiKeys = null;

async function getWbiKeys() {
if (wbiKeys) return wbiKeys;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://api.bilibili.com/x/web-interface/nav',
headers: { 'Referer': 'https://www.bilibili.com/' },
onload: (resp) => {
try {
const json = JSON.parse(resp.responseText);
if (json.code !== 0) return reject(new Error('获取WBI密钥失败'));
const imgUrl = json.data.wbi_img.img_url;
const subUrl = json.data.wbi_img.sub_url;
const imgKey = imgUrl.slice(imgUrl.lastIndexOf('/') + 1, imgUrl.lastIndexOf('.'));
const subKey = subUrl.slice(subUrl.lastIndexOf('/') + 1, subUrl.lastIndexOf('.'));
wbiKeys = { imgKey, subKey, mid: json.data.mid, uname: json.data.uname };
resolve(wbiKeys);
} catch (e) { reject(e); }
},
onerror: () => reject(new Error('获取WBI密钥网络错误')),
});
});
}
function getMixinKey(orig) { return MIXIN_KEY_ENC_TAB.map(i => orig[i]).join('').slice(0, 32); }
async function encWbi(params) {
const { imgKey, subKey } = await getWbiKeys();
const mixinKey = getMixinKey(imgKey + subKey);
params.wts = Math.round(Date.now() / 1000);
const query = Object.keys(params).sort().map(k =>
${encodeURIComponent(k)}=${encodeURIComponent(params[k])}).join('&');
return ${query}&w_rid=${md5(query + mixinKey)};
}
function md5(message) {
const buffer = new TextEncoder().encode(message);
const n = buffer.length;
const words = new Uint32Array((((n + 8) >> 6) + 1) << 4);
for (let i = 0; i < n; i++) words[i >> 2] |= buffer[i] << ((i % 4) * 8);
words[n >> 2] |= 0x80 << ((n % 4) * 8);
words[words.length - 2] = n * 8;
let [a, b, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
const K = Uint32Array.from({ length: 64 }, (_, i) => (Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0);
const S = [7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21];
const rotl = (x, n) => (x << n) | (x >>> (32 - n));
for (let i = 0; i < words.length; i += 16) {
let [A, B, C, D] = [a, b, c, d];
for (let j = 0; j < 64; j++) {
let f, g;
if (j < 16) { f = (B & C) | (~B & D); g = j; }
else if (j < 32) { f = (D & B) | (~D & C); g = (5 * j + 1) % 16; }
else if (j < 48) { f = B ^ C ^ D; g = (3 * j + 5) % 16; }
else { f = C ^ (B | ~D); g = (7 * j) % 16; }
const temp = D; D = C; C = B;
B = (B + rotl((A + f + K[j] + words[i + g]) | 0, S[j])) | 0;
A = temp;
}
a = (a + A) | 0; b = (b + B) | 0; c = (c + C) | 0; d = (d + D) | 0;
}
const hx = (x) => { let s = ''; for (let i = 0; i < 4; i++) s += ((x >>> (i * 8)) & 0xff).toString(16).padStart(2, '0'); return s; };
return hx(a) + hx(b) + hx(c) + hx(d);
}

// ============ 请求 ============
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const attempt = (retryCount) => {
if (State.cancelled) return reject(new Error('任务已取消'));
GM_xmlhttpRequest({
method: options.method || 'GET',
url: url,
headers: {
'Referer': 'https://www.bilibili.com/',
'Origin': 'https://www.bilibili.com',
...options.headers,
},
timeout: 15000,
onload: (resp) => {
if (resp.status === 412) {
log('触发HTTP 412风控,暂停30分钟...', 'warn');
handleRiskControl().then(() => attempt(retryCount)).catch(reject);
return;
}
if (resp.status !== 200) {
if (retryCount < CONFIG.RETRY_MAX) {
log(HTTP ${resp.status},重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error(HTTP ${resp.status}));
return;
}
try {
const json = JSON.parse(resp.responseText);
if (json.code === -352 || json.code === -412) {
log(业务风控 code=${json.code},暂停30分钟..., 'warn');
handleRiskControl().then(() => attempt(retryCount)).catch(reject);
return;
}
resolve(json);
} catch (e) { reject(new Error('JSON解析失败')); }
},
onerror: () => {
if (retryCount < CONFIG.RETRY_MAX) {
log(网络错误,重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error('网络请求失败'));
},
ontimeout: () => {
if (retryCount < CONFIG.RETRY_MAX) {
log(超时,重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error('请求超时'));
},
});
};
attempt(0);
});
}

async function handleRiskControl() {
State.running = false;
const resumeAt = Date.now() + CONFIG.RISK_COOLDOWN;
while (Date.now() < resumeAt) {
if (State.cancelled) throw new Error('任务已取消');
const remain = Math.ceil((resumeAt - Date.now()) / 1000);
updateStatusUI(风控暂停(剩余 ${Math.floor(remain / 60)}:${String(remain % 60).padStart(2, '0')}));
await sleep(1000);
}
log('风控暂停结束,继续任务', 'info');
State.running = true;
updateStatusUI('运行中');
}

async function waitWithControl(ms) {
const step = 100; let elapsed = 0;
while (elapsed < ms) {
if (State.cancelled) throw new Error('任务已取消');
if (State.paused) { await sleep(200); continue; }
await sleep(step); elapsed += step;
}
}

// ============ 业务 ============
async function fetchFolders(uid) {
log('正在获取收藏夹列表...');
State.currentTask = '获取收藏夹列表'; updateTaskUI();
const url = https://api.bilibili.com/x/v3/fav/folder/created/list-all?up_mid=${uid}&type=2;
const json = await request(url);
if (json.code !== 0 || !json.data) throw new Error(获取收藏夹列表失败: ${json.message});
const folders = json.data.list || [];
log(发现 ${folders.length} 个收藏夹, 'info');
return folders.map(f => ({ id: f.id, title: f.title, media_count: f.media_count }));
}

async function fetchFolderItems(folder) {
log(开始获取收藏夹「${folder.title}」(${folder.media_count}个));
State.currentTask = 收藏夹:${folder.title};
const items = [];
let page = 1;
const total = folder.media_count;
updateProgressUI(${folder.title}:0/${total});

while (items.length < total) {
if (State.cancelled) throw new Error('任务已取消');
await waitWithControl(CONFIG.REQUEST_INTERVAL);
const queryString = await encWbi({ media_id: folder.id, pn: page, ps: CONFIG.PAGE_SIZE, platform: 'web' });
const url = https://api.bilibili.com/x/v3/fav/resource/list?${queryString};
const json = await request(url);
if (json.code !== 0) { log(第${page}页失败: ${json.message}, 'warn'); break; }
const medias = (json.data && json.data.medias) || [];
if (medias.length === 0) break;
for (const m of medias) {
items.push({
bvid: m.bvid || '', aid: m.id || '',
title: m.title || '', intro: m.intro || '',
url: m.bvid ? https://www.bilibili.com/video/${m.bvid} : '',
cover: m.cover || '', duration: m.duration || 0,
upper: (m.upper && m.upper.name) || '',
fav_time: m.fav_time || 0, pubtime: m.pubtime || 0,
status: m.title === '已失效视频' ? '失效' : '正常',
});
}
updateProgressUI(${folder.title}:${items.length}/${total});
if (!json.data || !json.data.has_more) break;
page++;
}
log(收藏夹「${folder.title}」获取完成,共 ${items.length} 条);
return items;
}

// ============ XML ============
function generateXML(uname, uid) {
const esc = (s) => (s === null || s === undefined) ? '' : String(s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
let xml = '\n';
xml += <bilibili_favorites_backup export_time="${esc(new Date().toISOString())}" uname="${esc(uname)}" uid="${esc(uid)}">\n;
for (const folder of State.folders) {
xml += <folder id="${esc(folder.id)}" title="${esc(folder.title)}" count="${folder.items.length}">\n;
for (const v of folder.items) {
xml += ' \n';
}
xml += ' \n';
}
xml += '</bilibili_favorites_backup>\n';
return xml;
}

function downloadXML(uname, uid) {
const xml = generateXML(uname, uid);
const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const filename = ${sanitizeFilename(uname)}_${getDateStr()}_收藏夹.xml;
GM_download({
url, name: filename, saveAs: true,
onload: () => { URL.revokeObjectURL(url); log(XML已导出: ${filename}, 'info'); },
onerror: (err) => { URL.revokeObjectURL(url); log(导出失败: ${err.error}, 'error'); },
});
}

// ============ 主流程 ============
async function runBackup() {
if (State.running) { log('任务已在运行中', 'warn'); return; }
updateStatusUI('获取登录信息...');
const keys = await getWbiKeys().catch(() => null);
if (!keys) { log('无法获取登录信息,请确认已登录B站', 'error'); updateStatusUI('未登录'); return; }
const { mid: uid, uname } = keys;

State.running = true; State.paused = false; State.cancelled = false;
State.folders = []; State.logs = [];
updateStatusUI('运行中'); clearLogUI();
log(开始备份收藏夹,用户: ${uname} (${uid}), 'info');

try {
const folders = await fetchFolders(uid);
for (let i = 0; i < folders.length; i++) {
if (State.cancelled) break;
const f = folders[i];
log([${i + 1}/${folders.length}] 处理「${f.title}」);
const items = await fetchFolderItems(f);
State.folders.push({ ...f, items });
await waitWithControl(CONFIG.REQUEST_INTERVAL);
}
if (State.cancelled) { log('任务已取消', 'warn'); updateStatusUI('已取消'); }
else { log('数据获取完成,正在导出XML...', 'info'); downloadXML(uname, uid); updateStatusUI('已完成'); }
} catch (e) {
if (e.message === '任务已取消') { log('任务已取消', 'warn'); updateStatusUI('已取消'); }
else { log(任务出错: ${e.message}, 'error'); updateStatusUI('出错'); }
} finally {
State.running = false; State.currentTask = ''; updateTaskUI();
}
}

// ============ UI ============
let panel, statusEl, taskEl, progressEl, logEl;

function createUI() {
if ($('panel')) return;

// 自动堆叠
const existing = document.querySelectorAll('[data-bili-backup-panel]').length;
const bottomOffset = 80 + existing * 340;

panel = document.createElement('div');
panel.id = PREFIX + '-panel';
panel.setAttribute('data-bili-backup-panel', '1');
panel.style.cssText = position:fixed;right:16px;bottom:${bottomOffset}px;width:360px;background:#fff;border:1px solid #e0e0e0;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px;z-index:${99999 + existing};color:#333;font-family:-apple-system,sans-serif;;
panel.innerHTML = <div style="display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:#fb7299;color:#fff;border-radius:8px 8px 0 0;font-weight:600;"> <span>⭐ B站收藏夹备份</span> <span id="${PREFIX}-toggle" style="cursor:pointer;">▼</span> </div> <div id="${PREFIX}-body" style="padding:12px 14px;"> <div style="margin:3px 0;">状态:<span id="${PREFIX}-state">就绪</span></div> <div style="margin:3px 0;">任务:<span id="${PREFIX}-task">—</span></div> <div style="margin:3px 0;">进度:<span id="${PREFIX}-progress">—</span></div> <div style="display:flex;gap:8px;margin:10px 0;"> <button id="${PREFIX}-start" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#fb7299;color:#fff;">开始备份</button> <button id="${PREFIX}-pause" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#ffb800;color:#fff;">暂停</button> <button id="${PREFIX}-cancel" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#e74c3c;color:#fff;">取消</button> </div> <div id="${PREFIX}-log" style="max-height:180px;overflow-y:auto;background:#f8f8f8;border:1px solid #eee;border-radius:4px;padding:6px 8px;font-size:11px;font-family:monospace;line-height:1.5;color:#555;white-space:pre-wrap;word-break:break-all;"></div> </div> ;
document.body.appendChild(panel);

statusEl = $('state'); taskEl = $('task'); progressEl = $('progress'); logEl = $('log');

$('start').onclick = () => runBackup();
$('pause').onclick = () => {
if (!State.running) return;
State.paused = !State.paused;
$('pause').textContent = State.paused ? '继续' : '暂停';
updateStatusUI(State.paused ? '已暂停' : '运行中');
log(State.paused ? '任务已暂停' : '任务已继续');
};
$('cancel').onclick = () => {
if (!State.running && !State.paused) return;
State.cancelled = true; State.paused = false;
$('pause').textContent = '暂停';
log('正在取消任务...', 'warn');
};
$('toggle').onclick = () => {
const body = $('body');
const t = $('toggle');
const hidden = body.style.display === 'none';
body.style.display = hidden ? '' : 'none';
t.textContent = hidden ? '▼' : '▶';
};

log('面板已就绪,点击「开始备份」运行', 'info');
}

function updateStatusUI(text) { if (statusEl) statusEl.textContent = text; const b = $('start'); if (b) b.disabled = State.running; }
function updateTaskUI() { if (taskEl) taskEl.textContent = State.currentTask || '—'; }
function updateProgressUI(text) { if (progressEl) progressEl.textContent = text; }
function updateLogUI(msg, type) {
if (!logEl) return;
const color = type === 'error' ? '#e74c3c' : type === 'warn' ? '#e67e22' : type === 'info' ? '#2980b9' : '#555';
const d = document.createElement('div');
d.style.color = color; d.textContent = msg;
logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight;
while (logEl.children.length > 200) logEl.removeChild(logEl.firstChild);
}
function clearLogUI() { if (logEl) logEl.innerHTML = ''; }

function init() {
if (!location.href.includes('space.bilibili.com')) return;
if (document.readyState === 'complete') createUI();
else window.addEventListener('load', createUI);
}
init();
})();


B站稍后再看备份

---js
// UserScript
// @name B站稍后再看备份
// @namespace http://tampermonkey.net/
// @version 1.0.2
// @description 备份B站稍后再看列表,导出XML(文件名:B站ID_日期_稍后再看.xml)
// @author You
// @match https://space.bilibili.com/*
// @match https://www.bilibili.com/*
// @grant GM_xmlhttpRequest
// @grant GM_download
// @connect api.bilibili.com
// /UserScript

(function () {
'use strict';

const PREFIX = 'btv';
const CONFIG = {
REQUEST_INTERVAL: 1500,
RISK_COOLDOWN: 30 * 60 * 1000,
RETRY_MAX: 3,
};

const State = {
running: false, paused: false, cancelled: false,
currentTask: '', logs: [], videos: [],
};

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function $(id) { return document.getElementById(PREFIX + '-' + id); }

function log(msg, type = 'info') {
const time = new Date().toLocaleTimeString();
const entry = [${time}] [${type.toUpperCase()}] ${msg};
State.logs.push(entry);
if (State.logs.length > 500) State.logs.shift();
updateLogUI(entry, type);
console.log([B站稍后再看备份] ${entry});
}
function sanitizeFilename(name) {
return String(name || 'unknown').replace(/[\/😗?"<>|]/g, '_').trim() || 'unknown';
}
function getDateStr() {
const d = new Date();
return ${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')};
}

function request(url, options = {}) {
return new Promise((resolve, reject) => {
const attempt = (retryCount) => {
if (State.cancelled) return reject(new Error('任务已取消'));
GM_xmlhttpRequest({
method: options.method || 'GET',
url: url,
headers: {
'Referer': 'https://www.bilibili.com/',
'Origin': 'https://www.bilibili.com',
...options.headers,
},
timeout: 15000,
onload: (resp) => {
if (resp.status === 412) {
log('触发HTTP 412风控,暂停30分钟...', 'warn');
handleRiskControl().then(() => attempt(retryCount)).catch(reject);
return;
}
if (resp.status !== 200) {
if (retryCount < CONFIG.RETRY_MAX) {
log(HTTP ${resp.status},重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error(HTTP ${resp.status}));
return;
}
try {
const json = JSON.parse(resp.responseText);
if (json.code === -352 || json.code === -412) {
log(业务风控 code=${json.code},暂停30分钟..., 'warn');
handleRiskControl().then(() => attempt(retryCount)).catch(reject);
return;
}
resolve(json);
} catch (e) { reject(new Error('JSON解析失败')); }
},
onerror: () => {
if (retryCount < CONFIG.RETRY_MAX) {
log(网络错误,重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error('网络请求失败'));
},
ontimeout: () => {
if (retryCount < CONFIG.RETRY_MAX) {
log(超时,重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error('请求超时'));
},
});
};
attempt(0);
});
}

async function handleRiskControl() {
State.running = false;
const resumeAt = Date.now() + CONFIG.RISK_COOLDOWN;
while (Date.now() < resumeAt) {
if (State.cancelled) throw new Error('任务已取消');
const remain = Math.ceil((resumeAt - Date.now()) / 1000);
updateStatusUI(风控暂停(剩余 ${Math.floor(remain / 60)}:${String(remain % 60).padStart(2, '0')}));
await sleep(1000);
}
log('风控暂停结束,继续任务', 'info');
State.running = true;
updateStatusUI('运行中');
}

async function waitWithControl(ms) {
const step = 100; let elapsed = 0;
while (elapsed < ms) {
if (State.cancelled) throw new Error('任务已取消');
if (State.paused) { await sleep(200); continue; }
await sleep(step); elapsed += step;
}
}

async function getUserInfo() {
const json = await request('https://api.bilibili.com/x/web-interface/nav');
if (json.code !== 0) throw new Error('无法获取用户信息,请确认已登录B站');
return { uid: json.data.mid, uname: json.data.uname };
}

async function fetchToView() {
log('正在获取稍后再看列表...');
State.currentTask = '获取稍后再看'; updateTaskUI();
await waitWithControl(CONFIG.REQUEST_INTERVAL);
const json = await request('https://api.bilibili.com/x/v2/history/toview');
if (json.code !== 0 || !json.data) throw new Error(获取稍后再看失败: ${json.message});
const list = json.data.list || [];
updateProgressUI(已获取 ${list.length} 条);
log(稍后再看获取完成,共 ${list.length} 条);
return list.map(v => ({
bvid: v.bvid || '', aid: v.aid || '',
title: v.title || '', intro: v.desc || '',
url: v.bvid ? https://www.bilibili.com/video/${v.bvid} : '',
cover: v.pic || '', duration: v.duration || 0,
upper: (v.owner && v.owner.name) || '',
add_time: v.add_time || 0, pubtime: v.pubdate || 0,
status: '正常',
}));
}

function generateXML(uname, uid) {
const esc = (s) => (s === null || s === undefined) ? '' : String(s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
let xml = '\n';
xml += <bilibili_toview_backup export_time="${esc(new Date().toISOString())}" uname="${esc(uname)}" uid="${esc(uid)}" count="${State.videos.length}">\n;
for (const v of State.videos) {
xml += ' \n';
}
xml += '</bilibili_toview_backup>\n';
return xml;
}

function downloadXML(uname, uid) {
const xml = generateXML(uname, uid);
const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const filename = ${sanitizeFilename(uname)}_${getDateStr()}_稍后再看.xml;
GM_download({
url, name: filename, saveAs: true,
onload: () => { URL.revokeObjectURL(url); log(XML已导出: ${filename}, 'info'); },
onerror: (err) => { URL.revokeObjectURL(url); log(导出失败: ${err.error}, 'error'); },
});
}

async function runBackup() {
if (State.running) { log('任务已在运行中', 'warn'); return; }
State.running = true; State.paused = false; State.cancelled = false;
State.videos = []; State.logs = [];
updateStatusUI('运行中'); clearLogUI();

try {
const { uid, uname } = await getUserInfo();
log(开始备份稍后再看,用户: ${uname} (${uid}), 'info');
State.videos = await fetchToView();
if (State.cancelled) { log('任务已取消', 'warn'); updateStatusUI('已取消'); }
else { log('正在导出XML...', 'info'); downloadXML(uname, uid); updateStatusUI('已完成'); }
} catch (e) {
if (e.message === '任务已取消') { log('任务已取消', 'warn'); updateStatusUI('已取消'); }
else { log(任务出错: ${e.message}, 'error'); updateStatusUI('出错'); }
} finally {
State.running = false; State.currentTask = ''; updateTaskUI();
}
}

// ============ UI ============
let panel, statusEl, taskEl, progressEl, logEl;

function createUI() {
if ($('panel')) return;

const existing = document.querySelectorAll('[data-bili-backup-panel]').length;
const bottomOffset = 80 + existing * 340;

panel = document.createElement('div');
panel.id = PREFIX + '-panel';
panel.setAttribute('data-bili-backup-panel', '1');
panel.style.cssText = position:fixed;right:16px;bottom:${bottomOffset}px;width:360px;background:#fff;border:1px solid #e0e0e0;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px;z-index:${99999 + existing};color:#333;font-family:-apple-system,sans-serif;;
panel.innerHTML = <div style="display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:#00a1d6;color:#fff;border-radius:8px 8px 0 0;font-weight:600;"> <span>⏰ B站稍后再看备份</span> <span id="${PREFIX}-toggle" style="cursor:pointer;">▼</span> </div> <div id="${PREFIX}-body" style="padding:12px 14px;"> <div style="margin:3px 0;">状态:<span id="${PREFIX}-state">就绪</span></div> <div style="margin:3px 0;">任务:<span id="${PREFIX}-task">—</span></div> <div style="margin:3px 0;">进度:<span id="${PREFIX}-progress">—</span></div> <div style="display:flex;gap:8px;margin:10px 0;"> <button id="${PREFIX}-start" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#00a1d6;color:#fff;">开始备份</button> <button id="${PREFIX}-pause" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#ffb800;color:#fff;">暂停</button> <button id="${PREFIX}-cancel" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#e74c3c;color:#fff;">取消</button> </div> <div id="${PREFIX}-log" style="max-height:180px;overflow-y:auto;background:#f8f8f8;border:1px solid #eee;border-radius:4px;padding:6px 8px;font-size:11px;font-family:monospace;line-height:1.5;color:#555;white-space:pre-wrap;word-break:break-all;"></div> </div> ;
document.body.appendChild(panel);

statusEl = $('state'); taskEl = $('task'); progressEl = $('progress'); logEl = $('log');

$('start').onclick = () => runBackup();
$('pause').onclick = () => {
if (!State.running) return;
State.paused = !State.paused;
$('pause').textContent = State.paused ? '继续' : '暂停';
updateStatusUI(State.paused ? '已暂停' : '运行中');
log(State.paused ? '任务已暂停' : '任务已继续');
};
$('cancel').onclick = () => {
if (!State.running && !State.paused) return;
State.cancelled = true; State.paused = false;
$('pause').textContent = '暂停';
log('正在取消任务...', 'warn');
};
$('toggle').onclick = () => {
const body = $('body');
const t = $('toggle');
const hidden = body.style.display === 'none';
body.style.display = hidden ? '' : 'none';
t.textContent = hidden ? '▼' : '▶';
};

log('面板已就绪,点击「开始备份」运行', 'info');
}

function updateStatusUI(text) { if (statusEl) statusEl.textContent = text; const b = $('start'); if (b) b.disabled = State.running; }
function updateTaskUI() { if (taskEl) taskEl.textContent = State.currentTask || '—'; }
function updateProgressUI(text) { if (progressEl) progressEl.textContent = text; }
function updateLogUI(msg, type) {
if (!logEl) return;
const color = type === 'error' ? '#e74c3c' : type === 'warn' ? '#e67e22' : type === 'info' ? '#2980b9' : '#555';
const d = document.createElement('div');
d.style.color = color; d.textContent = msg;
logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight;
while (logEl.children.length > 200) logEl.removeChild(logEl.firstChild);
}
function clearLogUI() { if (logEl) logEl.innerHTML = ''; }

function init() {
if (!location.href.includes('bilibili.com')) return;
if (document.readyState === 'complete') createUI();
else window.addEventListener('load', createUI);
}
init();
})();


B站订阅的合集与收藏夹备份

---js
// UserScript
// @name B站订阅的合集与收藏夹备份
// @namespace http://tampermonkey.net/
// @version 1.0.2
// @description 备份B站「我追的合集/收藏夹」,导出XML(文件名:B站ID_日期_订阅收藏夹与合集.xml)
// @author You
// @match https://space.bilibili.com/*
// @grant GM_xmlhttpRequest
// @grant GM_download
// @connect api.bilibili.com
// /UserScript

(function () {
'use strict';

const PREFIX = 'bfol';
const CONFIG = {
REQUEST_INTERVAL: 1500,
RISK_COOLDOWN: 30 * 60 * 1000,
PAGE_SIZE: 20,
RETRY_MAX: 3,
};

const State = {
running: false, paused: false, cancelled: false,
currentTask: '', logs: [], items: [],
};

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function $(id) { return document.getElementById(PREFIX + '-' + id); }

function log(msg, type = 'info') {
const time = new Date().toLocaleTimeString();
const entry = [${time}] [${type.toUpperCase()}] ${msg};
State.logs.push(entry);
if (State.logs.length > 500) State.logs.shift();
updateLogUI(entry, type);
console.log([B站订阅备份] ${entry});
}
function sanitizeFilename(name) {
return String(name || 'unknown').replace(/[\/😗?"<>|]/g, '_').trim() || 'unknown';
}
function getDateStr() {
const d = new Date();
return ${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')};
}

// ============ WBI ============
const MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
20, 34, 44, 52
];
let wbiKeys = null;

async function getWbiKeys() {
if (wbiKeys) return wbiKeys;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://api.bilibili.com/x/web-interface/nav',
headers: { 'Referer': 'https://www.bilibili.com/' },
onload: (resp) => {
try {
const json = JSON.parse(resp.responseText);
if (json.code !== 0) return reject(new Error('获取WBI密钥失败'));
const imgUrl = json.data.wbi_img.img_url;
const subUrl = json.data.wbi_img.sub_url;
const imgKey = imgUrl.slice(imgUrl.lastIndexOf('/') + 1, imgUrl.lastIndexOf('.'));
const subKey = subUrl.slice(subUrl.lastIndexOf('/') + 1, subUrl.lastIndexOf('.'));
wbiKeys = { imgKey, subKey, mid: json.data.mid, uname: json.data.uname };
resolve(wbiKeys);
} catch (e) { reject(e); }
},
onerror: () => reject(new Error('获取WBI密钥网络错误')),
});
});
}
function getMixinKey(orig) { return MIXIN_KEY_ENC_TAB.map(i => orig[i]).join('').slice(0, 32); }
async function encWbi(params) {
const { imgKey, subKey } = await getWbiKeys();
const mixinKey = getMixinKey(imgKey + subKey);
params.wts = Math.round(Date.now() / 1000);
const query = Object.keys(params).sort().map(k =>
${encodeURIComponent(k)}=${encodeURIComponent(params[k])}).join('&');
return ${query}&w_rid=${md5(query + mixinKey)};
}
function md5(message) {
const buffer = new TextEncoder().encode(message);
const n = buffer.length;
const words = new Uint32Array((((n + 8) >> 6) + 1) << 4);
for (let i = 0; i < n; i++) words[i >> 2] |= buffer[i] << ((i % 4) * 8);
words[n >> 2] |= 0x80 << ((n % 4) * 8);
words[words.length - 2] = n * 8;
let [a, b, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
const K = Uint32Array.from({ length: 64 }, (_, i) => (Math.abs(Math.sin(i + 1)) * 4294967296) >>> 0);
const S = [7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21];
const rotl = (x, n) => (x << n) | (x >>> (32 - n));
for (let i = 0; i < words.length; i += 16) {
let [A, B, C, D] = [a, b, c, d];
for (let j = 0; j < 64; j++) {
let f, g;
if (j < 16) { f = (B & C) | (~B & D); g = j; }
else if (j < 32) { f = (D & B) | (~D & C); g = (5 * j + 1) % 16; }
else if (j < 48) { f = B ^ C ^ D; g = (3 * j + 5) % 16; }
else { f = C ^ (B | ~D); g = (7 * j) % 16; }
const temp = D; D = C; C = B;
B = (B + rotl((A + f + K[j] + words[i + g]) | 0, S[j])) | 0;
A = temp;
}
a = (a + A) | 0; b = (b + B) | 0; c = (c + C) | 0; d = (d + D) | 0;
}
const hx = (x) => { let s = ''; for (let i = 0; i < 4; i++) s += ((x >>> (i * 8)) & 0xff).toString(16).padStart(2, '0'); return s; };
return hx(a) + hx(b) + hx(c) + hx(d);
}

// ============ 请求 ============
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const attempt = (retryCount) => {
if (State.cancelled) return reject(new Error('任务已取消'));
GM_xmlhttpRequest({
method: options.method || 'GET',
url: url,
headers: {
'Referer': 'https://www.bilibili.com/',
'Origin': 'https://www.bilibili.com',
...options.headers,
},
timeout: 15000,
onload: (resp) => {
if (resp.status === 412) {
log('触发HTTP 412风控,暂停30分钟...', 'warn');
handleRiskControl().then(() => attempt(retryCount)).catch(reject);
return;
}
if (resp.status !== 200) {
if (retryCount < CONFIG.RETRY_MAX) {
log(HTTP ${resp.status},重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error(HTTP ${resp.status}));
return;
}
try {
const json = JSON.parse(resp.responseText);
if (json.code === -352 || json.code === -412) {
log(业务风控 code=${json.code},暂停30分钟..., 'warn');
handleRiskControl().then(() => attempt(retryCount)).catch(reject);
return;
}
resolve(json);
} catch (e) { reject(new Error('JSON解析失败')); }
},
onerror: () => {
if (retryCount < CONFIG.RETRY_MAX) {
log(网络错误,重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error('网络请求失败'));
},
ontimeout: () => {
if (retryCount < CONFIG.RETRY_MAX) {
log(超时,重试 ${retryCount + 1}/${CONFIG.RETRY_MAX}, 'warn');
setTimeout(() => attempt(retryCount + 1), 2000 * (retryCount + 1));
} else reject(new Error('请求超时'));
},
});
};
attempt(0);
});
}

async function handleRiskControl() {
State.running = false;
const resumeAt = Date.now() + CONFIG.RISK_COOLDOWN;
while (Date.now() < resumeAt) {
if (State.cancelled) throw new Error('任务已取消');
const remain = Math.ceil((resumeAt - Date.now()) / 1000);
updateStatusUI(风控暂停(剩余 ${Math.floor(remain / 60)}:${String(remain % 60).padStart(2, '0')}));
await sleep(1000);
}
log('风控暂停结束,继续任务', 'info');
State.running = true;
updateStatusUI('运行中');
}

async function waitWithControl(ms) {
const step = 100; let elapsed = 0;
while (elapsed < ms) {
if (State.cancelled) throw new Error('任务已取消');
if (State.paused) { await sleep(200); continue; }
await sleep(step); elapsed += step;
}
}

// ============ 业务 ============
async function fetchFollowingList(uid) {
log('正在获取「我追的合集/收藏夹」列表...');
State.currentTask = '获取追列表'; updateTaskUI();
const allItems = [];
let page = 1;
const pageSize = 30;

while (true) {
if (State.cancelled) throw new Error('任务已取消');
await waitWithControl(CONFIG.REQUEST_INTERVAL);
const queryString = await encWbi({ up_mid: uid, type: 21, pn: page, ps: pageSize });
const url = https://api.bilibili.com/x/v3/fav/folder/collected/list?${queryString};
const json = await request(url);
if (json.code !== 0) throw new Error(获取追列表失败: ${json.message} (code=${json.code}));
const list = (json.data && json.data.list) || [];
if (list.length === 0) break;
for (const item of list) {
allItems.push({
itemType: item.type === 'collection' ? 'collection' : 'folder',
id: item.id || item.media_id,
title: item.title || '',
cover: item.cover || '',
description: item.intro || item.description || '',
media_count: item.media_count || 0,
upper_mid: (item.upper && item.upper.mid) || item.mid || '',
upper_name: (item.upper && item.upper.name) || '',
ctime: item.ctime || 0,
mtime: item.mtime || 0,
});
}
log(第${page}页 ${list.length} 条,累计 ${allItems.length});
updateProgressUI(追列表:已获取 ${allItems.length} 条);
const total = (json.data && json.data.count) || 0;
if (allItems.length >= total || list.length < pageSize) break;
page++;
}
const collCount = allItems.filter(i => i.itemType === 'collection').length;
const folderCount = allItems.filter(i => i.itemType === 'folder').length;
log(追列表完成:共 ${allItems.length} 个(合集 ${collCount},收藏夹 ${folderCount}), 'info');
return allItems;
}

async function fetchCollectionVideos(item, uid) {
log(开始获取合集「${item.title}」...);
State.currentTask = 合集:${item.title};
const videos = [];
let page = 1;
let totalCount = item.media_count || 0;

while (true) {
if (State.cancelled) throw new Error('任务已取消');
await waitWithControl(CONFIG.REQUEST_INTERVAL);
const queryString = await encWbi({
mid: uid, season_id: item.id, sort_reverse: false,
page_num: page, page_size: CONFIG.PAGE_SIZE,
});
const url = https://api.bilibili.com/x/polymer/web-space/seasons_archives_list?${queryString};
const json = await request(url);
if (json.code !== 0) { log(第${page}页失败: ${json.message}, 'warn'); break; }
const archives = (json.data && json.data.archives) || [];
if (archives.length === 0) break;
for (const a of archives) {
videos.push({
bvid: a.bvid || '', aid: a.aid || '',
title: a.title || '', intro: '',
url: a.bvid ? https://www.bilibili.com/video/${a.bvid} : '',
cover: a.pic || '', duration: a.duration || 0,
upper: item.upper_name, pubdate: a.pubdate || 0, ctime: a.ctime || 0,
});
}
if (json.data && json.data.page && json.data.page.total) totalCount = json.data.page.total;
updateProgressUI(${item.title}:${videos.length}/${totalCount});
if (videos.length >= totalCount || archives.length < CONFIG.PAGE_SIZE) break;
page++;
}
log(合集「${item.title}」完成,共 ${videos.length} 个);
return videos;
}

async function fetchFolderVideos(item) {
log(开始获取收藏夹「${item.title}」...);
State.currentTask = 收藏夹:${item.title};
const videos = [];
let page = 1;
let totalCount = item.media_count || 0;

while (true) {
if (State.cancelled) throw new Error('任务已取消');
await waitWithControl(CONFIG.REQUEST_INTERVAL);
const queryString = await encWbi({
media_id: item.id, pn: page, ps: CONFIG.PAGE_SIZE, platform: 'web',
});
const url = https://api.bilibili.com/x/v3/fav/resource/list?${queryString};
const json = await request(url);
if (json.code !== 0) { log(第${page}页失败: ${json.message}, 'warn'); break; }
const medias = (json.data && json.data.medias) || [];
if (medias.length === 0) break;
for (const m of medias) {
videos.push({
bvid: m.bvid || '', aid: m.id || '',
title: m.title || '', intro: m.intro || '',
url: m.bvid ? https://www.bilibili.com/video/${m.bvid} : '',
cover: m.cover || '', duration: m.duration || 0,
upper: (m.upper && m.upper.name) || '',
fav_time: m.fav_time || 0, pubtime: m.pubtime || 0,
status: m.title === '已失效视频' ? '失效' : '正常',
});
}
if (json.data && json.data.info) totalCount = json.data.info.media_count || totalCount;
updateProgressUI(${item.title}:${videos.length}/${totalCount});
if (!json.data || !json.data.has_more) break;
page++;
}
log(收藏夹「${item.title}」完成,共 ${videos.length} 条);
return videos;
}

function generateXML(uname, uid) {
const esc = (s) => (s === null || s === undefined) ? '' : String(s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
let xml = '\n';
xml += <bilibili_following_backup export_time="${esc(new Date().toISOString())}" uname="${esc(uname)}" uid="${esc(uid)}" total="${State.items.length}">\n;
for (const item of State.items) {
const tag = item.itemType === 'collection' ? 'collection' : 'folder';
xml += <${tag} id="${esc(item.id)}" title="${esc(item.title)}";
xml += author_mid="${esc(item.upper_mid)}" author_name="${esc(item.upper_name)}" ;
xml += media_count="${item.videos.length}" ctime="${esc(item.ctime)}" mtime="${esc(item.mtime)}">\n;
xml += <description>${esc(item.description)}</description>\n;
xml += <cover>${esc(item.cover)}</cover>\n;
for (const v of item.videos) {
xml += ' \n';
}
xml += </${tag}>\n;
}
xml += '</bilibili_following_backup>\n';
return xml;
}

function downloadXML(uname, uid) {
const xml = generateXML(uname, uid);
const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const filename = ${sanitizeFilename(uname)}_${getDateStr()}_订阅收藏夹与合集.xml;
GM_download({
url, name: filename, saveAs: true,
onload: () => { URL.revokeObjectURL(url); log(XML已导出: ${filename}, 'info'); },
onerror: (err) => { URL.revokeObjectURL(url); log(导出失败: ${err.error}, 'error'); },
});
}

async function runBackup() {
if (State.running) { log('任务已在运行中', 'warn'); return; }
updateStatusUI('获取登录信息...');
const keys = await getWbiKeys().catch(() => null);
if (!keys) { log('无法获取登录信息,请确认已登录B站', 'error'); updateStatusUI('未登录'); return; }
const { mid: uid, uname } = keys;

State.running = true; State.paused = false; State.cancelled = false;
State.items = []; State.logs = [];
updateStatusUI('运行中'); clearLogUI();
log(开始备份订阅合集/收藏夹,用户: ${uname} (${uid}), 'info');

try {
const list = await fetchFollowingList(uid);
if (list.length === 0) log('列表为空', 'warn');
for (let i = 0; i < list.length; i++) {
if (State.cancelled) break;
const item = list[i];
log([${i + 1}/${list.length}] 处理「${item.title}」(${item.itemType}));
let videos;
if (item.itemType === 'collection') videos = await fetchCollectionVideos(item, item.upper_mid || uid);
else videos = await fetchFolderVideos(item);
State.items.push({ ...item, videos });
await waitWithControl(CONFIG.REQUEST_INTERVAL);
}
if (State.cancelled) { log('任务已取消', 'warn'); updateStatusUI('已取消'); }
else { log('数据获取完成,正在导出XML...', 'info'); downloadXML(uname, uid); updateStatusUI('已完成'); }
} catch (e) {
if (e.message === '任务已取消') { log('任务已取消', 'warn'); updateStatusUI('已取消'); }
else { log(任务出错: ${e.message}, 'error'); updateStatusUI('出错'); }
} finally {
State.running = false; State.currentTask = ''; updateTaskUI();
}
}

// ============ UI ============
let panel, statusEl, taskEl, progressEl, logEl;

function createUI() {
if ($('panel')) return;

const existing = document.querySelectorAll('[data-bili-backup-panel]').length;
const bottomOffset = 80 + existing * 340;

panel = document.createElement('div');
panel.id = PREFIX + '-panel';
panel.setAttribute('data-bili-backup-panel', '1');
panel.style.cssText = position:fixed;right:16px;bottom:${bottomOffset}px;width:380px;background:#fff;border:1px solid #e0e0e0;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px;z-index:${99999 + existing};color:#333;font-family:-apple-system,sans-serif;;
panel.innerHTML = <div style="display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:#fb7299;color:#fff;border-radius:8px 8px 0 0;font-weight:600;"> <span>📚 B站订阅合集/收藏夹备份</span> <span id="${PREFIX}-toggle" style="cursor:pointer;">▼</span> </div> <div id="${PREFIX}-body" style="padding:12px 14px;"> <div style="margin:3px 0;">状态:<span id="${PREFIX}-state">就绪</span></div> <div style="margin:3px 0;">任务:<span id="${PREFIX}-task">—</span></div> <div style="margin:3px 0;">进度:<span id="${PREFIX}-progress">—</span></div> <div style="display:flex;gap:8px;margin:10px 0;"> <button id="${PREFIX}-start" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#fb7299;color:#fff;">开始备份</button> <button id="${PREFIX}-pause" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#ffb800;color:#fff;">暂停</button> <button id="${PREFIX}-cancel" style="flex:1;padding:7px 0;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;background:#e74c3c;color:#fff;">取消</button> </div> <div id="${PREFIX}-log" style="max-height:180px;overflow-y:auto;background:#f8f8f8;border:1px solid #eee;border-radius:4px;padding:6px 8px;font-size:11px;font-family:monospace;line-height:1.5;color:#555;white-space:pre-wrap;word-break:break-all;"></div> </div> ;
document.body.appendChild(panel);

statusEl = $('state'); taskEl = $('task'); progressEl = $('progress'); logEl = $('log');

$('start').onclick = () => runBackup();
$('pause').onclick = () => {
if (!State.running) return;
State.paused = !State.paused;
$('pause').textContent = State.paused ? '继续' : '暂停';
updateStatusUI(State.paused ? '已暂停' : '运行中');
log(State.paused ? '任务已暂停' : '任务已继续');
};
$('cancel').onclick = () => {
if (!State.running && !State.paused) return;
State.cancelled = true; State.paused = false;
$('pause').textContent = '暂停';
log('正在取消任务...', 'warn');
};
$('toggle').onclick = () => {
const body = $('body');
const t = $('toggle');
const hidden = body.style.display === 'none';
body.style.display = hidden ? '' : 'none';
t.textContent = hidden ? '▼' : '▶';
};

log('面板已就绪,点击「开始备份」运行', 'info');
}

function updateStatusUI(text) { if (statusEl) statusEl.textContent = text; const b = $('start'); if (b) b.disabled = State.running; }
function updateTaskUI() { if (taskEl) taskEl.textContent = State.currentTask || '—'; }
function updateProgressUI(text) { if (progressEl) progressEl.textContent = text; }
function updateLogUI(msg, type) {
if (!logEl) return;
const color = type === 'error' ? '#e74c3c' : type === 'warn' ? '#e67e22' : type === 'info' ? '#2980b9' : '#555';
const d = document.createElement('div');
d.style.color = color; d.textContent = msg;
logEl.appendChild(d); logEl.scrollTop = logEl.scrollHeight;
while (logEl.children.length > 200) logEl.removeChild(logEl.firstChild);
}
function clearLogUI() { if (logEl) logEl.innerHTML = ''; }

function init() {
if (!location.href.includes('space.bilibili.com')) return;
if (document.readyState === 'complete') createUI();
else window.addEventListener('load', createUI);
}
init();
})();


posted @ 2026-10-04 00:09  ZnFeSnH  阅读(2)  评论(0)    收藏  举报