// ==UserScript== // @license MIT // @name 链接有效性检测器 (完整版 v1.5) // @namespace http://tampermonkey.net/ // @version 1.5 // @description 添加悬浮按钮检测页面链接,重试失败请求,对405/5xx错误回退到GET,页面内标记失效链接❌,使用Toastify显示日志,兼容GreasyFork。 // @author Your Name (或 AI) // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getResourceText // @connect * // @resource TOASTIFY_JS https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.js // @resource TOASTIFY_CSS https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css // @downloadURL none // ==/UserScript== (function() { 'use strict'; // --- 加载并注入 Toastify JS (GreasyFork 兼容) --- let Toastify; // 将 Toastify 定义在外面,以便全局访问 try { const toastifyCode = GM_getResourceText("TOASTIFY_JS"); if (toastifyCode) { // 使用 new Function 比 eval 稍安全 new Function(toastifyCode)(); Toastify = window.Toastify; // 假设它附加到 window if (!Toastify) { console.error("[链接检测器] Toastify JS executed, but Toastify object not found on window."); throw new Error("Toastify object not found after execution."); // 抛出错误以便进入 catch } console.log("[链接检测器] Toastify JS loaded and ready."); } else { throw new Error("Could not load Toastify JS text from @resource."); } } catch (e) { console.error("[链接检测器] Failed to load or execute Toastify JS:", e); // 提供一个基于 console.log 的后备通知机制 Toastify = function(options) { console.log(`[Toastify Fallback] ${options.text}`); return { showToast: function(){} }; // 返回一个空对象以防链式调用错误 }; alert("警告:通知库 Toastify 加载失败,脚本部分功能(悬浮通知)将受影响。\n请检查网络连接或脚本设置。\n错误信息已打印到控制台 (F12)。"); } // --- 配置 --- const CHECK_TIMEOUT = 10000; // 单个请求超时 (毫秒) const CONCURRENT_CHECKS = 5; // 同时进行的请求数 const MAX_RETRIES = 1; // 网络错误/超时的最大重试次数 (0表示不重试) const RETRY_DELAY = 500; // 重试前等待时间 (毫秒) const BROKEN_LINK_CLASS = 'link-checker-broken'; const CHECKED_LINK_CLASS = 'link-checker-checked'; // 用于标记已检查 // --- 失效链接图标 (红色 X SVG) --- const BROKEN_ICON_SVG = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='red' width='1em' height='1em'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E`; // --- 引入并添加样式 (Toastify CSS 和自定义样式) --- try { const toastifyCSS = GM_getResourceText("TOASTIFY_CSS"); GM_addStyle(toastifyCSS); } catch(e) { console.error("[链接检测器] Failed to load or add Toastify CSS:", e); // CSS 加载失败不影响核心功能,但通知样式会丢失 } GM_addStyle(` /* Toastify 居中 */ .toastify.on.toastify-center { margin-left: auto; margin-right: auto; transform: translateX(0); } /* 失效链接样式 */ .${BROKEN_LINK_CLASS} { color: red !important; /* 强制红色 */ text-decoration: line-through !important; /* 强制删除线 */ /* outline: 1px dashed red; /* 可选:添加虚线轮廓 */ } /* 在失效链接后添加图标 */ .${BROKEN_LINK_CLASS}::after { content: ''; /* 使用背景图 */ display: inline-block; width: 0.9em; /* 图标大小 */ height: 0.9em; /* 图标大小 */ margin-left: 4px; /* 图标与文字间距 */ vertical-align: middle; /* 垂直对齐 */ background-image: url("${BROKEN_ICON_SVG}"); background-repeat: no-repeat; background-size: contain; /* 缩放图标 */ cursor: help; /* 提示用户可以悬停查看详情 */ } /* 悬浮按钮样式 */ #linkCheckerButton { position: fixed; bottom: 25px; /* 调整位置 */ right: 25px; /* 调整位置 */ width: 55px; /* 调整大小 */ height: 55px; /* 调整大小 */ background-color: #0d6efd; /* Bootstrap 蓝色 */ color: white; border: none; border-radius: 50%; font-size: 22px; /* 图标大小 */ line-height: 55px; /* 垂直居中 */ text-align: center; cursor: pointer; box-shadow: 0 4px 10px rgba(0,0,0,0.3); z-index: 9999; transition: background-color 0.3s, transform 0.2s ease-out; display: flex; align-items: center; justify-content: center; user-select: none; /* 防止意外选中文本 */ } #linkCheckerButton:hover { background-color: #0a58ca; /* 悬停时深蓝色 */ transform: scale(1.1); } #linkCheckerButton:disabled { background-color: #adb5bd; /* 禁用时灰色 */ cursor: not-allowed; transform: none; box-shadow: 0 2px 5px rgba(0,0,0,0.2); } `); // --- 全局状态 --- let isChecking = false; let totalLinks = 0; let checkedLinks = 0; let brokenLinksCount = 0; let linkQueue = []; let activeChecks = 0; let brokenLinkDetailsForConsole = []; // 用于控制台输出 // --- 创建悬浮按钮 --- const button = document.createElement('button'); button.id = 'linkCheckerButton'; button.innerHTML = '🔗'; button.title = '点击开始检测页面链接'; document.body.appendChild(button); // --- 工具函数 --- function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // --- Toastify 通知函数 --- function showToast(text, type = 'info', duration = 3000) { // 确保 Toastify 对象存在且是函数 if (!Toastify || typeof Toastify !== 'function') { console.warn(`Toastify unavailable. Msg: [${type}] ${text}`); return; // 如果 Toastify 加载失败则不执行 } let backgroundColor; switch(type) { case 'success': backgroundColor = "linear-gradient(to right, #00b09b, #96c93d)"; break; case 'error': backgroundColor = "linear-gradient(to right, #ff5f6d, #ffc371)"; break; case 'warning': backgroundColor = "linear-gradient(to right, #f7b733, #fc4a1a)"; break; default: backgroundColor = "linear-gradient(to right, #0dcaf0, #0d6efd)"; // 信息使用蓝色渐变 } Toastify({ text: text, duration: duration, gravity: "bottom", // 在底部显示 position: "center", // 在中间显示 style: { background: backgroundColor, borderRadius: '5px', color: 'white' }, // 添加圆角和白色文字 stopOnFocus: true, // 鼠标悬停时停止计时 }).showToast(); } // --- 核心链接检测函数 (处理405/5xx,带重试) --- async function checkLink(linkElement, retryCount = 0) { const url = linkElement.href; // 初始过滤和标记 (仅在第一次尝试时) if (retryCount === 0) { if (!url || !url.startsWith('http')) { return { element: linkElement, status: 'skipped', url: url, message: '非HTTP(S)链接' }; } linkElement.classList.add(CHECKED_LINK_CLASS); // 标记为已检查(无论结果如何) } // --- 内部函数:执行实际的 HTTP 请求 --- const doRequest = (method) => { return new Promise((resolveRequest) => { GM_xmlhttpRequest({ method: method, url: url, timeout: CHECK_TIMEOUT, headers: { // 添加一些常见的请求头,可能有助于避免某些服务器拒绝 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'User-Agent': navigator.userAgent // 使用浏览器自身的 User-Agent }, onload: function(response) { // 如果是 HEAD 且返回 405 或 5xx,则准备尝试 GET if (method === 'HEAD' && (response.status === 405 || (response.status >= 500 && response.status < 600))) { console.log(`[链接检测] HEAD 收到 ${response.status}: ${url.substring(0, 100)}..., 尝试使用 GET...`); resolveRequest({ status: 'retry_with_get' }); return; } // 其他情况,根据状态码判断 if (response.status >= 200 && response.status < 400) { // 2xx (成功) 和 3xx (重定向) 都算 OK resolveRequest({ status: 'ok', statusCode: response.status, message: `方法 ${method}` }); } else { // 4xx (客户端错误, 非405) 或 其他错误 resolveRequest({ status: 'broken', statusCode: response.status, message: `方法 ${method} 错误 (${response.status})` }); } }, onerror: function(response) { // 网络层错误 resolveRequest({ status: 'error', message: `网络错误 (${response.error || 'Unknown Error'}) using ${method}` }); }, ontimeout: function() { // 超时 resolveRequest({ status: 'timeout', message: `请求超时 using ${method}` }); } }); }); }; // --- 主要逻辑:先尝试 HEAD,处理结果 --- let result = await doRequest('HEAD'); // 如果 HEAD 失败 (网络错误或超时) 且可以重试 if ((result.status === 'error' || result.status === 'timeout') && retryCount < MAX_RETRIES) { console.warn(`[链接检测] ${result.message}: ${url.substring(0, 100)}... (尝试 ${retryCount + 1}/${MAX_RETRIES}), 稍后重试 (HEAD)...`); await delay(RETRY_DELAY); return checkLink(linkElement, retryCount + 1); // 返回重试的 Promise } // 如果 HEAD 返回需要用 GET 重试的状态 if (result.status === 'retry_with_get') { result = await doRequest('GET'); // 等待 GET 请求的结果 // 如果 GET 也失败 (网络错误或超时) 且可以重试 (注意:这是针对GET的重试) if ((result.status === 'error' || result.status === 'timeout') && retryCount < MAX_RETRIES) { console.warn(`[链接检测] ${result.message}: ${url.substring(0, 100)}... (尝试 ${retryCount + 1}/${MAX_RETRIES}), 稍后重试 (GET)...`); await delay(RETRY_DELAY); // 简化处理:GET 重试失败后直接标记为 broken,不再循环 return { element: linkElement, status: 'broken', url: url, message: `${result.message} (GET 重试 ${MAX_RETRIES} 次后失败)` }; } // 如果 GET 返回了 retry_with_get 信号(理论上不应发生),也视为 broken if (result.status === 'retry_with_get'){ return { element: linkElement, status: 'broken', url: url, message: `GET 请求异常,收到重试信号` }; } } // --- 返回最终结果 --- if (result.status === 'ok') { return { element: linkElement, status: 'ok', url: url, statusCode: result.statusCode, message: result.message }; } else { // 所有其他非 OK 情况 (HEAD 错误且无重试, HEAD 405/5xx -> GET 错误, HEAD 其他 4xx, GET 错误等) 都视为 broken return { element: linkElement, status: 'broken', url: url, statusCode: result.statusCode, message: result.message || '未知错误' }; } } // --- 处理检测结果 --- function handleResult(result) { checkedLinks++; // 确保 reason 有一个默认值 const reason = result.message || (result.statusCode ? `状态码 ${result.statusCode}` : '未知原因'); // 移除检查中样式 (如果添加了) // result.element.classList.remove('link-checker-checking'); // (如果需要检查中样式) if (result.status === 'broken') { brokenLinksCount++; brokenLinkDetailsForConsole.push({ url: result.url, reason: reason }); // 记录到控制台列表 result.element.classList.add(BROKEN_LINK_CLASS); // 添加失效样式类 (触发 CSS 标记) result.element.title = `链接失效: ${reason}\nURL: ${result.url}`; // 更新悬停提示 console.warn(`[链接检测] 失效 (${reason}): ${result.url}`); // 避免过多 toast 刷屏,可以考虑只对特定错误类型弹窗,或限制数量 // showToast(`失效: ${result.url.substring(0,50)}... (${reason})`, 'error', 5000); } else if (result.status === 'ok') { console.log(`[链接检测] 正常 (${reason}, 状态码: ${result.statusCode}): ${result.url}`); // 如果之前被标记为 broken (例如上一次运行时),则清除标记 if (result.element.classList.contains(BROKEN_LINK_CLASS)) { result.element.classList.remove(BROKEN_LINK_CLASS); } // 清除可能存在的旧 title if (result.element.title.startsWith('链接失效:')) { result.element.title = ''; // 或者设置为 '链接有效' } } else if (result.status === 'skipped') { console.log(`[链接检测] 跳过 (${result.message}): ${result.url || '空链接'}`); } // 更新进度显示 const progressPercent = totalLinks > 0 ? Math.round((checkedLinks / totalLinks) * 100) : 0; const progressText = `检测中: ${checkedLinks}/${totalLinks} (失效: ${brokenLinksCount})`; button.innerHTML = `${progressPercent}%`; // 按钮显示百分比 button.title = progressText; // 悬停显示详细信息 // 从活动检查中移除,并尝试启动下一个 activeChecks--; processQueue(); // 检查是否全部完成 if (checkedLinks >= totalLinks) { // 使用 >= 以防万一计数出错 finishCheck(); } } // --- 队列处理 --- function processQueue() { // 当活动检查数小于并发限制,并且队列中还有链接时,启动新的检查 while (activeChecks < CONCURRENT_CHECKS && linkQueue.length > 0) { activeChecks++; const linkElement = linkQueue.shift(); // 可选:添加一个“检查中”的临时样式 // linkElement.classList.add('link-checker-checking'); checkLink(linkElement).then(handleResult); // 异步执行,结果由 handleResult 处理 } } // --- 开始检测 --- function startCheck() { if (isChecking) return; // 防止重复点击 isChecking = true; // --- 重置状态 --- checkedLinks = 0; brokenLinksCount = 0; linkQueue = []; activeChecks = 0; brokenLinkDetailsForConsole = []; // 清空上次的结果 // --- 清理页面上的旧标记 --- document.querySelectorAll(`a.${BROKEN_LINK_CLASS}`).forEach(el => { el.classList.remove(BROKEN_LINK_CLASS); // 清理旧的 title 提示 if (el.title.startsWith('链接失效:')) { el.title = ''; } }); // 清理可能存在的 checked 标记(如果之前中断) document.querySelectorAll(`a.${CHECKED_LINK_CLASS}`).forEach(el => { el.classList.remove(CHECKED_LINK_CLASS); }); // --- 更新 UI --- button.disabled = true; button.innerHTML = '0%'; button.title = '开始检测...'; showToast('🚀 开始检测页面链接...', 'info'); console.log('%c[链接检测] 开始检测...', 'color: blue; font-weight: bold;'); // --- 收集并过滤链接 --- const links = document.querySelectorAll('a[href]'); let skippedCount = 0; links.forEach(link => { const href = link.getAttribute('href'); // 获取原始 href 值 // 过滤条件: // 1. 没有 href 属性 // 2. href 为空或只是 '#' // 3. href 不是以 http:// 或 https:// 开头 if (!href || href.trim() === '' || href.startsWith('#') || !link.protocol.startsWith('http')) { // console.log(`[链接检测] 过滤 (无效或非HTTP/S): ${href || '空 href'}`); skippedCount++; return; // 跳过此链接 } linkQueue.push(link); // 加入待检测队列 }); totalLinks = linkQueue.length; // 实际要检测的链接数 if (totalLinks === 0) { showToast('🤷‍♂️ 页面上没有找到有效的 HTTP/HTTPS 链接。', 'warning'); console.log('[链接检测] 未找到有效链接。'); finishCheck(); // 直接结束 return; } showToast(`发现 ${totalLinks} 个有效链接 (过滤掉 ${skippedCount} 个),开始检测 (并发: ${CONCURRENT_CHECKS})...`, 'info', 5000); button.title = `检测中: 0/${totalLinks} (失效: 0)`; // --- 启动队列处理 --- processQueue(); } // --- 结束检测 --- function finishCheck() { isChecking = false; button.disabled = false; button.innerHTML = '🔗'; // 恢复图标 let summary = `✅ 检测完成!共检查 ${totalLinks} 个链接。`; if (brokenLinksCount > 0) { summary += `\n❌ 发现 ${brokenLinksCount} 个失效链接已在页面上标记。`; showToast(summary.replace('\n', ' '), 'error', 10000); // Toast 不支持换行,用空格代替 // 在控制台打印详细的失效链接列表 console.warn("-------------------- 失效链接列表 --------------------"); console.warn(`共检测到 ${brokenLinksCount} 个失效链接:`); console.groupCollapsed("点击展开详细列表"); // 默认折叠,避免刷屏 brokenLinkDetailsForConsole.forEach(detail => { console.warn(`- URL: ${detail.url}\n 原因: ${detail.reason}`); }); console.groupEnd(); console.warn("-----------------------------------------------------"); } else { summary += "\n🎉 所有链接均可访问!"; showToast(summary.replace('\n', ' '), 'success', 5000); console.log('%c[链接检测] 所有链接均可访问!', 'color: green; font-weight: bold;'); } button.title = summary + '\n\n(点击重新检测)'; // 悬停提示最终结果 console.log(`%c[链接检测] ${summary.replace('\n', ' ')}`, 'color: blue; font-weight: bold;'); // 确保 activeChecks 清零 (理论上应该已经是 0) activeChecks = 0; } // --- 添加按钮点击事件 --- button.addEventListener('click', startCheck); // --- 初始加载提示 --- console.log('[链接检测器] 脚本已加载 (v1.5 完整版),点击右下角悬浮按钮 🔗 开始检测。'); showToast('链接检测器已准备就绪 ✨', 'info', 2000); })(); // 脚本立即执行函数结束