Greasy Fork

Link Validity Checker

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

// ==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 仅红色删除线版),点击右下角悬浮按钮开始检测。');

})();