// ==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 // @downloadURL https://update.greasyfork.icu/scripts/570967/%E9%BE%99%E7%A9%BA%E4%BF%A1%E6%81%AF%E9%99%8D%E5%99%AA%E5%99%A8%20v041.user.js // @updateURL https://update.greasyfork.icu/scripts/570967/%E9%BE%99%E7%A9%BA%E4%BF%A1%E6%81%AF%E9%99%8D%E5%99%AA%E5%99%A8%20v041.meta.js // ==/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 = `${text}`; 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
*/ } .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 = `

噪声管理

用户
标题关键词
回帖关键词

勾选“深度屏蔽”后,该用户的回帖也会在帖子页面被隐藏。

屏蔽列表

批量导入/导出 (格式: userId,deepBlock):

每行一个关键词。帖子标题包含任意一个词都会在列表页被隐藏。

每行一个关键词。帖子内的回帖如果包含任意一个词,该楼层将被隐藏。

`; 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 = '
名单为空
'; 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 = `
${displayName} ID: ${user.userId} (点击访问主页)
× `; 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 = `

确认操作

您确定要执行此操作吗?

按 Enter 确认, Esc 取消
`; 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(); } })();