Greasy Fork

来自缓存

Greasy Fork is available in English.

Watermark Remover Suite

通用低风险去水印、站点专用处理(含 ZSXQ)、以及手动高风险清理按钮(支持拖动贴边与位置记忆)

// ==UserScript==
// @name         Watermark Remover Suite
// @namespace    https://blog.wayneshao.com/
// @version      1.1
// @description  通用低风险去水印、站点专用处理(含 ZSXQ)、以及手动高风险清理按钮(支持拖动贴边与位置记忆)
// @author       You
// @match        *://*/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  /* ========== 全局配置 ========== */
  const BUTTON_ID = 'wm-remover-sweep-btn';
  const BASE_STYLE_ID = 'wm-remover-base-style';
  const ZSXQ_STYLE_ID = 'wm-remover-zsxq-style';
  const BUTTON_STORAGE_KEY = 'wm-remover-button-pos-v1';

  const isZsxqDomain = /(^|\.)zsxq\.com$/i.test(window.location.hostname);

  /* ========== 全局状态 ========== */
  let sweepButton = null;
  let suppressClick = false;

  const dragState = {
    active: false,
    moved: false,
    pointerId: null,
    startX: 0,
    startY: 0,
  };

  let buttonPos = loadButtonPosition();

  const lowRiskObserver = new MutationObserver(handleLowRiskMutations);
  const specialHandlers = [
    {
      name: 'zsxq',
      test: () => isZsxqDomain,
      init: setupZsxqHandler,
    },
  ];

  /* ========== 初始化入口 ========== */
  injectBaseCss();

  whenReady(() => {
    ensureSweepButton();
    startLowRiskLogic();
    runSpecialHandlers();
  });

  /* ========== 通用低风险逻辑 ========== */
  function startLowRiskLogic() {
    lowRiskSweep(document);

    const startObserver = () => {
      if (!document.body) return false;
      lowRiskObserver.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['style'],
      });
      return true;
    };

    if (!startObserver()) {
      const watcher = new MutationObserver(() => {
        if (startObserver()) watcher.disconnect();
      });
      watcher.observe(document.documentElement, { childList: true });
    }
  }

  function handleLowRiskMutations(mutations) {
    for (const mutation of mutations) {
      if (mutation.type === 'childList') {
        mutation.addedNodes.forEach((node) => lowRiskSweep(node));
      } else if (mutation.type === 'attributes') {
        lowRiskProcessElement(mutation.target);
      }
    }
  }

  function lowRiskSweep(root) {
    if (!root) return;
    const startNode = root instanceof Document ? root.documentElement : root;
    walkDom(startNode, lowRiskProcessElement);
  }

  function lowRiskProcessElement(el) {
    if (!(el instanceof Element) || shouldSkipButton(el)) return;

    const inlineStyle = el.getAttribute('style');
    if (inlineStyle && /nullbackground/i.test(inlineStyle)) {
      el.setAttribute('style', inlineStyle.replace(/nullbackground/gi, 'background'));
    }

    const backgroundImage = el.style.getPropertyValue('background-image');
    if (backgroundImage && /url\(\s*data:image/i.test(backgroundImage)) {
      el.style.setProperty('background-image', 'none', 'important');
    }

    const background = el.style.getPropertyValue('background');
    if (background && /url\(\s*data:image/i.test(background)) {
      el.style.setProperty(
        'background',
        background.replace(/url\([^)]*\)/gi, 'none').trim(),
        'important'
      );
    }

    const maskImage =
      el.style.getPropertyValue('mask-image') ||
      el.style.getPropertyValue('-webkit-mask-image');
    if (maskImage && /url\(\s*data:image/i.test(maskImage)) {
      el.style.setProperty('mask-image', 'none', 'important');
      el.style.setProperty('-webkit-mask-image', 'none', 'important');
    }
  }

  /* ========== 站点专用逻辑(目前仅 ZSXQ) ========== */
  function runSpecialHandlers() {
    for (const handler of specialHandlers) {
      try {
        if (handler.test()) {
          handler.init();
        }
      } catch (err) {
        console.warn(`[Watermark Remover] 站点专用逻辑 ${handler.name} 初始化失败`, err);
      }
    }
  }

  function setupZsxqHandler() {
    injectZsxqCss();

    const state = {
      observer: null,
      observedRoots: new WeakSet(),
      interactionTimer: null,
    };

    const ensureObserver = () => {
      if (state.observer) return;
      state.observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          if (mutation.type === 'childList') {
            mutation.addedNodes.forEach((node) => processNode(node));
          } else if (mutation.type === 'attributes') {
            const target = mutation.target;
            if (target instanceof Element && shouldClearZsxqElement(target)) {
              requestAnimationFrame(() => clearZsxqWatermark(target));
            }
          }
        }
      });
      state.observer.observe(document.documentElement, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['style', 'watermark', 'data-watermark', 'class', 'data-testid'],
      });
    };

    const attachBodyObserver = () => {
      if (!document.body || state.observedRoots.has(document.body)) return false;
      state.observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['style'],
      });
      state.observedRoots.add(document.body);
      return true;
    };

    const processNode = (node) => {
      if (node instanceof Element) {
        if (node.shadowRoot) {
          observeShadowRoot(node.shadowRoot);
          scanZsxqElements(node.shadowRoot);
        }
        requestAnimationFrame(() => scanZsxqElements(node));
      } else if (node instanceof ShadowRoot || node instanceof DocumentFragment) {
        observeShadowRoot(node);
        requestAnimationFrame(() => scanZsxqElements(node));
      }
    };

    const observeShadowRoot = (root) => {
      if (!state.observer || state.observedRoots.has(root)) return;
      try {
        state.observer.observe(root, {
          childList: true,
          subtree: true,
          attributes: true,
          attributeFilter: ['style', 'class', 'watermark', 'data-watermark', 'data-testid'],
        });
        state.observedRoots.add(root);
      } catch (err) {
        console.debug('[Watermark Remover] 无法监听 ShadowRoot:', err);
      }
    };

    const scanZsxqElements = (root) => {
      const startNode = root instanceof Document ? root.documentElement : root;
      if (!startNode) return;
      walkDom(startNode, (el) => {
        if (shouldClearZsxqElement(el)) {
          clearZsxqWatermark(el);
        }
      });
    };

    const scheduleRescanAfterInteraction = () => {
      if (state.interactionTimer) clearTimeout(state.interactionTimer);
      state.interactionTimer = setTimeout(() => {
        state.interactionTimer = null;
        if (document.body) scanZsxqElements(document.body);
      }, 250);
    };

    const shouldClearZsxqElement = (el) => {
      if (!(el instanceof Element) || shouldSkipButton(el)) return false;

      if (
        el.hasAttribute('watermark') ||
        el.hasAttribute('data-watermark') ||
        (el.classList && [...el.classList].some((cls) => /watermark/i.test(cls)))
      ) {
        return true;
      }

      const testId = el.getAttribute('data-testid');
      if (testId && /watermark/i.test(testId)) return true;

      const inline = el.getAttribute('style');
      if (
        inline &&
        (/(?:background|background-image)\s*:\s*url\(\s*data:image/i.test(inline) ||
          /(?:mask|mask-image|webkit-mask-image)\s*:\s*url\(\s*data:image/i.test(inline))
      ) {
        return true;
      }

      const computed = safeComputedStyle(el);
      if (computed) {
        const bg = computed.backgroundImage;
        if (bg && bg.includes('data:image')) return true;

        const mask = computed.maskImage || computed.webkitMaskImage;
        if (mask && mask.includes('data:image')) return true;
      }

      return false;
    };

    const clearZsxqWatermark = (el) => {
      if (!(el instanceof Element) || shouldSkipButton(el)) return;

      const inlineStyle = el.getAttribute('style');
      if (inlineStyle && /nullbackground/i.test(inlineStyle)) {
        el.setAttribute('style', inlineStyle.replace(/nullbackground/gi, 'background'));
      }

      const inlineBgImage = el.style.getPropertyValue('background-image');
      if (inlineBgImage && inlineBgImage !== 'none' && inlineBgImage.includes('url(')) {
        el.style.setProperty('background-image', 'none', 'important');
      }

      const inlineBg = el.style.getPropertyValue('background');
      if (inlineBg && inlineBg.includes('url(')) {
        el.style.setProperty(
          'background',
          inlineBg.replace(/url\([^)]*\)/gi, 'none').trim(),
          'important'
        );
      }

      const inlineMask =
        el.style.getPropertyValue('mask-image') ||
        el.style.getPropertyValue('-webkit-mask-image');
      if (inlineMask && inlineMask.includes('url(')) {
        el.style.setProperty('mask-image', 'none', 'important');
        el.style.setProperty('-webkit-mask-image', 'none', 'important');
      }

      const computed = safeComputedStyle(el);
      if (computed) {
        const computedBg = computed.backgroundImage;
        if (computedBg && computedBg !== 'none' && computedBg.includes('url(')) {
          el.style.setProperty('background-image', 'none', 'important');
        }
        const computedMask = computed.maskImage || computed.webkitMaskImage;
        if (computedMask && computedMask !== 'none' && computedMask.includes('url(')) {
          el.style.setProperty('mask-image', 'none', 'important');
          el.style.setProperty('-webkit-mask-image', 'none', 'important');
        }
      }
    };

    ensureObserver();

    whenBody(() => {
      attachBodyObserver();
      scanZsxqElements(document.body);
    });

    document.addEventListener('click', scheduleRescanAfterInteraction, true);
    document.addEventListener('keydown', scheduleRescanAfterInteraction, true);
    setInterval(() => {
      if (document.body) scanZsxqElements(document.body);
    }, 3000);
  }

  function injectZsxqCss() {
    if (document.getElementById(ZSXQ_STYLE_ID)) return;
    const style = document.createElement('style');
    style.id = ZSXQ_STYLE_ID;
    style.textContent = `
      [watermark],
      [data-watermark],
      [class*="watermark" i],
      [data-testid*="watermark" i] {
        background-image: none !important;
        mask-image: none !important;
        -webkit-mask-image: none !important;
      }
      [watermark]::before,
      [watermark]::after,
      [data-watermark]::before,
      [data-watermark]::after,
      [class*="watermark" i]::before,
      [class*="watermark" i]::after {
        background-image: none !important;
        mask-image: none !important;
        -webkit-mask-image: none !important;
      }
    `;
    document.head.appendChild(style);
  }

  /* ========== 通用高风险逻辑(按钮触发) ========== */
  function highRiskSweep(root) {
    let processed = 0;
    walkDom(root, (el) => {
      if (!(el instanceof Element) || shouldSkipButton(el)) return;

      const computed = safeComputedStyle(el);
      let changed = false;

      if (computed) {
        const bg = computed.backgroundImage;
        if (bg && bg !== 'none') {
          el.style.setProperty('background-image', 'none', 'important');
          changed = true;
        }
        const mask = computed.maskImage || computed.webkitMaskImage;
        if (mask && mask !== 'none') {
          el.style.setProperty('mask-image', 'none', 'important');
          el.style.setProperty('-webkit-mask-image', 'none', 'important');
          changed = true;
        }
      }

      const inlineBg = el.style.getPropertyValue('background');
      if (inlineBg && inlineBg.includes('url(')) {
        el.style.setProperty(
          'background',
          inlineBg.replace(/url\([^)]*\)/gi, 'none').trim(),
          'important'
        );
        changed = true;
      }

      const inlineBgImage = el.style.getPropertyValue('background-image');
      if (inlineBgImage && inlineBgImage !== 'none') {
        el.style.setProperty('background-image', 'none', 'important');
        changed = true;
      }

      const inlineMask =
        el.style.getPropertyValue('mask-image') ||
        el.style.getPropertyValue('-webkit-mask-image');
      if (inlineMask && inlineMask !== 'none') {
        el.style.setProperty('mask-image', 'none', 'important');
        el.style.setProperty('-webkit-mask-image', 'none', 'important');
        changed = true;
      }

      if (changed) processed++;
    });
    return processed;
  }

  /* ========== 悬浮按钮逻辑(拖动贴边 & 位置记忆) ========== */
  function ensureSweepButton() {
    if (sweepButton) return;

    sweepButton = document.createElement('button');
    sweepButton.id = BUTTON_ID;
    sweepButton.type = 'button';
    sweepButton.textContent = '暴力去水印';
    sweepButton.title = '高风险:遍历全页面并移除所有背景 / 蒙层(包括 Shadow DOM)';

    applyButtonPlacement(buttonPos);

    sweepButton.addEventListener('pointerdown', onPointerDown);
    sweepButton.addEventListener('pointermove', onPointerMove);
    sweepButton.addEventListener('pointerup', onPointerUp);
    sweepButton.addEventListener('pointercancel', onPointerCancel);

    sweepButton.addEventListener('click', (event) => {
      if (suppressClick) {
        event.stopPropagation();
        event.preventDefault();
        return;
      }

      const root = document.body || document.documentElement;
      const start = performance.now();
      const count = highRiskSweep(root);
      const duration = (performance.now() - start).toFixed(1);
      console.info(`[Watermark Remover] 高风险清理:处理了 ${count} 个元素,用时 ${duration}ms`);
    });

    document.body.appendChild(sweepButton);
  }

  function onPointerDown(event) {
    if (!sweepButton) return;
    dragState.active = true;
    dragState.moved = false;
    dragState.pointerId = event.pointerId;
    dragState.startX = event.clientX;
    dragState.startY = event.clientY;
    try {
      sweepButton.setPointerCapture(event.pointerId);
    } catch (_) {}
  }

  function onPointerMove(event) {
    if (!dragState.active || !sweepButton || event.pointerId !== dragState.pointerId) return;

    const dx = event.clientX - dragState.startX;
    const dy = event.clientY - dragState.startY;

    if (!dragState.moved) {
      if (Math.hypot(dx, dy) > 4) {
        dragState.moved = true;
        sweepButton.classList.add('dragging');
      } else {
        return;
      }
    }

    event.preventDefault();

    const side = event.clientX >= window.innerWidth / 2 ? 'right' : 'left';
    applyButtonSide(side);

    const topRatio = clamp(event.clientY / window.innerHeight, 0.05, 0.95);
    applyButtonTop(topRatio);

    buttonPos = { side, top: topRatio };
  }

  function onPointerUp(event) {
    if (!dragState.active || !sweepButton || event.pointerId !== dragState.pointerId) return;
    try {
      sweepButton.releasePointerCapture(event.pointerId);
    } catch (_) {}

    if (dragState.moved) {
      event.preventDefault();
      saveButtonPosition(buttonPos);
      suppressClick = true;
      setTimeout(() => {
        suppressClick = false;
      }, 0);
    }

    sweepButton.classList.remove('dragging');
    dragState.active = false;
    dragState.moved = false;
    dragState.pointerId = null;
  }

  function onPointerCancel(event) {
    if (!dragState.active || !sweepButton || event.pointerId !== dragState.pointerId) return;
    try {
      sweepButton.releasePointerCapture(event.pointerId);
    } catch (_) {}
    sweepButton.classList.remove('dragging');
    dragState.active = false;
    dragState.moved = false;
    dragState.pointerId = null;
  }

  function applyButtonPlacement(pos) {
    applyButtonSide(pos.side);
    applyButtonTop(pos.top);
  }

  function applyButtonSide(side) {
    if (!sweepButton) return;
    if (side === 'right') {
      sweepButton.classList.add('side-right');
      sweepButton.classList.remove('side-left');
      sweepButton.style.left = 'auto';
      sweepButton.style.right = '0';
    } else {
      sweepButton.classList.add('side-left');
      sweepButton.classList.remove('side-right');
      sweepButton.style.left = '0';
      sweepButton.style.right = 'auto';
    }
    buttonPos.side = side === 'right' ? 'right' : 'left';
  }

  function applyButtonTop(topRatio) {
    if (!sweepButton) return;
    const clamped = clamp(topRatio, 0.05, 0.95);
    sweepButton.style.top = (clamped * 100).toFixed(2) + 'vh';
    buttonPos.top = clamped;
  }

  function injectBaseCss() {
    if (document.getElementById(BASE_STYLE_ID)) return;
    const style = document.createElement('style');
    style.id = BASE_STYLE_ID;
    style.textContent = `
      #${BUTTON_ID} {
        position: fixed;
        top: 50%;
        left: 0;
        transform: translate(-88%, -50%);
        padding: 11px 24px;
        border: none;
        border-radius: 0 18px 18px 0;
        font-size: 15px;
        font-weight: 700;
        letter-spacing: 0.08em;
        color: #ffffff;
        background: linear-gradient(135deg, #1d5fd7 0%, #0f3eb7 50%, #0d2a8e 100%);
        box-shadow: 0 16px 32px rgba(9, 40, 90, 0.45);
        text-shadow: 0 2px 3px rgba(0, 0, 0, 0.35);
        cursor: grab;
        z-index: 2147483646;
        opacity: 0.96;
        transition: transform 0.25s ease, opacity 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
        touch-action: none;
        user-select: none;
      }
      #${BUTTON_ID}.side-right {
        left: auto;
        right: 0;
        border-radius: 18px 0 0 18px;
        transform: translate(88%, -50%);
      }
      #${BUTTON_ID}.side-left:hover,
      #${BUTTON_ID}.side-left:focus-visible,
      #${BUTTON_ID}.side-left.dragging,
      #${BUTTON_ID}.side-right:hover,
      #${BUTTON_ID}.side-right:focus-visible,
      #${BUTTON_ID}.side-right.dragging {
        transform: translate(0, -50%);
        opacity: 1;
        box-shadow: 0 20px 36px rgba(9, 40, 90, 0.55);
      }
      #${BUTTON_ID}:active {
        background: linear-gradient(135deg, #184fc0 0%, #0c3296 100%);
        box-shadow: 0 12px 28px rgba(9, 40, 90, 0.5);
        cursor: grabbing;
      }
      #${BUTTON_ID}.dragging {
        transition: none;
      }
      #${BUTTON_ID}:focus,
      #${BUTTON_ID}:focus-visible {
        outline: none;
      }
      #${BUTTON_ID}::after {
        content: '⟲';
        margin-left: 10px;
        font-size: 14px;
        text-shadow: inherit;
      }
    `;
    document.head.appendChild(style);
  }

  /* ========== 状态工具 ========== */
  function loadButtonPosition() {
    const fallback = { side: 'left', top: 0.5 };
    try {
      if (typeof GM_getValue === 'function') {
        const stored = GM_getValue(BUTTON_STORAGE_KEY);
        if (stored && typeof stored === 'object') {
          return normalizeButtonPos(stored, fallback);
        }
      } else if (window.localStorage) {
        const raw = window.localStorage.getItem(BUTTON_STORAGE_KEY);
        if (raw) {
          return normalizeButtonPos(JSON.parse(raw), fallback);
        }
      }
    } catch (err) {
      console.debug('[Watermark Remover] 读取按钮位置失败:', err);
    }
    return { ...fallback };
  }

  function saveButtonPosition(pos) {
    const normalized = normalizeButtonPos(pos, { side: 'left', top: 0.5 });
    try {
      if (typeof GM_setValue === 'function') {
        GM_setValue(BUTTON_STORAGE_KEY, normalized);
      } else if (window.localStorage) {
        window.localStorage.setItem(BUTTON_STORAGE_KEY, JSON.stringify(normalized));
      }
    } catch (err) {
      console.debug('[Watermark Remover] 保存按钮位置失败:', err);
    }
  }

  function normalizeButtonPos(pos, fallback) {
    if (!pos || typeof pos !== 'object') return { ...fallback };
    const side = pos.side === 'right' ? 'right' : 'left';
    const top = clamp(typeof pos.top === 'number' ? pos.top : fallback.top, 0.05, 0.95);
    return { side, top };
  }

  /* ========== 辅助函数 ========== */
  function walkDom(root, cb) {
    if (!root) return;
    if (root instanceof Element) {
      cb(root);
      if (root.shadowRoot) walkDom(root.shadowRoot, cb);
      for (const child of root.children) {
        walkDom(child, cb);
      }
    } else if (
      root instanceof DocumentFragment ||
      root instanceof ShadowRoot ||
      root instanceof Document
    ) {
      const nodes = root.children || root.childNodes;
      for (const child of nodes) {
        if (child.nodeType === 1) walkDom(child, cb);
      }
    }
  }

  function shouldSkipButton(el) {
    return sweepButton && sweepButton.contains(el);
  }

  function clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }

  function whenReady(fn) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', fn, { once: true });
    } else {
      fn();
    }
  }

  function whenBody(fn) {
    if (document.body) {
      fn();
    } else {
      const watcher = new MutationObserver(() => {
        if (document.body) {
          watcher.disconnect();
          fn();
        }
      });
      watcher.observe(document.documentElement, { childList: true });
    }
  }

  function safeComputedStyle(el) {
    try {
      return window.getComputedStyle(el);
    } catch {
      return null;
    }
  }
})();