Link Validity Checker

增强版链接检测器:强制样式应用,改进DOM选择,支持表格内外所有链接的标记

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @license      MIT
// @name         Link Validity Checker
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  增强版链接检测器:强制样式应用,改进DOM选择,支持表格内外所有链接的标记
// @author       Axin & gemini 2.5 pro & Claude
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      *
// ==/UserScript==

(function() {
    'use strict';

    // --- 配置 ---
    const CHECK_TIMEOUT = 7000;
    const CONCURRENT_CHECKS = 5;
    const MAX_RETRIES = 1;
    const RETRY_DELAY = 500;
    const BROKEN_LINK_CLASS = 'link-checker-broken';
    const CHECKED_LINK_CLASS = 'link-checker-checked';

    // --- 内联 Toastify JS ---
    const Toastify = (function(t){
        var o = function(t){return new o.lib.init(t)};
        function i(t,o){return o.offset[t]?isNaN(o.offset[t])?o.offset[t]:o.offset[t]+"px":"0px"}
        function s(t,o){return!(!t||"string"!=typeof o)&&!!(t.className&&t.className.trim().split(/\s+/gi).indexOf(o)>-1)}
        return o.defaults={oldestFirst:!0,text:"Toastify is awesome!",node:void 0,duration:3e3,selector:void 0,callback:function(){},destination:void 0,newWindow:!1,close:!1,gravity:"toastify-top",positionLeft:!1,position:"",backgroundColor:"",avatar:"",className:"",stopOnFocus:!0,onClick:function(){},offset:{x:0,y:0},escapeMarkup:!0,ariaLive:"polite",style:{background:""}},o.lib=o.prototype={toastify:"1.12.0",constructor:o,init:function(t){return t||(t={}),this.options={},this.toastElement=null,this.options.text=t.text||o.defaults.text,this.options.node=t.node||o.defaults.node,this.options.duration=0===t.duration?0:t.duration||o.defaults.duration,this.options.selector=t.selector||o.defaults.selector,this.options.callback=t.callback||o.defaults.callback,this.options.destination=t.destination||o.defaults.destination,this.options.newWindow=t.newWindow||o.defaults.newWindow,this.options.close=t.close||o.defaults.close,this.options.gravity="bottom"===t.gravity?"toastify-bottom":o.defaults.gravity,this.options.positionLeft=t.positionLeft||o.defaults.positionLeft,this.options.position=t.position||o.defaults.position,this.options.backgroundColor=t.backgroundColor||o.defaults.backgroundColor,this.options.avatar=t.avatar||o.defaults.avatar,this.options.className=t.className||o.defaults.className,this.options.stopOnFocus=void 0===t.stopOnFocus?o.defaults.stopOnFocus:t.stopOnFocus,this.options.onClick=t.onClick||o.defaults.onClick,this.options.offset=t.offset||o.defaults.offset,this.options.escapeMarkup=void 0!==t.escapeMarkup?t.escapeMarkup:o.defaults.escapeMarkup,this.options.ariaLive=t.ariaLive||o.defaults.ariaLive,this.options.style=t.style||o.defaults.style,t.backgroundColor&&(this.options.style.background=t.backgroundColor),this},buildToast:function(){if(!this.options)throw"Toastify is not initialized";var t=document.createElement("div");for(var o in t.className="toastify on "+this.options.className,this.options.position?t.className+=" toastify-"+this.options.position:!0===this.options.positionLeft?(t.className+=" toastify-left",console.warn("Property `positionLeft` will be depreciated in further versions. Please use `position` instead.")):t.className+=" toastify-right",t.className+=" "+this.options.gravity,this.options.backgroundColor&&console.warn('DEPRECATION NOTICE: "backgroundColor" is being deprecated. Please use the "style.background" property.'),this.options.style)t.style[o]=this.options.style[o];if(this.options.ariaLive&&t.setAttribute("aria-live",this.options.ariaLive),this.options.node&&this.options.node.nodeType===Node.ELEMENT_NODE)t.appendChild(this.options.node);else if(this.options.escapeMarkup?t.innerText=this.options.text:t.innerHTML=this.options.text,""!==this.options.avatar){var s=document.createElement("img");s.src=this.options.avatar,s.className="toastify-avatar","left"==this.options.position||!0===this.options.positionLeft?t.appendChild(s):t.insertAdjacentElement("afterbegin",s)}if(!0===this.options.close){var e=document.createElement("button");e.type="button",e.setAttribute("aria-label","Close"),e.className="toast-close",e.innerHTML="&#10006;",e.addEventListener("click",function(t){t.stopPropagation(),this.removeElement(this.toastElement),window.clearTimeout(this.toastElement.timeOutValue)}.bind(this));var n=window.innerWidth>0?window.innerWidth:screen.width;("left"==this.options.position||!0===this.options.positionLeft)&&n>360?t.insertAdjacentElement("afterbegin",e):t.appendChild(e)}if(this.options.stopOnFocus&&this.options.duration>0){var a=this;t.addEventListener("mouseover",(function(o){window.clearTimeout(t.timeOutValue)})),t.addEventListener("mouseleave",(function(){t.timeOutValue=window.setTimeout((function(){a.removeElement(t)}),a.options.duration)}))}if(void 0!==this.options.destination&&t.addEventListener("click",function(t){t.stopPropagation(),!0===this.options.newWindow?window.open(this.options.destination,"_blank"):window.location=this.options.destination}.bind(this)),"function"==typeof this.options.onClick&&void 0===this.options.destination&&t.addEventListener("click",function(t){t.stopPropagation(),this.options.onClick()}.bind(this)),"object"==typeof this.options.offset){var l=i("x",this.options),r=i("y",this.options),p="left"==this.options.position?l:"-"+l,d="toastify-top"==this.options.gravity?r:"-"+r;t.style.transform="translate("+p+","+d+")"}return t},showToast:function(){var t;if(this.toastElement=this.buildToast(),!(t="string"==typeof this.options.selector?document.getElementById(this.options.selector):this.options.selector instanceof HTMLElement||"undefined"!=typeof ShadowRoot&&this.options.selector instanceof ShadowRoot?this.options.selector:document.body))throw"Root element is not defined";var i=o.defaults.oldestFirst?t.firstChild:t.lastChild;return t.insertBefore(this.toastElement,i),o.reposition(),this.options.duration>0&&(this.toastElement.timeOutValue=window.setTimeout(function(){this.removeElement(this.toastElement)}.bind(this),this.options.duration)),this},hideToast:function(){this.toastElement.timeOutValue&&clearTimeout(this.toastElement.timeOutValue),this.removeElement(this.toastElement)},removeElement:function(t){t.className=t.className.replace(" on",""),window.setTimeout(function(){this.options.node&&this.options.node.parentNode&&this.options.node.parentNode.removeChild(this.options.node),t.parentNode&&t.parentNode.removeChild(t),this.options.callback.call(t),o.reposition()}.bind(this),400)}},o.reposition=function(){for(var t,o={top:15,bottom:15},i={top:15,bottom:15},e={top:15,bottom:15},n=document.getElementsByClassName("toastify"),a=0;a<n.length;a++){t=!0===s(n[a],"toastify-top")?"toastify-top":"toastify-bottom";var l=n[a].offsetHeight;t=t.substr(9,t.length-1);(window.innerWidth>0?window.innerWidth:screen.width)<=360?(n[a].style[t]=e[t]+"px",e[t]+=l+15):!0===s(n[a],"toastify-left")?(n[a].style[t]=o[t]+"px",o[t]+=l+15):(n[a].style[t]=i[t]+"px",i[t]+=l+15)}return this},o.lib.init.prototype=o.lib,o
    })();

    // --- 内联 Toastify CSS ---
    const toastifyCSS = `.toastify{padding:12px 20px;color:#fff;display:inline-block;box-shadow:0 3px 6px -1px rgba(0,0,0,.12),0 10px 36px -4px rgba(77,96,232,.3);background:-webkit-linear-gradient(315deg,#73a5ff,#5477f5);background:linear-gradient(135deg,#73a5ff,#5477f5);position:fixed;opacity:0;transition:all .4s cubic-bezier(.215, .61, .355, 1);border-radius:2px;cursor:pointer;text-decoration:none;max-width:calc(50% - 20px);z-index:2147483647}.toastify.on{opacity:1}.toast-close{background:0 0;border:0;color:#fff;cursor:pointer;font-family:inherit;font-size:1em;opacity:.4;padding:0 5px}.toastify-right{right:15px}.toastify-left{left:15px}.toastify-top{top:-150px}.toastify-bottom{bottom:-150px}.toastify-rounded{border-radius:25px}.toastify-avatar{width:1.5em;height:1.5em;margin:-7px 5px;border-radius:2px}.toastify-center{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content;max-width:-moz-fit-content}@media only screen and (max-width:360px){.toastify-left,.toastify-right{margin-left:auto;margin-right:auto;left:0;right:0;max-width:fit-content}}`;
    GM_addStyle(toastifyCSS);

    // 增强CSS规则,使用更高优先级确保样式应用,但移除叉号标记
    GM_addStyle(`
        .toastify.on.toastify-center { margin-left: auto; margin-right: auto; transform: translateX(0); }

        /* 强化样式应用 - 使用更高特异性选择器和!important,仅保留红色和删除线 */
        a.${BROKEN_LINK_CLASS},
        table a.${BROKEN_LINK_CLASS},
        div a.${BROKEN_LINK_CLASS},
        span a.${BROKEN_LINK_CLASS},
        li a.${BROKEN_LINK_CLASS},
        td a.${BROKEN_LINK_CLASS},
        th a.${BROKEN_LINK_CLASS},
        *[class] a.${BROKEN_LINK_CLASS},
        *[id] a.${BROKEN_LINK_CLASS} {
            color: red !important;
            text-decoration: line-through !important;
            background-color: rgba(255,200,200,0.2) !important;
            padding: 0 2px !important;
            border-radius: 2px !important;
        }

        #linkCheckerButton {
            position: fixed;
            bottom: 20px;
            right: 20px;
            width: 60px;
            height: 60px;
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 50%;
            font-size: 24px;
            line-height: 60px;
            text-align: center;
            cursor: pointer;
            box-shadow: 0 4px 8px rgba(0,0,0,0.2);
            z-index: 9999;
            transition: background-color 0.3s, transform 0.2s;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        #linkCheckerButton:hover {
            background-color: #0056b3;
            transform: scale(1.1);
        }

        #linkCheckerButton:disabled {
            background-color: #cccccc;
            cursor: not-allowed;
            transform: none;
        }
    `);

    // --- 全局状态 ---
    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)); }

    function showToast(text, type = 'info', duration = 3000) {
        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 = "#0dcaf0";
        }
        Toastify({
            text: text,
            duration: duration,
            gravity: "bottom",
            position: "center",
            style: { background: backgroundColor },
            stopOnFocus: true
        }).showToast();
    }

    // --- 强制应用样式函数 (简化为仅应用红色和删除线) ---
    function forceApplyBrokenStyle(element) {
        // 确保样式被应用,通过直接操作DOM元素的style属性,但不添加叉号图标
        element.style.setProperty('color', 'red', 'important');
        element.style.setProperty('text-decoration', 'line-through', 'important');
        element.style.setProperty('background-color', 'rgba(255,200,200,0.2)', 'important');
    }

    // --- 核心链接检测函数 (处理405、404,带重试) ---
    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)链接' };
            }
            // 不添加CSS类,避免改变正常链接外观
        }

        // --- 内部函数:执行实际的 HTTP 请求 ---
        const doRequest = (method) => {
            return new Promise((resolveRequest) => {
                GM_xmlhttpRequest({
                    method: method,
                    url: url,
                    timeout: CHECK_TIMEOUT,
                    onload: function(response) {
                        // 如果是 HEAD 且返回 405 或 404 或 403,则尝试 GET
                        if (method === 'HEAD' && (response.status === 405 || response.status === 404 || response.status === 403 || (response.status >= 500 && response.status < 600))) {
                            console.log(`[链接检测] HEAD 收到 ${response.status}: ${url}, 尝试使用 GET...`);
                            resolveRequest({ status: 'retry_with_get' });
                            return; // 不再处理此 onload
                        }

                        // 其他情况,根据状态码判断
                        if (response.status >= 200 && response.status < 400) {
                            resolveRequest({ status: 'ok', statusCode: response.status, message: `方法 ${method}` });
                        } else {
                            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} (尝试 ${retryCount + 1}/${MAX_RETRIES}), 稍后重试 (HEAD)...`);
            await delay(RETRY_DELAY);
            return checkLink(linkElement, retryCount + 1); // 返回重试的 Promise
        }

        // 如果 HEAD 返回 405,则尝试 GET
        if (result.status === 'retry_with_get') {
            result = await doRequest('GET'); // 等待 GET 请求的结果

            // 如果 GET 失败 (网络错误或超时) 且可以重试
            if ((result.status === 'error' || result.status === 'timeout') && retryCount < MAX_RETRIES) {
                console.warn(`[链接检测] ${result.message}: ${url} (尝试 ${retryCount + 1}/${MAX_RETRIES}), 稍后重试 (GET)...`);
                await delay(RETRY_DELAY);
                // 直接标记为失败
                return { element: linkElement, status: 'broken', url: url, message: `${result.message} (GET 重试 ${MAX_RETRIES} 次后失败)` };
            }
        }

        // --- 返回最终结果 ---
        if (result.status === 'ok') {
            return { element: linkElement, status: 'ok', url: url, statusCode: result.statusCode, message: result.message };
        } else {
            // 所有其他情况都视为 broken
            return { element: linkElement, status: 'broken', url: url, statusCode: result.statusCode, message: result.message || '未知错误' };
        }
    }

    // --- 处理检测结果 ---
    function handleResult(result) {
        checkedLinks++;
        const reason = result.message || (result.statusCode ? `状态码 ${result.statusCode}` : '未知原因');

        if (result.status === 'broken') {
            brokenLinksCount++;
            brokenLinkDetailsForConsole.push({ url: result.url, reason: reason });

            // 使用CSS类和强制样式应用双重保障,但不添加叉号图标
            result.element.classList.add(BROKEN_LINK_CLASS);
            forceApplyBrokenStyle(result.element); // 强制应用样式

            result.element.title = `链接失效: ${reason}\nURL: ${result.url}`;
            console.warn(`[链接检测] 失效 (${reason}): ${result.url}`);
            showToast(`失效: ${result.url.substring(0,50)}... (${reason})`, 'error', 5000);
        } else if (result.status === 'ok') {
            console.log(`[链接检测] 正常 (${reason}, 状态码: ${result.statusCode}): ${result.url}`);
            if (result.element.title.startsWith('链接失效:')) {
                result.element.title = '';
            }
        } else if (result.status === 'skipped') {
            console.log(`[链接检测] 跳过 (${result.message}): ${result.url || '空链接'}`);
        }

        // 更新进度
        const progressText = `检测中: ${checkedLinks}/${totalLinks} (失效: ${brokenLinksCount})`;
        button.innerHTML = totalLinks > 0 ? `${Math.round((checkedLinks / totalLinks) * 100)}%` : '...';
        button.title = progressText;

        // 处理下一个
        activeChecks--;
        processQueue();

        // 检查完成
        if (checkedLinks === totalLinks) {
            finishCheck();
        }
    }

    // --- 队列处理 ---
    function processQueue() {
        while (activeChecks < CONCURRENT_CHECKS && linkQueue.length > 0) {
            activeChecks++;
            const linkElement = linkQueue.shift();
            checkLink(linkElement).then(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);
            if (el.title.startsWith('链接失效:')) el.title = '';

            // 重置内联样式
            el.style.removeProperty('color');
            el.style.removeProperty('text-decoration');
            el.style.removeProperty('background-color');
        });

        button.disabled = true;
        button.innerHTML = '0%';
        button.title = '开始检测...';
        showToast('开始检测页面链接...', 'info');
        console.log('[链接检测] 开始...');

        // 使用更全面的选择器获取所有链接
        const links = document.querySelectorAll('a[href]');
        let validLinksFound = 0;

        links.forEach(link => {
            // 跳过锚链接或非HTTP协议
            if (!link.href || link.getAttribute('href').startsWith('#') || !link.protocol.startsWith('http')) return;

            // 加入队列
            linkQueue.push(link);
            validLinksFound++;
        });

        totalLinks = validLinksFound;

        if (totalLinks === 0) {
            showToast('页面上没有找到有效的 HTTP/HTTPS 链接。', 'warning');
            finishCheck();
            return;
        }

        showToast(`发现 ${totalLinks} 个有效链接,开始检测...`, 'info', 5000);
        button.title = `检测中: 0/${totalLinks} (失效: 0)`;
        processQueue();
    }

    // --- 结束检测 ---
    function finishCheck() {
        isChecking = false;
        button.disabled = false;
        button.innerHTML = '🔗';
        let summary = `检测完成!共 ${totalLinks} 个链接。`;

        if (brokenLinksCount > 0) {
            summary += ` ${brokenLinksCount} 个失效链接已在页面上用红色删除线标记。`;
            showToast(summary, 'error', 10000);
            console.warn("----------------------------------------");
            console.warn(`检测到 ${brokenLinksCount} 个失效链接 (详细原因):`);
            console.group("失效链接详细列表 (控制台)");
            brokenLinkDetailsForConsole.forEach(detail => console.warn(`- ${detail.url} (原因: ${detail.reason})`));
            console.groupEnd();
            console.warn("----------------------------------------");
        } else {
            summary += " 所有链接均可访问!";
            showToast(summary, 'success', 5000);
        }
        button.title = summary + '\n点击重新检测';
        console.log(`[链接检测] ${summary}`);
        activeChecks = 0;
    }

    // --- 为动态加载的链接增加观察器 ---
    function setupMutationObserver() {
        // 创建一个观察器实例并传入回调函数
        const observer = new MutationObserver(mutations => {
            // 仅在非检测过程中处理
            if (!isChecking) return;

            // 处理DOM变化
            let newLinks = [];
            mutations.forEach(mutation => {
                // 对于添加的节点,查找其中的链接
                mutation.addedNodes.forEach(node => {
                    // 检查节点是否是元素节点
                    if (node.nodeType === 1) {
                        // 如果节点本身是链接
                        if (node.tagName === 'A' && node.href &&
                            !node.getAttribute('href').startsWith('#') &&
                            node.protocol.startsWith('http') &&
                            !node.classList.contains(BROKEN_LINK_CLASS)) {
                            newLinks.push(node);
                        }

                        // 或者包含链接
                        const childLinks = node.querySelectorAll('a[href]:not(.${BROKEN_LINK_CLASS})');
                        childLinks.forEach(link => {
                            if (link.href &&
                                !link.getAttribute('href').startsWith('#') &&
                                link.protocol.startsWith('http') &&
                                !link.classList.contains(BROKEN_LINK_CLASS)) {
                                newLinks.push(link);
                            }
                        });
                    }
                });
            });

            // 如果找到新链接,将它们加入检测队列
            if (newLinks.length > 0) {
                console.log(`[链接检测] 检测到 ${newLinks.length} 个新动态加载的链接,加入检测队列`);
                totalLinks += newLinks.length;
                newLinks.forEach(link => linkQueue.push(link));

                // 更新按钮显示
                button.title = `检测中: ${checkedLinks}/${totalLinks} (失效: ${brokenLinksCount})`;

                // 如果当前没有活跃检查,启动队列处理
                if (activeChecks === 0) {
                    processQueue();
                }
            }
        });

        // 配置观察选项
        const config = {
            childList: true,
            subtree: true
        };

        // 开始观察文档主体的所有变化
        observer.observe(document.body, config);

        return observer;
    }

    // --- 添加按钮事件 ---
    button.addEventListener('click', startCheck);

    // 初始化动态链接观察器
    const observer = setupMutationObserver();

    console.log('[链接检测器] 脚本已加载 (v1.5 仅红色删除线版),点击右下角悬浮按钮开始检测。');

})();