您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
智能展开折叠内容;防跳转、防误杀、ShadowDOM 兼容、可配置排除站;优化按钮判断逻辑,减少误触
当前为
// ==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); }); } })();