效果图:

哔哩哔哩仅显示关注的。
// ==UserScript==
// @name Bilibili 首页仅显示关注 UP 主
// @namespace https://www.bilibili.com/
// @version 3.0.5
// @description 多分组同时筛选 + 已看半透明标记 + 视频统计面板;用关注 UP 主最新视频替换首页推荐流;隐藏首页推荐、广告、推广卡片和频道栏;支持最近 N 天、排序、调试面板
// @author ChatGPT
// @match *://www.bilibili.com/*
// @exclude *://www.bilibili.com/video/*
// @exclude *://www.bilibili.com/bangumi/*
// @exclude *://www.bilibili.com/read/*
// @exclude *://www.bilibili.com/list/*
// @exclude *://www.bilibili.com/account/*
// @exclude *://www.bilibili.com/search*
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_NAME = '[B站首页关注视频]';
const CONFIG = {
containerId: 'following-videos-container',
styleId: 'following-videos-style',
bodyClass: 'bfv-following-only-active',
navApi: 'https://api.bilibili.com/x/web-interface/nav',
dynamicApi: 'https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all',
tagApi: 'https://api.bilibili.com/x/relation/tags',
tagUsersApi: 'https://api.bilibili.com/x/relation/tag',
scrollThreshold: 900,
minLoadInterval: 700,
mountRetryInterval: 300,
watchdogInterval: 1000,
retryTimes: 1,
retryDelay: 800,
// 已看记录保留天数
watchedRetainDays: 30,
// 顶栏分组标签最多显示几个分组名,超出显示「等 N 个」,避免分组过多时顶栏被撑爆
tagLabelMaxNames: 2,
storageKeys: {
recentDays: 'bfv_recent_days',
debugOpen: 'bfv_debug_open',
sortMode: 'bfv_sort_mode',
selectedTagIds: 'bfv3_selected_tag_ids',
watchedBvids: 'bfv3_watched_bvids',
statsOpen: 'bfv3_stats_open',
},
};
const state = {
routeToken: 0,
currentUrl: location.href,
observer: null,
watchdogTimer: null,
initializing: false,
loading: false,
hasMore: true,
offset: '',
lastLoadTime: 0,
videoMap: new Map(),
parsedCount: 0,
filteredOutCount: 0,
duplicateCount: 0,
started: false,
firstLoadFinished: false,
recentDays: getInitialRecentDays(),
sortMode: getInitialSortMode(),
selectedTagIds: getInitialSelectedTagIds(),
tagList: [],
tagMidCache: {},
loadingTagIds: new Set(),
// 已看视频
watchedBvids: loadWatchedBvids(),
// 统计面板
showStatsPanel: localStorage.getItem(CONFIG.storageKeys.statsOpen) === '1',
debugOpen: localStorage.getItem(CONFIG.storageKeys.debugOpen) === '1',
loginStatus: '未知',
loginMid: '',
loginName: '',
loginFace: '',
lastApiUrl: '',
lastApiStatus: '',
lastApiCode: '',
lastApiMessage: '',
lastApiError: '',
lastFetchAt: '',
lastSuccessAt: '',
lastResponseItemCount: 0,
lastParsedVideoCount: 0,
};
// ==================== 初始化辅助函数 ====================
function getInitialRecentDays() {
const raw = localStorage.getItem(CONFIG.storageKeys.recentDays);
const n = Number(raw);
if ([0, 1, 3, 7, 14, 30, 90].includes(n)) return n;
return 0;
}
function getInitialSortMode() {
const raw = localStorage.getItem(CONFIG.storageKeys.sortMode);
if (['time_desc', 'play_desc', 'danmaku_desc'].includes(raw)) return raw;
return 'time_desc';
}
function getInitialSelectedTagIds() {
const raw = localStorage.getItem(CONFIG.storageKeys.selectedTagIds);
if (raw) {
try {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) return arr.filter(function (n) { return Number.isFinite(n); });
} catch { /* ignore */ }
}
// 迁移旧版单选格式
var oldRaw = localStorage.getItem('bfv_selected_tag_id');
if (oldRaw) {
var n = Number(oldRaw);
if (n && n !== 0) return [n];
}
return []; // 空 = 全部
}
function loadWatchedBvids() {
try {
var raw = localStorage.getItem(CONFIG.storageKeys.watchedBvids);
if (!raw) return {};
var data = JSON.parse(raw);
if (typeof data !== 'object' || !data) return {};
// 清理过期条目
var cutoff = Date.now() - CONFIG.watchedRetainDays * 24 * 60 * 60 * 1000;
var cleaned = {};
var hasExpired = false;
Object.keys(data).forEach(function (bvid) {
if (typeof data[bvid] === 'number' && data[bvid] > cutoff) {
cleaned[bvid] = data[bvid];
} else {
hasExpired = true;
}
});
if (hasExpired) {
localStorage.setItem(CONFIG.storageKeys.watchedBvids, JSON.stringify(cleaned));
}
return cleaned;
} catch {
return {};
}
}
function saveWatchedBvids() {
try {
localStorage.setItem(CONFIG.storageKeys.watchedBvids, JSON.stringify(state.watchedBvids));
} catch { /* storage full, ignore */ }
}
function markAsWatched(bvid) {
if (!bvid) return;
state.watchedBvids[bvid] = Date.now();
saveWatchedBvids();
// 即时更新 DOM
var cards = document.querySelectorAll('.bfv-card[data-bvid="' + bvid + '"]');
cards.forEach(function (card) { card.classList.add('bfv-card--watched'); });
}
function clearAllWatched() {
state.watchedBvids = {};
saveWatchedBvids();
var cards = document.querySelectorAll('.bfv-card--watched');
cards.forEach(function (card) { card.classList.remove('bfv-card--watched'); });
updateStatsPanel();
}
function isBiliHomePage() {
var path = location.pathname;
return path === '/' || path === '/index.html' || path === '';
}
/********************************************************************
* 1. 路由监听
********************************************************************/
function patchHistoryEvents() {
try {
var rawPushState = history.pushState;
var rawReplaceState = history.replaceState;
history.pushState = function () {
var ret = rawPushState.apply(this, arguments);
window.dispatchEvent(new Event('bfv-url-change'));
return ret;
};
history.replaceState = function () {
var ret = rawReplaceState.apply(this, arguments);
window.dispatchEvent(new Event('bfv-url-change'));
return ret;
};
window.addEventListener('popstate', function () {
window.dispatchEvent(new Event('bfv-url-change'));
});
window.addEventListener('hashchange', function () {
window.dispatchEvent(new Event('bfv-url-change'));
});
window.addEventListener(
'bfv-url-change',
debounce(function () {
if (state.currentUrl !== location.href) {
state.currentUrl = location.href;
resetAndRun();
}
}, 150),
);
} catch (err) {
console.warn(SCRIPT_NAME + ' 路由监听安装失败:', err);
}
}
patchHistoryEvents();
/********************************************************************
* 2. 样式
********************************************************************/
function injectStyle() {
if (document.getElementById(CONFIG.styleId)) return;
var style = document.createElement('style');
style.id = CONFIG.styleId;
style.textContent = [
/* ====== 防刷新闪现:document-start 即隐藏首页原生内容,不依赖 body 类 ====== */
'.recommended-container,.recommended-swipe,.bili-feed4,.bili-feed4-layout,',
'.feed-card,.bili-video-card,.video-card-container,.adcard,.bili-ad,',
'.adblock-tips',
'{display:none!important;visibility:hidden!important;pointer-events:none!important}',
/* ====== 隐藏推荐流/广告/推广 ====== */
'body.' + CONFIG.bodyClass + ' .header-channel,',
'body.' + CONFIG.bodyClass + ' .header-channel-fixed,',
'body.' + CONFIG.bodyClass + ' .bili-header__channel,',
'body.' + CONFIG.bodyClass + ' .channel-icons,',
'body.' + CONFIG.bodyClass + ' .recommended-container,',
'body.' + CONFIG.bodyClass + ' .recommended-swipe,',
'body.' + CONFIG.bodyClass + ' .recommended-container_floor-aside,',
'body.' + CONFIG.bodyClass + ' .feed-roll-btn,',
'body.' + CONFIG.bodyClass + ' .roll-btn,',
'body.' + CONFIG.bodyClass + ' .feed-card,',
'body.' + CONFIG.bodyClass + ' .bili-video-card,',
'body.' + CONFIG.bodyClass + ' .video-card-container,',
'body.' + CONFIG.bodyClass + ' .floor-single-card,',
'body.' + CONFIG.bodyClass + ' .floor-card,',
'body.' + CONFIG.bodyClass + ' .adcard,',
'body.' + CONFIG.bodyClass + ' .bili-ad,',
'body.' + CONFIG.bodyClass + ' .banner-card,',
'body.' + CONFIG.bodyClass + ' .eva-extension-area,',
'body.' + CONFIG.bodyClass + ' .rcmd-card,',
'body.' + CONFIG.bodyClass + ' .rcmd-box,',
'body.' + CONFIG.bodyClass + ' .palette-button-outer,',
'body.' + CONFIG.bodyClass + ' .palette-button-wrap,',
'body.' + CONFIG.bodyClass + ' .palette-button-inner,',
'body.' + CONFIG.bodyClass + ' .storage-box,',
'body.' + CONFIG.bodyClass + ' .storable-items',
'{display:none!important;visibility:hidden!important;pointer-events:none!important}',
/* ====== 隐藏 bilibili 插件检测提示条(.adblock-tips) ====== */
'body.' + CONFIG.bodyClass + ' .adblock-tips',
'{display:none!important;visibility:hidden!important;pointer-events:none!important}',
/* 宽泛选择器限定 feed 区域 */
'body.' + CONFIG.bodyClass + ' main [data-loc-id],',
'body.' + CONFIG.bodyClass + ' .recommended-container [data-loc-id],',
'body.' + CONFIG.bodyClass + ' .recommended-swipe [data-loc-id],',
'body.' + CONFIG.bodyClass + ' .bili-feed4 [data-loc-id],',
'body.' + CONFIG.bodyClass + ' main [data-ad],',
'body.' + CONFIG.bodyClass + ' .recommended-container [data-ad],',
'body.' + CONFIG.bodyClass + ' main [class*="ad-card"],',
'body.' + CONFIG.bodyClass + ' main [class*="advert"]',
'{display:none!important;visibility:hidden!important;pointer-events:none!important}',
/* 保护头部导航 */
'body.' + CONFIG.bodyClass + ' .bili-header,',
'body.' + CONFIG.bodyClass + ' .bili-header *,',
'body.' + CONFIG.bodyClass + ' .bili-header__bar,',
'body.' + CONFIG.bodyClass + ' .bili-header__bar *,',
'body.' + CONFIG.bodyClass + ' #internationalHeader,',
'body.' + CONFIG.bodyClass + ' #internationalHeader *',
'{visibility:visible!important;pointer-events:auto!important}',
/* 我们的容器 */
'body.' + CONFIG.bodyClass + ' #' + CONFIG.containerId + ',',
'body.' + CONFIG.bodyClass + ' #' + CONFIG.containerId + ' *',
'{visibility:visible!important;pointer-events:auto!important}',
'body.' + CONFIG.bodyClass + ' #' + CONFIG.containerId,
'{display:block!important}',
/* ====== 容器基础布局 ====== */
'#' + CONFIG.containerId,
'{box-sizing:border-box;width:min(100%,1680px);margin:24px auto 48px;padding:0 36px;',
'color:#18191c;font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Helvetica,Arial,"Microsoft YaHei",sans-serif;',
'position:relative;z-index:10}',
/* ====== 顶栏 ====== */
'.bfv-topbar',
'{display:flex!important;align-items:center;justify-content:space-between;gap:12px;',
'flex-wrap:wrap;min-height:44px;margin-bottom:12px;padding:12px 16px;border-radius:10px;',
'background:linear-gradient(90deg,rgba(0,174,236,.12),rgba(251,114,153,.10));',
'border:1px solid rgba(0,174,236,.18);color:#18191c;font-size:15px}',
'.bfv-topbar-main{min-width:0;flex:0 1 auto}',
'.bfv-topbar-icon{font-size:22px;line-height:1;cursor:help}',
'.bfv-avatar',
'{display:inline-flex!important;align-items:center;justify-content:center;',
'width:34px;height:34px;border-radius:50%;overflow:hidden;flex-shrink:0;',
'background:#e3e5e7;border:1px solid rgba(0,174,236,.25);cursor:pointer;',
'text-decoration:none;transition:box-shadow .15s}',
'.bfv-avatar:hover{box-shadow:0 0 0 3px rgba(0,174,236,.18)}',
'.bfv-avatar img{display:block;width:100%;height:100%;object-fit:cover;border-radius:50%}',
'.bfv-tools',
'{display:flex!important;align-items:center;justify-content:flex-end;gap:10px;flex-wrap:wrap;',
'flex:1 1 auto;min-width:0}',
'.bfv-count,.bfv-filter',
'{display:inline-flex!important;align-items:center;height:30px;padding:0 10px;',
'border-radius:15px;background:rgba(255,255,255,.78);color:#61666d;font-size:13px;white-space:nowrap}',
/* 计数文案可能很长,允许收缩并省略,避免把同排控件挤出容器 */
'.bfv-count',
'{display:inline-block!important;line-height:30px;max-width:100%;min-width:0;',
'overflow:hidden;text-overflow:ellipsis;vertical-align:middle}',
'.bfv-filter{gap:6px;padding-right:8px}',
'.bfv-filter select',
'{height:24px;border:1px solid #dcdfe6;border-radius:6px;background:#fff;color:#18191c;font-size:13px;outline:none;cursor:pointer}',
/* ====== 分组多选下拉面板 ====== */
'.bfv-tag-dropdown{position:relative;display:inline-flex;min-width:0;max-width:100%}',
'.bfv-tag-toggle{cursor:pointer;user-select:none;gap:4px;min-width:0;max-width:100%}',
/* 分组名过长时截断,避免顶栏被撑爆 */
'#bfv-tag-label',
'{display:inline-block!important;min-width:0;max-width:150px;',
'overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.bfv-tag-toggle:hover{background:rgba(0,174,236,.12)}',
'.bfv-tag-arrow{font-size:10px;margin-left:2px;transition:transform .2s}',
'.bfv-tag-toggle.open .bfv-tag-arrow{transform:rotate(180deg)}',
'.bfv-tag-panel',
'{position:absolute;top:100%;right:0;margin-top:6px;min-width:200px;max-height:360px;',
'background:#fff;border:1px solid #e3e5e7;border-radius:10px;',
'box-shadow:0 6px 24px rgba(0,0,0,.12);z-index:1000;',
'display:none;flex-direction:column}',
'.bfv-tag-panel.open{display:flex}',
'.bfv-tag-options{max-height:260px;overflow-y:auto;padding:6px 0}',
'.bfv-tag-option',
'{display:flex;align-items:center;gap:8px;padding:8px 16px;cursor:pointer;',
'font-size:13px;color:#18191c;transition:background .12s}',
'.bfv-tag-option:hover{background:#f6f7f8}',
'.bfv-tag-option input[type="checkbox"]{accent-color:#00aeec;flex-shrink:0}',
'.bfv-tag-option .bfv-tag-count{color:#9499a0;font-size:12px;margin-left:auto}',
'.bfv-tag-actions',
'{display:flex;gap:6px;padding:8px 12px;border-top:1px solid #f1f2f3;}',
'.bfv-tag-actions .bfv-tiny-btn',
'{flex:1;height:28px;border:1px solid #dcdfe6;border-radius:6px;background:#fff;color:#61666d;',
'font-size:12px;cursor:pointer;text-align:center}',
'.bfv-tag-actions .bfv-tiny-btn:hover{background:#f6f7f8;color:#18191c}',
/* ====== 已看标记 ====== */
'.bfv-card--watched{opacity:.4}',
'.bfv-card--watched:hover{opacity:.65}',
'.bfv-card--watched .bfv-cover-wrap::after',
'{content:"✓ 已看";position:absolute;top:6px;left:6px;padding:2px 8px;border-radius:4px;',
'background:rgba(0,0,0,.65);color:#fff;font-size:11px;z-index:2}',
/* ====== 统计面板 ====== */
'.bfv-stats-panel',
'{display:none;box-sizing:border-box;margin:0 0 16px;padding:14px 16px;border-radius:10px;',
'border:1px solid rgba(251,114,153,.22);background:rgba(255,248,250,.96);',
'color:#18191c;font-size:13px;line-height:1.7}',
'.bfv-stats-panel.active{display:block}',
'.bfv-stats-title',
'{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;font-weight:600}',
'.bfv-stats-summary',
'{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid rgba(251,114,153,.12)}',
'.bfv-stats-item',
'{display:flex;align-items:center;gap:4px;font-size:14px}',
'.bfv-stats-item .bfv-stats-num{font-weight:600;color:#00aeec}',
'.bfv-stats-table{width:100%;border-collapse:collapse;font-size:12px}',
'.bfv-stats-table th',
'{text-align:left;padding:4px 8px;color:#9499a0;font-weight:400;border-bottom:1px solid #f1f2f3}',
'.bfv-stats-table td{padding:5px 8px;border-bottom:1px solid #fafafa}',
'.bfv-stats-table .bfv-stats-bar-wrap{width:60px;height:6px;background:#f1f2f3;border-radius:3px;display:inline-block;vertical-align:middle;margin-left:6px}',
'.bfv-stats-table .bfv-stats-bar{height:100%;background:#00aeec;border-radius:3px;min-width:2px}',
/* ====== 小按钮 ====== */
'.bfv-small-btn',
'{height:30px;padding:0 12px;border:1px solid rgba(0,174,236,.28);border-radius:15px;',
'background:rgba(255,255,255,.78);color:#00aeec;font-size:13px;cursor:pointer;white-space:nowrap}',
'.bfv-small-btn:hover{background:#e6f7ff;border-color:#00aeec}',
'.bfv-close-tip',
'{border:none;background:transparent;color:#9499a0;cursor:pointer;font-size:18px;padding:2px 8px}',
'.bfv-close-tip:hover{color:#18191c}',
/* ====== 调试面板 ====== */
'.bfv-debug-panel',
'{display:none;box-sizing:border-box;margin:0 0 16px;padding:12px 14px;border-radius:10px;',
'border:1px solid rgba(0,174,236,.22);background:rgba(246,248,250,.96);',
'color:#18191c;font-size:13px;line-height:1.6}',
'.bfv-debug-panel.active{display:block}',
'.bfv-debug-title',
'{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px;font-weight:600}',
'.bfv-debug-grid',
'{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px 14px}',
'.bfv-debug-item',
'{min-width:0;padding:6px 8px;border-radius:8px;background:#fff;border:1px solid #eef0f2}',
'.bfv-debug-label{color:#9499a0;font-size:12px}',
'.bfv-debug-value',
'{margin-top:2px;color:#18191c;overflow-wrap:anywhere;word-break:break-all;',
'font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}',
'.bfv-debug-error{color:#f85a54}',
/* ====== 提示消息 ====== */
'.bfv-alert',
'{box-sizing:border-box;width:100%;margin-bottom:16px;padding:12px 16px;border-radius:8px;font-size:14px;line-height:1.6}',
'.bfv-alert-error{color:#fff;background:#f85a54}',
'.bfv-alert-warning{color:#7a4b00;background:#fff1d6;border:1px solid #ffd591}',
'.bfv-alert-info{color:#006280;background:#e6f7ff;border:1px solid #91d5ff}',
/* ====== 视频网格 ====== */
'.bfv-grid',
'{display:grid!important;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:22px 18px}',
/* ====== 视频卡片 ====== */
'.bfv-card',
'{display:block!important;min-width:0;color:#18191c;text-decoration:none;border-radius:8px;transition:transform .18s ease,opacity .25s ease}',
'.bfv-card:hover{transform:translateY(-3px)}',
'.bfv-cover-wrap',
'{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:8px;background:#f1f2f3}',
'.bfv-cover',
'{display:block!important;width:100%;height:100%;object-fit:cover;transition:transform .22s ease;background:#f1f2f3}',
'.bfv-card:hover .bfv-cover{transform:scale(1.045)}',
'.bfv-stat-bar',
'{position:absolute;left:0;right:0;bottom:0;display:flex!important;align-items:center;',
'justify-content:space-between;gap:8px;box-sizing:border-box;padding:18px 8px 6px;',
'color:#fff;font-size:12px;line-height:1;',
'background:linear-gradient(to top,rgba(0,0,0,.62),rgba(0,0,0,0));text-shadow:0 1px 2px rgba(0,0,0,.35)}',
'.bfv-stat-left{display:flex!important;align-items:center;gap:8px;min-width:0}',
'.bfv-title',
'{display:-webkit-box!important;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;',
'min-height:42px;margin-top:10px;color:#18191c;font-size:15px;line-height:21px;font-weight:500;word-break:break-all}',
'.bfv-card:hover .bfv-title{color:#00aeec}',
'.bfv-meta',
'{display:flex!important;align-items:center;gap:6px;margin-top:7px;color:#9499a0;font-size:13px;line-height:18px;min-width:0}',
'.bfv-up-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
/* ====== 骨架屏 ====== */
'.bfv-loading,.bfv-end',
'{box-sizing:border-box;width:100%;margin:22px 0;padding:14px;text-align:center;color:#9499a0;font-size:14px}',
'.bfv-skeleton-cover,.bfv-skeleton-line',
'{background:linear-gradient(90deg,#f1f2f3 25%,#e3e5e7 37%,#f1f2f3 63%);background-size:400% 100%;animation:bfvSkeleton 1.3s ease infinite}',
'.bfv-skeleton-cover{width:100%;aspect-ratio:16/9;border-radius:8px}',
'.bfv-skeleton-line{height:14px;border-radius:7px;margin-top:10px}',
'.bfv-skeleton-line.short{width:58%}',
'@keyframes bfvSkeleton{0%{background-position:100% 50%}100%{background-position:0 50%}}',
/* ====== 响应式 ====== */
'@media (min-width:1400px){.bfv-grid{grid-template-columns:repeat(5,minmax(0,1fr))}}',
'@media (max-width:1100px){',
'.bfv-grid{grid-template-columns:repeat(3,minmax(0,1fr))}',
'.bfv-debug-grid{grid-template-columns:repeat(2,minmax(0,1fr))}',
'}',
'@media (max-width:760px){',
'#' + CONFIG.containerId + '{margin-top:16px;padding:0 12px}',
'.bfv-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px 12px}',
'.bfv-topbar{align-items:center;font-size:14px}',
'.bfv-tools{justify-content:flex-start}',
'.bfv-debug-grid{grid-template-columns:1fr}',
'.bfv-tag-panel{right:auto;left:0}',
'.bfv-stats-summary{gap:8px}',
'}',
].join('');
if (document.head) {
document.head.appendChild(style);
} else if (document.documentElement) {
document.documentElement.appendChild(style);
}
}
/********************************************************************
* 3. DOM
********************************************************************/
var ORIGINAL_RECOMMEND_SELECTORS = [
'.header-channel', '.header-channel-fixed', '.bili-header__channel', '.channel-icons',
'.recommended-container', '.recommended-swipe', '.recommended-container_floor-aside',
'.feed-roll-btn', '.roll-btn', '.feed-card', '.bili-video-card', '.video-card-container',
'.floor-single-card', '.floor-card', '.adcard', '.bili-ad', '.banner-card',
'.eva-extension-area', '.rcmd-card', '.rcmd-box',
'.palette-button-outer', '.palette-button-wrap', '.palette-button-inner',
'.storage-box', '.storable-items',
'.adblock-tips',
'[data-loc-id]', '[data-ad]', '[class*="ad-card"]', '[class*="advert"]',
];
function ensureBodyClass() {
if (!isBiliHomePage()) return;
if (document.body) document.body.classList.add(CONFIG.bodyClass);
}
function removeBodyClass() {
if (document.body) document.body.classList.remove(CONFIG.bodyClass);
}
function createContainer() {
if (!isBiliHomePage()) return null;
if (!document.body) return null;
var existing = document.getElementById(CONFIG.containerId);
if (existing) return existing;
var container = document.createElement('section');
container.id = CONFIG.containerId;
container.innerHTML = [
'<div class="bfv-topbar" id="bfv-topbar">',
' <div class="bfv-topbar-main">',
' <span class="bfv-topbar-icon" id="bfv-topbar-icon" title="仅显示我关注的 UP 主最新视频 · 已屏蔽首页推荐、广告、推广卡片和频道栏">📺</span>',
' </div>',
' <div class="bfv-tools">',
' <a class="bfv-avatar" id="bfv-avatar" href="https://space.bilibili.com/" title="个人中心" target="_self">',
' <img id="bfv-avatar-img" src="" alt="头像">',
' </a>',
' <span class="bfv-count" id="bfv-count">已显示 0 个</span>',
// ====== 分组多选下拉 ======
' <div class="bfv-tag-dropdown" id="bfv-tag-dropdown">',
' <button class="bfv-filter bfv-tag-toggle" id="bfv-tag-toggle" title="按关注分组筛选(可多选)">',
' <span>分组</span>',
' <span id="bfv-tag-label">全部关注</span>',
' <span class="bfv-tag-arrow">▾</span>',
' </button>',
' <div class="bfv-tag-panel" id="bfv-tag-panel">',
' <div class="bfv-tag-options" id="bfv-tag-options"></div>',
' <div class="bfv-tag-actions">',
' <button class="bfv-tiny-btn" id="bfv-tag-select-all">全选</button>',
' <button class="bfv-tiny-btn" id="bfv-tag-deselect-all">清空</button>',
' </div>',
' </div>',
' </div>',
' <label class="bfv-filter" title="只显示指定时间范围内发布的视频">',
' <span>时间范围</span>',
' <select id="bfv-recent-days">',
' <option value="0">全部</option><option value="1">1 天内</option><option value="3">3 天内</option>',
' <option value="7">7 天内</option><option value="14">14 天内</option>',
' <option value="30">30 天内</option><option value="90">90 天内</option>',
' </select>',
' </label>',
' <label class="bfv-filter" title="对当前已加载的视频排序">',
' <span>排序</span>',
' <select id="bfv-sort-mode">',
' <option value="time_desc">最新发布</option>',
' <option value="play_desc">播放量最高</option>',
' <option value="danmaku_desc">弹幕最多</option>',
' </select>',
' </label>',
' <button class="bfv-small-btn" id="bfv-stats-toggle" title="显示视频统计面板">统计</button>',
' <button class="bfv-small-btn" id="bfv-clear-watched" title="清除所有已看记录">清除已看</button>',
' <button class="bfv-small-btn" id="bfv-debug-toggle" title="显示接口与脚本状态">调试</button>',
' <button class="bfv-close-tip" id="bfv-close-tip" title="隐藏说明">×</button>',
' </div>',
'</div>',
'<div id="bfv-stats-panel" class="bfv-stats-panel"></div>',
'<div id="bfv-debug-panel" class="bfv-debug-panel"></div>',
'<div id="bfv-message"></div>',
'<div id="bfv-grid" class="bfv-grid"></div>',
'<div id="bfv-loading" class="bfv-loading" style="display:none;">正在加载关注视频...</div>',
'<div id="bfv-end" class="bfv-end" style="display:none;">没有更多关注视频了</div>',
].join('');
var anchor = findStableMountAnchor();
if (anchor && anchor.parentNode) {
anchor.parentNode.insertBefore(container, anchor);
} else {
document.body.appendChild(container);
}
// 关闭顶栏
var closeBtn = container.querySelector('#bfv-close-tip');
var topbar = container.querySelector('#bfv-topbar');
if (closeBtn && topbar) {
closeBtn.addEventListener('click', function () { topbar.style.display = 'none'; });
}
// ====== 分组多选下拉事件 ======
setupTagDropdown(container);
// 时间范围
var recentSelect = container.querySelector('#bfv-recent-days');
if (recentSelect) {
recentSelect.value = String(state.recentDays);
recentSelect.addEventListener('change', function () {
state.recentDays = Number(recentSelect.value) || 0;
localStorage.setItem(CONFIG.storageKeys.recentDays, String(state.recentDays));
reloadForFilterChange();
});
}
// 排序
var sortSelect = container.querySelector('#bfv-sort-mode');
if (sortSelect) {
sortSelect.value = state.sortMode;
sortSelect.addEventListener('change', function () {
var v = sortSelect.value;
if (['time_desc', 'play_desc', 'danmaku_desc'].indexOf(v) !== -1) state.sortMode = v;
else state.sortMode = 'time_desc';
localStorage.setItem(CONFIG.storageKeys.sortMode, state.sortMode);
renderAllVideos();
updateDebugPanel();
updateStatsPanel();
});
}
// 统计面板
var statsToggle = container.querySelector('#bfv-stats-toggle');
var statsPanel = container.querySelector('#bfv-stats-panel');
if (statsPanel && state.showStatsPanel) statsPanel.classList.add('active');
if (statsToggle && statsPanel) {
statsToggle.textContent = state.showStatsPanel ? '隐藏统计' : '统计';
statsToggle.addEventListener('click', function () {
state.showStatsPanel = !state.showStatsPanel;
localStorage.setItem(CONFIG.storageKeys.statsOpen, state.showStatsPanel ? '1' : '0');
statsPanel.classList.toggle('active', state.showStatsPanel);
statsToggle.textContent = state.showStatsPanel ? '隐藏统计' : '统计';
updateStatsPanel();
});
}
// 清除已看
var clearWatchedBtn = container.querySelector('#bfv-clear-watched');
if (clearWatchedBtn) {
clearWatchedBtn.addEventListener('click', function () {
if (confirm('确定要清除所有已看记录吗?(共 ' + Object.keys(state.watchedBvids).length + ' 条)')) {
clearAllWatched();
}
});
}
// 调试面板
var debugToggle = container.querySelector('#bfv-debug-toggle');
var debugPanel = container.querySelector('#bfv-debug-panel');
if (debugPanel && state.debugOpen) debugPanel.classList.add('active');
if (debugToggle && debugPanel) {
debugToggle.textContent = state.debugOpen ? '隐藏调试' : '调试';
debugToggle.addEventListener('click', function () {
state.debugOpen = !state.debugOpen;
localStorage.setItem(CONFIG.storageKeys.debugOpen, state.debugOpen ? '1' : '0');
debugPanel.classList.toggle('active', state.debugOpen);
debugToggle.textContent = state.debugOpen ? '隐藏调试' : '调试';
updateDebugPanel();
});
}
updateCount();
updateDebugPanel();
updateStatsPanel();
updateAvatar();
return container;
}
function setupTagDropdown(container) {
var toggle = container.querySelector('#bfv-tag-toggle');
var panel = container.querySelector('#bfv-tag-panel');
var optionsBox = container.querySelector('#bfv-tag-options');
var selectAllBtn = container.querySelector('#bfv-tag-select-all');
var deselectAllBtn = container.querySelector('#bfv-tag-deselect-all');
if (!toggle || !panel || !optionsBox) return;
// 点击外部关闭面板
document.addEventListener('click', function (e) {
if (!container.contains(e.target)) {
panel.classList.remove('open');
toggle.classList.remove('open');
}
}, true);
toggle.addEventListener('click', function (e) {
e.stopPropagation();
var isOpen = panel.classList.toggle('open');
toggle.classList.toggle('open', isOpen);
if (isOpen) populateTagCheckboxes();
});
// 全选
if (selectAllBtn) {
selectAllBtn.addEventListener('click', function (e) {
e.stopPropagation();
var tagIds = state.tagList.map(function (t) { return t.tagid; }).filter(function (id) { return id !== 0; });
state.selectedTagIds = tagIds;
saveSelectedTagIds();
populateTagCheckboxes();
updateTagLabel();
reloadForFilterChange();
});
}
// 清空
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', function (e) {
e.stopPropagation();
state.selectedTagIds = [];
saveSelectedTagIds();
populateTagCheckboxes();
updateTagLabel();
reloadForFilterChange();
});
}
}
function populateTagCheckboxes() {
var optionsBox = document.getElementById('bfv-tag-options');
if (!optionsBox) return;
var html = '';
var selected = state.selectedTagIds;
for (var i = 0; i < state.tagList.length; i++) {
var tag = state.tagList[i];
// 跳过 默认分组(tagid=0) 且里面无用户的情况
if (tag.tagid === 0 && tag.count === 0) continue;
var checked = selected.length === 0 || selected.indexOf(tag.tagid) !== -1 ? ' checked' : '';
var name = escapeHtml(tag.name);
html += '<label class="bfv-tag-option">' +
'<input type="checkbox" value="' + tag.tagid + '"' + checked + ' data-tagid="' + tag.tagid + '">' +
'<span>' + name + '</span>' +
'<span class="bfv-tag-count">' + (tag.count || 0) + '</span>' +
'</label>';
}
optionsBox.innerHTML = html;
// 绑定 checkbox 事件
var checkboxes = optionsBox.querySelectorAll('input[type="checkbox"]');
for (var j = 0; j < checkboxes.length; j++) {
checkboxes[j].addEventListener('change', function () {
handleTagCheckboxChange();
});
}
}
function handleTagCheckboxChange() {
var optionsBox = document.getElementById('bfv-tag-options');
if (!optionsBox) return;
var checkboxes = optionsBox.querySelectorAll('input[type="checkbox"]');
var ids = [];
for (var i = 0; i < checkboxes.length; i++) {
if (checkboxes[i].checked) {
ids.push(Number(checkboxes[i].getAttribute('data-tagid')));
}
}
state.selectedTagIds = ids;
saveSelectedTagIds();
updateTagLabel();
reloadForFilterChange();
}
function saveSelectedTagIds() {
localStorage.setItem(CONFIG.storageKeys.selectedTagIds, JSON.stringify(state.selectedTagIds));
}
function updateTagLabel() {
var label = document.getElementById('bfv-tag-label');
if (!label) return;
label.textContent = getSelectedTagLabel();
label.title = '已选分组:' + getSelectedTagFullLabel();
}
function findTagById(tagid) {
for (var i = 0; i < state.tagList.length; i++) {
if (state.tagList[i].tagid === tagid) return state.tagList[i];
}
return null;
}
function getSelectedTagNames() {
var ids = state.selectedTagIds;
var names = [];
if (!ids || ids.length === 0) return names;
for (var i = 0; i < ids.length; i++) {
var tag = findTagById(ids[i]);
if (tag) names.push(tag.name);
}
return names;
}
/** 完整分组名列表(用于 title 提示) */
function getSelectedTagFullLabel() {
var names = getSelectedTagNames();
return names.length > 0 ? names.join('+') : '全部关注';
}
/**
* 顶栏展示用的短标签:分组过多时只保留前 N 个 + 「等 N 个」,
* 否则长串会被同时写进「分组」按钮和计数区,把整排控件挤出容器。
*/
function getSelectedTagLabel() {
var names = getSelectedTagNames();
if (names.length === 0) return '全部关注';
var max = CONFIG.tagLabelMaxNames;
if (names.length <= max) return names.join('+');
return names.slice(0, max).join('+') + ' 等' + names.length + '个';
}
function findStableMountAnchor() {
var selectors = ['.recommended-container', '.bili-feed4', '.feed2', '.container.is-version8', 'main', '#app'];
for (var i = 0; i < selectors.length; i++) {
var el = document.querySelector(selectors[i]);
if (el && el.parentNode && !el.closest('#' + CONFIG.containerId)) return el;
}
return null;
}
function ensureContainerExists() {
if (!isBiliHomePage()) return null;
var container = document.getElementById(CONFIG.containerId);
if (!container || !document.body.contains(container)) container = createContainer();
return container;
}
function hideOriginalRecommendationDom(root) {
if (!isBiliHomePage()) return;
if (!root) return;
var container = document.getElementById(CONFIG.containerId);
if (!container) return;
try {
if (root instanceof Element && matchesAny(root, ORIGINAL_RECOMMEND_SELECTORS)) {
if (!root.closest('#' + CONFIG.containerId) && !isInsideHeader(root)) {
hideNode(root);
}
}
if (!root.querySelectorAll) return;
for (var i = 0; i < ORIGINAL_RECOMMEND_SELECTORS.length; i++) {
(function (sel) {
var nodes = root.querySelectorAll(sel);
nodes.forEach(function (node) {
if (!(node instanceof Element)) return;
if (node.closest('#' + CONFIG.containerId)) return;
if (isInsideHeader(node)) return;
hideNode(node);
});
})(ORIGINAL_RECOMMEND_SELECTORS[i]);
}
} catch (err) {
console.warn(SCRIPT_NAME + ' 隐藏原推荐 DOM 失败:', err);
}
}
function isInsideHeader(el) {
try {
return !!(
el.closest('.bili-header') || el.closest('.bili-header__bar') ||
el.closest('#internationalHeader') || el.closest('.right-entry') ||
el.closest('.left-entry') || el.closest('.header-entry-mini') ||
el.closest('.bili-header-m')
);
} catch { return false; }
}
function hideNode(node) {
node.style.setProperty('display', 'none', 'important');
node.style.setProperty('visibility', 'hidden', 'important');
node.style.setProperty('pointer-events', 'none', 'important');
}
function installMutationObserver() {
if (!isBiliHomePage()) return;
if (state.observer) return;
state.observer = new MutationObserver(function (mutations) {
if (!isBiliHomePage()) { deactivateScriptOnNonHomePage(); return; }
ensureBodyClass();
ensureContainerExists();
for (var i = 0; i < mutations.length; i++) {
var addedNodes = mutations[i].addedNodes || [];
for (var j = 0; j < addedNodes.length; j++) {
var node = addedNodes[j];
if (!(node instanceof Element)) continue;
if (node.id === CONFIG.containerId || node.closest('#' + CONFIG.containerId)) continue;
hideOriginalRecommendationDom(node);
if (matchesAny(node, ORIGINAL_RECOMMEND_SELECTORS) && !isInsideHeader(node)) {
hideNode(node);
}
}
}
});
var target = document.documentElement || document.body;
if (target) {
state.observer.observe(target, { childList: true, subtree: true });
}
}
function installWatchdog() {
clearInterval(state.watchdogTimer);
if (!isBiliHomePage()) return;
state.watchdogTimer = setInterval(function () {
if (!isBiliHomePage()) { deactivateScriptOnNonHomePage(); return; }
var container = ensureContainerExists();
if (!container) return;
ensureBodyClass();
updateCount();
updateDebugPanel();
updateStatsPanel();
var grid = document.getElementById('bfv-grid');
if (grid && grid.children.length === 0 && !state.loading && !state.initializing && !state.firstLoadFinished) {
initialLoad(state.routeToken);
}
hideOriginalRecommendationDom();
}, CONFIG.watchdogInterval);
}
function matchesAny(el, selectors) {
try {
for (var i = 0; i < selectors.length; i++) {
if (el.matches(selectors[i])) return true;
}
return false;
} catch { return false; }
}
/********************************************************************
* 4. API
********************************************************************/
function updateAvatar() {
var avatar = document.getElementById('bfv-avatar');
var img = document.getElementById('bfv-avatar-img');
if (!avatar || !img) return;
img.src = state.loginFace || '';
img.alt = state.loginName || '头像';
avatar.href = 'https://space.bilibili.com/' + (state.loginMid ? state.loginMid + '/' : '');
}
async function checkLogin() {
try {
state.loginStatus = '检查中';
updateDebugPanel();
var res = await fetch(CONFIG.navApi, { method: 'GET', credentials: 'include', cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
var json = await res.json();
if (json.code !== 0) {
state.loginStatus = '异常'; state.loginMid = ''; state.loginName = ''; state.loginFace = '';
updateDebugPanel();
return { ok: false, message: '登录状态检查失败:' + (json.message || json.msg || json.code) };
}
if (!json.data || json.data.isLogin !== true) {
state.loginStatus = '未登录'; state.loginMid = ''; state.loginName = ''; state.loginFace = '';
updateDebugPanel();
return { ok: false, message: '请先登录 B 站以查看关注视频' };
}
state.loginStatus = '已登录';
state.loginMid = String(json.data.mid || '');
state.loginName = String(json.data.uname || '');
state.loginFace = String(json.data.face || '');
updateDebugPanel();
return { ok: true, mid: json.data.mid, uname: json.data.uname };
} catch (err) {
state.loginStatus = '检查失败'; state.loginMid = ''; state.loginName = ''; state.loginFace = '';
updateDebugPanel();
return { ok: false, message: '登录状态检查失败,请刷新页面或重新登录 B 站' };
}
}
// ==================== 分组 API ====================
async function fetchTagList() {
try {
var res = await fetch(CONFIG.tagApi, { method: 'GET', credentials: 'include', cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
var json = await res.json();
if (json.code !== 0) { console.warn(SCRIPT_NAME + ' 获取关注分组失败:', json.message); return []; }
return Array.isArray(json.data) ? json.data : [];
} catch (err) { console.warn(SCRIPT_NAME + ' 获取关注分组失败:', err); return []; }
}
async function fetchTagUsers(tagid, page) {
page = page || 1;
try {
var url = CONFIG.tagUsersApi + '?tagid=' + tagid + '&pn=' + page + '&ps=50';
var res = await fetch(url, { method: 'GET', credentials: 'include', cache: 'no-store' });
if (!res.ok) throw new Error('HTTP ' + res.status);
var json = await res.json();
if (json.code !== 0) { console.warn(SCRIPT_NAME + ' 获取分组用户失败:', json.message); return null; }
return { users: Array.isArray(json.data) ? json.data : [] };
} catch (err) { console.warn(SCRIPT_NAME + ' 获取分组用户失败:', err); return null; }
}
async function loadAllMidsForTag(tagid) {
if (state.tagMidCache[tagid]) return;
if (state.loadingTagIds.has(tagid)) return;
state.loadingTagIds.add(tagid);
try {
var page = 1, midSet = new Set(), hasMore = true;
while (hasMore) {
var result = await fetchTagUsers(tagid, page);
if (!result) break;
var count = result.users.length;
for (var i = 0; i < result.users.length; i++) {
if (result.users[i].mid) midSet.add(String(result.users[i].mid));
}
hasMore = count >= 50;
page += 1;
if (page > 20) break;
}
state.tagMidCache[tagid] = midSet;
console.debug(SCRIPT_NAME + ' 分组 ' + tagid + ' 加载完成,共 ' + midSet.size + ' 个 UP 主');
} catch (err) {
console.warn(SCRIPT_NAME + ' 加载分组 mids 失败:', err);
} finally {
state.loadingTagIds.delete(tagid);
}
}
async function ensureAllSelectedTagMidsLoaded() {
var ids = state.selectedTagIds;
for (var i = 0; i < ids.length; i++) {
if (!state.tagMidCache[ids[i]]) {
await loadAllMidsForTag(ids[i]);
}
}
}
// ==================== 动态 API ====================
async function fetchFollowingVideosWithRetry() {
var lastErr = null;
for (var i = 0; i <= CONFIG.retryTimes; i++) {
try { return await fetchFollowingVideos(); } catch (err) {
lastErr = err;
state.lastApiError = String(err && err.message ? err.message : err);
updateDebugPanel();
if (i < CONFIG.retryTimes) await sleep(CONFIG.retryDelay);
}
}
throw lastErr;
}
async function fetchFollowingVideos() {
var params = 'type=video&timezone_offset=' + (-new Date().getTimezoneOffset());
if (state.offset) params += '&offset=' + encodeURIComponent(state.offset);
var url = CONFIG.dynamicApi + '?' + params;
state.lastApiUrl = url;
state.lastApiStatus = '请求中'; state.lastApiCode = ''; state.lastApiMessage = ''; state.lastApiError = '';
state.lastFetchAt = formatDateTime(Date.now());
updateDebugPanel();
var res = await fetch(url, {
method: 'GET', credentials: 'include', cache: 'no-store',
headers: { Accept: 'application/json, text/plain, */*' },
});
state.lastApiStatus = String(res.status); updateDebugPanel();
if (!res.ok) throw new Error('关注动态接口 HTTP ' + res.status);
var json = await res.json();
state.lastApiCode = String(json.code); state.lastApiMessage = String(json.message || json.msg || ''); updateDebugPanel();
if (json.code !== 0) throw new Error(json.message || json.msg || '关注动态接口 code=' + json.code);
var data = json.data || {};
var rawItems = Array.isArray(data.items) ? data.items : [];
var videos = rawItems.map(parseDynamicVideoItem).filter(Boolean);
state.lastResponseItemCount = rawItems.length;
state.lastParsedVideoCount = videos.length;
state.lastSuccessAt = formatDateTime(Date.now());
state.lastApiError = '';
updateDebugPanel();
return { videos: videos, hasMore: Boolean(data.has_more), offset: data.offset || '' };
}
function parseDynamicVideoItem(item) {
try {
var modules = item.modules || {};
var author = modules.module_author || {};
var dynamic = modules.module_dynamic || {};
var major = dynamic.major || {};
var archive = major.archive || findArchiveDeep(major);
if (!archive) return null;
var bvid = archive.bvid || archive.bv_id || extractBvid(archive.jump_url || (item.basic && item.basic.jump_url));
if (!bvid) return null;
return {
bvid: bvid,
title: archive.title || archive.desc || (dynamic.desc && dynamic.desc.text) || '未命名视频',
cover: normalizeUrl(archive.cover || archive.pic || archive.cover_url),
url: normalizeVideoUrl(archive.jump_url || (item.basic && item.basic.jump_url) || 'https://www.bilibili.com/video/' + bvid),
play: Number((archive.stat && archive.stat.play) || archive.play || 0) || 0,
danmaku: Number((archive.stat && archive.stat.danmaku) || archive.danmaku || 0) || 0,
upName: author.name || archive.author_name || (archive.owner && archive.owner.name) || (archive.upper && archive.upper.name) || '未知 UP 主',
mid: author.mid || (archive.owner && archive.owner.mid) || (archive.upper && archive.upper.mid) || (item.basic && item.basic.mid) || '',
pubTime: author.pub_time || archive.pub_time || archive.pubtime || '',
pubTimestamp: normalizeTimestamp(author.pub_ts || archive.pubdate || archive.ctime || archive.pub_ts || '') || parseRelativeTimeToTimestamp(author.pub_time || archive.pub_time || ''),
duration: formatDuration(archive.duration_text || archive.duration || ''),
insertedAt: Date.now(),
};
} catch (err) {
console.warn(SCRIPT_NAME + ' 解析动态视频失败:', err, item);
return null;
}
}
function findArchiveDeep(obj) {
if (!obj || typeof obj !== 'object') return null;
if (obj.archive && typeof obj.archive === 'object') return obj.archive;
if (obj.bvid || obj.bv_id) return obj;
var keys = Object.keys(obj);
for (var i = 0; i < keys.length; i++) {
var val = obj[keys[i]];
if (val && typeof val === 'object') {
var found = findArchiveDeep(val);
if (found) return found;
}
}
return null;
}
function extractBvid(url) {
if (!url) return '';
var m = String(url).match(/BV[0-9A-Za-z]{10}/);
return m ? m[0] : '';
}
function normalizeUrl(url) {
if (!url) return '';
var s = String(url);
if (s.indexOf('//') === 0) return 'https:' + s;
if (/^http:\/\//i.test(s)) return s.replace(/^http:\/\//i, 'https://');
return s;
}
function normalizeVideoUrl(url) {
var s = normalizeUrl(url);
if (!s) return '#';
if (s.indexOf('/video/') === 0) return 'https://www.bilibili.com' + s;
if (/^BV[0-9A-Za-z]{10}$/.test(s)) return 'https://www.bilibili.com/video/' + s;
return s;
}
/********************************************************************
* 5. 渲染
********************************************************************/
function setMessage(type, text) {
var box = document.getElementById('bfv-message');
if (!box) return;
if (!text) { box.innerHTML = ''; return; }
var cls = type === 'error' ? 'bfv-alert-error' : type === 'warning' ? 'bfv-alert-warning' : 'bfv-alert-info';
box.innerHTML = '<div class="bfv-alert ' + cls + '">' + escapeHtml(text) + '</div>';
}
function setLoading(show) { var el = document.getElementById('bfv-loading'); if (el) el.style.display = show ? 'block' : 'none'; }
function setEnd(show) { var el = document.getElementById('bfv-end'); if (el) el.style.display = show ? 'block' : 'none'; }
function clearGrid() {
var grid = document.getElementById('bfv-grid');
if (grid) grid.innerHTML = '';
updateCount();
updateDebugPanel();
updateStatsPanel();
}
function renderSkeleton(count) {
count = count || 10;
var grid = document.getElementById('bfv-grid');
if (!grid) return;
grid.innerHTML = Array.from({ length: count }).map(function () {
return '<div class="bfv-skeleton-card"><div class="bfv-skeleton-cover"></div><div class="bfv-skeleton-line"></div><div class="bfv-skeleton-line short"></div></div>';
}).join('');
}
function addVideosToStore(videos) {
var added = 0;
for (var i = 0; i < videos.length; i++) {
var video = videos[i];
if (!video || !video.bvid) continue;
state.parsedCount += 1;
if (state.videoMap.has(video.bvid)) { state.duplicateCount += 1; continue; }
if (!isVideoAllowedByRecentDays(video)) { state.filteredOutCount += 1; continue; }
if (!isVideoAllowedByTag(video)) { state.filteredOutCount += 1; continue; }
state.videoMap.set(video.bvid, video);
added += 1;
}
return added;
}
function renderAllVideos() {
var grid = document.getElementById('bfv-grid');
if (!grid) return;
var videos = getSortedVideos();
var frag = document.createDocumentFragment();
for (var i = 0; i < videos.length; i++) {
frag.appendChild(createVideoCard(videos[i]));
}
grid.innerHTML = '';
grid.appendChild(frag);
updateCount();
updateDebugPanel();
updateStatsPanel();
}
function getSortedVideos() {
var videos = Array.from(state.videoMap.values());
videos.sort(function (a, b) {
if (state.sortMode === 'play_desc') {
return (Number(b.play) - Number(a.play)) || (Number(b.pubTimestamp) - Number(a.pubTimestamp)) || String(a.bvid).localeCompare(String(b.bvid));
}
if (state.sortMode === 'danmaku_desc') {
return (Number(b.danmaku) - Number(a.danmaku)) || (Number(b.pubTimestamp) - Number(a.pubTimestamp)) || String(a.bvid).localeCompare(String(b.bvid));
}
return (Number(b.pubTimestamp) - Number(a.pubTimestamp)) || (Number(b.insertedAt) - Number(a.insertedAt)) || String(a.bvid).localeCompare(String(b.bvid));
});
return videos;
}
function createVideoCard(video) {
var a = document.createElement('a');
a.className = 'bfv-card';
a.href = video.url || 'https://www.bilibili.com/video/' + video.bvid;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.title = video.title;
a.setAttribute('data-bvid', video.bvid);
// 已看标记
if (state.watchedBvids[video.bvid]) {
a.classList.add('bfv-card--watched');
}
// 点击标记为已看
a.addEventListener('click', function () {
markAsWatched(video.bvid);
});
var play = formatNumber(video.play);
var danmaku = formatNumber(video.danmaku);
var pub = formatPubTime(video.pubTime || video.pubTimestamp);
a.innerHTML = [
'<div class="bfv-cover-wrap">',
' <img class="bfv-cover" src="' + escapeAttr(video.cover) + '" alt="' + escapeAttr(video.title) + '" loading="lazy" onerror="this.style.opacity=\'0.25\';">',
' <div class="bfv-stat-bar">',
' <div class="bfv-stat-left">',
' <span title="播放量">▶ ' + escapeHtml(play) + '</span>',
' <span title="弹幕数">💬 ' + escapeHtml(danmaku) + '</span>',
' </div>',
(video.duration ? '<span>' + escapeHtml(video.duration) + '</span>' : ''),
' </div>',
'</div>',
'<div class="bfv-title">' + escapeHtml(video.title) + '</div>',
'<div class="bfv-meta" title="' + escapeAttr(video.upName) + '">',
' <span>UP</span>',
' <span class="bfv-up-name">' + escapeHtml(video.upName) + '</span>',
(pub ? '<span>·</span><span>' + escapeHtml(pub) + '</span>' : ''),
'</div>',
].join('');
return a;
}
function updateCount() {
var el = document.getElementById('bfv-count');
if (!el) return;
var shown = state.videoMap.size;
var range = state.recentDays > 0 ? '|' + state.recentDays + '天内' : '';
var tagLabel = getSelectedTagLabel();
var tagText = tagLabel !== '全部关注' ? '|' + tagLabel : '';
el.textContent = '已显示 ' + shown + ' 个' + range + tagText;
}
/********************************************************************
* 6. 统计面板
********************************************************************/
function calculateStats() {
var videos = Array.from(state.videoMap.values());
var now = Date.now();
var dayAgo = now - 24 * 60 * 60 * 1000;
var total = videos.length;
var totalPlay = 0;
var totalDanmaku = 0;
var recent24h = 0;
var watchedCount = 0;
// 按分组统计
var groupStats = {};
for (var i = 0; i < videos.length; i++) {
var v = videos[i];
totalPlay += Number(v.play) || 0;
totalDanmaku += Number(v.danmaku) || 0;
if (Number(v.pubTimestamp) > dayAgo) recent24h += 1;
if (state.watchedBvids[v.bvid]) watchedCount += 1;
// 判断属于哪个分组
for (var j = 0; j < state.tagList.length; j++) {
var tag = state.tagList[j];
if (tag.tagid === 0 && tag.count === 0) continue;
var midSet = state.tagMidCache[tag.tagid];
if (midSet && midSet.has(String(v.mid || ''))) {
if (!groupStats[tag.tagid]) {
groupStats[tag.tagid] = { name: tag.name, count: 0, play: 0, danmaku: 0 };
}
groupStats[tag.tagid].count += 1;
groupStats[tag.tagid].play += Number(v.play) || 0;
groupStats[tag.tagid].danmaku += Number(v.danmaku) || 0;
break; // 每个视频只归属第一个匹配分组
}
}
}
return {
total: total,
totalPlay: totalPlay,
totalDanmaku: totalDanmaku,
avgPlay: total > 0 ? Math.round(totalPlay / total) : 0,
avgDanmaku: total > 0 ? Math.round(totalDanmaku / total) : 0,
recent24h: recent24h,
watchedCount: watchedCount,
unwatchedCount: total - watchedCount,
groupStats: groupStats,
};
}
function updateStatsPanel() {
var panel = document.getElementById('bfv-stats-panel');
if (!panel || !state.showStatsPanel) return;
var stats = calculateStats();
var maxGroupCount = 0;
var groupKeys = Object.keys(stats.groupStats);
for (var i = 0; i < groupKeys.length; i++) {
var gs = stats.groupStats[groupKeys[i]];
if (gs.count > maxGroupCount) maxGroupCount = gs.count;
}
var groupRows = '';
for (var j = 0; j < groupKeys.length; j++) {
var g = stats.groupStats[groupKeys[j]];
var pct = maxGroupCount > 0 ? Math.round(g.count / maxGroupCount * 100) : 0;
groupRows += '<tr>' +
'<td>' + escapeHtml(g.name) + '</td>' +
'<td>' + g.count + ' 个<span class="bfv-stats-bar-wrap"><span class="bfv-stats-bar" style="width:' + pct + 'px"></span></span></td>' +
'<td>' + formatNumber(g.play) + '</td>' +
'<td>' + formatNumber(g.danmaku) + '</td>' +
'<td>' + (g.count > 0 ? formatNumber(Math.round(g.play / g.count)) : '0') + '</td>' +
'</tr>';
}
panel.innerHTML = [
'<div class="bfv-stats-title">',
' <span>📊 视频统计</span>',
' <span style="font-weight:400;color:#9499a0;">当前已加载视频的分布概览</span>',
'</div>',
'<div class="bfv-stats-summary">',
' <span class="bfv-stats-item">📹 总计 <span class="bfv-stats-num">' + stats.total + '</span> 个视频</span>',
' <span class="bfv-stats-item">▶ <span class="bfv-stats-num">' + formatNumber(stats.totalPlay) + '</span> 播放</span>',
' <span class="bfv-stats-item">💬 <span class="bfv-stats-num">' + formatNumber(stats.totalDanmaku) + '</span> 弹幕</span>',
' <span class="bfv-stats-item">🕐 24h内 <span class="bfv-stats-num">' + stats.recent24h + '</span> 个</span>',
' <span class="bfv-stats-item">👁 未看 <span class="bfv-stats-num">' + stats.unwatchedCount + '</span> / 已看 ' + stats.watchedCount + '</span>',
' <span class="bfv-stats-item">📈 均播放 <span class="bfv-stats-num">' + formatNumber(stats.avgPlay) + '</span></span>',
'</div>',
(groupKeys.length > 0 ?
'<table class="bfv-stats-table"><thead><tr><th>分组</th><th>视频数</th><th>播放量</th><th>弹幕数</th><th>均播放</th></tr></thead><tbody>' + groupRows + '</tbody></table>' :
'<div style="color:#9499a0;font-size:12px;">加载分组数据后可按分组统计</div>'
),
].join('');
}
/********************************************************************
* 7. 调试面板
********************************************************************/
function updateDebugPanel() {
var panel = document.getElementById('bfv-debug-panel');
if (!panel) return;
var containerExists = !!document.getElementById(CONFIG.containerId);
var grid = document.getElementById('bfv-grid');
var gridChildren = grid ? grid.children.length : 0;
var sortText = state.sortMode === 'play_desc' ? '播放量最高' : state.sortMode === 'danmaku_desc' ? '弹幕最多' : '最新发布';
var items = [
['脚本版本', '3.0.5'],
['当前页面', location.href],
['是否首页', isBiliHomePage() ? '是' : '否'],
['当前 offset', state.offset || '空'],
['是否还有更多', state.hasMore ? '是' : '否'],
['已显示数量', String(state.videoMap.size)],
['网格 DOM 数量', String(gridChildren)],
['接口解析累计', String(state.parsedCount)],
['过滤掉(天+分组)', String(state.filteredOutCount)],
['重复 BV 跳过', String(state.duplicateCount)],
['时间范围', state.recentDays > 0 ? state.recentDays + ' 天内' : '全部'],
['当前排序', sortText],
['选中分组 IDs', JSON.stringify(state.selectedTagIds)],
['选中分组标签', getSelectedTagFullLabel()],
['分组列表', state.tagList.map(function (t) { return t.name + '(' + t.count + ')'; }).join(', ') || '未加载'],
['已看记录数', String(Object.keys(state.watchedBvids).length)],
['登录状态', state.loginStatus],
['登录用户', state.loginName ? state.loginName + ' (' + state.loginMid + ')' : '无'],
['has observer', state.observer ? '是' : '否'],
['has watchdog', state.watchdogTimer ? '是' : '否'],
['容器存在', containerExists ? '是' : '否'],
['最后请求时间', state.lastFetchAt || '无'],
['最后 HTTP 状态', state.lastApiStatus || '无'],
['最后 API code', state.lastApiCode || '无'],
['最后原始 items 数', String(state.lastResponseItemCount)],
['最后解析视频数', String(state.lastParsedVideoCount)],
['最后错误', state.lastApiError || '无'],
];
panel.innerHTML = [
'<div class="bfv-debug-title"><span>🛠 调试面板</span><span style="font-weight:400;color:#9499a0;">排查接口或页面结构变化</span></div>',
'<div class="bfv-debug-grid">',
items.map(function (kv) {
var isErr = kv[0] === '最后错误' && kv[1] !== '无';
return '<div class="bfv-debug-item"><div class="bfv-debug-label">' + escapeHtml(kv[0]) +
'</div><div class="bfv-debug-value' + (isErr ? ' bfv-debug-error' : '') + '">' + escapeHtml(kv[1]) + '</div></div>';
}).join(''),
'</div>',
].join('');
}
/********************************************************************
* 8. 过滤逻辑
********************************************************************/
function isVideoAllowedByRecentDays(video) {
var days = Number(state.recentDays) || 0;
if (days <= 0) return true;
var ts = normalizeTimestamp(video.pubTimestamp) || parseRelativeTimeToTimestamp(video.pubTime);
if (!ts) return false;
return ts >= (Date.now() - days * 24 * 60 * 60 * 1000);
}
/**
* 多分组过滤:取所有选中分组 mids 的并集
* selectedTagIds 为空 → 显示全部
*/
function isVideoAllowedByTag(video) {
var ids = state.selectedTagIds;
if (!ids || ids.length === 0) return true;
for (var i = 0; i < ids.length; i++) {
var midSet = state.tagMidCache[ids[i]];
if (midSet && midSet.has(String(video.mid || ''))) return true;
}
return false;
}
/********************************************************************
* 9. 加载逻辑
********************************************************************/
async function initialLoad(routeToken) {
if (!isBiliHomePage()) return;
if (state.loading || state.initializing) return;
state.initializing = true;
try {
injectStyle();
ensureBodyClass();
var container = ensureContainerExists();
if (!container) return;
installMutationObserver();
installWatchdog();
setMessage('', '');
setEnd(false);
renderSkeleton();
updateCount();
updateDebugPanel();
var login = await checkLogin();
if (routeToken !== state.routeToken || !isBiliHomePage()) return;
if (!login.ok) {
clearGrid(); setLoading(false); setEnd(false);
state.firstLoadFinished = true;
setMessage('error', login.message || '请先登录 B 站以查看关注视频');
hideOriginalRecommendationDom();
updateDebugPanel();
return;
}
updateAvatar();
// 加载分组列表
fetchTagList().then(function (tags) {
if (routeToken !== state.routeToken) return;
state.tagList = tags;
populateTagCheckboxes();
updateTagLabel();
updateDebugPanel();
updateStatsPanel();
}).catch(function () {});
// 预加载选中分组的 mids
await ensureAllSelectedTagMidsLoaded();
if (routeToken !== state.routeToken || !isBiliHomePage()) return;
clearGrid();
state.initializing = false;
await loadMore(routeToken, true);
} finally {
if (routeToken === state.routeToken) {
state.initializing = false;
updateDebugPanel();
}
}
}
async function loadMore(routeToken, isFirst) {
if (!isBiliHomePage()) return;
if (state.loading) return;
if (!state.hasMore && !isFirst) return;
var now = Date.now();
if (!isFirst && now - state.lastLoadTime < CONFIG.minLoadInterval) return;
state.loading = true;
state.lastLoadTime = now;
setLoading(true);
setEnd(false);
updateDebugPanel();
try {
var result = await fetchFollowingVideosWithRetry();
if (routeToken !== state.routeToken || !isBiliHomePage()) return;
if (isFirst) { state.videoMap.clear(); clearGrid(); }
addVideosToStore(result.videos);
renderAllVideos();
state.offset = result.offset || '';
state.hasMore = Boolean(result.hasMore && state.offset);
if (!result.videos.length && isFirst) {
setMessage('info', '暂时没有获取到关注 UP 主发布的视频动态。');
} else if (state.videoMap.size === 0 && state.recentDays > 0 && isFirst) {
setMessage('info', '最近 ' + state.recentDays + ' 天内暂时没有筛选到关注 UP 主发布的视频。');
} else if (state.videoMap.size === 0 && state.selectedTagIds.length > 0 && isFirst) {
setMessage('info', '「' + getSelectedTagLabel() + '」分组下暂时没有视频动态,请尝试滚动加载更多或切换到其他分组。');
} else {
setMessage('', '');
}
if (!state.hasMore) setEnd(true);
hideOriginalRecommendationDom();
} catch (err) {
console.error(SCRIPT_NAME + ' 加载失败:', err);
if (routeToken !== state.routeToken || !isBiliHomePage()) return;
var msg = String(err && err.message ? err.message : err);
state.lastApiError = msg;
if (/未登录|-101|login/i.test(msg)) {
setMessage('error', '请先登录 B 站以查看关注视频');
} else {
setMessage('warning', '关注视频加载失败:' + msg);
}
} finally {
if (routeToken === state.routeToken && isBiliHomePage()) {
state.loading = false;
state.firstLoadFinished = true;
setLoading(false);
updateCount();
updateDebugPanel();
updateStatsPanel();
hideOriginalRecommendationDom();
}
}
}
function installScrollLoader() {
window.removeEventListener('scroll', onScroll, true);
if (!isBiliHomePage()) return;
window.addEventListener('scroll', onScroll, { passive: true, capture: true });
}
function onScroll() {
if (!isBiliHomePage()) { deactivateScriptOnNonHomePage(); return; }
ensureBodyClass();
ensureContainerExists();
hideOriginalRecommendationDom();
if (state.loading || state.initializing || !state.hasMore) return;
var scrollTop = window.scrollY || document.documentElement.scrollTop || 0;
var viewport = window.innerHeight || document.documentElement.clientHeight || 0;
var fullHeight = Math.max(
document.body ? document.body.scrollHeight : 0,
document.documentElement ? document.documentElement.scrollHeight : 0,
);
if (fullHeight - (scrollTop + viewport) <= CONFIG.scrollThreshold) {
loadMore(state.routeToken, false);
}
}
function reloadForFilterChange() {
if (!isBiliHomePage()) return;
state.routeToken += 1;
state.initializing = false;
state.loading = false;
state.hasMore = true;
state.offset = '';
state.lastLoadTime = 0;
state.videoMap.clear();
state.parsedCount = 0;
state.filteredOutCount = 0;
state.duplicateCount = 0;
state.firstLoadFinished = false;
clearGrid();
setMessage('', '');
setEnd(false);
updateCount();
updateDebugPanel();
updateStatsPanel();
initialLoad(state.routeToken);
}
/********************************************************************
* 10. 启动、重置、非首页停用
********************************************************************/
function resetStateForRoute() {
state.routeToken += 1;
state.initializing = false;
state.loading = false;
state.hasMore = true;
state.offset = '';
state.lastLoadTime = 0;
state.videoMap.clear();
state.parsedCount = 0;
state.filteredOutCount = 0;
state.duplicateCount = 0;
state.started = false;
state.firstLoadFinished = false;
state.recentDays = getInitialRecentDays();
state.sortMode = getInitialSortMode();
state.selectedTagIds = getInitialSelectedTagIds();
}
function resetAndRun() {
resetStateForRoute();
ready(function () {
if (!isBiliHomePage()) { deactivateScriptOnNonHomePage(); return; }
startHomeMode();
});
}
function startHomeMode() {
if (!isBiliHomePage()) return;
injectStyle();
ensureBodyClass();
waitForHomeReady(function () {
if (!isBiliHomePage()) return;
ensureContainerExists();
installMutationObserver();
installWatchdog();
installScrollLoader();
updateCount();
updateDebugPanel();
updateStatsPanel();
if (!state.started) { state.started = true; initialLoad(state.routeToken); }
});
}
function waitForHomeReady(callback) {
var count = 0;
var timer = setInterval(function () {
if (!isBiliHomePage()) { clearInterval(timer); return; }
count += 1;
var hasBody = !!document.body;
var hasApp = !!document.querySelector('#app');
var hasHomeSignal = !!document.querySelector('.recommended-container') ||
!!document.querySelector('.bili-feed4') || !!document.querySelector('.feed2') ||
!!document.querySelector('.channel-icons') || !!document.querySelector('.header-channel') ||
!!document.querySelector('.v-popover-wrap') || !!document.querySelector('main');
if (hasBody && (hasApp || hasHomeSignal || count > 20)) { clearInterval(timer); callback(); }
}, CONFIG.mountRetryInterval);
}
function deactivateScriptOnNonHomePage() {
try {
removeBodyClass();
var styleEl = document.getElementById(CONFIG.styleId);
if (styleEl) styleEl.remove();
var container = document.getElementById(CONFIG.containerId);
if (container) container.remove();
if (state.observer) { state.observer.disconnect(); state.observer = null; }
clearInterval(state.watchdogTimer);
state.watchdogTimer = null;
window.removeEventListener('scroll', onScroll, true);
state.initializing = false;
state.loading = false;
state.hasMore = false;
state.offset = '';
state.videoMap.clear();
state.parsedCount = 0;
state.filteredOutCount = 0;
state.duplicateCount = 0;
state.started = false;
state.firstLoadFinished = false;
} catch (err) { console.warn(SCRIPT_NAME + ' 停用失败:', err); }
}
function ready(fn) {
var called = false;
var run = function () { if (called) return; if (!document.body) return; called = true; fn(); };
if (document.body) { run(); return; }
var timer = setInterval(function () { if (document.body) { clearInterval(timer); run(); } }, 30);
document.addEventListener('DOMContentLoaded', function () { clearInterval(timer); run(); }, { once: true });
}
/********************************************************************
* 11. 时间处理
********************************************************************/
function normalizeTimestamp(value) {
if (value === null || value === undefined || value === '') return 0;
if (value instanceof Date) { var t = value.getTime(); return Number.isFinite(t) ? t : 0; }
var n = Number(value);
if (!Number.isFinite(n) || n <= 0) return 0;
if (n > 1e12) return n;
if (n > 1e9) return n * 1000;
return 0;
}
function parseRelativeTimeToTimestamp(text) {
if (!text) return 0;
var s = String(text).trim(), now = Date.now();
if (!s) return 0;
if (/刚刚/.test(s)) return now;
var m = s.match(/(\d+)\s*秒前/); if (m) return now - Number(m[1]) * 1000;
m = s.match(/(\d+)\s*分钟前/); if (m) return now - Number(m[1]) * 60000;
m = s.match(/(\d+)\s*小时前/); if (m) return now - Number(m[1]) * 3600000;
m = s.match(/(\d+)\s*天前/); if (m) return now - Number(m[1]) * 86400000;
if (/昨天/.test(s)) return now - 86400000;
if (/前天/.test(s)) return now - 172800000;
m = s.match(/(\d{4})\s*(?:年|-|\/|\.)\s*(\d{1,2})\s*(?:月|-|\/|\.)\s*(\d{1,2})(?:日)?/);
if (m) { var d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); return Number.isFinite(d.getTime()) ? d.getTime() : 0; }
m = s.match(/(?:^|[^\d])(\d{1,2})\s*(?:月|-|\/|\.)\s*(\d{1,2})(?:日)?/);
if (m) { var y = new Date().getFullYear(); var d2 = new Date(y, Number(m[1]) - 1, Number(m[2])); return Number.isFinite(d2.getTime()) ? d2.getTime() : 0; }
return 0;
}
function formatPubTime(value) {
if (!value) return '';
if (typeof value === 'string' && /[年月日天时分秒前昨天刚刚]/.test(value)) return value;
var ts = normalizeTimestamp(value);
if (!ts) return String(value);
var diff = Date.now() - ts;
if (diff < 0) return '';
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前';
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前';
if (diff < 604800000) return Math.floor(diff / 86400000) + '天前';
var d = new Date(ts);
return (d.getMonth() + 1) + '-' + ('0' + d.getDate()).slice(-2);
}
function formatDateTime(value) {
var d = new Date(value);
if (!Number.isFinite(d.getTime())) return '';
var pad = function (n) { return ('0' + n).slice(-2); };
return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
}
/********************************************************************
* 12. 工具
********************************************************************/
function debounce(fn, wait) {
var timer = null;
return function () { clearTimeout(timer); timer = setTimeout(function () { fn.apply(this, arguments); }, wait); };
}
function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
function escapeHtml(input) {
return String(input || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
function escapeAttr(input) { return escapeHtml(input).replace(/`/g, '`'); }
function formatNumber(num) {
var n = Number(num || 0);
if (!Number.isFinite(n) || n <= 0) return '0';
if (n >= 100000000) return trimZero(n / 100000000) + '亿';
if (n >= 10000) return trimZero(n / 10000) + '万';
return String(Math.floor(n));
}
function trimZero(n) { return n.toFixed(1).replace(/\.0$/, ''); }
function formatDuration(value) {
if (value === null || value === undefined || value === '') return '';
if (typeof value === 'string') return value;
var sec = Number(value);
if (!Number.isFinite(sec) || sec <= 0) return '';
var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = Math.floor(sec % 60);
if (h > 0) return h + ':' + ('0' + m).slice(-2) + ':' + ('0' + s).slice(-2);
return m + ':' + ('0' + s).slice(-2);
}
/********************************************************************
* 13. 启动
********************************************************************/
if (isBiliHomePage()) injectStyle();
resetAndRun();
})();
哔哩哔哩批量关注UP主,批量导出UP主
// ==UserScript==
// @name B站关注列表 · 批量导出 / 批量关注
// @namespace https://adzhp.cc/
// @version 1.1.1
// @description 在B站空间关注页一键导出全部关注或指定分组(TXT / CSV / JSON),并可粘贴 UID 列表批量关注,带进度、限速、暂停与失败重试
// @author you
// @match https://space.bilibili.com/*
// @match https://www.bilibili.com/*
// @icon https://www.bilibili.com/favicon.ico
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
if (window.__BZFT_LOADED__) return;
window.__BZFT_LOADED__ = true;
/* ==========================================================
* 接口常量
* ========================================================== */
const API_NAV = 'https://api.bilibili.com/x/web-interface/nav';
const API_FOLLOWINGS = 'https://api.bilibili.com/x/relation/followings';
const API_MODIFY = 'https://api.bilibili.com/x/relation/modify';
const API_TAGS = 'https://api.bilibili.com/x/relation/tags'; // 关注分组列表
const API_TAG = 'https://api.bilibili.com/x/relation/tag'; // 分组内的关注
const TAG_ALL = 'all'; // 下拉框里「全部关注」的取值
const PAGE_SIZE = 50; // 每页条数(接口上限 50)
const MAX_PAGES = 600; // 保险丝:最多翻 600 页(3 万人)
const RATE_LIMIT_CODES = [-352, -412, -509, -799];
// ==== BZFT-PURE-BEGIN ====
/* ==========================================================
* 纯函数区:MD5 + WBI 签名 + UID 解析(无 DOM 依赖)
* ========================================================== */
/* ---------- MD5(RFC 1321 精简实现) ---------- */
const MD5_S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
const MD5_K = new Int32Array(64);
for (let i = 0; i < 64; i++) {
MD5_K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 4294967296) | 0;
}
function md5(str) {
const bytes = new TextEncoder().encode(str);
const len = bytes.length;
const blocks = (((len + 8) >> 6) + 1) * 16;
const words = new Int32Array(blocks);
for (let i = 0; i < len; i++) {
words[i >> 2] |= bytes[i] << ((i % 4) * 8);
}
words[len >> 2] |= 0x80 << ((len % 4) * 8);
words[blocks - 2] = (len * 8) | 0;
words[blocks - 1] = Math.floor((len * 8) / 4294967296);
let a0 = 0x67452301, b0 = 0xefcdab89 | 0, c0 = 0x98badcfe | 0, d0 = 0x10325476;
for (let off = 0; off < blocks; off += 16) {
let A = a0, B = b0, C = c0, D = d0;
for (let i = 0; i < 64; i++) {
let F, g;
if (i < 16) {
F = (B & C) | (~B & D);
g = i;
} else if (i < 32) {
F = (D & B) | (~D & C);
g = (5 * i + 1) % 16;
} else if (i < 48) {
F = B ^ C ^ D;
g = (3 * i + 5) % 16;
} else {
F = C ^ (B | ~D);
g = (7 * i) % 16;
}
const tmp = D;
D = C;
C = B;
const sum = (A + F + MD5_K[i] + words[off + g]) | 0;
const s = MD5_S[(i >> 4) * 4 + (i % 4)];
B = (B + ((sum << s) | (sum >>> (32 - s)))) | 0;
A = tmp;
}
a0 = (a0 + A) | 0;
b0 = (b0 + B) | 0;
c0 = (c0 + C) | 0;
d0 = (d0 + D) | 0;
}
const hex = (n) => {
let out = '';
for (let i = 0; i < 4; i++) {
out += ((n >>> (i * 8)) & 0xff).toString(16).padStart(2, '0');
}
return out;
};
return hex(a0) + hex(b0) + hex(c0) + hex(d0);
}
/* ---------- 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
];
function getMixinKey(imgKey, subKey) {
const raw = imgKey + subKey;
return MIXIN_KEY_ENC_TAB.map((n) => raw[n] || '').join('').slice(0, 32);
}
/** 返回签名后的完整 query 串(含 w_rid / wts) */
function encWbi(params, imgKey, subKey, nowSec) {
const mixinKey = getMixinKey(imgKey, subKey);
const p = Object.assign({}, params, { wts: nowSec || Math.round(Date.now() / 1000) });
const query = Object.keys(p)
.sort()
.map((k) => {
const v = String(p[k]).replace(/[!'()*]/g, '');
return encodeURIComponent(k) + '=' + encodeURIComponent(v);
})
.join('&');
return query + '&w_rid=' + md5(query + mixinKey);
}
/* ---------- UID 列表解析 ---------- */
function parseUidList(text, selfMid) {
const out = [];
const seen = new Set();
const push = (n) => {
const id = parseInt(n, 10);
if (!id || id <= 0 || id > 999999999999) return;
if (selfMid && id === selfMid) return;
if (seen.has(id)) return;
seen.add(id);
out.push(id);
};
const raw = String(text || '').trim();
if (!raw) return out;
// 1) 导出的 JSON
if (raw[0] === '{' || raw[0] === '[') {
try {
const data = JSON.parse(raw);
const list = Array.isArray(data) ? data : (data.list || []);
list.forEach((it) => {
if (typeof it === 'number') push(it);
else if (it && it.mid) push(it.mid);
});
if (out.length) return out;
} catch (e) { /* 落回文本解析 */ }
}
// 2) 纯文本:每行优先取空间链接里的 UID,否则取第一个 2~15 位数字
raw.split(/\r?\n/).forEach((line) => {
const seg = line.trim();
if (!seg) return;
const spaceLink = seg.match(/space\.bilibili\.com\/(\d+)/);
if (spaceLink) return push(spaceLink[1]);
const nums = seg.match(/\d{2,15}/g);
if (nums) nums.forEach(push);
});
return out;
}
// ==== BZFT-PURE-END ====
/* ==========================================================
* 小工具
* ========================================================== */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const rand = (min, max) => min + Math.random() * (max - min);
function getCookie(name) {
const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
return m ? decodeURIComponent(m[1]) : '';
}
function fmtTime(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
}
function fmtDuration(ms) {
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + '分' + String(s % 60).padStart(2, '0') + '秒';
}
function stamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes());
}
function download(filename, content, mime) {
const blob = new Blob([content], { type: (mime || 'text/plain') + ';charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
/**
* 保存文件:优先走 File System Access API(直接写盘,不经过浏览器下载管理器),
* 避免「下载前询问保存位置」+ 下载被拦截/接管时点按钮没反应(临时 .tmp 文件不落地)。
* 不支持或失败时退回传统的 blob + <a download>。
* 返回 'saved' | 'cancel' | 'download'
*/
async function saveFile(filename, content, mime) {
const type = mime || 'text/plain';
const blob = new Blob([content], { type: type + ';charset=utf-8' });
if (typeof window.showSaveFilePicker === 'function') {
try {
// 注意:这里必须在用户点击的同一个调用栈里(不能先 await 别的东西),否则会丢手势
const handle = await window.showSaveFilePicker({ suggestedName: filename });
const writer = await handle.createWritable();
await writer.write(blob);
await writer.close();
return 'saved';
} catch (e) {
if (e && e.name === 'AbortError') return 'cancel'; // 用户自己取消了
console.warn('[BZFT] 另存为失败,回退到浏览器下载:', e);
}
}
download(filename, content, mime);
return 'download';
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:0;';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
ta.remove();
return ok;
}
}
/* ==========================================================
* 网络层
* ========================================================== */
class ApiError extends Error {
constructor(code, message) {
super(message || ('接口返回 code=' + code));
this.code = code;
}
}
async function httpJson(url, opts) {
opts = opts || {};
const res = await fetch(url, {
method: opts.method || 'GET',
credentials: 'include',
headers: opts.body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
body: opts.body
});
if (res.status === 412) {
throw new ApiError(-412, '请求被拦截(HTTP 412),已触发风控,请稍后重试');
}
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch (e) {
throw new ApiError(-1, '返回内容不是 JSON:' + text.slice(0, 100));
}
return json;
}
/* ---------- WBI keys(缓存 1 小时) ---------- */
let wbiCache = { imgKey: '', subKey: '', at: 0 };
async function getWbiKeys(force) {
if (!force && wbiCache.imgKey && Date.now() - wbiCache.at < 3600 * 1000) {
return wbiCache;
}
const nav = await httpJson(API_NAV);
const img = nav && nav.data && nav.data.wbi_img;
if (!img) throw new ApiError(nav.code, '获取 WBI 签名密钥失败');
const pick = (u) => (u || '').split('/').pop().split('.')[0];
wbiCache = { imgKey: pick(img.img_url), subKey: pick(img.sub_url), at: Date.now() };
return wbiCache;
}
/** 带 WBI 签名的 GET,自动重试风控 */
async function apiGet(url, params, opt) {
opt = opt || {};
const retries = opt.retries == null ? 2 : opt.retries;
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const keys = opt.noWbi ? null : await getWbiKeys(attempt > 0);
const qs = keys ? encWbi(params, keys.imgKey, keys.subKey) : new URLSearchParams(params).toString();
const json = await httpJson(url + (url.includes('?') ? '&' : '?') + qs);
if (json.code !== 0) throw new ApiError(json.code, json.message);
return json.data;
} catch (e) {
lastErr = e;
const rateLimited = RATE_LIMIT_CODES.includes(e.code);
if (!rateLimited || attempt === retries) throw e;
await sleep(2000 * (attempt + 1));
}
}
throw lastErr;
}
/** 取当前登录账号 mid */
async function loadSelf() {
const nav = await httpJson(API_NAV);
if (!nav.data || !nav.data.isLogin) {
throw new ApiError(-101, '未检测到登录状态,请先登录 B 站');
}
state.selfMid = nav.data.mid;
return state.selfMid;
}
/* ---------- 关注列表 ---------- */
/** 把接口里的用户对象统一成内部结构(关注 / 分组成员两种返回都兼容) */
function mapUser(it) {
return {
mid: it.mid,
uname: it.uname,
sign: it.sign || '',
face: it.face || '',
mtime: it.mtime || 0,
attribute: it.attribute,
special: it.special || 0,
official: (it.official_verify && it.official_verify.desc) || '',
vip: (it.vip && it.vip.vipStatus) || 0
};
}
function dedupeUsers(list) {
const seen = new Set();
return list.filter((u) => (seen.has(u.mid) ? false : seen.add(u.mid)));
}
async function fetchFollowings(mid, hooks) {
const all = [];
let total = 0;
for (let pn = 1; pn <= MAX_PAGES; pn++) {
if (hooks.shouldStop()) break;
const params = { vmid: mid, pn: pn, ps: PAGE_SIZE };
const data = await apiGet(API_FOLLOWINGS, params);
const list = (data && data.list) || [];
total = (data && data.total) || total;
if (!list.length) break;
list.forEach((it) => all.push(mapUser(it)));
hooks.onProgress(all.length, total);
if (list.length < PAGE_SIZE) break;
if (total && all.length >= total) break;
await sleep(rand(350, 750));
}
const out = dedupeUsers(all);
// 缓存:分组导出时用来补全关注时间,避免重复抓取
if (out.length) state.followCache[mid] = out;
return out;
}
/* ---------- 关注分组 ---------- */
/** 读取分组列表:[{ tagid, name, count }],含「特别关注」(-10) 与「默认分组」(0) */
async function fetchTags(mid) {
const data = await apiGet(API_TAGS, { vmid: mid }, { noWbi: true });
const arr = Array.isArray(data) ? data : [];
return arr.map((t) => ({
tagid: t.tagid,
name: t.name || ('分组 ' + t.tagid),
count: t.count || 0
}));
}
/** 按 tagid 分页拉取分组成员(该接口不返回关注时间) */
async function fetchTagMembers(mid, tagid, hooks) {
const all = [];
for (let pn = 1; pn <= MAX_PAGES; pn++) {
if (hooks.shouldStop()) break;
const params = { mid: mid, tagid: tagid, pn: pn, ps: PAGE_SIZE, order: '', jsonp: 'jsonp' };
const data = await apiGet(API_TAG, params, { noWbi: true });
const list = Array.isArray(data) ? data : ((data && data.list) || []);
if (!list.length) break;
list.forEach((it) => all.push(mapUser(it)));
hooks.onProgress(all.length, hooks.total || 0);
if (list.length < PAGE_SIZE) break;
if (hooks.total && all.length >= hooks.total) break;
await sleep(rand(300, 650));
}
return dedupeUsers(all);
}
/** 用已缓存的全部关注补全 mtime / special(分组接口不返回这些字段) */
function applyFollowMeta(list, mid) {
const cache = state.followCache[mid];
if (!cache || !cache.length) return list;
const map = new Map(cache.map((u) => [u.mid, u]));
return list.map((u) => {
const hit = map.get(u.mid);
if (!hit) return u;
return Object.assign({}, u, {
mtime: hit.mtime || u.mtime,
special: hit.special || u.special,
attribute: hit.attribute == null ? u.attribute : hit.attribute
});
});
}
/* ---------- 关注 / 取关 ---------- */
async function modifyRelation(fid, act) {
const csrf = getCookie('bili_jct');
if (!csrf) throw new ApiError(-111, '未找到 bili_jct(CSRF),请重新登录');
const body = new URLSearchParams({
fid: String(fid),
act: String(act),
re_src: '11',
csrf: csrf
}).toString();
const json = await httpJson(API_MODIFY, { method: 'POST', body: body });
if (json.code !== 0) throw new ApiError(json.code, json.message);
return json;
}
/* ==========================================================
* 界面(Shadow DOM 隔离样式)
* ========================================================== */
const CSS = `
:host, * { box-sizing: border-box; }
* { font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
.launcher {
position: absolute; right: 22px; bottom: 88px;
width: 54px; height: 54px; border-radius: 50%;
background: linear-gradient(135deg, #fb7299, #ff9db4);
color: #fff; font-size: 12px; font-weight: 700; letter-spacing: .5px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; pointer-events: auto; user-select: none;
box-shadow: 0 6px 18px rgba(251,114,153,.45);
transition: transform .15s ease, box-shadow .15s ease;
}
.launcher:hover { transform: translateY(-2px) scale(1.04); box-shadow: 0 10px 24px rgba(251,114,153,.55); }
.launcher[hidden] { display: none; }
.panel {
position: absolute; right: 22px; bottom: 88px;
width: 400px; max-width: calc(100vw - 32px);
max-height: calc(100vh - 110px);
background: #fff; color: #18191c;
border: 1px solid #e3e5e7; border-radius: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,.16);
display: flex; flex-direction: column; overflow: hidden;
pointer-events: auto; font-size: 13px; line-height: 1.5;
}
.panel[hidden] { display: none; }
.head {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; background: #fff;
border-bottom: 1px solid #f1f2f3; cursor: move; user-select: none;
}
.head .dot { width: 8px; height: 8px; border-radius: 50%; background: #fb7299; flex: none; }
.head .title { flex: 1; font-weight: 700; font-size: 13px; color: #18191c; }
.head .close {
border: 0; background: transparent; color: #9499a0; font-size: 18px;
line-height: 1; cursor: pointer; padding: 2px 4px; border-radius: 6px;
}
.head .close:hover { background: #f1f2f3; color: #18191c; }
.tabs { display: flex; gap: 6px; padding: 8px 12px 0; background: #fff; }
.tab {
flex: 1; padding: 7px 0; border: 0; border-radius: 8px;
background: #f6f7f8; color: #61666d; font-size: 12.5px; font-weight: 600;
cursor: pointer; transition: all .15s;
}
.tab:hover { background: #eef0f2; }
.tab.active { background: #ffe9f0; color: #d63a63; }
.body { padding: 12px; overflow-y: auto; }
.body::-webkit-scrollbar { width: 6px; }
.body::-webkit-scrollbar-thumb { background: #dcdfe2; border-radius: 3px; }
.pane { display: none; }
.pane.active { display: block; }
.row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.row[hidden] { display: none; }
.row.btns { flex-wrap: wrap; gap: 6px; }
label { color: #61666d; font-size: 12px; white-space: nowrap; }
.input, .textarea {
flex: 1; min-width: 0; padding: 7px 9px;
border: 1px solid #e3e5e7; border-radius: 8px;
background: #fff; color: #18191c; font-size: 12.5px; outline: none;
transition: border-color .15s;
}
.input:focus, .textarea:focus { border-color: #fb7299; }
.input.small { flex: none; width: 66px; text-align: center; }
.select {
flex: 1; min-width: 0; padding: 7px 9px;
border: 1px solid #e3e5e7; border-radius: 8px;
background: #fff; color: #18191c; font-size: 12.5px; outline: none;
cursor: pointer; transition: border-color .15s;
}
.select:focus { border-color: #fb7299; }
.select:disabled { background: #f6f7f8; color: #9499a0; cursor: not-allowed; }
.textarea { width: 100%; height: 104px; resize: vertical; line-height: 1.6; font-family: ui-monospace, Consolas, monospace; }
.btn {
padding: 7px 12px; border: 1px solid #e3e5e7; border-radius: 8px;
background: #fff; color: #18191c; font-size: 12.5px; font-weight: 600;
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.btn:hover:not(:disabled) { border-color: #fb7299; color: #d63a63; background: #fff5f8; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary { background: #fb7299; border-color: #fb7299; color: #fff; }
.btn.primary:hover:not(:disabled) { background: #e85f87; border-color: #e85f87; color: #fff; }
.btn.ghost { background: #f6f7f8; }
.check { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; color: #61666d; font-size: 12px; }
.check input { accent-color: #fb7299; }
.hint { color: #9499a0; font-size: 11.5px; margin: -2px 0 8px; line-height: 1.5; }
.progress { height: 6px; background: #f1f2f3; border-radius: 3px; overflow: hidden; margin: 10px 0 6px; }
.progress .bar { height: 100%; width: 0; background: linear-gradient(90deg, #fb7299, #ffa3bb); border-radius: 3px; transition: width .25s ease; }
.stat { color: #61666d; font-size: 12px; margin-bottom: 10px; font-variant-numeric: tabular-nums; }
.stat b { color: #18191c; }
.stat .ok { color: #2ea44f; }
.stat .skip { color: #d68a00; }
.stat .bad { color: #e5484d; }
.log {
margin-top: 10px; padding: 8px; height: 128px; overflow-y: auto;
background: #fafbfc; border: 1px solid #f1f2f3; border-radius: 8px;
font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; line-height: 1.7; color: #61666d;
}
.log::-webkit-scrollbar { width: 6px; }
.log::-webkit-scrollbar-thumb { background: #dcdfe2; border-radius: 3px; }
.log div { white-space: pre-wrap; word-break: break-all; }
.log .ok { color: #2ea44f; }
.log .warn { color: #d68a00; }
.log .err { color: #e5484d; }
.log .dim { color: #9499a0; }
`;
function buildUI() {
const host = document.createElement('div');
host.id = 'bzft-host';
host.style.cssText = 'position:fixed;inset:0;z-index:2147483000;pointer-events:none;';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = CSS;
shadow.appendChild(style);
const wrap = document.createElement('div');
wrap.innerHTML = `
<div class="launcher" id="launcher" title="B站关注 · 批量导出 / 导入">关注</div>
<div class="panel" id="panel" hidden>
<div class="head" id="head">
<span class="dot"></span>
<span class="title">B站关注 · 批量导出 / 导入</span>
<button class="close" id="btnClose" title="收起">×</button>
</div>
<div class="tabs">
<button class="tab active" data-pane="export">导出关注</button>
<button class="tab" data-pane="import">批量关注</button>
</div>
<div class="body">
<section class="pane active" id="pane-export">
<div class="row">
<label>目标 UID</label>
<input class="input" id="expMid" placeholder="留空 = 我自己">
<button class="btn ghost" id="btnFillSelf">我自己</button>
</div>
<div class="hint">留空导出自己的全部关注;填别人的 UID 最多只能看到前 100 个。</div>
<div class="row">
<label>分组</label>
<select class="select" id="expTag"><option value="all">全部关注</option></select>
<button class="btn ghost" id="btnLoadTags" title="重新读取分组列表">刷新</button>
</div>
<div class="hint" id="expTagHint">选择分组后只抓取该分组的成员,比整份关注列表更快。</div>
<div class="row" id="expMetaRow" hidden>
<label class="check"><input type="checkbox" id="expMeta"> 补全关注时间(较慢)</label>
</div>
<div class="row btns">
<button class="btn primary" id="btnExport">开始导出</button>
<button class="btn" id="btnExpStop" disabled>停止</button>
</div>
<div class="progress"><div class="bar" id="expBar"></div></div>
<div class="stat" id="expStat">尚未导出</div>
<div class="row btns">
<button class="btn" id="btnCopyUid" disabled>复制 UID</button>
<button class="btn" id="btnDlTxt" disabled>下载 TXT</button>
<button class="btn" id="btnDlCsv" disabled>下载 CSV</button>
<button class="btn" id="btnDlJson" disabled>下载 JSON</button>
</div>
<div class="log" id="expLog"></div>
</section>
<section class="pane" id="pane-import">
<div class="hint">每行一个 UID,也支持逗号/空格分隔、空间链接,或直接粘贴导出的 JSON。</div>
<textarea class="textarea" id="impText" placeholder="12345678 87654321 https://space.bilibili.com/23456789"></textarea>
<div class="row" style="margin-top:8px">
<label>间隔(秒)</label>
<input class="input small" id="impDelay" type="number" min="1" max="30" step="0.5" value="2.5">
<label class="check"><input type="checkbox" id="impRetry" checked> 风控自动重试</label>
</div>
<div class="row btns">
<button class="btn primary" id="btnImport">开始关注</button>
<button class="btn" id="btnPause" disabled>暂停</button>
<button class="btn" id="btnImpStop" disabled>停止</button>
</div>
<div class="progress"><div class="bar" id="impBar"></div></div>
<div class="stat" id="impStat">等待开始</div>
<div class="row btns">
<button class="btn" id="btnCopyFail" disabled>复制失败 UID</button>
</div>
<div class="log" id="impLog"></div>
</section>
</div>
</div>`;
while (wrap.firstChild) shadow.appendChild(wrap.firstChild);
return shadow;
}
/* ==========================================================
* 状态与 UI 引用
* ========================================================== */
const state = {
selfMid: 0,
exporting: false,
exportStop: false,
exported: null,
importing: false,
paused: false,
stop: false,
failed: [],
groups: [], // 当前账号的关注分组 [{ tagid, name, count }]
groupsMid: 0, // groups 属于哪个 mid
followCache: {} // mid -> 全部关注(用于给分组结果补全关注时间)
};
let $ = null;
function log(box, msg, cls) {
const line = document.createElement('div');
if (cls) line.className = cls;
const d = new Date();
const t = String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') + ':' +
String(d.getSeconds()).padStart(2, '0');
line.textContent = '[' + t + '] ' + msg;
box.appendChild(line);
while (box.childElementCount > 400) box.removeChild(box.firstChild);
box.scrollTop = box.scrollHeight;
}
const expLog = (m, c) => log($('#expLog'), m, c);
const impLog = (m, c) => log($('#impLog'), m, c);
function setBar(el, ratio) {
$(el).style.width = Math.max(0, Math.min(100, ratio * 100)).toFixed(1) + '%';
}
/* ==========================================================
* 关注分组(下拉框)
* ========================================================== */
function escHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
})[c]);
}
function currentMid() {
return parseInt(($('#expMid').value || '').trim(), 10) || state.selfMid || 0;
}
function renderGroupSelect() {
const sel = $('#expTag');
const prev = sel.value;
const opts = ['<option value="' + TAG_ALL + '">全部关注</option>'];
state.groups.forEach((g) => {
opts.push('<option value="' + g.tagid + '">' + escHtml(g.name) + '(' + g.count + ')</option>');
});
sel.innerHTML = opts.join('');
if (prev && Array.prototype.some.call(sel.options, (o) => o.value === prev)) sel.value = prev;
}
/** 只有按分组导出时,「补全关注时间」才有意义 */
function syncMetaRow() {
const byGroup = $('#expTag').value !== TAG_ALL;
$('#expMetaRow').hidden = !byGroup;
if (!byGroup) $('#expMeta').checked = false;
}
/** 分组只属于自己:填了别人的 UID 就禁用下拉框 */
function syncTagAvailability() {
const mid = currentMid();
const isSelf = !mid || mid === state.selfMid;
const sel = $('#expTag');
sel.disabled = !isSelf;
if (!isSelf) sel.value = TAG_ALL;
$('#expTagHint').textContent = isSelf
? '选择分组后只抓取该分组的成员,比整份关注列表更快;勾选「补全关注时间」会额外抓取全部关注。'
: '分组只属于账号本人,导出他人时只能抓取全部关注。';
syncMetaRow();
}
async function loadGroups(mid) {
const tags = await fetchTags(mid);
state.groups = tags;
state.groupsMid = mid;
renderGroupSelect();
syncTagAvailability();
return tags;
}
/* ==========================================================
* 导出流程
* ========================================================== */
async function doExport() {
if (state.exporting) return;
let mid = parseInt(($('#expMid').value || '').trim(), 10);
try {
if (!state.selfMid) await loadSelf();
} catch (e) {
expLog('✗ ' + e.message, 'err');
return;
}
if (!mid) mid = state.selfMid;
$('#expMid').value = mid;
// 分组选择:all = 全部关注,其余为 tagid(0 = 默认分组,-10 = 特别关注)
const tagVal = $('#expTag').value || TAG_ALL;
const tagId = tagVal === TAG_ALL ? null : parseInt(tagVal, 10);
const group = tagId === null ? null : state.groups.find((g) => g.tagid === tagId);
const tagName = tagId === null ? '' : (group ? group.name : String(tagId));
if (tagId !== null && mid !== state.selfMid) {
expLog('✗ 分组导出只能导出自己的关注,请把「目标 UID」留空或改回 ' + state.selfMid, 'err');
return;
}
state.exporting = true;
state.exportStop = false;
state.exported = null;
$('#btnExport').disabled = true;
$('#btnExpStop').disabled = false;
$('#btnCopyUid').disabled = true;
$('#btnDlTxt').disabled = true;
$('#btnDlCsv').disabled = true;
$('#btnDlJson').disabled = true;
setBar('#expBar', 0);
$('#expStat').innerHTML = '正在读取…';
expLog(tagId === null
? '开始导出 UID ' + mid + (mid === state.selfMid ? '(我自己)' : '(他人)')
: '开始导出分组「' + tagName + '」' + (mid === state.selfMid ? '' : '(UID ' + mid + ')'));
const t0 = Date.now();
try {
let list;
if (tagId === null) {
list = await fetchFollowings(mid, {
shouldStop: () => state.exportStop,
onProgress: (got, total) => {
$('#expStat').innerHTML = '已获取 <b>' + got + '</b> 个' +
(total ? ' / 共 <b>' + total + '</b> 个' : '');
setBar('#expBar', total ? got / total : Math.min(0.95, got / (got + 60)));
}
});
} else {
const total = group ? group.count : 0;
list = await fetchTagMembers(mid, tagId, {
total: total,
shouldStop: () => state.exportStop,
onProgress: (got) => {
$('#expStat').innerHTML = '分组「' + escHtml(tagName) + '」已获取 <b>' + got + '</b> 个' +
(total ? ' / 共 <b>' + total + '</b> 个' : '');
setBar('#expBar', total ? got / total : Math.min(0.95, got / (got + 30)));
}
});
if (!list.length) {
expLog('分组「' + tagName + '」里没有成员' + (state.exportStop ? '(已手动停止)' : ''), 'warn');
$('#expStat').innerHTML = '该分组为空';
setBar('#expBar', 0);
return;
}
if ($('#expMeta').checked && !state.followCache[mid]) {
expLog('正在抓取全部关注以补全关注时间…', 'dim');
try {
await fetchFollowings(mid, {
shouldStop: () => state.exportStop,
onProgress: (got, t) => {
$('#expStat').innerHTML = '补全关注时间:<b>' + got + '</b>' +
(t ? ' / 共 <b>' + t + '</b> 个' : '');
}
});
} catch (e) {
expLog('! 补全关注时间失败(不影响导出):' + (e.message || e), 'warn');
}
}
list = applyFollowMeta(list, mid);
}
if (!list.length) {
expLog('没有读到任何关注(可能是隐私设置,或需要重新登录)', 'warn');
$('#expStat').innerHTML = '结果为空';
setBar('#expBar', 0);
return;
}
state.exported = {
version: 2,
exportAt: Date.now(),
selfMid: state.selfMid,
targetMid: mid,
tagid: tagId,
tagName: tagName,
total: list.length,
list: list
};
const scope = tagId === null ? '个关注' : '个分组「' + escHtml(tagName) + '」成员';
setBar('#expBar', 1);
$('#expStat').innerHTML = '导出完成:共 <b>' + list.length + '</b> ' + scope + ',用时 ' + fmtDuration(Date.now() - t0);
expLog('✓ 完成,共 ' + list.length + ' 个' + (tagId === null ? '' : '(分组「' + tagName + '」)') +
',用时 ' + fmtDuration(Date.now() - t0), 'ok');
if (state.exportStop) {
expLog('注意:这是手动停止后的部分结果,可以再次点击「开始导出」重新抓取', 'warn');
}
if (tagId === null && mid !== state.selfMid && list.length >= 100) {
expLog('提示:非本人主页的列表最多只能读到前 100 个', 'warn');
}
if (tagId !== null && !state.followCache[mid]) {
expLog('提示:分组接口不返回关注时间,需要的话可勾选「补全关注时间」后重导', 'dim');
}
$('#btnCopyUid').disabled = false;
$('#btnDlTxt').disabled = false;
$('#btnDlCsv').disabled = false;
$('#btnDlJson').disabled = false;
} catch (e) {
expLog('✗ 导出失败:' + (e.message || e), 'err');
if (e.code === -352 || e.code === -412) {
expLog('疑似触发风控,建议隔几分钟再试,或降低频率', 'warn');
}
$('#expStat').innerHTML = '导出中断';
} finally {
state.exporting = false;
$('#btnExport').disabled = false;
$('#btnExpStop').disabled = true;
}
}
function csvCell(v) {
const s = String(v == null ? '' : v);
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function buildTxt() {
return state.exported.list.map((u) => u.mid).join('\n');
}
function buildCsv() {
const g = state.exported.tagName;
const rows = [g ? ['UID', '昵称', '关注时间', '特别关注', '签名', '分组'] : ['UID', '昵称', '关注时间', '特别关注', '签名']];
state.exported.list.forEach((u) => {
const row = [u.mid, u.uname, fmtTime(u.mtime), u.special ? '是' : '', u.sign];
if (g) row.push(g);
rows.push(row);
});
return '\uFEFF' + rows.map((r) => r.map(csvCell).join(',')).join('\r\n');
}
function buildJson() {
return JSON.stringify({
version: 2,
exportAt: new Date(state.exported.exportAt).toISOString(),
targetMid: state.exported.targetMid,
tag: state.exported.tagName
? { tagid: state.exported.tagid, name: state.exported.tagName }
: null,
total: state.exported.total,
list: state.exported.list.map((u) => ({
mid: u.mid,
uname: u.uname,
mtime: u.mtime,
followTime: fmtTime(u.mtime),
special: u.special,
sign: u.sign
}))
}, null, 2);
}
/** 文件名里带上分组名,避免多次导出互相覆盖 */
function exportFileName(ext) {
const e = state.exported;
const g = e.tagName ? '-' + String(e.tagName).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 40) : '';
return 'bilibili-following-' + e.targetMid + g + '-' + stamp() + '.' + ext;
}
function saveResultText(r, label) {
if (r === 'saved') return '✓ 已保存 ' + label;
if (r === 'cancel') return '已取消保存 ' + label;
return '✓ 已交给浏览器下载 ' + label;
}
/* ==========================================================
* 导入(批量关注)流程
* ========================================================== */
async function doImport() {
if (state.importing) return;
try {
if (!state.selfMid) await loadSelf();
} catch (e) {
impLog('✗ ' + e.message, 'err');
return;
}
if (!getCookie('bili_jct')) {
impLog('✗ 未找到 bili_jct(CSRF Token),请重新登录后刷新页面', 'err');
return;
}
const uids = parseUidList($('#impText').value, state.selfMid);
if (!uids.length) {
impLog('✗ 没有解析到有效 UID', 'err');
return;
}
const delaySec = Math.max(1, Math.min(30, parseFloat($('#impDelay').value) || 2.5));
const autoRetry = $('#impRetry').checked;
const est = fmtDuration(uids.length * delaySec * 1000);
const ok = window.confirm(
'即将关注 ' + uids.length + ' 个用户\n' +
'间隔 ' + delaySec + ' 秒,预计耗时约 ' + est + '\n\n' +
'确定开始吗?(中途可暂停 / 停止)'
);
if (!ok) return;
state.importing = true;
state.paused = false;
state.stop = false;
state.failed = [];
$('#btnImport').disabled = true;
$('#btnPause').disabled = false;
$('#btnPause').textContent = '暂停';
$('#btnImpStop').disabled = false;
$('#btnCopyFail').disabled = true;
setBar('#impBar', 0);
let done = 0, success = 0, skipped = 0, failed = 0, rateHits = 0;
const t0 = Date.now();
const renderStat = () => {
$('#impStat').innerHTML =
'进度 <b>' + done + '/' + uids.length + '</b> ' +
'<span class="ok">成功 ' + success + '</span> ' +
'<span class="skip">跳过 ' + skipped + '</span> ' +
'<span class="bad">失败 ' + failed + '</span>';
};
impLog('开始批量关注,共 ' + uids.length + ' 个,间隔 ' + delaySec + 's');
for (let i = 0; i < uids.length; i++) {
if (state.stop) break;
while (state.paused && !state.stop) await sleep(200);
if (state.stop) break;
const uid = uids[i];
let handled = false;
for (let attempt = 0; attempt <= (autoRetry ? 2 : 0) && !handled; attempt++) {
try {
await modifyRelation(uid, 1);
success++;
done++;
handled = true;
rateHits = 0;
impLog('✓ ' + uid + ' 已关注', 'ok');
} catch (e) {
const code = e.code;
if (code === 22014) {
skipped++;
done++;
handled = true;
impLog('· ' + uid + ' 已经关注过了,跳过', 'dim');
} else if (code === -101 || code === -111) {
impLog('✗ ' + e.message + ',任务终止', 'err');
state.stop = true;
handled = true;
} else if (code === 22009) {
impLog('✗ 已达关注上限,任务终止', 'err');
state.stop = true;
handled = true;
} else if (RATE_LIMIT_CODES.includes(code) || code === -412) {
rateHits++;
if (attempt < (autoRetry ? 2 : 0)) {
const wait = 5000 * (attempt + 1);
impLog('! ' + uid + ' 触发风控,' + (wait / 1000) + 's 后重试', 'warn');
await sleep(wait);
} else {
failed++;
done++;
handled = true;
state.failed.push(uid);
impLog('✗ ' + uid + ' 风控拦截,已记入失败列表', 'err');
if (rateHits >= 3) {
state.paused = true;
$('#btnPause').textContent = '继续';
impLog('⚠ 连续风控,已自动暂停。建议等几分钟再点「继续」', 'warn');
}
}
} else if (code === 22002 || code === 22003 || code === 22013 || code === 40061 || code === 22001) {
failed++;
done++;
handled = true;
state.failed.push(uid);
impLog('✗ ' + uid + ' ' + (e.message || ('code ' + code)), 'err');
} else {
failed++;
done++;
handled = true;
state.failed.push(uid);
impLog('✗ ' + uid + ' ' + (e.message || e), 'err');
}
}
}
setBar('#impBar', done / uids.length);
renderStat();
if (i < uids.length - 1 && !state.stop) {
await sleep(delaySec * 1000 * rand(0.85, 1.25));
}
}
state.importing = false;
$('#btnImport').disabled = false;
$('#btnPause').disabled = true;
$('#btnPause').textContent = '暂停';
$('#btnImpStop').disabled = true;
$('#btnCopyFail').disabled = state.failed.length === 0;
const summary = (state.stop ? '已停止' : '全部完成') +
':成功 ' + success + ',跳过 ' + skipped + ',失败 ' + failed +
',用时 ' + fmtDuration(Date.now() - t0);
impLog('—— ' + summary, state.stop ? 'warn' : 'ok');
if (state.failed.length) impLog('失败 UID 可点「复制失败 UID」后再次导入', 'dim');
}
/* ==========================================================
* 事件绑定
* ========================================================== */
function bind() {
const panel = $('#panel');
const launcher = $('#launcher');
const openPanel = () => {
panel.hidden = false;
launcher.hidden = true;
};
const closePanel = () => {
panel.hidden = true;
launcher.hidden = false;
};
launcher.addEventListener('click', openPanel);
$('#btnClose').addEventListener('click', closePanel);
// 拖拽
(function enableDrag() {
const head = $('#head');
let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0;
head.addEventListener('mousedown', (e) => {
if (e.target.closest('button')) return;
const r = panel.getBoundingClientRect();
dragging = true;
sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top;
panel.style.left = r.left + 'px';
panel.style.top = r.top + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const nx = Math.min(Math.max(0, ox + e.clientX - sx), window.innerWidth - w);
const ny = Math.min(Math.max(0, oy + e.clientY - sy), window.innerHeight - h);
panel.style.left = nx + 'px';
panel.style.top = ny + 'px';
});
window.addEventListener('mouseup', () => { dragging = false; });
})();
// tab 切换
shadowTabs().forEach((tab) => {
tab.addEventListener('click', () => {
shadowTabs().forEach((t) => t.classList.toggle('active', t === tab));
['export', 'import'].forEach((k) => {
$('#pane-' + k).classList.toggle('active', tab.dataset.pane === k);
});
});
});
// 导出
$('#btnFillSelf').addEventListener('click', async () => {
try {
if (!state.selfMid) await loadSelf();
$('#expMid').value = state.selfMid;
syncTagAvailability();
if (state.groupsMid !== state.selfMid) {
await loadGroups(state.selfMid);
}
} catch (e) {
expLog('✗ ' + e.message, 'err');
}
});
$('#btnExport').addEventListener('click', doExport);
$('#btnExpStop').addEventListener('click', () => {
state.exportStop = true;
expLog('正在停止…', 'warn');
});
// 分组
$('#btnLoadTags').addEventListener('click', async () => {
if (state.exporting) return;
try {
if (!state.selfMid) await loadSelf();
const tags = await loadGroups(state.selfMid);
expLog('✓ 已读取 ' + tags.length + ' 个分组', 'ok');
} catch (e) {
expLog('✗ 读取分组失败:' + (e.message || e), 'err');
}
});
$('#expMid').addEventListener('input', () => syncTagAvailability());
$('#expTag').addEventListener('change', () => {
syncMetaRow();
const v = $('#expTag').value;
if (v === TAG_ALL) return;
const g = state.groups.find((x) => String(x.tagid) === v);
if (g) expLog('已选择分组「' + g.name + '」,共 ' + g.count + ' 个', 'dim');
});
$('#btnCopyUid').addEventListener('click', async () => {
if (!state.exported) return;
const ok = await copyText(buildTxt());
expLog(ok ? '✓ UID 列表已复制到剪贴板' : '✗ 复制失败,请改用下载', ok ? 'ok' : 'err');
});
$('#btnDlTxt').addEventListener('click', async () => {
if (!state.exported) return;
const r = await saveFile(exportFileName('txt'), buildTxt());
expLog(saveResultText(r, 'TXT'), r === 'cancel' ? 'warn' : 'ok');
});
$('#btnDlCsv').addEventListener('click', async () => {
if (!state.exported) return;
const r = await saveFile(exportFileName('csv'), buildCsv(), 'text/csv');
expLog(saveResultText(r, 'CSV(Excel 可直接打开)'), r === 'cancel' ? 'warn' : 'ok');
});
$('#btnDlJson').addEventListener('click', async () => {
if (!state.exported) return;
const r = await saveFile(exportFileName('json'), buildJson(), 'application/json');
expLog(saveResultText(r, 'JSON(可直接粘贴回导入框)'), r === 'cancel' ? 'warn' : 'ok');
});
// 导入
$('#btnImport').addEventListener('click', doImport);
$('#btnPause').addEventListener('click', () => {
state.paused = !state.paused;
$('#btnPause').textContent = state.paused ? '继续' : '暂停';
impLog(state.paused ? '已暂停' : '继续执行', 'warn');
});
$('#btnImpStop').addEventListener('click', () => {
state.stop = true;
state.paused = false;
impLog('正在停止…', 'warn');
});
$('#btnCopyFail').addEventListener('click', async () => {
const ok = await copyText(state.failed.join('\n'));
impLog(ok ? '✓ 失败 UID 已复制' : '✗ 复制失败', ok ? 'ok' : 'err');
});
// 输入框实时统计
$('#impText').addEventListener('input', () => {
if (state.importing) return;
const n = parseUidList($('#impText').value, state.selfMid).length;
$('#impStat').textContent = n ? ('已识别 ' + n + ' 个有效 UID(已自动去重)') : '等待开始';
});
}
function shadowTabs() {
return Array.prototype.slice.call($('#panel').querySelectorAll('.tab'));
}
/* ==========================================================
* 启动
* ========================================================== */
function boot() {
const shadow = buildUI();
$ = (sel) => shadow.querySelector(sel);
bind();
loadSelf().then(() => {
$('#expMid').placeholder = '留空 = 我自己(' + state.selfMid + ')';
expLog('已识别当前账号 UID ' + state.selfMid, 'dim');
expLog('点「开始导出」抓取全部关注;在「分组」里选一个分组可只导出该分组', 'dim');
return loadGroups(state.selfMid).then((tags) => {
if (tags.length) {
expLog('✓ 已读取 ' + tags.length + ' 个关注分组,可在下拉框里选择', 'dim');
}
}).catch((e) => {
expLog('读取分组失败(不影响全部导出):' + (e.message || e), 'warn');
});
}).catch((e) => {
expLog('未登录或读取失败:' + e.message, 'warn');
});
// 空间页自动展开,其它页面保持收起
if (/space\.bilibili\.com\/\d+\/relation/.test(location.href)) {
$('#panel').hidden = false;
$('#launcher').hidden = true;
}
}
if (document.body) {
boot();
} else {
window.addEventListener('DOMContentLoaded', boot, { once: true });
}
})();
备注一下可批量导入的舞蹈区up
358038645
3706951785515569
452431855
23284747
1325484690
5925230
3546823809567163
3546735129397769
12140983
275252427
172847856
1186219743
3546778081167686
1502536424
8535965
99639987
23237718
404353265
1555413697
100201761
171368594
431841455
255900282
1928909127
3707019510942522
30952147
34753408
411363248
143960627
14079121
24225876
441807008
694310228
328053629
229104302
3494358099692362
390806772
1766152
4889986
277915484
20580358
29457406
65890998
499445692
3493111095363723
693593809
277641906
35388081
1337856191
1525569786
1576086264
3537123743370142
34227094
3546919714425092
400367060
47834551
3632305100818628
397026969
3546946620885274
2386334
488277176
8060090
3546762004400474
8361911
440570431
1574624
8681587
51588985
643663223
404078509
66296905
674709378
1220096592
555243347
386402481
434999640
3546802366188256
73249627
673507076
3494358733031979
1598128093
16322326
1905253852
253665397
58037060
3493086938270372
416914547
3494380809750611
91136837
935459
498099165
11718651
3461572800284736
20067185
13410681
2800079
19702611
1500854734
1347290389
114597456
1905878
8598025
5899772
13574291
28098151
21648772
1627957
2456543
1505943026
129160871
251014432
3546374568151461
34129428
367262849
4726086
259333
339335440
58222051
3314672
8023419
35579222
346124580
508317181
36811595
434476793