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