LinuxDo Sight shield

守护你的眼睛,远离你不喜欢的任何用户,你有权选择你想看见的东西。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();
})();