链接有效性检测器 (完整版 v1.5)

添加悬浮按钮检测页面链接,重试失败请求,对405/5xx错误回退到GET,页面内标记失效链接❌,使用Toastify显示日志,兼容GreasyFork。

目前为 2025-04-29 提交的版本。查看 最新版本

// ==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
// ==/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);

})(); // 脚本立即执行函数结束