// ==UserScript==
// @license MIT
// @name B站话题过滤器
// @namespace BiliTopicFilter
// @version 1.0
// @description 修复UI弹出问题的稳定版本
// @author maxwell
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDBjMzdjIiBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJzNC40OCAxMCAxMCAxMCAxMC00LjQ4IDEwLTEwUzE3LjUyIDIgMTIgMnptMCAxOGMtNC40MSAwLTgtMy41OS04LThzMy41OS04IDgtOCA4IDMuNTkgOCA4LTMuNTkgOC04IDh6bS0xLjA3LTcuMjRMOS4zMyAxMi43bC0xLjQxLTEuNDEgMi44My0yLjgzIDIuODMgMi44MyA0LjI0LTQuMjQgMS40MSAxLjQxLTVsLTUuMDA1IDV6Ii8+PC9zdmc+
// @match https://www.bilibili.com/v/topic/detail/*
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
class ConfigManager {
constructor() {
this.unsavedChanges = false;
this.cache = {
settings: {
userFilter: true,
keywordFilter: true,
hideMethod: 'overlay'
},
blocked: {
users: new Set(),
keywords: new Set(),
whitelist: new Set()
}
};
this.load();
}
load() {
const saved = GM_getValue('filterConfig');
if (saved) {
this.cache.settings = saved.settings || this.cache.settings;
this.cache.blocked.users = new Set(saved.blocked?.users || []);
this.cache.blocked.keywords = new Set(saved.blocked?.keywords || []);
this.cache.blocked.whitelist = new Set(saved.blocked?.whitelist || []);
}
}
save() {
GM_setValue('filterConfig', {
settings: this.cache.settings,
blocked: {
users: [...this.cache.blocked.users],
keywords: [...this.cache.blocked.keywords],
whitelist: [...this.cache.blocked.whitelist]
}
});
this.unsavedChanges = false;
}
addItem(type, value) {
if (type === 'users' || type === 'whitelist') {
if (!/^\d+$/.test(value)) return;
}
this.cache.blocked[type].add(value.toString());
this.unsavedChanges = true;
}
removeItem(type, value) {
this.cache.blocked[type].delete(value.toString());
this.unsavedChanges = true;
}
}
class FilterUI {
constructor(config) {
this.config = config;
this.initUI();
this.bindGlobalEvents();
this.hide(); // 初始隐藏
}
initUI() {
this.container = document.createElement('div');
this.container.className = 'filter-main';
this.render();
document.body.appendChild(this.container);
}
render() {
this.container.innerHTML = `
<div class="header">
<h3>内容过滤器</h3>
<button class="close-btn">×</button>
</div>
<div class="tabs">
<button data-tab="users" class="active">用户屏蔽</button>
<button data-tab="keywords">关键词屏蔽</button>
<button data-tab="whitelist">白名单</button>
<button data-tab="settings">设置</button>
</div>
<div class="content">
<div data-tab="users" class="tab-pane active">
<div class="input-group">
<input type="text" placeholder="输入UID,多个用逗号分隔">
<button class="add-btn">添加</button>
</div>
<div class="item-list" data-type="users"></div>
</div>
<div data-tab="keywords" class="tab-pane">
<div class="input-group">
<input type="text" placeholder="输入关键词,多个用逗号分隔">
<button class="add-btn">添加</button>
</div>
<div class="item-list" data-type="keywords"></div>
</div>
<div data-tab="whitelist" class="tab-pane">
<div class="input-group">
<input type="text" placeholder="输入UID,多个用逗号分隔">
<button class="add-btn">添加</button>
</div>
<div class="item-list" data-type="whitelist"></div>
</div>
<div data-tab="settings" class="tab-pane">
<label>
<input type="checkbox" data-setting="userFilter">启用用户屏蔽
</label>
<label>
<input type="checkbox" data-setting="keywordFilter">启用关键词屏蔽
</label>
<label>
<input type="radio" name="hideMethod" value="overlay" data-setting="hideMethod">显示屏蔽层
</label>
<label>
<input type="radio" name="hideMethod" value="remove" data-setting="hideMethod">完全隐藏
</label>
</div>
</div>
<div class="footer">
<button class="save-btn">保存配置</button>
<span class="save-status"></span>
</div>
`;
this.refreshView();
}
bindGlobalEvents() {
// 关闭按钮事件
this.container.querySelector('.close-btn').addEventListener('click', () => this.hide());
// 标签切换事件
this.container.querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', () => this.switchTab(btn.dataset.tab));
});
// 添加按钮事件
this.container.querySelectorAll('.add-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.handleAddItem(btn.closest('.tab-pane'));
});
});
// 删除按钮事件
this.container.addEventListener('click', e => {
if (e.target.classList.contains('delete-btn')) {
this.handleDeleteItem(e.target.closest('.list-item'));
}
});
// 保存按钮
this.container.querySelector('.save-btn').addEventListener('click', () => this.handleSave());
// 设置项变更
this.container.querySelectorAll('[data-setting]').forEach(input => {
input.addEventListener('change', () => this.handleSettingChange(input));
});
}
show() {
this.container.style.display = 'block';
this.refreshView();
}
hide() {
this.container.style.display = 'none';
}
switchTab(tabName) {
// 切换标签按钮状态
this.container.querySelectorAll('[data-tab]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabName);
});
// 切换内容面板
this.container.querySelectorAll('.tab-pane').forEach(pane => {
pane.classList.toggle('active', pane.dataset.tab === tabName);
});
}
handleAddItem(pane) {
const type = pane.dataset.tab;
const input = pane.querySelector('input');
const values = input.value.split(',').map(v => v.trim()).filter(Boolean);
if (type === 'users' || type === 'whitelist') {
if (values.some(v => !/^\d+$/.test(v))) {
alert('UID必须为数字');
return;
}
}
values.forEach(v => this.config.addItem(type, v));
input.value = '';
this.refreshList(type);
}
handleDeleteItem(item) {
const type = item.parentElement.dataset.type;
const value = item.dataset.value;
this.config.removeItem(type, value);
this.refreshList(type); // 另一个调用点
}
handleSave() {
this.config.save();
this.showStatus('保存成功!');
}
handleSettingChange(input) {
const settingKey = input.dataset.setting;
const value = input.type === 'checkbox'
? input.checked
: input.value;
// 更新配置
this.config.cache.settings[settingKey] = value;
this.config.unsavedChanges = true;
}
refreshList(type) {
const list = this.container.querySelector(`[data-type="${type}"]`);
list.innerHTML = [...this.config.cache.blocked[type]].map(value => `
<div class="list-item" data-value="${value}">
${value}
<button class="delete-btn">×</button>
</div>
`).join('');
}
refreshView() {
// 刷新所有列表
['users', 'keywords', 'whitelist'].forEach(type => this.refreshList(type));
// 更新设置项状态
Object.entries(this.config.cache.settings).forEach(([key, val]) => {
const input = this.container.querySelector(`[data-setting="${key}"]`);
if (!input) return;
if (input.type === 'checkbox') {
input.checked = val;
} else if (input.type === 'radio') {
input.checked = (input.value === val);
}
});
}
showStatus(text) {
const status = this.container.querySelector('.save-status');
status.textContent = text;
setTimeout(() => status.textContent = '', 2000);
}
}
GM_addStyle(`
/* 基础容器 */
.filter-main {
position: fixed;
right: 20px;
bottom: 20px;
width: 420px;
height: 560px; /* 固定高度 */
background: #2D2F33;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
color: #FFFFFF;
font-family: system-ui, sans-serif;
z-index: 99999;
display: none;
flex-direction: column; /* 垂直布局 */
}
/* 头部区域 */
.filter-main .header {
padding: 18px 24px;
border-bottom: 1px solid #404040;
position: relative;
padding-right: 60px;
}
.filter-main .header h3 {
margin: 0;
font-size: 17px;
font-weight: 500;
letter-spacing: 0.5px;
}
.filter-main .close-btn {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
background: rgba(255,255,255,0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.filter-main .close-btn:hover {
opacity: 0.8;
}
/* 标签导航 */
.filter-main .tabs {
display: flex;
padding: 0 16px;
background: #36383D;
flex-shrink: 0;
border-radius: 8px 8px 0 0;
}
.filter-main .tabs button {
flex: 1;
padding: 14px 0;
margin: 0 4px;
font-size: 13px;
color: #9DA3AD;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
position: relative;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.filter-main .tabs button:hover {
color: #FFFFFF;
}
.filter-main .tabs button.active {
color: #FFFFFF;
background: rgba(255,255,255,0.08);
}
/* 内容区域 */
.filter-main .content {
padding: 16px 24px;
flex: 1; /* 填充剩余空间 */
overflow-y: auto;
}
.filter-main .tab-pane {
display: none;
}
.filter-main .tab-pane.active {
display: block;
}
/* 输入组 */
.filter-main .input-group {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.filter-main input[type="text"] {
flex: 1;
padding: 10px 14px;
background: #3A3D41;
border: 1px solid #4E5156;
border-radius: 8px;
color: #FFFFFF;
font-size: 13px;
transition: all 0.2s;
}
.filter-main input[type="text"]:focus {
border-color: #0095FF;
outline: none;
box-shadow: 0 0 0 2px rgba(0,149,255,0.15);
}
/* 按钮样式 */
.filter-main .add-btn,
.filter-main .save-btn {
padding: 9px 18px;
background: #0095FF;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.filter-main .add-btn:hover,
.filter-main .save-btn:hover {
background: #007ACC;
transform: translateY(-1px);
}
/* 列表项 */
.filter-main .item-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-main .list-item {
background: #3A3D41;
padding: 6px 14px;
border-radius: 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.filter-main .delete-btn {
width: 18px;
height: 18px;
background: #FF5555;
color: white !important;
border: none;
border-radius: 50%;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s;
}
.filter-main .delete-btn:hover {
transform: scale(1.1);
}
/* 设置项 */
/* .filter-main [data-setting] {
margin: 14px 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
} */
.filter-main [data-tab="settings"] label {
display: inline-flex;
line-height: 36px;
width: 100%;
align-items: center;
margin-right: 20px;
vertical-align: middle;
}
.filter-main [type="checkbox"],
.filter-main [type="radio"] {
margin: 0 6px 0 0;
width: 16px;
height: 16px;
accent-color: #0095FF;
}
/* 底部区域 */
.filter-main .footer {
padding: 16px 24px;
border-top: 1px solid #404040;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-main .save-status {
color: #00C853;
font-size: 12px;
opacity: 0.9;
}
`);
class ContentFilter {
static init(config) {
const nativeOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
if (url.startsWith('//api.bilibili.com/x/polymer/web-dynamic/v1/feed/topic')) {
this.addEventListener('readystatechange', function () {
if (this.readyState === 4) {
try {
const response = JSON.parse(this.responseText);
if (response.code === 0) {
response.data.topic_card_list.items =
response.data.topic_card_list.items.filter(item =>
ContentFilter.shouldKeep(item, config)
);
// 当前 xhr 对象上定义 responseText
Object.defineProperty(this, "responseText", {
writable: true,
});
Object.defineProperty(this, 'responseText', {
value: JSON.stringify(response)
});
}
} catch (e) { }
}
});
}
nativeOpen.apply(this, arguments);
}
}
static shouldKeep(item, config) {
const authorId = item.dynamic_card_item?.modules?.module_author?.mid?.toString() || '';
const content = [
item.dynamic_card_item?.modules?.module_dynamic?.major?.opus?.title || '',
item.dynamic_card_item?.modules?.module_dynamic?.major?.opus?.summary?.text || '',
item.dynamic_card_item?.modules?.module_dynamic?.desc?.text || ''
].join(' ').toLowerCase();
if (config.cache.blocked.whitelist.has(authorId)) return true;
if (config.cache.settings.userFilter && config.cache.blocked.users.has(authorId)) return false;
if (config.cache.settings.keywordFilter &&
[...config.cache.blocked.keywords].some(kw => content.includes(kw.toLowerCase()))) return false;
return true;
}
}
// 初始化系统
const config = new ConfigManager();
let filterUIInstance = null;
GM_registerMenuCommand('打开过滤设置', () => {
if (!filterUIInstance) {
filterUIInstance = new FilterUI(config);
}
filterUIInstance.show();
});
ContentFilter.init(config);
})();