DOM Query Tool

DOM查询增强,支持穿透Shadow DOM获取元素

Αυτός ο κώδικας δεν πρέπει να εγκατασταθεί άμεσα. Είναι μια βιβλιοθήκη για άλλους κώδικες που περιλαμβάνεται μέσω της οδηγίας meta // @require https://update.greasyfork.org/scripts/552691/1678285/DOM%20Query%20Tool.js

// ==UserScript==
// @name         DOM Query Tool
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @author       Feny
// @description  DOM查询增强,支持穿透Shadow DOM获取元素
// @license      MIT
// @run-at       document-start
// @grant        unsafeWindow
// @match        *://*/*
// ==/UserScript==

(function (window) {
  "use strict";

  const EventTypes = {
    CLICK: "click",
    MOUSE_MOVE: "mousemove",
    MOUSE_OVER: "mouseover",
  };

  const GMTools = {
    EMPTY: "",
    /**
     * 判断字符串是否不包含数字
     * @param {string} str - 待检测的字符串
     * @returns {boolean} 不包含任何数字返回true,否则返回false
     */
    hasNoDigits: (str) => !/\d/.test(str),
    /**
     * 判断当前窗口是否为顶层窗口
     * @returns {boolean} 是顶层窗口返回true,否则返回false
     */
    isTopWin: () => window.top === window,
    /**
     * 向指定窗口发送跨窗口消息(支持自定义消息来源标识)
     * @param {Window} win - 目标窗口对象(如iframe的contentWindow、父窗口等)
     * @param {Object} data - 要发送的消息数据(示例:{ action: 'play', episode: 5 })
     * @param {string} [source="GM_MESSAGE"] - 消息来源标识(用于接收方验证消息合法性)
     */
    postMessage: (win, data, source = "GM_MESSAGE") => win?.postMessage({ source, ...data }, "*"),
    /**
     * 向页面中所有非空src的iframe发送消息
     * @param {Object} data - 要发送给iframe的消息数据(示例:{ action: 'play', episode: 5 })
     * @param {string} source - 消息来源标识(可选,默认'GM_MESSAGE')
     */
    sendToIFrames(data, source) {
      this.querys("iframe:not([src=''], [src='#'])").forEach((iframe) => this.postMessage(iframe?.contentWindow, data, source));
    },
    /**
     * 获取元素的矩形信息
     * @param {Element} el - 目标元素
     * @returns {DOMRect|null} 元素的边界矩形对象,元素不存在则返回null
     */
    getElementRect: (el) => el?.getBoundingClientRect(),
    /**
     * 异步等待指定毫秒数
     * @param {number} ms - 等待时间(毫秒)
     * @returns {Promise<void>} 等待完成的Promise
     */
    sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
    /**
     * 保留小数位数,移除末尾多余的0
     * @param {number|string} value - 待格式化的值
     * @param {number} [digits=2] - 保留的小数位数
     * @returns {string} 格式化后的字符串
     */
    toFixed: (value, digits = 2) => (+value).toFixed(digits).replace(/\.?0+$/, ""),
    /**
     * 判断元素是否可见
     * @param {Element} el - 目标元素
     * @returns {boolean} 可见返回true,否则返回false
     */
    isVisible: (el) => !!(el?.offsetWidth || el?.offsetHeight || el?.getClientRects().length),
    /**
     * 阻止事件的默认行为及传播
     * @param {Event} event - 事件对象
     */
    preventDefault: (event) => event.preventDefault() & event.stopPropagation() & event.stopImmediatePropagation(),
    /**
     * 判断元素是否包含指定类名中的任意一个
     * @param {Element} el - 目标元素
     * @param {...string|string[]} classes - 类名列表(支持数组嵌套)
     * @returns {boolean} 包含任意类名返回true,否则返回false
     */
    hasCls: (el, ...classes) => classes.flat().some((cls) => el?.classList.contains(cls)),
    /**
     * 从元素中移除指定类名
     * @param {Element} el - 目标元素
     * @param {...string} classes - 要移除的类名
     */
    delCls: (el, ...classes) => el?.classList.remove(...classes),
    /**
     * 向元素添加指定类名
     * @param {Element} el - 目标元素
     * @param {...string} classes - 要添加的类名
     */
    addCls: (el, ...classes) => el?.classList.add(...classes),
    isElement(node) {
      return node instanceof Element;
    },
    isDocument(node) {
      return node instanceof Document;
    },
    /**
     * 重写Element的attachShadow方法,用于监听ShadowRoot的创建
     * 为每个新创建的ShadowRoot触发"attached"事件,便于跟踪Shadow DOM
     */
    hackAttachShadow() {
      if (Element.prototype.__attachShadow__) return;
      Element.prototype.__attachShadow__ = Element.prototype.attachShadow;
      Element.prototype.attachShadow = function (options) {
        if (this._shadowRoot) return this._shadowRoot;
        const shadowRoot = (this._shadowRoot = this.__attachShadow__.call(this, options));
        const shadowEvent = new CustomEvent("attached", { bubbles: true, detail: { shadowRoot } });
        document.dispatchEvent(shadowEvent);
        return shadowRoot;
      };

      Element.prototype.attachShadow.toString = () => Element.prototype.__attachShadow__.toString();
    },
    /**
     * 生成器:获取节点下所有ShadowRoot(支持深度遍历)
     * @param {Node} node - 起始节点(Element或Document)
     * @param {boolean} [deep=false] - 是否深度遍历子ShadowRoot
     * @yields {ShadowRoot} 遍历到的ShadowRoot对象
     */
    *getShadowRoots(node, deep = false) {
      if (!node || (!this.isElement(node) && !this.isDocument(node))) return;
      if (this.isElement(node) && node._shadowRoot) {
        yield node._shadowRoot;
      }
      const doc = this.isDocument(node) ? node : node.getRootNode({ composed: true });
      if (!doc.createTreeWalker) return;
      let currentNode;
      const toWalk = [node];
      while ((currentNode = toWalk.pop())) {
        const walker = doc.createTreeWalker(currentNode, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_DOCUMENT_FRAGMENT, {
          acceptNode: (child) => (this.isElement(child) && child._shadowRoot ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP),
        });
        let walkerNode = walker.nextNode();
        while (walkerNode) {
          if (this.isElement(walkerNode) && walkerNode._shadowRoot) {
            if (deep) {
              toWalk.push(walkerNode._shadowRoot);
            }
            yield walkerNode._shadowRoot;
          }
          walkerNode = walker.nextNode();
        }
      }
      return;
    },
    /**
     * 增强版querySelector:支持查询普通DOM和Shadow DOM
     * @param {string} selector - CSS选择器
     * @param {Node} [subject=document] - 查询的起始节点(默认document)
     * @returns {Element|null} 匹配的第一个元素,无匹配返回null
     */
    query(selector, subject = document) {
      const immediate = subject.querySelector(selector);
      if (immediate) return immediate;
      const shadowRoots = [...this.getShadowRoots(subject, true)];
      for (const root of shadowRoots) {
        const match = root.querySelector(selector);
        if (match) return match;
      }
      return null;
    },
    /**
     * 增强版querySelectorAll:支持查询普通DOM和Shadow DOM
     * @param {string} selector - CSS选择器
     * @param {Node} [subject=document] - 查询的起始节点(默认document)
     * @returns {Element[]} 所有匹配的元素数组(去重)
     */
    querys(selector, subject = document) {
      const results = [...subject.querySelectorAll(selector)];
      const shadowRoots = [...this.getShadowRoots(subject, true)];
      for (const root of shadowRoots) {
        results.push(...root.querySelectorAll(selector));
      }
      return results;
    },
    /**
     * 创建 MutationObserver(DOM 变化监听器)
     * @param {Node|string} target - 监听目标(可传 Node 节点或 CSS 选择器字符串)
     * @param {Function} callback - DOM 变化时触发的回调函数(接收 mutationList 和 observer 参数)
     * @param {Object} [options] - 监听配置(如 attributes: true 表示监听属性变化,默认包含 childList 和 subtree)
     * @returns {MutationObserver} - 创建好的 MutationObserver 实例(需自行管理 disconnect)
     */
    createObserver(target, callback, options) {
      const observer = new MutationObserver(callback);
      target = target instanceof Element ? target : this.query(target);
      observer.observe(target, { childList: true, subtree: true, ...options });
      return observer;
    },
    /**
     * 安全处理 HTML 字符串
     * @param {string} htmlStr - 待处理的 HTML 字符串(如 "<div class='box'>内容</div>")
     * @returns {string|TrustedHTML} - 安全的 HTML(支持 Trusted Types 则返回 TrustedHTML 对象,否则返回原字符串)
     */
    trustedHTML(htmlStr) {
      if (!window.trustedTypes?.createPolicy) return htmlStr;
      const policy = trustedTypes.defaultPolicy ?? trustedTypes.createPolicy("default", { createHTML: (input) => input });
      return policy.createHTML(htmlStr);
    },
    /**
     * 获取元素的所有祖先节点(包括可能的Shadow DOM宿主)
     * @param {Element} element - 目标元素
     * @param {boolean} [withSelf=false] - 是否包含元素自身
     * @param {number} [maxLevel=Infinity] - 最大遍历层级
     * @returns {Element[]} 祖先节点数组(从顶层到当前元素)
     */
    getParents(element, withSelf = false, maxLevel = Infinity) {
      const parents = withSelf && element ? [element] : [];
      for (let current = element, level = 0; current && level < maxLevel; level++) {
        current = current.parentNode instanceof ShadowRoot ? current?.getRootNode()?.host : current?.parentElement;
        current && parents.unshift(current);
      }
      return parents;
    },
    /**
     * 获取元素的父级链选择器(从元素自身到body的选择器路径)
     * @param {Element} element - 目标元素
     * @param {boolean} [nth=false] - 是否为同类型元素添加nth-of-type索引(用于区分同层级同标签元素)
     * @returns {string} 由 " > " 连接的选择器链字符串(如 "div.container > p#intro > span.highlight")
     */
    getParentChain(element, nth = false) {
      const parents = [];
      for (let current = element; current && current !== document.body; current = current.parentElement) {
        parents.unshift(this.getTagInfo(current, nth));
        if (current.id && this.hasNoDigits(current.id)) break;
      }
      return parents.join(" > ");
    },
    /**
     * 获取单个元素的CSS选择器信息(优先使用ID,其次类名,最后标签名)
     * @param {Element} ele - 目标元素
     * @param {boolean} [nth=false] - 是否添加nth-of-type索引
     * @returns {string} 元素的CSS选择器字符串(如 "#username"、".nav-item"、"div:nth-of-type(2)")
     */
    getTagInfo(ele, nth = false) {
      // id不是数字和中文
      if (ele.id && this.hasNoDigits(ele.id) && !/[\u4e00-\u9fa5]/.test(ele.id)) return `#${ele.id}`;
      let selector = ele.tagName.toLowerCase();
      const classes = Array.from(ele.classList);

      // 处理类选择器
      if (classes.length) {
        const validClasses = classes.filter(this.hasNoDigits, this);
        selector += /[:[\]]/.test(ele.className)
          ? `[class="${ele.className}"]`
          : validClasses.length
          ? `.${validClasses.join(".")}`
          : this.EMPTY;
      }

      // 非首个同类型元素添加nth-of-type
      if (nth && ele.parentElement) {
        const siblings = Array.from(ele.parentElement.children).filter((sib) => sib.tagName === ele.tagName);
        const index = siblings.indexOf(ele);
        if (index > 0) selector += `:nth-of-type(${index + 1})`;
      }

      return selector;
    },
    /**
     * 查找元素的最近匹配祖先
     * @param {Element} element - 目标元素
     * @param {string} selector - CSS选择器
     * @param {number} [maxLevel=3] - 最大查找层级
     * @returns {Element|null} 匹配的最近祖先,无匹配返回null
     */
    closest(element, selector, maxLevel = 3) {
      for (let level = 0; element && level < maxLevel; level++, element = element.parentElement) {
        if (element.matches(selector)) return element;
      }
      return null;
    },
    /**
     * 通过 XPath 查找元素,支持文本内容或属性值匹配
     * @param {'text'|'attr'} mode - 匹配模式:'text' 匹配文本内容,'attr' 匹配任意属性
     * @param {...string|string[]} texts - 要匹配的文本(可嵌套数组)
     * @returns {Element[]} 匹配的元素数组
     */
    findByText(mode, ...texts) {
      const flatTexts = texts.flat();
      const expr = Object.is(mode, "text")
        ? `.//*[${flatTexts.map((t) => `contains(text(), '${t.replace(/'/g, "\\'")}')`).join(" or ")}]`
        : `.//*[${flatTexts.map((t) => `@*[contains(., '${t.replace(/'/g, "\\'")}')]`).join(" or ")}]`;
      const nodes = document.evaluate(expr, document.body, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
      return Array.from({ length: nodes.snapshotLength }, (_, i) => nodes.snapshotItem(i)).filter((el) => !el.matches("script"));
    },
    /**
     * 获取元素的中心点坐标
     * @param {Element} element - 目标元素
     * @returns {Object} 包含centerX和centerY的坐标对象
     */
    getCenterPoint(element) {
      if (!element) return { centerX: 0, centerY: 0 };
      const { top, left, width, height } = this.getElementRect(element);
      return { centerX: left + width / 2, centerY: top + height / 2 }; // 元素中心点
    },
    /**
     * 判断坐标点是否在元素内部
     * @param {number} pointX - 点的X坐标
     * @param {number} pointY - 点的Y坐标
     * @param {Element} element - 目标元素
     * @returns {boolean} 点在元素内返回true,否则返回false
     */
    pointInElement(pointX, pointY, element) {
      if (!element) return false;
      const { top, left, right, bottom } = this.getElementRect(element);
      return pointX >= left && pointX <= right && pointY >= top && pointY <= bottom;
    },
    /**
     * 触发元素的鼠标移动事件
     * @param {Element} element - 目标元素
     */
    triggerMousemove(element) {
      const { centerX, centerY } = this.getCenterPoint(element);
      for (let y = 0; y < centerY; y += 10) this.dispatchMouseEvent(element, EventTypes.MOUSE_MOVE, centerX, y);
    },
    /**
     * 触发元素的鼠标悬停事件
     * @param {Element} element - 目标元素
     */
    triggerMouseHover(element) {
      const { centerX, centerY } = this.getCenterPoint(element);
      this.dispatchMouseEvent(element, EventTypes.MOUSE_OVER, centerX, centerY);
    },
    /**
     * 向元素派发鼠标事件
     * @param {Element} element - 目标元素
     * @param {string} eventType - 事件类型(如'mousemove'、'click')
     * @param {number} clientX - 鼠标X坐标
     * @param {number} clientY - 鼠标Y坐标
     */
    dispatchMouseEvent(element, eventType, clientX, clientY) {
      const dict = { clientX, clientY, bubbles: true };
      element?.dispatchEvent(new MouseEvent(eventType, dict));
    },
    freqTimes: new Map(),
    /**
     * 判断操作是否过于频繁(防抖/节流)
     * @param {string} [key="default"] - 操作标识
     * @param {number} [gap=300] - 时间间隔(毫秒)
     * @param {boolean} [isThrottle=false] - 是否启用节流模式(true为节流,false为防抖)
     * @returns {boolean} 防抖模式:过于频繁返回true;节流模式:不执行返回true
     */
    isFrequent(key = "default", gap = 300, isThrottle = false) {
      const now = Date.now();
      const last = this.freqTimes.get(key) ?? 0;
      const delta = now - last;

      // 限制模式:返回是否过于频繁
      if (!isThrottle) return this.freqTimes.set(key, now) && delta < gap;

      // 节流模式:间隔满足时执行一次
      return delta >= gap ? this.freqTimes.set(key, now) && false : true;
    },
    limitCountMap: new Map(),
    /**
     * 判断操作是否超过次数限制
     * @param {string} [key="default"] - 操作标识
     * @param {number} [maxCount=5] - 最大允许次数
     * @returns {boolean} 超过限制返回true,否则返回false
     */
    isOverLimit(key = "default", maxCount = 5) {
      const count = this.limitCountMap.get(key) ?? 0;
      if (count < maxCount) return this.limitCountMap.set(key, count + 1) && false;
      return true;
    },
    /**
     * 重置指定操作的次数计数器
     * @param {string} [key="default"] - 操作标识
     */
    resetLimitCounter(key = "default") {
      this.limitCountMap.set(key, 0);
    },
  };

  GMTools.hackAttachShadow();
  unsafeWindow.GMTools = window.GMTools = GMTools;
})(window);