Greasy Fork

Greasy Fork is available in English.

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

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

当前为 2025-04-29 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @license      MIT
// @name         链接有效性检测器 (完整版 v1.5)
// @namespace    http://tampermonkey.net/
// @version      1.5.1
// @description  添加悬浮按钮检测页面链接,重试失败请求,对405/5xx错误回退到GET,页面内标记失效链接❌,使用Toastify显示日志,兼容GreasyFork。
// @author       Axin & gemini 2.5 pro
// @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);

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