守护你的眼睛,远离你不喜欢的任何用户,你有权选择你想看见的东西。
// ==UserScript==
// @name LinuxDo Sight shield
// @namespace https://github.com/Ooxygen7
// @version 1.2.0
// @description 守护你的眼睛,远离你不喜欢的任何用户,你有权选择你想看见的东西。
// @author -
// @match https://linux.do/*
// @match https://www.linux.do/*
// @run-at document-start
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_addValueChangeListener
// @license MIT
// @noframes
// ==/UserScript==
(function () {
'use strict';
const STORE_KEY = 'ldub_blocked_users';
const KEYWORD_STORE_KEY = 'ldub_blocked_keywords';
const CFG_KEY = 'ldub_config';
const HIDDEN = 'ldub-hidden';
const STYLE_ID = 'ldub-style';
let blockedList = loadList();
let blockedSet = new Set(blockedList.map(normalize));
let keywordList = loadKeywordList();
let keywordNeedles = keywordList.map(normalizeKeyword).filter(Boolean);
let cfg = Object.assign({ enabled: true, showCardButton: true }, loadConfig());
cfg.topicMode = 'op';
cfg.hideBoosts = true;
function loadList() {
try {
const value = JSON.parse(GM_getValue(STORE_KEY, '[]'));
return Array.isArray(value) ? value : [];
} catch (_) {
return [];
}
}
function loadKeywordList() {
try {
const value = JSON.parse(GM_getValue(KEYWORD_STORE_KEY, '[]'));
return Array.isArray(value) ? value : [];
} catch (_) {
return [];
}
}
function loadConfig() {
try {
return JSON.parse(GM_getValue(CFG_KEY, '{}')) || {};
} catch (_) {
return {};
}
}
function saveList() {
GM_setValue(STORE_KEY, JSON.stringify(blockedList));
blockedSet = new Set(blockedList.map(normalize));
}
function saveKeywords() {
GM_setValue(KEYWORD_STORE_KEY, JSON.stringify(keywordList));
keywordNeedles = keywordList.map(normalizeKeyword).filter(Boolean);
}
function saveConfig() {
GM_setValue(CFG_KEY, JSON.stringify(cfg));
}
function normalize(value) {
return String(value || '').trim().replace(/^@+/, '').toLowerCase();
}
function normalizeKeyword(value) {
return String(value || '').trim().toLowerCase();
}
function isBlocked(username) {
const normalized = normalize(username);
return normalized !== '' && blockedSet.has(normalized);
}
function hasBlockedKeyword(container) {
if (!container || !keywordNeedles.length) return false;
const text = normalizeKeyword(container.textContent);
return text !== '' && keywordNeedles.some(keyword => keyword && text.includes(keyword));
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function splitNames(input) {
return String(input || '').split(/[\s,,]+/).map(normalize).filter(Boolean);
}
function splitKeywords(input) {
return String(input || '').split(/[\n,,]+/).map(value => value.trim()).filter(Boolean);
}
function usernameFromHref(href) {
const match = String(href || '').match(/\/u\/([^/?#]+)/i);
return match ? decodeURIComponent(match[1]) : null;
}
function usernameOf(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return null;
return element.getAttribute('data-user-card')
|| element.getAttribute('data-username')
|| usernameFromHref(element.getAttribute('href'));
}
function hasBlockedUser(container) {
if (!container) return false;
for (const element of container.querySelectorAll('[data-user-card], [data-username], a[href*="/u/"]')) {
if (isBlocked(usernameOf(element))) return true;
}
return false;
}
function blockedMentionRegex() {
if (!blockedSet.size) return null;
const names = Array.from(blockedSet).filter(Boolean).sort((a, b) => b.length - a.length).map(escapeRegExp);
if (!names.length) return null;
return new RegExp('(^|[^A-Za-z0-9_.-])@(' + names.join('|') + ')(?![A-Za-z0-9_.-])', 'gi');
}
function hideMentionElement(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return;
const text = String(element.textContent || '').trim();
if (!text.startsWith('@')) return;
const username = usernameOf(element) || text.replace(/^@+/, '');
if (isBlocked(username)) hide(element);
}
function shouldSkipMentionTextNode(node) {
const parent = node?.parentElement;
return !parent || parent.closest(
'script, style, textarea, input, code, pre, .d-editor, .d-editor-input, .ldub-card-btn, .ldub-mention-hidden'
);
}
function hideMentionInTextNode(node) {
if (node.nodeType !== Node.TEXT_NODE || shouldSkipMentionTextNode(node)) return;
const text = node.nodeValue || '';
if (!text.includes('@')) return;
const regex = blockedMentionRegex();
if (!regex) return;
let match;
let lastIndex = 0;
let changed = false;
const fragment = document.createDocumentFragment();
while ((match = regex.exec(text))) {
const prefix = match[1] || '';
const mentionStart = match.index + prefix.length;
const mentionEnd = regex.lastIndex;
if (mentionStart < lastIndex) continue;
if (mentionStart > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, mentionStart)));
}
const span = document.createElement('span');
span.className = 'ldub-mention-hidden';
span.setAttribute('data-ldub-mention', match[2] || '');
span.textContent = text.slice(mentionStart, mentionEnd);
hide(span);
fragment.appendChild(span);
lastIndex = mentionEnd;
changed = true;
}
if (!changed) return;
if (lastIndex < text.length) fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
node.parentNode.replaceChild(fragment, node);
}
function scanMentionText(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
return node.nodeValue?.includes('@') && !shouldSkipMentionTextNode(node)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
}
});
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(hideMentionInTextNode);
}
function collect(root, selector) {
if (!root || root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_NODE) return [];
const nodes = [];
if (root.nodeType === Node.ELEMENT_NODE && root.matches(selector)) nodes.push(root);
nodes.push(...root.querySelectorAll(selector));
return nodes;
}
function hide(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return;
element.classList.add(HIDDEN);
element.hidden = true;
element.style.setProperty('display', 'none', 'important');
}
function unhideAll() {
document.querySelectorAll('.' + HIDDEN).forEach(element => {
element.classList.remove(HIDDEN);
element.hidden = false;
element.style.removeProperty('display');
});
}
function postAuthor(post) {
return post.querySelector(
'.topic-avatar a.main-avatar[data-user-card], ' +
'.post-avatar a.main-avatar[data-user-card], ' +
'.topic-meta-data .names a[data-user-card], ' +
'.names a[data-user-card]'
);
}
function scanPosts(root) {
for (const post of collect(root, 'article[data-post-id]')) {
const author = postAuthor(post);
const target = post.closest('.topic-post') || post;
const content = post.querySelector('.cooked') || post;
if (isBlocked(usernameOf(author)) || hasBlockedKeyword(content)) hide(target);
}
}
function scanOpenedTopic(root) {
for (const owner of collect(root, '.topic-post.post--topic-owner')) {
const author = owner.querySelector('a.main-avatar[data-user-card]');
if (!isBlocked(usernameOf(author))) continue;
const topic = owner.closest('.regular.ember-view');
hide(topic || owner.closest('#topic') || owner);
}
for (const title of collect(root, '#topic-title, .topic-title')) {
if (!hasBlockedKeyword(title)) continue;
const topic = title.closest('.regular.ember-view') || document.querySelector('#topic');
hide(topic || title);
if (topic && !topic.contains(title)) hide(title);
}
}
function posterLinks(row) {
const scoped = row.querySelectorAll(
'.posters a[data-user-card], .topic-list-data.posters a[data-user-card], td.posters a[data-user-card]'
);
return scoped.length ? Array.from(scoped) : Array.from(row.querySelectorAll('a[data-user-card]'));
}
function scanTopicRows(root) {
for (const row of collect(root, '.topic-list-item, .latest-topic-list-item')) {
const keywordArea = row.querySelector('.main-link, .topic-list-data.main-link, .title, .topic-excerpt') || row;
if (hasBlockedKeyword(keywordArea)) {
hide(row);
continue;
}
const posters = posterLinks(row);
if (!posters.length) continue;
if (isBlocked(usernameOf(posters[0]))) {
hide(row);
continue;
}
for (const poster of posters) {
if (isBlocked(usernameOf(poster))) hide(poster);
}
}
}
function scanSearchResults(root) {
for (const result of collect(root, '.fps-result, .search-result')) {
const author = result.querySelector('.author [data-user-card], .author a[href*="/u/"]');
if (isBlocked(usernameOf(author)) || hasBlockedKeyword(result)) hide(result);
}
}
function scanTopicMapUsers(root) {
for (const avatar of collect(root, '.topic-map__users-list a[data-user-card]')) {
if (!isBlocked(usernameOf(avatar))) continue;
const item = avatar.parentElement;
hide(item?.parentElement?.classList.contains('topic-map__users-list') ? item : avatar);
}
}
function scanMentions(root) {
if (!blockedSet.size) return;
for (const placeholder of collect(root, '[data-ldub-mention]')) {
if (isBlocked(placeholder.getAttribute('data-ldub-mention'))) hide(placeholder);
}
for (const mention of collect(root, 'a.mention, span.mention, a[data-user-card], a[href*="/u/"]')) {
hideMentionElement(mention);
}
const textRootSelector = [
'.cooked',
'.excerpt',
'.topic-excerpt',
'.topic-list-item',
'.latest-topic-list-item',
'.fps-result',
'.search-result',
'.user-stream-item',
'.activity-stream .item',
'.bookmark-list-item'
].join(', ');
const textRoots = new Set(collect(root, textRootSelector));
if (root.nodeType === Node.ELEMENT_NODE && root.closest(textRootSelector)) textRoots.add(root);
textRoots.forEach(scanMentionText);
}
function scanBoosts(root) {
if (!cfg.hideBoosts) return;
const selector = '.discourse-boosts__bubble, .post-boost, .discourse-boost, [class*="boost-item"], [class*="boost-bubble"]';
for (const boost of collect(root, selector)) {
if (hasBlockedUser(boost) || hasBlockedKeyword(boost)) hide(boost);
}
}
function scanQuotes(root) {
for (const quote of collect(root, 'aside.quote[data-username], .quote[data-username]')) {
if (isBlocked(quote.getAttribute('data-username')) || hasBlockedKeyword(quote)) hide(quote);
}
}
function scanFeeds(root) {
const selector = '.user-stream-item, .activity-stream .item, .bookmark-list-item';
for (const item of collect(root, selector)) {
const author = item.querySelector('a.main-avatar[data-user-card], .author [data-user-card], .avatar[data-user-card]');
if (isBlocked(usernameOf(author)) || hasBlockedKeyword(item)) hide(item);
}
}
function scan(root) {
if (!cfg.enabled || (blockedSet.size === 0 && keywordNeedles.length === 0)) return;
scanPosts(root);
scanOpenedTopic(root);
scanTopicRows(root);
scanSearchResults(root);
scanTopicMapUsers(root);
scanMentions(root);
scanBoosts(root);
scanQuotes(root);
scanFeeds(root);
}
function maybeInjectCardButton(root) {
if (!cfg.showCardButton) return;
for (const card of collect(root, '.user-card, #user-card')) {
if (card.querySelector('.ldub-card-btn')) continue;
const profile = card.querySelector('a[data-user-card], a[href^="/u/"]');
const username = usernameOf(profile);
if (!username) continue;
const host = card.querySelector('.usercard-controls, .user-card-controls, .card-row .controls, .names, .card-content') || card;
const button = document.createElement('button');
button.className = 'ldub-card-btn';
button.textContent = isBlocked(username) ? '已屏蔽 ✓' : '🚫 本地屏蔽';
button.title = '将 @' + username + ' 加入本地屏蔽名单';
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
if (!isBlocked(username)) {
blockedList.push(username);
saveList();
scan(document);
}
button.textContent = '已屏蔽 ✓';
});
host.appendChild(button);
}
}
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = [
'.' + HIDDEN + '{display:none !important;}',
'.ldub-card-btn{display:inline-block;margin:6px 4px 2px;padding:4px 10px;font-size:12px;line-height:1.4;cursor:pointer;border:1px solid var(--primary-low-mid,#aaa);border-radius:6px;background:var(--secondary,#fff);color:var(--primary,#333);}',
'.ldub-card-btn:hover{background:var(--danger-low,#fdecec);border-color:var(--danger,#c00);color:var(--danger,#c00);}'
].join('');
(document.head || document.documentElement).appendChild(style);
}
let pending = false;
function requestFullScan() {
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
scan(document);
maybeInjectCardButton(document);
});
}
function scanMutationNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const parent = node.parentElement;
if (cfg.enabled && blockedSet.size) hideMentionInTextNode(node);
if (parent) scanMutationNode(parent);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
scan(node);
maybeInjectCardButton(node);
const container = node.closest(
'article[data-post-id], .topic-post.post--topic-owner, .topic-list-item, .latest-topic-list-item, ' +
'.fps-result, .search-result, .discourse-boosts__bubble, .post-boost, .discourse-boost, ' +
'.topic-map__users-list, .user-stream-item, .activity-stream .item, .bookmark-list-item, ' +
'.cooked, .excerpt, .topic-excerpt, #topic-title, .topic-title'
);
if (container && container !== node) {
scan(container);
maybeInjectCardButton(container);
}
}
function startObserver() {
new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(scanMutationNode);
continue;
}
if (mutation.type === 'characterData') {
scanMutationNode(mutation.target);
continue;
}
if (mutation.target.classList?.contains(HIDDEN)) continue;
if (mutation.attributeName === 'data-user-card' || mutation.attributeName === 'data-username' || mutation.attributeName === 'href' || mutation.attributeName === 'class') {
scanMutationNode(mutation.target);
}
}
}).observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ['data-user-card', 'data-username', 'href', 'class']
});
}
function hookRouting() {
const schedule = () => setTimeout(requestFullScan, 0);
for (const method of ['pushState', 'replaceState']) {
const original = history[method];
history[method] = function () {
const result = original.apply(this, arguments);
schedule();
return result;
};
}
window.addEventListener('popstate', schedule);
}
function addUsers() {
const input = prompt('输入要屏蔽的用户名(@ 后的用户名;可用空格或逗号分隔):');
if (!input) return;
let added = 0;
for (const username of splitNames(input)) {
if (!blockedSet.has(username)) {
blockedList.push(username);
blockedSet.add(username);
added++;
}
}
if (added) saveList();
scan(document);
alert('已添加 ' + added + ' 人;当前共屏蔽 ' + blockedList.length + ' 人。');
}
function manageUsers() {
if (!blockedList.length) {
alert('当前没有屏蔽任何用户。');
return;
}
const input = prompt('当前名单:\n' + blockedList.join('\n') + '\n\n输入要移除的用户名(多个用逗号分隔);输入 * 清空全部。');
if (input === null) return;
const value = input.trim();
if (value === '*') blockedList = [];
else if (value) {
const removed = new Set(splitNames(value));
blockedList = blockedList.filter(username => !removed.has(normalize(username)));
} else return;
saveList();
unhideAll();
scan(document);
alert('名单已更新;当前共屏蔽 ' + blockedList.length + ' 人。');
}
function addKeywords() {
const input = prompt('输入要屏蔽的关键字(可用换行或逗号分隔;短语可以包含空格):');
if (!input) return;
let added = 0;
const existing = new Set(keywordList.map(normalizeKeyword));
for (const keyword of splitKeywords(input)) {
const normalized = normalizeKeyword(keyword);
if (!existing.has(normalized)) {
keywordList.push(keyword.trim());
existing.add(normalized);
added++;
}
}
if (added) saveKeywords();
scan(document);
alert('已添加 ' + added + ' 个关键字;当前共屏蔽 ' + keywordList.length + ' 个关键字。');
}
function manageKeywords() {
if (!keywordList.length) {
alert('当前没有屏蔽任何关键字。');
return;
}
const input = prompt('当前关键字:\n' + keywordList.join('\n') + '\n\n输入要移除的关键字(多个用换行或逗号分隔);输入 * 清空全部。');
if (input === null) return;
const value = input.trim();
if (value === '*') keywordList = [];
else if (value) {
const removed = new Set(splitKeywords(value).map(normalizeKeyword));
keywordList = keywordList.filter(keyword => !removed.has(normalizeKeyword(keyword)));
} else return;
saveKeywords();
unhideAll();
scan(document);
alert('关键字名单已更新;当前共屏蔽 ' + keywordList.length + ' 个关键字。');
}
function toggle() {
cfg.enabled = !cfg.enabled;
saveConfig();
if (cfg.enabled) scan(document);
else unhideAll();
alert('本地屏蔽已' + (cfg.enabled ? '开启' : '关闭') + '。');
}
function registerMenus() {
GM_registerMenuCommand('➕ 添加屏蔽用户', addUsers);
GM_registerMenuCommand('🧾 管理屏蔽名单 (' + blockedList.length + ')', manageUsers);
GM_registerMenuCommand('🔑 添加屏蔽关键字', addKeywords);
GM_registerMenuCommand('🧩 管理屏蔽关键字 (' + keywordList.length + ')', manageKeywords);
GM_registerMenuCommand('🔇 ' + (cfg.enabled ? '关闭' : '开启') + '本地屏蔽', toggle);
}
function init() {
injectStyle();
registerMenus();
if (typeof GM_addValueChangeListener === 'function') {
GM_addValueChangeListener(STORE_KEY, (_, __, value, remote) => {
if (!remote) return;
try {
blockedList = JSON.parse(value) || [];
blockedSet = new Set(blockedList.map(normalize));
unhideAll();
scan(document);
} catch (_) {}
});
GM_addValueChangeListener(KEYWORD_STORE_KEY, (_, __, value, remote) => {
if (!remote) return;
try {
keywordList = JSON.parse(value) || [];
keywordNeedles = keywordList.map(normalizeKeyword).filter(Boolean);
unhideAll();
scan(document);
} catch (_) {}
});
}
const ready = () => {
requestFullScan();
startObserver();
hookRouting();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready, { once: true });
else ready();
}
init();
})();