// ==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);
})(); // 脚本立即执行函数结束