Link Validity Checker

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

  1. // ==UserScript==
  2. // @license MIT
  3. // @name Link Validity Checker
  4. // @namespace http://tampermonkey.net/
  5. // @version 2.0
  6. // @description 增强版链接检测器:强制样式应用,改进DOM选择,支持表格内外所有链接的标记
  7. // @author Axin & gemini 2.5 pro & Claude
  8. // @match *://*/*
  9. // @grant GM_xmlhttpRequest
  10. // @grant GM_addStyle
  11. // @connect *
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // --- 配置 ---
  18. const CHECK_TIMEOUT = 7000;
  19. const CONCURRENT_CHECKS = 5;
  20. const MAX_RETRIES = 1;
  21. const RETRY_DELAY = 500;
  22. const BROKEN_LINK_CLASS = 'link-checker-broken';
  23. const CHECKED_LINK_CLASS = 'link-checker-checked';
  24.  
  25. // --- 内联 Toastify JS ---
  26. const Toastify = (function(t){
  27. var o = function(t){return new o.lib.init(t)};
  28. function i(t,o){return o.offset[t]?isNaN(o.offset[t])?o.offset[t]:o.offset[t]+"px":"0px"}
  29. function s(t,o){return!(!t||"string"!=typeof o)&&!!(t.className&&t.className.trim().split(/\s+/gi).indexOf(o)>-1)}
  30. 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
  31. })();
  32.  
  33. // --- 内联 Toastify CSS ---
  34. 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}}`;
  35. GM_addStyle(toastifyCSS);
  36.  
  37. // 增强CSS规则,使用更高优先级确保样式应用,但移除叉号标记
  38. GM_addStyle(`
  39. .toastify.on.toastify-center { margin-left: auto; margin-right: auto; transform: translateX(0); }
  40.  
  41. /* 强化样式应用 - 使用更高特异性选择器和!important,仅保留红色和删除线 */
  42. a.${BROKEN_LINK_CLASS},
  43. table a.${BROKEN_LINK_CLASS},
  44. div a.${BROKEN_LINK_CLASS},
  45. span a.${BROKEN_LINK_CLASS},
  46. li a.${BROKEN_LINK_CLASS},
  47. td a.${BROKEN_LINK_CLASS},
  48. th a.${BROKEN_LINK_CLASS},
  49. *[class] a.${BROKEN_LINK_CLASS},
  50. *[id] a.${BROKEN_LINK_CLASS} {
  51. color: red !important;
  52. text-decoration: line-through !important;
  53. background-color: rgba(255,200,200,0.2) !important;
  54. padding: 0 2px !important;
  55. border-radius: 2px !important;
  56. }
  57.  
  58. #linkCheckerButton {
  59. position: fixed;
  60. bottom: 20px;
  61. right: 20px;
  62. width: 60px;
  63. height: 60px;
  64. background-color: #007bff;
  65. color: white;
  66. border: none;
  67. border-radius: 50%;
  68. font-size: 24px;
  69. line-height: 60px;
  70. text-align: center;
  71. cursor: pointer;
  72. box-shadow: 0 4px 8px rgba(0,0,0,0.2);
  73. z-index: 9999;
  74. transition: background-color 0.3s, transform 0.2s;
  75. display: flex;
  76. align-items: center;
  77. justify-content: center;
  78. }
  79.  
  80. #linkCheckerButton:hover {
  81. background-color: #0056b3;
  82. transform: scale(1.1);
  83. }
  84.  
  85. #linkCheckerButton:disabled {
  86. background-color: #cccccc;
  87. cursor: not-allowed;
  88. transform: none;
  89. }
  90. `);
  91.  
  92. // --- 全局状态 ---
  93. let isChecking = false;
  94. let totalLinks = 0;
  95. let checkedLinks = 0;
  96. let brokenLinksCount = 0;
  97. let linkQueue = [];
  98. let activeChecks = 0;
  99. let brokenLinkDetailsForConsole = [];
  100.  
  101. // --- 创建按钮 ---
  102. const button = document.createElement('button');
  103. button.id = 'linkCheckerButton';
  104. button.innerHTML = '🔗';
  105. button.title = '点击开始检测页面链接';
  106. document.body.appendChild(button);
  107.  
  108. // --- 工具函数 ---
  109. function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
  110.  
  111. function showToast(text, type = 'info', duration = 3000) {
  112. let backgroundColor;
  113. switch (type) {
  114. case 'success':
  115. backgroundColor = "linear-gradient(to right, #00b09b, #96c93d)";
  116. break;
  117. case 'error':
  118. backgroundColor = "linear-gradient(to right, #ff5f6d, #ffc371)";
  119. break;
  120. case 'warning':
  121. backgroundColor = "linear-gradient(to right, #f7b733, #fc4a1a)";
  122. break;
  123. default:
  124. backgroundColor = "#0dcaf0";
  125. }
  126. Toastify({
  127. text: text,
  128. duration: duration,
  129. gravity: "bottom",
  130. position: "center",
  131. style: { background: backgroundColor },
  132. stopOnFocus: true
  133. }).showToast();
  134. }
  135.  
  136. // --- 强制应用样式函数 (简化为仅应用红色和删除线) ---
  137. function forceApplyBrokenStyle(element) {
  138. // 确保样式被应用,通过直接操作DOM元素的style属性,但不添加叉号图标
  139. element.style.setProperty('color', 'red', 'important');
  140. element.style.setProperty('text-decoration', 'line-through', 'important');
  141. element.style.setProperty('background-color', 'rgba(255,200,200,0.2)', 'important');
  142. }
  143.  
  144. // --- 核心链接检测函数 (处理405、404,带重试) ---
  145. async function checkLink(linkElement, retryCount = 0) {
  146. const url = linkElement.href;
  147.  
  148. // 初始过滤和标记 (仅在第一次尝试时)
  149. if (retryCount === 0) {
  150. if (!url || !url.startsWith('http')) {
  151. return { element: linkElement, status: 'skipped', url: url, message: '非HTTP(S)链接' };
  152. }
  153. // 不添加CSS类,避免改变正常链接外观
  154. }
  155.  
  156. // --- 内部函数:执行实际的 HTTP 请求 ---
  157. const doRequest = (method) => {
  158. return new Promise((resolveRequest) => {
  159. GM_xmlhttpRequest({
  160. method: method,
  161. url: url,
  162. timeout: CHECK_TIMEOUT,
  163. onload: function(response) {
  164. // 如果是 HEAD 且返回 405 或 404 或 403,则尝试 GET
  165. if (method === 'HEAD' && (response.status === 405 || response.status === 404 || response.status === 403 || (response.status >= 500 && response.status < 600))) {
  166. console.log(`[链接检测] HEAD 收到 ${response.status}: ${url}, 尝试使用 GET...`);
  167. resolveRequest({ status: 'retry_with_get' });
  168. return; // 不再处理此 onload
  169. }
  170.  
  171. // 其他情况,根据状态码判断
  172. if (response.status >= 200 && response.status < 400) {
  173. resolveRequest({ status: 'ok', statusCode: response.status, message: `方法 ${method}` });
  174. } else {
  175. resolveRequest({ status: 'broken', statusCode: response.status, message: `方法 ${method} 错误 (${response.status})` });
  176. }
  177. },
  178. onerror: function(response) {
  179. resolveRequest({ status: 'error', message: `网络错误 (${response.error || 'Unknown Error'}) using ${method}` });
  180. },
  181. ontimeout: function() {
  182. resolveRequest({ status: 'timeout', message: `请求超时 using ${method}` });
  183. }
  184. });
  185. });
  186. };
  187.  
  188. // --- 主要逻辑:先尝试 HEAD,处理结果 ---
  189. let result = await doRequest('HEAD');
  190.  
  191. // 如果 HEAD 失败 (网络错误或超时) 且可以重试
  192. if ((result.status === 'error' || result.status === 'timeout') && retryCount < MAX_RETRIES) {
  193. console.warn(`[链接检测] ${result.message}: ${url} (尝试 ${retryCount + 1}/${MAX_RETRIES}), 稍后重试 (HEAD)...`);
  194. await delay(RETRY_DELAY);
  195. return checkLink(linkElement, retryCount + 1); // 返回重试的 Promise
  196. }
  197.  
  198. // 如果 HEAD 返回 405,则尝试 GET
  199. if (result.status === 'retry_with_get') {
  200. result = await doRequest('GET'); // 等待 GET 请求的结果
  201.  
  202. // 如果 GET 失败 (网络错误或超时) 且可以重试
  203. if ((result.status === 'error' || result.status === 'timeout') && retryCount < MAX_RETRIES) {
  204. console.warn(`[链接检测] ${result.message}: ${url} (尝试 ${retryCount + 1}/${MAX_RETRIES}), 稍后重试 (GET)...`);
  205. await delay(RETRY_DELAY);
  206. // 直接标记为失败
  207. return { element: linkElement, status: 'broken', url: url, message: `${result.message} (GET 重试 ${MAX_RETRIES} 次后失败)` };
  208. }
  209. }
  210.  
  211. // --- 返回最终结果 ---
  212. if (result.status === 'ok') {
  213. return { element: linkElement, status: 'ok', url: url, statusCode: result.statusCode, message: result.message };
  214. } else {
  215. // 所有其他情况都视为 broken
  216. return { element: linkElement, status: 'broken', url: url, statusCode: result.statusCode, message: result.message || '未知错误' };
  217. }
  218. }
  219.  
  220. // --- 处理检测结果 ---
  221. function handleResult(result) {
  222. checkedLinks++;
  223. const reason = result.message || (result.statusCode ? `状态码 ${result.statusCode}` : '未知原因');
  224.  
  225. if (result.status === 'broken') {
  226. brokenLinksCount++;
  227. brokenLinkDetailsForConsole.push({ url: result.url, reason: reason });
  228.  
  229. // 使用CSS类和强制样式应用双重保障,但不添加叉号图标
  230. result.element.classList.add(BROKEN_LINK_CLASS);
  231. forceApplyBrokenStyle(result.element); // 强制应用样式
  232.  
  233. result.element.title = `链接失效: ${reason}\nURL: ${result.url}`;
  234. console.warn(`[链接检测] 失效 (${reason}): ${result.url}`);
  235. showToast(`失效: ${result.url.substring(0,50)}... (${reason})`, 'error', 5000);
  236. } else if (result.status === 'ok') {
  237. console.log(`[链接检测] 正常 (${reason}, 状态码: ${result.statusCode}): ${result.url}`);
  238. if (result.element.title.startsWith('链接失效:')) {
  239. result.element.title = '';
  240. }
  241. } else if (result.status === 'skipped') {
  242. console.log(`[链接检测] 跳过 (${result.message}): ${result.url || '空链接'}`);
  243. }
  244.  
  245. // 更新进度
  246. const progressText = `检测中: ${checkedLinks}/${totalLinks} (失效: ${brokenLinksCount})`;
  247. button.innerHTML = totalLinks > 0 ? `${Math.round((checkedLinks / totalLinks) * 100)}%` : '...';
  248. button.title = progressText;
  249.  
  250. // 处理下一个
  251. activeChecks--;
  252. processQueue();
  253.  
  254. // 检查完成
  255. if (checkedLinks === totalLinks) {
  256. finishCheck();
  257. }
  258. }
  259.  
  260. // --- 队列处理 ---
  261. function processQueue() {
  262. while (activeChecks < CONCURRENT_CHECKS && linkQueue.length > 0) {
  263. activeChecks++;
  264. const linkElement = linkQueue.shift();
  265. checkLink(linkElement).then(handleResult); // 异步执行
  266. }
  267. }
  268.  
  269. // --- 开始检测 ---
  270. function startCheck() {
  271. if (isChecking) return;
  272. isChecking = true;
  273.  
  274. // 重置状态
  275. checkedLinks = 0;
  276. brokenLinksCount = 0;
  277. linkQueue = [];
  278. activeChecks = 0;
  279. brokenLinkDetailsForConsole = [];
  280.  
  281. // 清理之前的标记
  282. document.querySelectorAll(`a.${BROKEN_LINK_CLASS}`).forEach(el => {
  283. el.classList.remove(BROKEN_LINK_CLASS);
  284. if (el.title.startsWith('链接失效:')) el.title = '';
  285.  
  286. // 重置内联样式
  287. el.style.removeProperty('color');
  288. el.style.removeProperty('text-decoration');
  289. el.style.removeProperty('background-color');
  290. });
  291.  
  292. button.disabled = true;
  293. button.innerHTML = '0%';
  294. button.title = '开始检测...';
  295. showToast('开始检测页面链接...', 'info');
  296. console.log('[链接检测] 开始...');
  297.  
  298. // 使用更全面的选择器获取所有链接
  299. const links = document.querySelectorAll('a[href]');
  300. let validLinksFound = 0;
  301.  
  302. links.forEach(link => {
  303. // 跳过锚链接或非HTTP协议
  304. if (!link.href || link.getAttribute('href').startsWith('#') || !link.protocol.startsWith('http')) return;
  305.  
  306. // 加入队列
  307. linkQueue.push(link);
  308. validLinksFound++;
  309. });
  310.  
  311. totalLinks = validLinksFound;
  312.  
  313. if (totalLinks === 0) {
  314. showToast('页面上没有找到有效的 HTTP/HTTPS 链接。', 'warning');
  315. finishCheck();
  316. return;
  317. }
  318.  
  319. showToast(`发现 ${totalLinks} 个有效链接,开始检测...`, 'info', 5000);
  320. button.title = `检测中: 0/${totalLinks} (失效: 0)`;
  321. processQueue();
  322. }
  323.  
  324. // --- 结束检测 ---
  325. function finishCheck() {
  326. isChecking = false;
  327. button.disabled = false;
  328. button.innerHTML = '🔗';
  329. let summary = `检测完成!共 ${totalLinks} 个链接。`;
  330.  
  331. if (brokenLinksCount > 0) {
  332. summary += ` ${brokenLinksCount} 个失效链接已在页面上用红色删除线标记。`;
  333. showToast(summary, 'error', 10000);
  334. console.warn("----------------------------------------");
  335. console.warn(`检测到 ${brokenLinksCount} 个失效链接 (详细原因):`);
  336. console.group("失效链接详细列表 (控制台)");
  337. brokenLinkDetailsForConsole.forEach(detail => console.warn(`- ${detail.url} (原因: ${detail.reason})`));
  338. console.groupEnd();
  339. console.warn("----------------------------------------");
  340. } else {
  341. summary += " 所有链接均可访问!";
  342. showToast(summary, 'success', 5000);
  343. }
  344. button.title = summary + '\n点击重新检测';
  345. console.log(`[链接检测] ${summary}`);
  346. activeChecks = 0;
  347. }
  348.  
  349. // --- 为动态加载的链接增加观察器 ---
  350. function setupMutationObserver() {
  351. // 创建一个观察器实例并传入回调函数
  352. const observer = new MutationObserver(mutations => {
  353. // 仅在非检测过程中处理
  354. if (!isChecking) return;
  355.  
  356. // 处理DOM变化
  357. let newLinks = [];
  358. mutations.forEach(mutation => {
  359. // 对于添加的节点,查找其中的链接
  360. mutation.addedNodes.forEach(node => {
  361. // 检查节点是否是元素节点
  362. if (node.nodeType === 1) {
  363. // 如果节点本身是链接
  364. if (node.tagName === 'A' && node.href &&
  365. !node.getAttribute('href').startsWith('#') &&
  366. node.protocol.startsWith('http') &&
  367. !node.classList.contains(BROKEN_LINK_CLASS)) {
  368. newLinks.push(node);
  369. }
  370.  
  371. // 或者包含链接
  372. const childLinks = node.querySelectorAll('a[href]:not(.${BROKEN_LINK_CLASS})');
  373. childLinks.forEach(link => {
  374. if (link.href &&
  375. !link.getAttribute('href').startsWith('#') &&
  376. link.protocol.startsWith('http') &&
  377. !link.classList.contains(BROKEN_LINK_CLASS)) {
  378. newLinks.push(link);
  379. }
  380. });
  381. }
  382. });
  383. });
  384.  
  385. // 如果找到新链接,将它们加入检测队列
  386. if (newLinks.length > 0) {
  387. console.log(`[链接检测] 检测到 ${newLinks.length} 个新动态加载的链接,加入检测队列`);
  388. totalLinks += newLinks.length;
  389. newLinks.forEach(link => linkQueue.push(link));
  390.  
  391. // 更新按钮显示
  392. button.title = `检测中: ${checkedLinks}/${totalLinks} (失效: ${brokenLinksCount})`;
  393.  
  394. // 如果当前没有活跃检查,启动队列处理
  395. if (activeChecks === 0) {
  396. processQueue();
  397. }
  398. }
  399. });
  400.  
  401. // 配置观察选项
  402. const config = {
  403. childList: true,
  404. subtree: true
  405. };
  406.  
  407. // 开始观察文档主体的所有变化
  408. observer.observe(document.body, config);
  409.  
  410. return observer;
  411. }
  412.  
  413. // --- 添加按钮事件 ---
  414. button.addEventListener('click', startCheck);
  415.  
  416. // 初始化动态链接观察器
  417. const observer = setupMutationObserver();
  418.  
  419. console.log('[链接检测器] 脚本已加载 (v1.5 仅红色删除线版),点击右下角悬浮按钮开始检测。');
  420.  
  421. })();