// ==UserScript==
// @name Bilibili 收藏夹 JSON 查看/保存
// @namespace https://space.bilibili.com/398910090
// @version 2.0
// @description 仅在非私密收藏夹界面可用,私密收藏夹不可用,可查看和保存收藏夹界面的json,可点击收藏夹视频卡片菜单的查看json按钮来查看单个视频的json数据
// @author Ace
// @match https://space.bilibili.com/*/favlist*
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(() => {
'use strict';
const cfg = {
api: 'https://api.bilibili.com/x/v3/fav/resource/list',
ps: 40
};
const getMid = () => new URLSearchParams(location.search).get('fid');
const buildUrl = (mid, pn = 1) =>
`${cfg.api}?media_id=${mid}&pn=${pn}&ps=${cfg.ps}&keyword=&order=mtime&type=0&tid=0&platform=web&web_location=333.1387`;
const getHeaders = () => ({
'User-Agent': navigator.userAgent,
'Referer': location.href
});
const fetchJSON = async (mid, pn = 1) => {
const res = await fetch(buildUrl(mid, pn), { headers: getHeaders() });
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '接口错误');
return json;
};
const fetchAll = async (mid) => {
const list = [];
let pn = 1, hasMore = true;
while (hasMore) {
const { data } = await fetchJSON(mid, pn);
list.push(...data.medias);
hasMore = data.has_more;
pn++;
}
return { code: 0, data: { medias: list } };
};
const openTab = (obj) => {
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>JSON 浏览器</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body{margin:0;font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;font-size:14px;background:#1e1e1e;color:#d4d4d4;line-height:1.45}
#search{width:100%;padding:8px 12px;border:none;border-bottom:1px solid #444;background:#252526;color:#d4d4d4;outline:none}
#tools{padding:8px 12px;background:#252526;display:flex;gap:8px}
button{padding:4px 10px;border:1px solid #444;background:#3c3c3c;color:#d4d4d4;cursor:pointer;border-radius:3px}
button:hover{background:#484848}
#tree{padding:12px;white-space:pre;cursor:pointer}
.collapsed>ul{display:none}
li{list-style:none;margin-left:20px;position:relative}
li::before{content:"▶";position:absolute;left:-14px;transition:transform .1s}
li.collapsed::before{transform:rotate(0)}
li.expanded::before{transform:rotate(90deg)}
.key{color:#9cdcfe}.str{color:#ce9178}.num{color:#b5cea8}.bool{color:#569cd6}.null{color:#569cd6}
mark{background:#515c6a;border-radius:2px}
</style>
</head>
<body>
<input id="search" placeholder="搜索键或值 ↵" autocomplete="off">
<div id="tools">
<button id="expandAll">全部展开</button>
<button id="collapseAll">全部折叠</button>
<button id="copyRaw">复制原始 JSON</button>
</div>
<ul id="tree"></ul>
<script>
const data = ${JSON.stringify(obj)};
const tree=document.getElementById('tree'),search=document.getElementById('search');
function escapeHtml(str){return str.replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
function build(o, path = '') {
if (o === null) return '<span class="null">null</span>';
if (typeof o === 'boolean') return '<span class="bool">' + o + '</span>';
if (typeof o === 'number') return '<span class="num">' + o + '</span>';
if (typeof o === 'string') return '<span class="str">"' + escapeHtml(o) + '"</span>';
let out = '';
if (Array.isArray(o)) {
out += '[ ' + o.length + ' ]<ul>';
o.forEach((v, i) => {
const hasChildren = typeof v === 'object' && v !== null && (Array.isArray(v) ? v.length : Object.keys(v).length);
out += '<li class="' + (hasChildren ? 'collapsed' : '') + '"><span class="key">' + i + '</span>: ' + build(v, path + '[' + i + ']') + '</li>';
});
out += '</ul>';
} else {
out += '{ ' + Object.keys(o).length + ' }<ul>';
for (let [k, v] of Object.entries(o)) {
const hasChildren = typeof v === 'object' && v !== null && (Array.isArray(v) ? v.length : Object.keys(v).length);
out += '<li class="' + (hasChildren ? 'collapsed' : '') + '"><span class="key">' + escapeHtml(k) + '</span>: ' + build(v, path + (path ? '.' : '') + k) + '</li>';
}
out += '</ul>';
}
return out;
}
tree.innerHTML=build(data);
tree.addEventListener('click',e=>{
const li=e.target.closest('li');
if(li){li.classList.toggle('collapsed');li.classList.toggle('expanded');}
});
document.getElementById('expandAll').onclick = () => tree.querySelectorAll('li').forEach(n => {
n.classList.remove('collapsed');
n.classList.add('expanded');
});
document.getElementById('collapseAll').onclick = () => tree.querySelectorAll('li').forEach(n => {
n.classList.remove('expanded');
n.classList.add('collapsed');
});
document.getElementById('copyRaw').onclick=()=>navigator.clipboard.writeText(JSON.stringify(data,null,2)).then(()=>alert('已复制'));
search.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const q = search.value.trim().toLowerCase();
if (!q) {
tree.innerHTML = build(data);
return;
}
function filter(o, path = '') {
if (typeof o !== 'object' || o === null) return o;
if (Array.isArray(o)) {
const filtered = o.map((v, i) => filter(v, path + '[' + i + ']')).filter(v => v !== undefined);
return filtered.length ? filtered : undefined;
}
const n = {};
for (let [k, v] of Object.entries(o)) {
const fullPath = path + (path ? '.' : '') + k;
if (k.toLowerCase().includes(q) || String(v).toLowerCase().includes(q)) {
n[k] = v;
} else {
const f = filter(v, fullPath);
if (f !== undefined) n[k] = f;
}
}
return Object.keys(n).length ? n : undefined;
}
tree.innerHTML = build(filter(data) || {});
});
</script>
</body></html>`;
const w = window.open('', '_blank');
w.document.write(html);
w.document.close();
};
const saveFile = (obj) => {
const name = prompt('文件名:', GM_getValue('fname', 'favlist.json')) || 'favlist.json';
GM_setValue('fname', name);
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
const u = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: u, download: name });
a.click();
URL.revokeObjectURL(u);
};
const addJsonButton = () => {
const observer = new MutationObserver(() => {
document.querySelectorAll('.bili-video-card__info--right').forEach((menu) => {
if (!menu.querySelector('.view-json-btn')) {
const btn = document.createElement('button');
btn.textContent = '查看 JSON 数据';
btn.className = 'view-json-btn';
btn.style.cssText = 'margin-left: 10px; cursor: pointer; color: #00a1d6; border: none; background: none;';
btn.onclick = async () => {
const videoCard = menu.closest('.bili-video-card');
const bvid = videoCard?.querySelector('.bili-video-card__info--tit a')?.href.match(/\/video\/(BV\w+)/)?.[1];
if (!bvid) return alert('无法获取视频 bvid');
const mid = getMid();
if (!mid) return alert('无法获取当前收藏夹 ID');
try {
const allData = await fetchAll(mid);
const videoData = allData.data.medias.find((item) => item.bvid === bvid);
if (!videoData) return alert('未找到匹配的视频 JSON 数据');
openTab(videoData);
} catch (e) {
alert(e.message);
}
};
menu.appendChild(btn);
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
};
const observeDynamicMenu = () => {
const done = new WeakSet();
const injectBtn = (card) => {
// 等菜单真正出现再塞按钮
const tryInject = () => {
const menu = document.querySelector('.bili-card-dropdown-popper');
if (!menu || done.has(menu)) return;
const btn = document.createElement('div');
btn.className = 'bili-card-dropdown-popper__item';
btn.textContent = '🔍 查看JSON';
btn.style.cssText = 'color:#00a1d6;cursor:pointer;white-space:nowrap;';
btn.onclick = async () => {
const bvid = card.dataset.bsbBvid
|| card.querySelector('a[href*="/video/BV"]')?.href.match(/BV\w+/)?.[0];
if (!bvid) return alert('无法获取 bvid');
const mid = getMid();
if (!mid) return alert('无法获取收藏夹 ID');
try {
const allData = await fetchAll(mid);
const item = allData.data.medias.find(m => m.bvid === bvid);
if (!item) return alert('未找到该视频 JSON');
openTab(item);
} catch (e) {
alert(e.message);
}
};
menu.appendChild(btn);
done.add(menu);
};
// 每 50ms 检查一次,最多 1 秒
let t = 0;
const id = setInterval(() => {
if (document.querySelector('.bili-card-dropdown-popper') || t++ > 20) {
clearInterval(id);
tryInject();
}
}, 50);
};
/* 悬停即注入,不用点击 */
document.body.addEventListener('mouseenter', (e) => {
const card = e.target.closest('.bili-video-card');
if (card) injectBtn(card);
}, true);
};
/* ---------- 入口(每次点击都重新读取 fid) ---------- */
GM_registerMenuCommand('📖 查看 JSON(当前页)', async () => {
const mid = getMid();
if (!mid) return alert('无法获取当前收藏夹 ID');
try { openTab(await fetchJSON(mid)); } catch (e) { alert(e.message); }
});
GM_registerMenuCommand('💾 保存 JSON(全部)', async () => {
const mid = getMid();
if (!mid) return alert('无法获取当前收藏夹 ID');
try { saveFile(await fetchAll(mid)); } catch (e) { alert(e.message); }
});
// 启动时添加 JSON 按钮
addJsonButton();
// 启动时监听动态菜单
observeDynamicMenu();
})();