// ==UserScript==
// @name B站壳层 · Naive UI 重设计
// @namespace bili-shell-naive
// @version 0.2.0
// @description 隐藏官方首页 UI,挂载基于 Naive UI 的重设计壳层;尝试拉取真实推荐/热榜,并过滤广告条目。可与「B站去广告」共存。
// @author b站脚本
// @match *://www.bilibili.com/*
// @match *://bilibili.com/*
// @run-at document-start
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @require https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js
// @require https://unpkg.com/naive-ui@2.40.1/dist/index.prod.js
// @inject-into page
// @compatible edge 脚本猫/篡改猴
// @compatible chrome 脚本猫/篡改猴
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const ENABLED_KEY = 'bili_shell_enabled_v1';
const enabled = () => {
try {
const v = GM_getValue(ENABLED_KEY, true);
return v !== false && v !== 'false';
} catch (_) {
return true;
}
};
// ---------------------------------------------------------------------------
// 立刻隐藏官方 UI,避免闪屏
// ---------------------------------------------------------------------------
function injectHideCss() {
GM_addStyle(`
html.bili-shell-active > body {
overflow: hidden !important;
}
html.bili-shell-active #app,
html.bili-shell-active #app * {
visibility: hidden !important;
}
html.bili-shell-active #app {
display: none !important;
pointer-events: none !important;
}
#bili-shell-root {
position: fixed;
inset: 0;
z-index: 2147483000;
overflow: auto;
background: #0f1012;
color: #f1f2f3;
}
#bili-shell-root * { box-sizing: border-box; }
#bili-shell-root a { color: inherit; text-decoration: none; }
#bili-shell-root button { font-family: inherit; }
`);
}
function activateShellClass(on) {
document.documentElement.classList.toggle('bili-shell-active', !!on);
}
// ---------------------------------------------------------------------------
// API(同域 GM 请求 + 广告过滤)
// ---------------------------------------------------------------------------
function gmFetch(url) {
return new Promise((resolve, reject) => {
try {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: {
Referer: 'https://www.bilibili.com/',
Origin: 'https://www.bilibili.com',
},
timeout: 12000,
onload(res) {
if (res.status >= 200 && res.status < 300) {
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
reject(e);
}
} else {
reject(new Error('HTTP ' + res.status));
}
},
onerror: () => reject(new Error('network error')),
ontimeout: () => reject(new Error('timeout')),
});
} catch (e) {
reject(e);
}
});
}
function isAdItem(item) {
if (!item || typeof item !== 'object') return false;
if (item.is_ad === 1 || item.is_ad === true) return true;
if (item.isAd === 1 || item.isAd === true) return true;
if (item.is_ad_loc === 1 || item.is_ad_loc === true) return true;
if (typeof item.ad_cb === 'string' && item.ad_cb.length > 0) return true;
if (item.creative_id && item.source_id && String(item.source_id) === '5614') return true;
if (item.type === 'bili_ad' || item.item === 'bili_ad') return true;
if (item.card && (item.card.is_ad || item.card.isAd)) return true;
return false;
}
function filterAdList(list) {
if (!Array.isArray(list)) return [];
return list.filter((x) => !isAdItem(x));
}
function pickListFromPayload(json) {
if (!json || typeof json !== 'object') return [];
const data = json.data;
if (!data) return [];
if (Array.isArray(data.item)) return data.item;
if (Array.isArray(data.list)) return data.list;
if (data.archives && Array.isArray(data.archives)) return data.archives;
return [];
}
function mapRcmdItem(raw, index) {
const title = raw.title || raw.desc || '未命名视频';
const owner = raw.owner || {};
const up = owner.name || raw.author || raw.name || 'UP主';
const mid = owner.mid || raw.mid || 0;
const cover =
raw.pic ||
raw.cover ||
(raw.cover_url_text ? String(raw.cover_url_text).replace(/^\/\//, 'https://') : '') ||
'';
const bvid = raw.bvid || '';
const aid = raw.aid || raw.id || '';
const goto = raw.goto || '';
// 广告/带货在映射层再挡一道
if (isAdItem(raw)) return null;
let href = 'https://www.bilibili.com/';
if (bvid) href = 'https://www.bilibili.com/video/' + bvid;
else if (goto === 'av' && aid) href = 'https://www.bilibili.com/video/av' + aid;
else if (raw.uri) href = String(raw.uri).startsWith('http') ? raw.uri : 'https:' + raw.uri;
else if (goto === 'live' && raw.room_id) href = 'https://live.bilibili.com/' + raw.room_id;
else if (goto === 'bangumi' && (raw.season_id || raw.epid)) {
href = raw.epid
? 'https://www.bilibili.com/bangumi/play/ep' + raw.epid
: 'https://www.bilibili.com/bangumi/ss' + raw.season_id;
}
const stat = raw.stat || {};
const view = stat.view || raw.play || 0;
const like = stat.like || raw.like || 0;
const danmaku = stat.danmaku || raw.video_review || 0;
const pubdate = raw.pubdate || raw.ctime || 0;
return {
id: bvid || aid || 'item-' + index + '-' + Math.random().toString(36).slice(2, 7),
title: String(title),
up: String(up),
mid,
cover: String(cover || '').replace(/^\/\//, 'https://'),
href,
view,
like,
danmaku,
pubdate,
goto,
area: raw.rcmd_reason && raw.rcmd_reason.content ? String(raw.rcmd_reason.content) : '',
duration: formatDuration(raw.duration),
live: goto === 'live',
score: 0,
};
}
function formatDuration(d) {
if (d == null || d === '') return '';
if (typeof d === 'number') {
const s = Math.max(0, Math.floor(d));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mm = String(m).padStart(2, '0');
const ss = String(sec).padStart(2, '0');
return h > 0 ? h + ':' + mm + ':' + ss : m + ':' + ss;
}
return String(d);
}
function formatCount(n) {
n = Number(n) || 0;
if (n >= 100000000) return (n / 100000000).toFixed(1).replace(/\.0$/, '') + '亿';
if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, '') + '万';
return String(n);
}
async function loadRecommendations() {
const endpoints = [
// 热门(相对稳定,无 WBI)
'https://api.bilibili.com/x/web-interface/popular?ps=24&pn=1',
// 推荐流(旧路径,有时仍可用)
'https://api.bilibili.com/x/web-interface/index/top/rcmd?ps=24&fresh_idx=1&feed_version=V3',
];
for (const url of endpoints) {
try {
const json = await gmFetch(url);
if (json && json.code === 0) {
const list = filterAdList(pickListFromPayload(json))
.map((raw, i) => mapRcmdItem(raw, i))
.filter(Boolean);
if (list.length) return { list, source: url.includes('popular') ? '热门' : '推荐' };
}
} catch (_) {
/* try next */
}
}
return { list: null, source: '离线示例' };
}
async function loadRanking() {
const endpoints = [
'https://api.bilibili.com/x/web-interface/ranking/v2?rid=0&type=all',
'https://api.bilibili.com/x/web-interface/popular/series/one?number=10',
];
for (const url of endpoints) {
try {
const json = await gmFetch(url);
if (json && json.code === 0) {
let list = pickListFromPayload(json);
if (!list.length && json.data && json.data.list) list = json.data.list;
const mapped = filterAdList(list)
.map((raw, i) => mapRcmdItem(raw, i))
.filter(Boolean);
if (mapped.length) return mapped;
}
} catch (_) {}
}
return null;
}
// ---------------------------------------------------------------------------
// Mock(接口全挂时的兜底)
// ---------------------------------------------------------------------------
function mockVideos() {
const grads = [
'linear-gradient(135deg, #3b1d2a, #1a2740 55%, #123038)',
'linear-gradient(135deg, #243047, #1b1f2a 50%, #2a1830)',
'linear-gradient(135deg, #1d3a34, #15202b 55%, #3a2030)',
'linear-gradient(135deg, #40203a, #1a1d2e 50%, #102a38)',
'linear-gradient(135deg, #2a1f14, #221828 50%, #142430)',
'linear-gradient(135deg, #14283a, #1f1a2e 50%, #2e1a24)',
];
const titles = [
'【壳层演示】接口不可用时的离线推荐流',
'如何判断推荐流里哪些是广告',
'从零替换 B 站首页(脚本猫方案)',
'Naive UI 做内容站的边界在哪里',
'WBI 签名:前端抓包后要补的一步',
'深色 UI 对比度怎么调才不瞎',
'虚拟列表:长信息流性能关键',
'把去广告脚本和壳层合在一起',
'播放页要不要也重写?',
'脚本猫 vs 浏览器扩展怎么选',
];
const ups = ['设计瘫痪中', '阿码同学', '像素厨房', '夜行电台'];
return titles.map((title, i) => ({
id: 'mock-' + i,
title,
up: ups[i % ups.length],
cover: '',
href: '#',
view: 120000 + i * 33000,
like: 8000 + i * 400,
danmaku: 1000 + i * 90,
duration: (5 + i) + ':' + String(10 + i).padStart(2, '0'),
live: i === 2,
area: '示例',
score: 0,
_grad: grads[i % grads.length],
mock: true,
}));
}
// ---------------------------------------------------------------------------
// 挂载 Vue 壳层
// ---------------------------------------------------------------------------
function getLibs() {
const w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const Vue = w.Vue || window.Vue;
const naive = w.naive || window.naive;
return { Vue, naive, w };
}
function createShellApp(Vue, naive) {
const { createApp, ref, computed, h, onMounted } = Vue;
const {
NConfigProvider,
NInput,
NIcon,
NButton,
NBadge,
NTag,
NRate,
NEmpty,
NDrawer,
NDrawerContent,
NAlert,
NSpace,
NCard,
NSpin,
NImage,
darkTheme,
} = naive;
const SearchIcon = {
render() {
return h(
'svg',
{
width: 18,
height: 18,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': 2,
},
[h('circle', { cx: 11, cy: 11, r: 7 }), h('path', { d: 'M20 20l-3.5-3.5' })]
);
},
};
const MailIcon = {
render() {
return h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'currentColor' }, [
h('path', {
d: 'M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 4-8 5L4 8V6l8 5 8-5z',
}),
]);
},
};
const template = `
{{ feedTitle }}
{{ videos.length }} 条 · {{ sourceLabel }}