Greasy Fork

Greasy Fork is available in English.

网页自动展开(全站适用·防误跳转版)

智能展开折叠内容;防跳转、防误杀、ShadowDOM 兼容、可配置排除站;优化按钮判断逻辑,减少误触

当前为 2025-10-15 提交的版本,查看 最新版本

// ==UserScript==
// @name         网页自动展开(全站适用·防误跳转版)
// @version      1.1.1
// @description  智能展开折叠内容;防跳转、防误杀、ShadowDOM 兼容、可配置排除站;优化按钮判断逻辑,减少误触
// @namespace    Kiwifruit13
// @match        *://*/*
// @run-at       document-idle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @license      MIT
// ==/UserScript==

(() => {
    'use strict';

    /* ===================== 用户配置 ===================== */
    const DEFAULT_EXCLUDE = [
        'www.toutiao.com',
        'm.toutiao.com',
        'zh.moegirl.org.cn',
        'moegirl.org.cn',
        'www.zhihu.com',
        'v.douyin.com',
        'www.alipay.com',
        'wx.tenpay.com',
        'ebank.*.com'
    ];
    const EXCLUDE_HOSTS = GM_getValue('exclude_hosts', DEFAULT_EXCLUDE);
    const SCAN_DEBOUNCE = 300; // ms
    const VIEWPORT_MARGIN = '50%'; // IntersectionObserver 提前量
    const GUARD_SECONDS = 3; // 解锁后守护时长
    /* =================================================== */

    const expandedSet = new WeakSet();
    const clickedSet = new WeakSet();

    /* ---------- 0. 排除站判断 ---------- */
    function isExcluded() {
        const hostname = location.hostname;
        return EXCLUDE_HOSTS.some(pattern => {
            const regex = new RegExp('^(.+\\.)?' + pattern.replace(/\*/g, '.*') + '$', 'i');
            return regex.test(hostname);
        });
    }
    if (isExcluded()) {
        return;
    }

    /* ---------- 1. 工具函数 ---------- */
    function* walk(root) {
        yield* root.querySelectorAll('*');
        for (const element of root.querySelectorAll('*')) {
            if (element.shadowRoot) {
                yield* walk(element.shadowRoot);
            }
        }
    }

    function notify(message) {
        if (typeof GM_notification === 'function') {
            GM_notification({
                text: message,
                title: 'AutoExpand',
                timeout: 3000
            });
        }
    }

    /* ---------- 2. 过滤函数 ---------- */
    const EXPAND_KEYWORDS = [
        '展开', '查看', '阅读', '显示', '更多', '全文', '全部', '继续',
        'Read', 'More', 'Full', 'Expand', 'Continue', 'Show', 'View'
    ];

    // 扩充 BAD_TXT 以过滤更多可能引起跳转或导航的词语
    const BAD_TXT = /登录|关注|订阅|点赞|收藏|分享|评论|回复|举报|广告|下载|安装|打开APP|跳转|前往|视频|动态|稿件|列表|更多视频|展开视频|查看全部视频|Up主|导航|菜单|链接|Link|Home|About|Contact|Login|Sign Up|Register|Share|Like|Follow|Subscribe|Shop|Buy|Add to Cart|Cart|Checkout|Pay|Order|History|Settings|Profile|Account|Logout|退出|注册|购物车|结算|支付|订单|历史|设置|个人|账户|登出|展开列表|展开动态|展开视频列表|展开文章列表|展开问答列表|展开商品列表|展开结果列表|展开搜索结果|展开相关新闻|展开相关推荐|展开相关文章|展开相关视频|展开相关动态|展开相关话题|展开相关用户|展开相关链接|展开更多内容|展开剩余内容|展开全部内容|展开全部|显示更多|查看更多|加载更多|下一页|上一页|首页|尾页|返回|Back|Next|Previous|Menu|Navigation|Sidebar|Header|Footer|Pagination|Carousel|Slider|Banner|Promotion|Sponsored|Ad|Advertisement|推广|广告|下载App|App下载|打开App|安装App|立即下载|立即安装|App内打开|App内查看|在App中|App中|App|客户端|Client/i;

    // 定义可能表示列表、导航、广告等区域的祖先元素特征
    const AVOID_PARENT_SELECTORS = [
        '[role="navigation"]',
        '[data-type*="list"]',
        '[class*="list"]',
        '[class*="List"]', // 注意大小写
        '[class*="feed"]',
        '[class*="Feed"]',
        '[class*="nav"]',
        '[class*="Nav"]',
        '[class*="menu"]',
        '[class*="Menu"]',
        '[class*="sidebar"]',
        '[class*="Sidebar"]',
        '[class*="header"]',
        '[class*="Header"]',
        '[class*="footer"]',
        '[class*="Footer"]',
        '[class*="carousel"]', // 轮播图
        '[class*="slider"]', // 滑块
        '[class*="pagination"]', // 分页
        'ul', 'ol', // HTML 列表标签
        '[class*="video"]', // 示例:B站视频列表相关的类名
        '[class*="bangumi"]', // 示例:B站番剧列表相关的类名
        '[class*="media"]', // 示例:媒体列表相关的类名
        '[class*="item"]', // 示例:通用列表项
        '[class*="card"]', // 示例:卡片列表项
        '[class*="row"]', // 示例:网格行
        '[class*="col"]', // 示例:网格列
        // 可根据需要添加更多
    ];

    function isSafeExpandButton(element) {
        if (!element || element.nodeType !== 1) {
            return false;
        }
        if (clickedSet.has(element)) {
            return false;
        }

        // --- 新增:检查祖先元素是否包含列表、导航等标识 ---
        // 在 while 循环之前定义一个辅助函数
        // 这是防止误触列表展开按钮的关键改进
        let parent = element.parentElement;
        while (parent && parent !== document.body) {
            const className = parent.className || '';
            const id = parent.id || '';
            const tagName = parent.tagName.toLowerCase();
            const role = parent.getAttribute('role') || '';

            // 检查是否匹配定义的模式
            if (AVOID_PARENT_SELECTORS.some(selector => {
                // 如果 sel 是标签名,直接比较
                if (selector === tagName) {
                    return true;
                }
                // 如果 sel 是属性选择器,使用 matches 方法
                if (selector.startsWith('[') && parent.matches) {
                    return parent.matches(selector);
                }
                // 如果 sel 是类名或ID的一部分,进行字符串包含检查
                // (这种方式不如 matches 精确,但覆盖更多情况)
                if (selector.startsWith('[class*="') || selector.startsWith('[id*="')) {
                    const matchClassOrId = selector.match(/\[.*\*="(.+)"\]/);
                    if (matchClassOrId && (className.includes(matchClassOrId[1]) || id.includes(matchClassOrId[1]))) {
                        return true;
                    }
                }
                // 检查 role 属性
                if (selector.includes('role') && role.includes('navigation')) {
                    return true;
                }
                return false;
            })) {
                // 如果祖先元素匹配任何一个模式,则认为按钮不安全
                return false;
            }
            parent = parent.parentElement;
        }
        // --- 新增结束 ---

        const rect = element.getBoundingClientRect();
        if (rect.height > 60 || rect.width > 300) {
            return false; // 尺寸过滤
        }

        const text = (element.innerText || element.textContent || '').trim();
        if (!text || text.length > 40 || BAD_TXT.test(text)) {
            return false; // 文本过滤
        }

        if (!EXPAND_KEYWORDS.some(keyword => text.includes(keyword))) {
            return false; // 必须包含展开关键词
        }

        if (element.tagName === 'A') {
            const href = element.getAttribute('href') || '';
            const isExternal = /^(https?:)?\/\//i.test(href) && !href.includes(location.host);
            if (isExternal) {
                return false; // 外部链接过滤
            }
        }

        // --- 新增:检查 aria-controls 指向的元素 ---
        // 如果按钮有 aria-controls,检查目标元素是否是可展开的内容容器
        const controlsId = element.getAttribute('aria-controls');
        if (controlsId) {
            const targetElement = document.getElementById(controlsId);
            if (targetElement && !isCollapsedContainer(targetElement)) {
                // 如果目标元素不是被折叠的内容,则该按钮可能不是“展开内容”按钮
                // 或者,如果目标元素存在,但其内容 *已经* 展开了 (expandedSet.has(targetElement)),则无需点击
                if (expandedSet.has(targetElement)) {
                    return false; // 目标已展开,无需点击
                }
                // 如果目标不是可展开容器,也可以选择返回 false
                // return !isCollapsedContainer(targetElement); // 这会更严格
            }
        }
        // --- 新增结束 ---

        const isRealButton =
            element.tagName === 'BUTTON' ||
            element.getAttribute('role') === 'button' ||
            element.getAttribute('aria-expanded') != null;
        return isRealButton; // 检查是否为按钮类型
    }

    function isCollapsedContainer(element) {
        if (expandedSet.has(element)) {
            return false;
        }
        if (element.tagName === 'BODY' || element.tagName === 'HTML') {
            return false;
        }
        const style = window.getComputedStyle(element);
        const maxHeight = parseFloat(style.maxHeight);
        const hasMaxHeight = maxHeight > 0 && maxHeight < 600;
        const hasOverflowHidden = style.overflow === 'hidden' || style.overflowY === 'hidden';
        const isScrollable = element.scrollHeight > element.clientHeight + 20;

        // 排除滚动视口
        const isFullHeight = element.getBoundingClientRect().height >= window.innerHeight * 0.85;
        const hasScrollBehavior = /auto|scroll/.test(style.overflowY);
        if (isFullHeight && hasScrollBehavior) {
            return false;
        }

        // 排除懒加载图片容器
        if (element.querySelector('img[data-src], img[loading="lazy"]')) {
            return false;
        }

        return hasMaxHeight && hasOverflowHidden && isScrollable;
    }

    /* ---------- 3. 解锁 + 守护 ---------- */
    function unlock(element) {
        element.style.setProperty('max-height', 'none', 'important');
        element.style.setProperty('height', 'auto', 'important');
        element.style.setProperty('overflow', 'visible', 'important');
        element.classList.add('userscript-ae-expanded');
        expandedSet.add(element);

        // 守护:定时检查,防止页面JS重置样式
        let guardianInterval = setInterval(() => {
            const currentStyle = window.getComputedStyle(element);
            if (parseFloat(currentStyle.maxHeight) < 600) {
                unlock(element);
            }
        }, 1000);

        // 在守护时长后停止守护
        setTimeout(() => clearInterval(guardianInterval), GUARD_SECONDS * 1000);
    }

    /* ---------- 4. 扫描 ---------- */
    function scan(root = document.body) {
        try {
            for (const element of walk(root)) {
                if (isSafeExpandButton(element)) {
                    element.click();
                    clickedSet.add(element);
                }
                if (isCollapsedContainer(element)) {
                    unlock(element);
                }
            }
        } catch (error) {
            console.warn('[AutoExpand] scan error:', error);
            notify(`扫描异常: ${error.message}`);
        }
    }

    /* ---------- 5. 样式只一次 ---------- */
    if (!document.getElementById('userscript-ae-style')) {
        const styleElement = document.createElement('style');
        styleElement.id = 'userscript-ae-style';
        styleElement.textContent = `
      .userscript-ae-expanded::before,
      .userscript-ae-expanded::after {
          display: none !important;
          content: "" !important;
      }
    `;
        document.head.appendChild(styleElement);
    }

    /* ---------- 6. MutationObserver 增量 ---------- */
    let mutationObserverTimer;
    const mutationObserver = new MutationObserver(mutationRecords => {
        const targets = new Set();
        mutationRecords.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === 1) {
                    targets.add(node);
                }
            });
        });

        if (targets.size > 0) {
            clearTimeout(mutationObserverTimer);
            mutationObserverTimer = setTimeout(() => {
                targets.forEach(scan);
            }, SCAN_DEBOUNCE);
        }
    });
    mutationObserver.observe(document.body, { childList: true, subtree: true });

    /* ---------- 7. IntersectionObserver 视口 ---------- */
    const intersectionObserver = new IntersectionObserver(entries => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                scan(entry.target);
            }
        });
    }, {
        rootMargin: VIEWPORT_MARGIN
    });

    document.querySelectorAll('div, section, article').forEach(element => {
        intersectionObserver.observe(element);
    });

    /* ---------- 8. 配置菜单 ---------- */
    GM_registerMenuCommand('⚙️ 展开脚本设置', () => {
        const currentExclusions = EXCLUDE_HOSTS.join('\n'); // 使用 \n 作为换行符
        const promptMessage = `每行一个域名,支持通配符 *.domain.com
当前排除列表:
${currentExclusions}`;
        const userInput = prompt(promptMessage);

        if (userInput !== null) { // 检查用户是否点击了取消
            // 按换行符分割,过滤空行,并保存
            const newExclusions = userInput.split(/\s*\n\s*/).filter(domain => domain.trim() !== '');
            GM_setValue('exclude_hosts', newExclusions);
            location.reload(); // 简单暴力地重载页面以应用新设置
        }
    });

    /* ---------- 9. 初次执行 ---------- */
    if (document.readyState === 'complete') {
        setTimeout(scan, 1000);
    } else {
        window.addEventListener('load', () => {
            setTimeout(scan, 1000);
        });
    }

})();