Greasy Fork is available in English.
让您的龙空浏览体验回归平静与高效。新增显示用户名功能。安装任何插件,在浏览器运行任何代码前,请先问问AI,防止代码中包含恶意攻击内容。
// ==UserScript==
// @name 龙空信息降噪器 v0.4.1
// @namespace http://tampermonkey.net/
// @version 0.4.1
// @description 让您的龙空浏览体验回归平静与高效。新增显示用户名功能。安装任何插件,在浏览器运行任何代码前,请先问问AI,防止代码中包含恶意攻击内容。
// @author liudev & AI Assistant
// @match https://www.lkong.com/forum/*
// @match https://www.lkong.com/thread/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// ================== 1. 数据存储与加载 ==================
const STORAGE_KEY_USERS = 'lkong_blocked_users';
const STORAGE_KEY_TITLE_KEYWORDS = 'lkong_blocked_title_keywords';
const STORAGE_KEY_REPLY_KEYWORDS = 'lkong_blocked_reply_keywords';
let blockedUsers = []; // { userId: string, deepBlock: boolean }[]
let blockedTitleKeywords = new Set();
let blockedReplyKeywords = new Set();
async function loadBlockedData() {
try {
const [storedUsersStr, storedTitleKeywords, storedReplyKeywords] = await Promise.all([
GM_getValue(STORAGE_KEY_USERS, '[]'),
GM_getValue(STORAGE_KEY_TITLE_KEYWORDS, '[]'),
GM_getValue(STORAGE_KEY_REPLY_KEYWORDS, '[]')
]);
// --- 用户数据迁移与加载 ---
let usersData = JSON.parse(storedUsersStr);
if (usersData.length > 0 && typeof usersData[0] === 'string') {
// 旧版数据 (string[]), 迁移到新版 ({ userId, deepBlock })
console.log('LKong Blocker: 检测到旧版用户数据,正在迁移...');
blockedUsers = usersData.map(userId => ({ userId: userId, deepBlock: false }));
await saveBlockedData(); // 迁移后立即保存
} else {
blockedUsers = usersData;
}
blockedTitleKeywords = new Set(JSON.parse(storedTitleKeywords));
blockedReplyKeywords = new Set(JSON.parse(storedReplyKeywords));
} catch (e) {
console.error('LKong Blocker: 加载噪声名单失败', e);
blockedUsers = [];
blockedTitleKeywords = new Set();
blockedReplyKeywords = new Set();
}
}
async function saveBlockedData() {
try {
await Promise.all([
GM_setValue(STORAGE_KEY_USERS, JSON.stringify(blockedUsers)),
GM_setValue(STORAGE_KEY_TITLE_KEYWORDS, JSON.stringify(Array.from(blockedTitleKeywords))),
GM_setValue(STORAGE_KEY_REPLY_KEYWORDS, JSON.stringify(Array.from(blockedReplyKeywords)))
]);
} catch(e) {
console.error('LKong Blocker: 保存噪声名单失败', e);
}
}
// ================== 辅助功能:API获取用户名 ==================
async function fetchUserName(userId) {
if (!userId) return null;
const query = {
"operationName": "ViewUserContentsPage",
"variables": { "uid": parseInt(userId, 10), "page": 1, "isDigest": false },
"query": "query ViewUserContentsPage($uid: Int!, $isDigest: Boolean!, $page: Int) {\n content: userReplies(uid: $uid, isDigest: $isDigest, page: $page) {\n author {\n name\n __typename\n }\n __typename\n }\n}"
};
try {
const response = await fetch("https://api.lkong.com/api", {
"method": "POST",
"credentials": "include", // ★★★ 必须加上这一行 ★★★
"headers": { "content-type": "application/json" },
"body": JSON.stringify(query)
});
const json = await response.json();
// 如果未登录,返回特定错误以便调试
if (json.errors) {
console.warn('LK-Blocker: API返回权限错误,可能登录状态失效', json.errors);
return null;
}
const replies = json?.data?.content;
if (replies && replies.length > 0 && replies[0].author) {
return replies[0].author.name;
}
return null; // 有权限,但用户真的没发过贴,或者被全站屏蔽了
} catch (error) {
console.error("LK-Blocker: API获取用户名网络异常", error);
return null;
}
}
// ================== 2. 核心处理逻辑 ==================
// --- 2.1 论坛列表页:过滤帖子标题 ---
function processThreadsData(threads) {
if (!threads || !Array.isArray(threads)) return;
for (const thread of threads) {
const uid = (thread.author?.uid || thread.authorid)?.toString();
const tid = thread.tid?.toString();
const title = thread.subject || '';
if (!uid || !tid) continue;
const threadLink = document.querySelector(`a[href*="/thread/${tid}"]`);
if (!threadLink) continue;
// Find the containing thread item using multiple fallbacks (avoid fragile css-xxxxx)
const threadItem = threadLink.closest('.css-760i8n')
|| threadLink.closest('div[class*="thread"]')
|| threadLink.closest('article')
|| threadLink.closest('li')
|| threadLink.closest('[data-tid]');
if (!threadItem || threadItem.dataset.lkProcessed === 'true') continue;
threadItem.dataset.lkProcessed = 'true';
if (blockedUsers.some(user => user.userId === uid)) {
console.log(`LKong Blocker: 已按用户 [${uid}] 净化帖子 (TID: ${tid})。`);
threadItem.style.display = 'none';
continue;
}
const currentTitle = title || threadLink.textContent;
for (const keyword of blockedTitleKeywords) {
if (keyword && currentTitle.includes(keyword)) {
console.log(`LKong Blocker: 已按标题关键词 [${keyword}] 净化帖子 (标题: ${currentTitle})。`);
threadItem.style.display = 'none';
threadItem.dataset.lkKeywordBlocked = 'true';
break;
}
}
if (threadItem.dataset.lkKeywordBlocked === 'true') continue;
let authorContainer = threadItem.querySelector('.author');
if (!authorContainer) {
// fallback to author link or nearest container
const authorLink = threadItem.querySelector('a[href^="/user/"]') || threadItem.querySelector('a[href*="author="]');
authorContainer = authorLink ? (authorLink.closest('div') || authorLink) : null;
}
if (authorContainer) addBlockButton(authorContainer, uid, threadItem);
}
}
// --- 列表页添加按钮:点击后调用API获取名字再屏蔽 ---
function addBlockButton(anchorElement, userId, threadItem) {
if (anchorElement.querySelector('.lk-block-btn')) return;
const blockButton = document.createElement('a');
blockButton.href = '#';
blockButton.textContent = '[净化]';
blockButton.className = 'lk-block-btn';
blockButton.style.cssText = 'margin-left: 8px; font-size: 12px; color: #999;';
blockButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// ============ 1. 优先尝试从 DOM 提取用户名 ============
let domUserName = '';
if (threadItem) {
// 根据你提供的 HTML 结构: .author 下的第一个链接通常是用户名
const authorLink = threadItem.querySelector('.author a[href*="/user/"]');
if (authorLink) {
domUserName = authorLink.textContent.trim();
} else {
// 备用:搜索结果页有时候结构不一样,或者是 avatar
const avatarImg = threadItem.querySelector('.ant-avatar img, img[class*="avatar"]');
if (avatarImg && avatarImg.alt) {
domUserName = avatarImg.alt;
}
}
}
// ===================================================
// 如果提取到了名字,就显示名字;否则显示ID
const displayName = domUserName || `ID:${userId}`;
confirmationModal.show(`确定要净化用户: 【${displayName}】 吗?\n该用户的帖子将从列表中消失。`, async () => {
let finalName = domUserName;
// ============ 2. 如果DOM没抓到,才请求API ============
if (!finalName) {
try {
const apiName = await fetchUserName(userId);
if (apiName) finalName = apiName;
} catch(err) {
console.error(err);
}
}
// 默认值
if (!finalName) finalName = '未知用户';
// ============ 3. 保存 ============
if (!blockedUsers.some(u => u.userId === userId)) {
blockedUsers.push({ userId: userId, deepBlock: false, userName: finalName });
await saveBlockedData();
// 隐藏当前行
if (threadItem) {
threadItem.style.display = 'none';
// 有时候列表由虚线分隔,把分隔线也隐藏可能更好看,但这取决于具体CSS
if(threadItem.nextElementSibling && threadItem.nextElementSibling.tagName === 'HR') {
threadItem.nextElementSibling.style.display = 'none';
}
}
}
});
});
anchorElement.appendChild(blockButton);
}
// --- 2.2 帖子详情页:过滤回帖内容 ---
function processPost(postElement) {
// 使用两个状态:pending表示处理中,true表示已彻底处理
if (postElement.dataset.lkPostProcessed === 'true' || postElement.dataset.lkPostProcessed === 'pending') {
return;
}
postElement.dataset.lkPostProcessed = 'pending';
let retryCount = 0;
const maxRetries = 50; // 轮询15次,每次间隔100毫秒 (总等待约 1.5 秒),给React框架挂载元素的时间
function attemptExtraction() {
let userId = null;
// 尝试第一种链接: a[href*="?author="] (只看TA)
const authorFilterLink = postElement.querySelector('a[href*="?author="]');
if (authorFilterLink) {
try {
// 加入第二个参数保证如果取到相对路径也能正常解析
const url = new URL(authorFilterLink.href, window.location.origin);
userId = url.searchParams.get('author');
} catch (e) { /* 忽略无效URL */ }
}
// 尝试第二种: a[href^="/user/"] (用户主页链接)
if (!userId) {
const userProfileLink = postElement.querySelector('a[href^="/user/"]');
if (userProfileLink) {
userId = userProfileLink.href.split('/').pop();
}
}
// 【核心修复】:如果没有获取到ID,并且还没有超时,我们再稍微等一等页面挂载DOM
if (!userId && retryCount < maxRetries) {
retryCount++;
setTimeout(attemptExtraction, 1000);
return;
}
// 到这一步,无论成败,代表彻底完成了检索操作
postElement.dataset.lkPostProcessed = 'true';
// ============ 后面才是正式的过滤/执行逻辑 ============
if (userId) {
// 执行添加屏蔽按钮
addPurifyButtons(postElement, userId);
const blockedUser = blockedUsers.find(u => u.userId === userId);
if (blockedUser && blockedUser.deepBlock) {
console.log(`LKong Blocker: 已按用户 [${userId}] (深度屏蔽) 净化此楼层。`);
postElement.style.display = 'none';
return; // 用户屏蔽优先,直接阻断
}
} else {
console.log(`LKong Blocker: 未能提取到楼层发帖人ID (可能是帖子架构异常或网络太慢),该楼层仅应用关键词过滤。`);
}
// 关键词屏蔽检查 (在用户未被屏蔽 或 取不到用户的退拽保护下执行)
const contentDiv = postElement.querySelector('.main-content');
if (contentDiv) {
const contentText = contentDiv.textContent || '';
for (const keyword of blockedReplyKeywords) {
if (keyword && contentText.includes(keyword)) {
console.log(`LKong Blocker: 已按回帖关键词 [${keyword}] 净化此楼层。`);
postElement.style.display = 'none';
return;
}
}
}
// 最后添加折叠按钮
addFoldingFeature(postElement);
}
// 启动获取检测逻辑
attemptExtraction();
}
// --- 2.3 帖子详情页:添加净化按钮 ---
function addPurifyButtons(postElement, userId) {
// 1. 定位操作栏 (放按钮的地方)
const findActionsContainer = (el) => {
if(!el) return null;
// 尝试在当前元素或子元素找
let target = el.querySelector && el.querySelector('.css-9ph873, .css-1feda4v, .post-actions, .actions');
if (target) return target;
// 尝试去父级找(适应 React 渲染层级偏差)
if (el.parentElement) {
target = el.parentElement.querySelector('.css-9ph873, .css-1feda4v, .post-actions, .actions');
if (target) return target;
}
return null;
};
const actionsContainer = findActionsContainer(postElement);
if (!actionsContainer || actionsContainer.querySelector('.lk-purify-btn')) return;
const reportBtn = actionsContainer.querySelector('.css-rhu9bd, .css-j5tknx, a[title*="举报"]');
const createButton = (text, className, titlePrefix, isDeep) => {
const btn = document.createElement('div');
btn.className = `lk-action-wrapper ${className}`;
btn.innerHTML = `<span>${text}</span>`;
btn.title = `${titlePrefix} (ID:${userId})`;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
let foundName = '';
// ============ 核心逻辑:向上雷达扫描 ============
let pointer = btn.parentElement;
let levels = 0;
// 向上爬 7 层,这足以跳出 .main-content 进入 .main-content-wrap
while(pointer && levels < 7) {
// 场景 1: 楼主层布局 (Top-level layout)
// 对应你提供的第二段代码: .user-wrapper > strong
const userWrapper = pointer.querySelector('.user-wrapper strong');
if (userWrapper) {
foundName = userWrapper.textContent;
console.log(`LK-Blocker: 命中楼主布局 (Level ${levels})`);
break;
}
// 场景 2: 普通回复布局 (Reply layout)
// 对应代码: .left-area > .user > h2
// 只要容器内有 .left-area,我们就在这个范围内细找
const leftArea = pointer.querySelector('.left-area');
if (leftArea) {
const h2 = leftArea.querySelector('.user h2');
// 排除只显示"楼主"字样的情况,必须找用户名
if (h2) {
// 优先取 h2 下的第一个 span(它最干净,不含其他杂质)
const nameSpan = h2.querySelector('span:first-child');
if (nameSpan) {
foundName = nameSpan.textContent.trim();
} else {
// 只有实在找不到span,才拿整个h2,但要做字符串清洗
// 暴力清洗:只要空格前的第一部分
foundName = h2.textContent.split(/[\s\n\t]+|楼主|Lv\./)[0].trim();
}
console.log(`LK-Blocker: 命中回帖布局 (Level ${levels})`);
break;
}
}
// 场景 3: 响应式/移动端窄屏布局
// 有时候 .left-area 没了,但头像还在,图片 alt 是最稳的
const avatarImg = pointer.querySelector('img.ant-avatar-image, .ant-avatar img');
if (avatarImg && avatarImg.alt && avatarImg.alt.length > 0) {
// 防止取到 "avatar" 这种无效文本,只取看似名字的
if (avatarImg.alt !== 'avatar') {
foundName = avatarImg.alt;
// 不break,因为上面两个文本查找更精准,这个作为备选先存着
// 但如果是楼主布局,通常上面那个user-wrapper已经命中了
}
}
pointer = pointer.parentElement;
levels++;
}
// 数据清洗
if (foundName) foundName = foundName.replace(/[\r\n\t]/g, '').trim();
// ===========================================
const displayName = foundName || `ID:${userId}`;
const actionText = isDeep ? '深度净化' : '净化';
const warningText = isDeep ? '他的所有主题和回帖都将被隐藏。' : '他的所有主题都将被隐藏。';
const confirmMsg = `确定要${actionText}用户: 【${displayName}】\n(ID: ${userId}) 吗?\n${warningText}`;
confirmationModal.show(confirmMsg, async () => {
const existingUser = blockedUsers.find(u => u.userId === userId);
if (existingUser) {
if (existingUser.deepBlock !== isDeep) {
existingUser.deepBlock = isDeep;
if (foundName) existingUser.userName = foundName;
await saveBlockedData();
}
} else {
const nameToSave = foundName || '未知用户';
blockedUsers.push({ userId: userId, deepBlock: isDeep, userName: nameToSave });
await saveBlockedData();
}
alert(`用户 ${displayName} 已被${isDeep ? '深度' : ''}净化。`);
window.location.reload();
});
});
return btn;
};
const purifyBtn = createButton('净化', 'lk-purify-btn', '净化', false);
const deepPurifyBtn = createButton('深度净化', 'lk-deep-purify-btn', '深度净化', true);
if (reportBtn) {
actionsContainer.insertBefore(purifyBtn, reportBtn);
actionsContainer.insertBefore(deepPurifyBtn, reportBtn);
} else {
actionsContainer.appendChild(purifyBtn);
actionsContainer.appendChild(deepPurifyBtn);
}
}
function initPostObserver() {
// Use multiple fallbacks to find a stable root to observe
const targetNode = document.querySelector('div.css-xt623x')
|| document.querySelector('div.css-1gnk3bx')
|| document.getElementById('__next')
|| document.querySelector('div[data-reactroot]');
if (!targetNode) {
setTimeout(initPostObserver, 500);
return;
}
const findPostForContent = (contentEl) => {
return contentEl.closest('.css-1pp9a0y')
|| contentEl.closest('div.posts-ancor')
|| contentEl.closest('div[class*="post"]')
|| contentEl.closest('article')
|| contentEl.closest('li')
|| contentEl.closest('[data-floor]')
|| contentEl.parentElement;
};
// 1. 首次加载时处理已有帖子:以 .thread-content 为锚点,找到所属帖子容器
targetNode.querySelectorAll('.main-content').forEach(contentEl => {
const postEl = findPostForContent(contentEl);
if (postEl) processPost(postEl);
});
// 2. 创建观察器处理动态加载:当有新节点加入时,查找其子树中的 .thread-content
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.matches && node.matches('.main-content')) {
const postEl = findPostForContent(node);
if (postEl) processPost(postEl);
}
node.querySelectorAll && node.querySelectorAll('.main-content').forEach(contentEl => {
const postEl = findPostForContent(contentEl);
if (postEl) processPost(postEl);
});
}
});
}
}
});
observer.observe(targetNode, { childList: true, subtree: true });
console.log("LKong Blocker: 帖子内容监视器已启动。");
}
// --- 2.4 帖子折叠功能 ---
function addFoldingFeature(postElement) {
const content = postElement.querySelector('.main-content');
// Based on user feedback, the button should be next to the floor number (e.g., #1, #2).
// The floor number is inside a div with the class 'right-area'.
const rightArea = postElement.querySelector('.right-area');
if (!content || !rightArea || rightArea.querySelector('.fold-button')) {
return; // Skip if essential elements are missing or button exists
}
if (content.offsetHeight > 600) {
const foldButton = document.createElement('a');
foldButton.textContent = '折叠';
foldButton.className = 'fold-button';
foldButton.href = 'javascript:void(0);';
foldButton.dataset.folded = 'false';
// Prepend the button to the 'right-area' div to place it before the floor number.
rightArea.prepend(foldButton);
foldButton.addEventListener('click', (e) => {
e.preventDefault();
const isFolded = foldButton.dataset.folded === 'true';
if (isFolded) {
content.classList.remove('folded');
foldButton.textContent = '折叠';
foldButton.dataset.folded = 'false';
} else {
content.classList.add('folded');
foldButton.textContent = '展开';
foldButton.dataset.folded = 'true';
}
});
}
}
function createFoldAllButton() {
if (document.getElementById('fold-all-btn')) return;
const foldAllBtn = document.createElement('div');
foldAllBtn.id = 'fold-all-btn';
foldAllBtn.textContent = '一键折叠';
document.body.appendChild(foldAllBtn);
foldAllBtn.addEventListener('click', () => {
const foldButtons = document.querySelectorAll('.fold-button');
foldButtons.forEach(button => {
if (button.dataset.folded === 'false') {
button.click();
}
});
});
}
// ================== 3. 数据拦截 (用于论坛列表页) ==================
// (这部分无需修改)
function handleApiResponse(responseText) {
try {
const data = JSON.parse(responseText);
const threads = data?.data?.threads;
if (threads) {
setTimeout(() => processThreadsData(threads), 500);
}
} catch (e) { /* 忽略 */ }
}
const originalXhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function(...args) {
this.addEventListener('load', function() {
if (this.responseURL?.includes('api.lkong.com/api') && this.status === 200) {
if (this.responseType === 'blob') this.response.text().then(handleApiResponse);
else handleApiResponse(this.responseText);
}
});
return originalXhrSend.apply(this, args);
};
const originalFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async function(...args) {
const response = await originalFetch(...args);
const url = args[0] instanceof Request ? args[0].url : args[0];
if (typeof url === 'string' && url.includes('api.lkong.com/api')) {
response.clone().text().then(handleApiResponse);
}
return response;
};
// ================== 4. UI 与 DOM 相关操作 ==================
function handleInitialData() {
const nextDataScript = document.getElementById('__NEXT_DATA__');
if (!nextDataScript) return;
try {
const data = JSON.parse(nextDataScript.textContent);
const allThreads = data?.props?.pageProps?.threads || [];
if (data?.props?.pageProps?.source?.topThreads) {
allThreads.push(...data.props.pageProps.source.topThreads);
}
if (allThreads.length > 0) setTimeout(() => processThreadsData(allThreads), 100);
} catch (e) {
console.error('LKong Blocker: 解析 __NEXT_DATA__ 失败', e);
}
}
// (修改) 扩展管理UI以支持三类屏蔽
function createManagerUI() {
GM_addStyle(`
/* --- General --- */
#lk-block-manager-btn {position: fixed; bottom: 135px; right: 20px; background-color: #007bff; color: white; padding: 10px 15px; border-radius: 5px; cursor: pointer; z-index: 9999; font-size: 14px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
#lk-block-manager-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0,0,0,0.2);
}
#lk-block-manager-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 550px;
max-width: 95vw;
max-height: 90vh;
background-color: #fcfcfc;
border: 1px solid #e0e0e0;
border-radius: 12px;
z-index: 10000;
box-shadow: 0 5px 20px rgba(0,0,0,0.2);
display: none;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* --- Header --- */
.lk-manager-header {
padding: 16px 24px;
border-bottom: 1px solid #e0e0e0;
}
.lk-manager-header h3 {
margin: 0;
text-align: center;
font-size: 18px;
font-weight: 600;
color: #212121;
}
/* --- Body & Tabs --- */
.lk-manager-body {
padding: 0 24px 24px 24px;
overflow-y: auto;
flex-grow: 1;
}
.lk-tabs {
display: flex;
border-bottom: 1px solid #e0e0e0;
margin: 0 -24px 20px -24px; /* Extend to panel edges */
padding: 0 24px;
}
.lk-tab {
padding: 12px 16px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px; /* Overlap the container border */
font-size: 15px;
color: #666;
transition: all 0.2s ease;
}
.lk-tab:hover {
background-color: #f5f5f5;
color: #333;
}
.lk-tab.active {
color: #2196F3;
font-weight: 600;
border-bottom-color: #2196F3;
}
.lk-tab-content { display: none; }
.lk-tab-content.active { display: block; }
/* --- Form Elements --- */
.lk-manager-body textarea {
width: 100%;
box-sizing: border-box;
height: 250px;
margin-bottom: 10px;
font-family: "SF Mono", "Fira Code", "Consolas", monospace;
resize: vertical;
border: 1px solid #ccc;
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.lk-manager-body textarea:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.lk-manager-body p {
font-size: 13px;
color: #666;
margin-top: 0;
margin-bottom: 10px;
line-height: 1.5;
}
/* --- Footer --- */
.lk-manager-footer {
padding: 16px 24px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
background-color: #f5f5f5;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
gap: 12px;
}
/* --- Unified Button Styles --- */
.lk-btn {
padding: 9px 18px;
cursor: pointer;
border: 1px solid transparent;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
text-align: center;
transition: all 0.2s ease-in-out;
-webkit-font-smoothing: antialiased;
}
.lk-btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.08);
}
.lk-btn:active {
transform: translateY(0);
box-shadow: none;
filter: brightness(0.95);
}
/* Button Color Modifiers */
.lk-btn.lk-btn-primary { background-color: #4CAF50; color: white; border-color: #4CAF50; }
.lk-btn.lk-btn-secondary { background-color: #2196F3; color: white; border-color: #2196F3; }
.lk-btn.lk-btn-danger { background-color: #f44336; color: white; border-color: #f44336; }
.lk-btn.lk-btn-default { background-color: #f0f0f0; color: #333; border-color: #ccc; }
.lk-btn.lk-btn-default:hover { background-color: #e0e0e0; border-color: #bbb; }
/* --- User List Specifics --- */
#lk-blocked-users-list {
height: 220px;
overflow-y: auto;
border: 1px solid #e0e0e0;
padding: 8px;
margin-bottom: 15px;
border-radius: 6px;
background-color: #fff;
}
.lk-user-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.lk-user-item:not(:last-child) {
border-bottom: 1px solid #f0f0f0;
}
.lk-user-item:hover {
background-color: #f5f5f5;
}
.lk-user-item label {
flex-grow: 1;
display: flex;
align-items: center; /* Vertical alignment for checkbox */
cursor: pointer;
}
.lk-user-item input[type="checkbox"] {
margin-right: 12px;
width: 16px;
height: 16px;
accent-color: #2196F3;
}
.lk-user-item .user-id {
font-family: "SF Mono", "Fira Code", "Consolas", monospace;
font-size: 14px;
color: #333;
}
.lk-user-item .remove-btn {
margin-left: 15px;
color: #f44336;
cursor: pointer;
font-weight: bold;
font-size: 20px;
line-height: 1;
transition: color 0.2s ease, transform 0.2s ease;
}
.lk-user-item .remove-btn:hover {
color: #d32f2f;
transform: scale(1.2);
}
/* --- Add User Form & Import/Export --- */
.lk-add-user-form {
display: flex;
gap: 10px;
margin-top: 15px;
}
.lk-add-user-form input {
flex-grow: 1;
padding: 9px 12px;
border: 1px solid #ccc;
border-radius: 6px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.lk-add-user-form input:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.lk-add-user-form button { /* Uses .lk-btn styles now */
flex-shrink: 0;
}
.lk-import-export-section {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0; /* Replaces <hr> */
}
.lk-import-export-section textarea {
height: 100px;
}
.lk-import-export-buttons {
display: flex;
gap: 10px;
margin-top: 10px;
}
.lk-import-export-buttons button {
flex-grow: 1;
}
/* --- Confirmation Modal --- */
#lk-confirm-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10001; /* Above panel, below modal */
display: none;
align-items: center;
justify-content: center;
}
#lk-confirm-modal {
background-color: #fcfcfc;
padding: 24px;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0,0,0,0.25);
width: 400px;
max-width: 90vw;
z-index: 10002;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
transform: scale(0.95);
opacity: 0;
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
}
#lk-confirm-modal.visible {
transform: scale(1);
opacity: 1;
}
#lk-confirm-modal h4 {
margin-top: 0;
margin-bottom: 12px;
font-size: 18px;
font-weight: 600;
color: #212121;
text-align: center;
}
#lk-confirm-modal p {
margin-top: 0;
margin-bottom: 24px;
font-size: 15px;
color: #666;
line-height: 1.6;
text-align: center;
}
.lk-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.lk-confirm-hint {
margin-top: 16px;
text-align: center;
font-size: 12px;
color: #999;
}
/* --- Post Folding --- */
.fold-button {
margin-right: 10px;
color: #1890ff;
cursor: pointer;
font-size: 14px;
}
.fold-button:hover {
text-decoration: underline;
}
.main-content.folded {
display: none;
}
/* --- LK custom action wrapper & buttons (avoid using site css-xxxxx) --- */
.lk-action-wrapper {
display: inline-flex;
align-items: center;
margin-right: 6px;
cursor: pointer;
}
.lk-purify-btn, .lk-deep-purify-btn {
padding: 4px 8px;
background: transparent;
color: #666;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
margin-left: 6px;
}
.lk-purify-btn:hover, .lk-deep-purify-btn:hover {
color: #2196F3;
}
#fold-all-btn { position: fixed; bottom: 90px; right: 20px; background-color: #007bff; color: white; padding: 10px 15px; border-radius: 5px; cursor: pointer; z-index: 9999; font-size: 14px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
#fold-all-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0,0,0,0.2);
}
.lk-user-item {
/* 修改这一项,让它稍微高一点好放两行文字 */
align-items: flex-start;
padding: 10px;
}
.lk-user-info { display: flex; flex-direction: column; margin-left: 10px; flex-grow: 1; }
.lk-user-name { font-weight: bold; color: #333; font-size: 14px; }
.lk-user-id-link { font-size: 12px; color: #999; text-decoration: none; margin-top: 4px; }
.lk-user-id-link:hover { color: #2196F3; text-decoration: underline; }
#lk-update-names-btn { font-size: 12px; padding: 5px 10px; }
`);
const managerBtn = document.createElement('div');
managerBtn.id = 'lk-block-manager-btn';
managerBtn.textContent = '管理噪声';
document.body.appendChild(managerBtn);
const panel = document.createElement('div');
panel.id = 'lk-block-manager-panel';
panel.innerHTML = `
<div class="lk-manager-header"><h3>噪声管理</h3></div>
<div class="lk-manager-body">
<div class="lk-tabs">
<div class="lk-tab active" data-tab="users">用户</div>
<div class="lk-tab" data-tab="title_keywords">标题关键词</div>
<div class="lk-tab" data-tab="reply_keywords">回帖关键词</div>
</div>
<div id="lk-tab-users" class="lk-tab-content active">
<p>勾选“深度屏蔽”后,该用户的回帖也会在帖子页面被隐藏。</p>
<!-- 新增:功能栏 -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<span style="font-weight:bold; color:#555;">屏蔽列表</span>
<button id="lk-update-names-btn" class="lk-btn lk-btn-default">↺ 自动获取所有未知昵称</button>
</div>
<div id="lk-blocked-users-list"></div>
<div class="lk-add-user-form">
<input type="text" id="lk-new-user-id" placeholder="输入用户ID,多个用逗号/空格/换行分隔">
<button id="lk-add-user-btn" class="lk-btn lk-btn-secondary">添加</button>
</div>
<div class="lk-import-export-section">
<p>批量导入/导出 (格式: userId,deepBlock):</p>
<textarea id="lk-import-export-textarea"></textarea>
<div class="lk-import-export-buttons">
<button id="lk-export-btn" class="lk-btn lk-btn-default">导出到剪贴板</button>
<button id="lk-import-btn" class="lk-btn lk-btn-default">从文本框导入</button>
<button id="lk-clear-all-users-btn" class="lk-btn lk-btn-danger" style="margin-left: auto;">清空所有ID</button>
</div>
</div>
</div>
<div id="lk-tab-title_keywords" class="lk-tab-content">
<p>每行一个关键词。帖子标题包含任意一个词都会在列表页被隐藏。</p>
<textarea id="lk-blocked-title-keywords-textarea"></textarea>
</div>
<div id="lk-tab-reply_keywords" class="lk-tab-content">
<p>每行一个关键词。帖子内的回帖如果包含任意一个词,该楼层将被隐藏。</p>
<textarea id="lk-blocked-reply-keywords-textarea"></textarea>
</div>
</div>
<div class="lk-manager-footer">
<button id="lk-close-btn" class="lk-btn lk-btn-default">关闭</button>
<button id="lk-save-btn" class="lk-btn lk-btn-primary">保存并刷新</button>
</div>
`;
document.body.appendChild(panel);
const userListContainer = panel.querySelector('#lk-blocked-users-list');
const addUserBtn = panel.querySelector('#lk-add-user-btn');
const newUserIdInput = panel.querySelector('#lk-new-user-id');
function renderBlockedUsersList() {
userListContainer.innerHTML = '';
if (blockedUsers.length === 0) {
userListContainer.innerHTML = '<div style="text-align:center;color:#ccc;padding:20px;">名单为空</div>';
return;
}
blockedUsers.forEach(user => {
const displayName = user.userName || '❓ 未获取昵称';
const displayClass = user.userName ? 'lk-user-name' : 'lk-user-name style="color:#999"';
const item = document.createElement('div');
item.className = 'lk-user-item';
item.innerHTML = `
<label style="margin-top:2px;">
<input type="checkbox" class="deep-block-cb" data-userid="${user.userId}" ${user.deepBlock ? 'checked' : ''}>
</label>
<div class="lk-user-info">
<span ${displayClass}>${displayName}</span>
<a href="/user/${user.userId}" target="_blank" class="lk-user-id-link">ID: ${user.userId} (点击访问主页)</a>
</div>
<span class="remove-btn" data-userid="${user.userId}" title="移除">×</span>
`;
userListContainer.appendChild(item);
});
// 重新绑定事件
userListContainer.querySelectorAll('.deep-block-cb').forEach(cb => {
cb.addEventListener('change', (e) => {
const targetUser = blockedUsers.find(u => u.userId === e.target.dataset.userid);
if (targetUser) {
targetUser.deepBlock = e.target.checked;
saveBlockedData();
}
});
});
userListContainer.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const uid = e.target.dataset.userid;
const uInfo = blockedUsers.find(u => u.userId === uid);
const name = uInfo && uInfo.userName ? uInfo.userName : uid;
confirmationModal.show(`确定移除 ${name} 吗?`, () => {
blockedUsers = blockedUsers.filter(u => u.userId !== uid);
saveBlockedData();
renderBlockedUsersList();
});
});
});
}
const updateNamesBtn = panel.querySelector('#lk-update-names-btn');
updateNamesBtn.addEventListener('click', async () => {
const unknownList = blockedUsers.filter(u => !u.userName || u.userName === '未知用户' || u.userName === '❓ 未获取昵称' || u.userName.includes('获取失败'));
if (unknownList.length === 0) {
alert('所有用户的昵称都已经是最新的了!');
return;
}
const confirmUpdate = confirm(`发现 ${unknownList.length} 个用户没有记录昵称。是否立即通过API请求获取?\n(这可能需要几秒钟)`);
if (!confirmUpdate) return;
updateNamesBtn.disabled = true;
let successCount = 0;
for (let i = 0; i < unknownList.length; i++) {
const user = unknownList[i];
updateNamesBtn.textContent = `获取中 (${i + 1}/${unknownList.length})...`;
// 为了防止并发过高被API拦截,每个请求间隔 300 毫秒
if (i > 0) await new Promise(r => setTimeout(r, 300));
const name = await fetchUserName(user.userId);
if (name) {
user.userName = name;
successCount++;
} else {
user.userName = "获取失败(无动态)"; // 标记,避免下次反复请求死循环
}
}
await saveBlockedData();
renderBlockedUsersList();
updateNamesBtn.disabled = false;
updateNamesBtn.textContent = '↺ 自动获取所有未知昵称';
alert(`完成!成功更新了 ${successCount} 个用户的昵称。`);
});
addUserBtn.addEventListener('click', () => {
const idInputs = newUserIdInput.value.split(/[,\s\n]+/);
let newUsersAdded = false;
for (const id of idInputs) {
const userId = id.trim();
// Validate that it is a numerical ID and not empty
if (userId && /^\d+$/.test(userId)) {
// Check if it already exists to avoid duplicates
if (!blockedUsers.some(u => u.userId === userId)) {
blockedUsers.push({ userId, deepBlock: false });
newUsersAdded = true;
}
}
}
// After processing all IDs, save the updated list and re-render
if (newUsersAdded) {
saveBlockedData();
renderBlockedUsersList();
}
// Clear the input field
newUserIdInput.value = '';
});
// --- Keyboard Shortcut for Add User ---
newUserIdInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Prevent form submission if it were in a form
addUserBtn.click();
}
});
// --- Import/Export Logic ---
const exportBtn = panel.querySelector('#lk-export-btn');
const importBtn = panel.querySelector('#lk-import-btn');
const importExportTextarea = panel.querySelector('#lk-import-export-textarea');
exportBtn.addEventListener('click', () => {
const exportData = blockedUsers.map(user => `${user.userId},${user.deepBlock}`).join('\n');
importExportTextarea.value = exportData;
navigator.clipboard.writeText(exportData).then(() => {
alert('已导出并复制到剪贴板!');
}).catch(err => {
console.error('LKong Blocker: 复制到剪贴板失败', err);
alert('导出成功,但自动复制失败。请手动复制。');
});
});
importBtn.addEventListener('click', () => {
const importData = importExportTextarea.value.trim();
if (!importData) {
alert('导入内容为空。');
return;
}
confirmationModal.show('确定要从文本框导入吗?这将合并现有列表,重复的用户ID将被覆盖。', () => {
const lines = importData.split('\n');
const importedUsersMap = new Map();
let invalidLines = 0;
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue; // Skip empty lines
const parts = trimmedLine.split(',');
const userId = parts[0].trim();
if (!/^\d+$/.test(userId)) {
invalidLines++;
continue;
}
if (parts.length === 1) {
// Old format: just userId, default deepBlock to false
importedUsersMap.set(userId, { userId, deepBlock: false });
} else if (parts.length >= 2) {
// New format: userId,deepBlock
const deepBlock = parts[1].trim().toLowerCase() === 'true';
importedUsersMap.set(userId, { userId, deepBlock });
} else {
// Any other case is considered invalid
invalidLines++;
}
}
if (invalidLines > 0) {
alert(`有 ${invalidLines} 行格式不正确,已被忽略。`);
}
if (importedUsersMap.size === 0) {
alert('没有解析到有效的用户数据。');
return;
}
// Merge logic
const existingUsersMap = new Map(blockedUsers.map(u => [u.userId, u]));
for (const [userId, user] of importedUsersMap.entries()) {
existingUsersMap.set(userId, user);
}
blockedUsers = Array.from(existingUsersMap.values());
blockedUsers.sort((a, b) => parseInt(a.userId, 10) - parseInt(b.userId, 10)); // Sort for consistency
saveBlockedData();
renderBlockedUsersList();
alert(`导入成功!共处理 ${importedUsersMap.size} 个用户。`);
importExportTextarea.value = ''; // Clear textarea after import
});
});
const clearAllUsersBtn = panel.querySelector('#lk-clear-all-users-btn');
clearAllUsersBtn.addEventListener('click', () => {
confirmationModal.show('您确定要清空所有已屏蔽的用户ID吗?此操作无法撤销。', () => {
blockedUsers = [];
saveBlockedData();
renderBlockedUsersList();
alert('所有用户ID已被清空。');
});
});
const tabs = panel.querySelectorAll('.lk-tab');
const titleKeywordTextarea = panel.querySelector('#lk-blocked-title-keywords-textarea');
const replyKeywordTextarea = panel.querySelector('#lk-blocked-reply-keywords-textarea');
const saveBtn = panel.querySelector('#lk-save-btn');
const closeBtn = panel.querySelector('#lk-close-btn');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
panel.querySelector('.lk-tab.active').classList.remove('active');
panel.querySelector('.lk-tab-content.active').classList.remove('active');
tab.classList.add('active');
panel.querySelector(`#lk-tab-${tab.dataset.tab}`).classList.add('active');
});
});
// --- Panel Keyboard Shortcuts & Visibility ---
let panelKeydownHandler = null;
const hidePanel = () => {
panel.style.display = 'none';
if (panelKeydownHandler) {
window.removeEventListener('keydown', panelKeydownHandler);
panelKeydownHandler = null;
}
};
const showPanel = () => {
renderBlockedUsersList();
titleKeywordTextarea.value = Array.from(blockedTitleKeywords).join('\n');
replyKeywordTextarea.value = Array.from(blockedReplyKeywords).join('\n');
panel.style.display = 'flex';
panelKeydownHandler = (e) => {
if (panel.style.display !== 'flex') return;
// Check for active element to avoid conflicts
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA') && e.key !== 'Escape') {
// Allow typing in inputs, but let Escape work
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
// still allow save shortcut from inputs
} else {
return;
}
}
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
saveBtn.click();
} else if (e.key === 'Escape') {
e.preventDefault();
hidePanel();
}
};
window.addEventListener('keydown', panelKeydownHandler);
};
managerBtn.addEventListener('click', showPanel);
closeBtn.addEventListener('click', hidePanel);
saveBtn.addEventListener('click', async () => {
blockedTitleKeywords = new Set(titleKeywordTextarea.value.split('\n').map(kw => kw.trim()).filter(Boolean));
blockedReplyKeywords = new Set(replyKeywordTextarea.value.split('\n').map(kw => kw.trim()).filter(Boolean));
await saveBlockedData();
alert('关键词名单已保存!页面将刷新以应用更改。');
hidePanel(); // Use the new function to ensure listener removal
window.location.reload();
});
}
function createConfirmationModal() {
const modalOverlay = document.createElement('div');
modalOverlay.id = 'lk-confirm-modal-overlay';
modalOverlay.innerHTML = `
<div id="lk-confirm-modal">
<h4>确认操作</h4>
<p id="lk-confirm-msg">您确定要执行此操作吗?</p>
<div class="lk-confirm-actions">
<button id="lk-confirm-cancel-btn" class="lk-btn lk-btn-default">取消</button>
<button id="lk-confirm-ok-btn" class="lk-btn lk-btn-primary">确认</button>
</div>
<div class="lk-confirm-hint">按 Enter 确认, Esc 取消</div>
</div>
`;
document.body.appendChild(modalOverlay);
const modal = modalOverlay.querySelector('#lk-confirm-modal');
const cancelBtn = modalOverlay.querySelector('#lk-confirm-cancel-btn');
let okBtn = modalOverlay.querySelector('#lk-confirm-ok-btn');
// This will hold the currently active keydown handler
let keydownHandler = null;
const hide = () => {
if (keydownHandler) {
window.removeEventListener('keydown', keydownHandler, true);
keydownHandler = null;
}
modal.classList.remove('visible');
setTimeout(() => { modalOverlay.style.display = 'none'; }, 200);
};
modalOverlay.addEventListener('click', (e) => {
if (e.target.id === 'lk-confirm-modal-overlay') {
hide();
}
});
cancelBtn.addEventListener('click', hide);
return {
show: (message, onConfirm) => {
modal.querySelector('#lk-confirm-msg').textContent = message;
// Clone and replace the button to ensure old listeners are removed
const newOkBtn = okBtn.cloneNode(true);
okBtn.parentNode.replaceChild(newOkBtn, okBtn);
okBtn = newOkBtn;
const confirmAndHide = () => {
onConfirm();
hide();
};
okBtn.addEventListener('click', confirmAndHide);
// Define and add the keydown listener for this specific showing
keydownHandler = (e) => {
// Only act if the modal is visible
if (modalOverlay.style.display !== 'flex') return;
if (e.key === 'Enter') {
e.preventDefault();
confirmAndHide();
} else if (e.key === 'Escape') {
e.preventDefault();
hide();
}
};
window.addEventListener('keydown', keydownHandler, true);
modalOverlay.style.display = 'flex';
setTimeout(() => {
modal.classList.add('visible');
okBtn.focus(); // Focus the confirm button
}, 10);
}
};
}
// ================== 5. 启动脚本 ==================
let confirmationModal; // Make it accessible script-wide
async function main() {
await loadBlockedData();
if (document.body) {
createManagerUI();
confirmationModal = createConfirmationModal(); // Initialize the modal
// 根据当前页面路径,执行不同的核心逻辑
if (window.location.pathname.startsWith('/thread/')) {
// 在帖子详情页,启动帖子内容监视器
initPostObserver();
createFoldAllButton();
} else if (window.location.pathname.startsWith('/forum/')) {
// 在论坛列表页,处理首屏数据
handleInitialData();
}
} else {
document.addEventListener('DOMContentLoaded', main, { once: true });
}
}
// 脚本启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main, { once: true });
} else {
main();
}
})();