// ==UserScript== // @name LeetCode CN 题目提取 // @namespace http://tampermonkey.net/ // @version 7.8 // @description 结构化提取 LeetCode 题目,高级玻璃态 UI,稳定剪贴板,适配 SPA // @author GitHub Copilot // @match https://leetcode.cn/problems/* // @grant none // ==/UserScript== (function() { 'use strict'; // ========================================== // 1. UI 样式注入 (Glassmorphism & Animations) // ========================================== const injectStyles = () => { if (document.getElementById('lc-glass-styles')) return; const style = document.createElement('style'); style.id = 'lc-glass-styles'; style.innerHTML = ` /* 悬浮按钮 - 毛玻璃效果 */ #lc-glass-copy-btn { position: fixed; bottom: 40px; right: 40px; z-index: 999999; padding: 14px 24px; font-size: 15px; font-weight: 500; color: #ffffff; background: rgba(30, 30, 30, 0.65); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 20px; box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255,255,255,0.05); cursor: pointer; transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); display: flex; align-items: center; gap: 8px; animation: slideFadeUp 0.6s ease-out forwards; user-select: none; } /* 浅色模式适配 */ @media (prefers-color-scheme: light) { #lc-glass-copy-btn { background: rgba(255, 255, 255, 0.7); color: #333333; border: 1px solid rgba(255, 255, 255, 0.6); box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(255,255,255,0.4); } } #lc-glass-copy-btn:hover { transform: translateY(-4px) scale(1.02); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255,255,255,0.2); background: rgba(45, 45, 45, 0.75); } @media (prefers-color-scheme: light) { #lc-glass-copy-btn:hover { background: rgba(255, 255, 255, 0.9); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15), inset 0 0 0 1px rgba(255,255,255,0.8); } } #lc-glass-copy-btn:active { transform: translateY(2px) scale(0.98); transition: all 0.1s; } /* Toast 提示框 */ .lc-glass-toast { position: fixed; bottom: 100px; right: 40px; z-index: 999999; padding: 12px 24px; font-size: 14px; color: #ffffff; background: rgba(40, 167, 69, 0.8); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.2); opacity: 0; transform: translateY(20px); transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55); pointer-events: none; } .lc-glass-toast.show { opacity: 1; transform: translateY(0); } .lc-glass-toast.error { background: rgba(220, 53, 69, 0.8); } /* 初始进场动画 */ @keyframes slideFadeUp { 0% { opacity: 0; transform: translateY(30px); } 100% { opacity: 1; transform: translateY(0); } } `; document.head.appendChild(style); }; // ========================================== // 2. 剪贴板双重降级写入 + 日志 // ========================================== const copyToClipboard = async (text) => { console.log('copy start'); try { // Priority 1: 现代 Clipboard API await navigator.clipboard.writeText(text); console.log('clipboard api success'); return true; } catch (err) { console.log('clipboard api failed', err); // Priority 2: 传统 fallback try { const textarea = document.createElement('textarea'); textarea.value = text; // 隐藏元素,防止页面跳动 textarea.style.position = 'fixed'; textarea.style.top = '-9999px'; textarea.style.left = '-9999px'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); const success = document.execCommand('copy'); document.body.removeChild(textarea); if (success) { console.log('execCommand copy success'); return true; } else { console.log('execCommand copy failed'); return false; } } catch (fallbackErr) { console.log('execCommand copy failed', fallbackErr); return false; } } }; // ========================================== // 3. 内容提纯与重组 // ========================================== const extractTitle = () => { // 通常 document.title 的格式为 "1. 两数之和 - 力扣..." let titleBlock = document.title.split('-')[0].trim(); // 如果未加载完成可能只拿到 "力扣",做个检查 if (titleBlock && titleBlock !== "力扣" && titleBlock !== "LeetCode") { return titleBlock; } // 备用选择器 const titleEl = document.querySelector('h1 a, .text-title-large a, [data-cypress="QuestionTitle"]'); return titleEl ? titleEl.innerText.trim() : "未知题目"; }; const extractDifficulty = () => { // 利用专属类名探测 const diffEls = document.querySelectorAll('[class*="text-difficulty-"]'); if (diffEls.length > 0) return diffEls[0].innerText.trim(); // 备用:暴力文本匹配 const tags = Array.from(document.querySelectorAll('span, div')).slice(0, 200); for (let tag of tags) { const text = tag.innerText?.trim(); if (['简单', '中等', '困难'].includes(text)) { return text; } } return "未知"; }; const extractStructuredContent = () => { const container = document.querySelector('[data-track-load="description_content"]'); if (!container) throw new Error("未找到题目正文容器 !"); let contentLines = []; // 仅抓取顶层/块级元素,过滤不必要的嵌套和杂质 const childNodes = Array.from(container.children); childNodes.forEach(child => { // 跳过可能混入的无关元素、隐藏元素、无关按钮 const tagName = child.tagName.toLowerCase(); const text = child.innerText?.trim(); if (!text) return; // 重点提取这些标签类型 if (['p', 'pre', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'].includes(tagName)) { contentLines.push(text); } }); const rawContent = contentLines.join('\n\n'); // 清洗:把连续超过3个的换行压缩为2个换行,去除首尾空白 let cleanedContent = rawContent.replace(/\n{3,}/g, '\n\n').trim(); // 拼接成标准格式 return `题目:${extractTitle()}\n难度:${extractDifficulty()}\n\n题目描述:\n${cleanedContent}`; }; // ========================================== // 4. UI 提示反馈 // ========================================== const showToast = (message, isError = false) => { let toast = document.getElementById('lc-glass-toast'); if (!toast) { toast = document.createElement('div'); toast.id = 'lc-glass-toast'; document.body.appendChild(toast); } toast.className = `lc-glass-toast ${isError ? 'error' : ''}`; toast.innerText = message; // 触发重绘以呈现动画 void toast.offsetWidth; toast.classList.add('show'); // 3秒后隐藏 setTimeout(() => toast.classList.remove('show'), 3000); }; // ========================================== // 5. 主点击事件处理 // ========================================== const handleCopyAction = async () => { const btn = document.getElementById('lc-glass-copy-btn'); if (!btn) return; const originalText = btn.innerHTML; btn.innerHTML = '⏳ 提取中...'; btn.style.pointerEvents = 'none'; try { const formattedText = extractStructuredContent(); const success = await copyToClipboard(formattedText); if (success) { btn.innerHTML = '✅ 复制成功'; showToast('已复制到系统剪贴板!'); } else { throw new Error("写入剪贴板API全量失败"); } } catch (error) { console.error(error); btn.innerHTML = '❌ 提取失败'; showToast('复制失败:' + error.message, true); } finally { // 2 秒后恢复按键状态 setTimeout(() => { const currentBtn = document.getElementById('lc-glass-copy-btn'); if (currentBtn) { currentBtn.innerHTML = '📋 复制题目'; currentBtn.style.pointerEvents = 'auto'; } }, 2000); } }; // ========================================== // 6. 挂载与 SPA 监听 // ========================================== const mountButton = () => { // 防止重复挂载 if (document.getElementById('lc-glass-copy-btn')) return; injectStyles(); const btn = document.createElement('div'); btn.id = 'lc-glass-copy-btn'; btn.innerHTML = '📋 复制题目'; btn.addEventListener('click', handleCopyAction); document.body.appendChild(btn); }; // 初始化 SPA 路由防撕裂挂载 const initSPAObserver = () => { let lastUrl = window.location.href; // 首屏挂载 setTimeout(mountButton, 1000); // 监听由于 Vue/React 导致的 DOM 变化与路由更新 const observer = new MutationObserver(() => { const currentUrl = window.location.href; if (currentUrl !== lastUrl) { lastUrl = currentUrl; // 页面切换,清理旧的元素重新挂载 const oldBtn = document.getElementById('lc-glass-copy-btn'); if (oldBtn) oldBtn.remove(); setTimeout(mountButton, 1500); // 给新内容一定的渲染时间 } else if (!document.getElementById('lc-glass-copy-btn')) { // 如果在同一页面被外部脚本意外删除了,补发注入 mountButton(); } }); observer.observe(document.body, { childList: true, subtree: true }); }; // 启动脚本 initSPAObserver(); })();