您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
从linux do论坛页面获取文章的板块、标题、链接、标签和内容总结,并在标题旁添加复制按钮。支持设置界面配置。
// ==UserScript== // @name 从linux do获取论坛文章数据与复制 // @namespace http://tampermonkey.net/ // @version 0.15.1 // @description 从linux do论坛页面获取文章的板块、标题、链接、标签和内容总结,并在标题旁添加复制按钮。支持设置界面配置。 // @author @Loveyless https://github.com/Loveyless/linuxdo-share // @match *://*.linux.do/* // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_registerMenuCommand // @run-at document-idle // 更可靠的运行时间,等待DOM和资源加载完成且浏览器空闲 // ==/UserScript== (function () { 'use strict'; // #region 脚本配置与常量 // ========================================================== /** * @description 默认配置项,当油猴存储中没有对应值时使用。 */ const DEFAULT_CONFIG = { // 是否启用 AI 进行内容总结 USE_AI_FOR_SUMMARY: false, // AI 模式 gemini/openaiCompatible AI_MODE: 'gemini', // AI Key,如果 USE_AI_FOR_SUMMARY 为 true,则需要填写此项 获取: API_KEY: '', // AI 基础地址 API_BASE_URL: 'https://generativelanguage.googleapis.com', // Gemini 模型名称 MODEL_NAME: 'gemini-2.5-flash-lite', // 总结后的最大字符数 LOCAL_SUMMARY_MAX_CHARS: 90, // 自定义总结 Prompt CUSTOM_SUMMARY_PROMPT: `你是一个信息获取专家,可以精准的总结文章的精华内容和重点,请对以下文章内容进行归纳总结,回复不要有对我的问候语,或者(你好这是我的总结)(总结)等类似废话,直接返回你的总结,长度不超过{maxChars}个字符(或尽可能短,保持中文语义完整): {content}`, // 文章复制模板 ARTICLE_COPY_TEMPLATE: [ `{{title}}`, `@{{username}}-{{category}}/{{tags}}`, ``, `{{summary}}`, `{{link}}`, ].join('\n') }; // #endregion // #region 配置管理 // ========================================================== /** * @description 从油猴存储中获取指定键的配置值。 * @param {string} key - 配置项的键名。 * @returns {*} 对应配置项的值,如果不存在则返回默认值。 */ function getConfig(key) { return GM_getValue(key, DEFAULT_CONFIG[key]); } /** * @description 将配置值保存到油猴存储中。 * @param {string} key - 配置项的键名。 * @param {*} value - 要保存的配置值。 */ function setConfig(key, value) { GM_setValue(key, value); } /** * @description 创建一个动态配置代理对象。 * 当访问 CONFIG.someKey 时,会自动调用 getConfig('someKey')。 * 当设置 CONFIG.someKey = value 时,会自动调用 setConfig('someKey', value)。 */ const CONFIG = new Proxy({}, { get(target, prop) { return getConfig(prop); }, set(target, prop, value) { setConfig(prop, value); return true; } }); // #endregion // #region 样式注入 // ========================================================== /** * @description 脚本所需的全部 CSS 样式字符串。 */ const copyBtnStyle = /*css*/` .copy-button { /* 统一命名为 .copy-button */ --button-bg: #e5e6eb; --button-hover-bg: #d7dbe2; --button-text-color: #4e5969; --button-hover-text-color: #164de5; --button-border-radius: 6px; --button-diameter: 24px; --button-outline-width: 2px; --button-outline-color: #9f9f9f; --tooltip-bg: #1d2129; --toolptip-border-radius: 4px; --tooltip-font-family: JetBrains Mono, Consolas, Menlo, Roboto Mono, monospace; --tooltip-font-size: 12px; --tootip-text-color: #fff; --tooltip-padding-x: 7px; --tooltip-padding-y: 7px; --tooltip-offset: 8px; } html[style*="color-scheme: dark"] .copy-button { --button-bg: #353434; --button-hover-bg: #464646; --button-text-color: #ccc; --button-outline-color: #999; --button-hover-text-color: #8bb9fe; --tooltip-bg: #f4f3f3; --tootip-text-color: #111; } .copy-button { box-sizing: border-box; width: var(--button-diameter); height: var(--button-diameter); border-radius: var(--button-border-radius); background-color: var(--button-bg); color: var(--button-text-color); border: none; cursor: pointer; position: relative; outline: var(--button-outline-width) solid transparent; transition: all 0.2s ease; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; margin-left: 8px; } /* 调整标题的父元素 (h1[data-topic-id]) 为 flex 布局,确保按钮能紧随标题且对齐 */ h1[data-topic-id] { display: flex !important; /* 强制 flexbox */ align-items: center !important; /* 垂直居中对齐 */ gap: 8px; /* 增加标题和按钮之间的间距 */ } h1[data-topic-id] .fancy-title { margin-right: 0 !important; /* 覆盖可能存在的右外边距 */ } .tooltip { position: absolute; opacity: 0; left: calc(100% + var(--tooltip-offset)); top: 50%; transform: translateY(-50%); white-space: nowrap; font: var(--tooltip-font-size) var(--tooltip-font-family); color: var(--tootip-text-color); background: var(--tooltip-bg); padding: var(--tooltip-padding-y) var(--tooltip-padding-x); border-radius: var(--toolptip-border-radius); pointer-events: none; transition: all var(--tooltip-transition-duration, 0.3s) cubic-bezier(0.68, -0.55, 0.265, 1.55); z-index: 1000; } .tooltip::before { content: attr(data-text-initial); } .tooltip::after { content: ""; width: var(--tooltip-padding-y); height: var(--tooltip-padding-y); background: inherit; position: absolute; top: 50%; left: calc(var(--tooltip-padding-y) / 2 * -1); transform: translateY(-50%) rotate(45deg); z-index: -999; pointer-events: none; } .copy-button svg { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } .checkmark, .failedmark { display: none; } .copy-button:hover .tooltip, .copy-button:focus:not(:focus-visible) .tooltip { opacity: 1; visibility: visible; } .copy-button:focus:not(:focus-visible) .tooltip::before { content: attr(data-text-end); } .copy-button.copy-failed:focus:not(:focus-visible) .tooltip::before { content: attr(data-text-failed); } .copy-button:focus:not(:focus-visible) .clipboard { display: none; } .copy-button:focus:not(:focus-visible) .checkmark { display: block; } .copy-button.copy-failed:focus:not(:focus-visible) .checkmark { display: none; } .copy-button.copy-failed:focus:not(:focus-visible) .failedmark { display: block; } .copy-button:hover, .copy-button:focus { background-color: var(--button-hover-bg); } .copy-button:active { outline: var(--button-outline-width) solid var(--button-outline-color); } .copy-button:hover svg { color: var(--button-hover-text-color); } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } /* 当按钮处于 loading 状态时,应用脉冲动画 */ .copy-button.loading { animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; } .copy-button.loading .checkmark, .copy-button.loading .failedmark { display: none; /* Loading 时隐藏对勾和叉号 */ } /* 设置界面样式 - 使用 dialog 标签 */ .linuxdo-settings-dialog { border: none; border-radius: 12px; padding: 0; width: 90%; max-width: 520px; max-height: 85vh; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: transparent; overflow: visible; } .linuxdo-settings-dialog::backdrop { background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(4px); animation: fadeIn 0.2s ease-out; } .linuxdo-settings-content { background: white; border-radius: 12px; padding: 28px; overflow-y: auto; max-height: 85vh; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); position: relative; animation: slideIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } html[style*="color-scheme: dark"] .linuxdo-settings-content { background: #2d2d2d; color: #fff; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideIn { from { opacity: 0; transform: scale(0.9) translateY(-20px); } to { opacity: 1; transform: scale(1) translateY(0); } } .linuxdo-settings-dialog[closing] { animation: slideOut 0.2s ease-in forwards; } .linuxdo-settings-dialog[closing]::backdrop { animation: fadeOut 0.2s ease-in forwards; } @keyframes slideOut { from { opacity: 1; transform: scale(1) translateY(0); } to { opacity: 0; transform: scale(0.95) translateY(-10px); } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } .linuxdo-settings-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; padding-bottom: 16px; border-bottom: 2px solid #f0f0f0; position: relative; } html[style*="color-scheme: dark"] .linuxdo-settings-header { border-bottom-color: #404040; } .linuxdo-settings-title { font-size: 20px; font-weight: 700; margin: 0; color: #1a1a1a; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } html[style*="color-scheme: dark"] .linuxdo-settings-title { background: linear-gradient(135deg, #8bb9fe 0%, #a8c8ff 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .linuxdo-settings-close { background: #f8f9fa; border: 1px solid #e9ecef; font-size: 18px; cursor: pointer; color: #6c757d; padding: 0; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; border-radius: 8px; transition: all 0.2s ease; position: relative; overflow: hidden; } .linuxdo-settings-close::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: left 0.5s; } .linuxdo-settings-close:hover { background: #e9ecef; color: #495057; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .linuxdo-settings-close:hover::before { left: 100%; } .linuxdo-settings-close:active { transform: translateY(0); box-shadow: 0 2px 6px rgba(0,0,0,0.1); } html[style*="color-scheme: dark"] .linuxdo-settings-close { background: #404040; border-color: #555; color: #ccc; } html[style*="color-scheme: dark"] .linuxdo-settings-close:hover { background: #4a4a4a; color: #fff; } .linuxdo-settings-form { display: flex; flex-direction: column; gap: 20px; } .linuxdo-settings-field { display: flex; flex-direction: column; gap: 8px; position: relative; } .linuxdo-settings-label { font-weight: 600; font-size: 14px; color: #374151; margin-bottom: 4px; } html[style*="color-scheme: dark"] .linuxdo-settings-label { color: #d1d5db; } .linuxdo-settings-input, .linuxdo-settings-textarea { width: 100%; } .linuxdo-settings-input, .linuxdo-settings-textarea { padding: 12px 16px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 14px; font-family: inherit; transition: all 0.2s ease; background: #ffffff; color: #374151; margin-bottom: 0px !important; height: 48px; } .linuxdo-settings-input:focus, .linuxdo-settings-textarea:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); transform: translateY(-1px); } html[style*="color-scheme: dark"] .linuxdo-settings-input, html[style*="color-scheme: dark"] .linuxdo-settings-textarea { background: #374151; border-color: #4b5563; color: #f9fafb; } html[style*="color-scheme: dark"] .linuxdo-settings-input:focus, html[style*="color-scheme: dark"] .linuxdo-settings-textarea:focus { border-color: #8bb9fe; box-shadow: 0 0 0 3px rgba(139, 185, 254, 0.1); } .linuxdo-settings-textarea { resize: vertical; min-height: 100px; line-height: 1.5; } .linuxdo-settings-checkbox, .linuxdo-settings-label { margin: 0px !important; } .linuxdo-settings-checkbox-wrapper { display: flex; align-items: center; gap: 12px; padding: 12px 0; cursor: pointer; border-radius: 8px; transition: background-color 0.2s ease; } .linuxdo-settings-checkbox-wrapper:hover { background-color: rgba(102, 126, 234, 0.05); } html[style*="color-scheme: dark"] .linuxdo-settings-checkbox-wrapper:hover { background-color: rgba(139, 185, 254, 0.05); } .linuxdo-settings-checkbox { width: 20px !important; height: 20px !important; border: 2px solid #d1d5db; border-radius: 4px; background: white; cursor: pointer; transition: all 0.2s ease; position: relative; } .linuxdo-settings-checkbox:checked { background: #667eea; border-color: #667eea; } .linuxdo-settings-checkbox:checked::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; font-size: 12px; font-weight: bold; } html[style*="color-scheme: dark"] .linuxdo-settings-checkbox { border-color: #6b7280; background: #374151; } html[style*="color-scheme: dark"] .linuxdo-settings-checkbox:checked { background: #8bb9fe; border-color: #8bb9fe; } .linuxdo-settings-buttons { display: flex; gap: 12px; justify-content: flex-end; margin-top: 20px; padding-top: 16px; border-top: 1px solid #e5e5e5; } html[style*="color-scheme: dark"] .linuxdo-settings-buttons { border-top-color: #444; } .linuxdo-settings-button { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; background: white; color: #333; cursor: pointer; font-size: 14px; font-family: inherit; } .linuxdo-settings-button:hover { background: #f5f5f5; } .linuxdo-settings-button.primary { background: #007bff; color: white; border-color: #007bff; } .linuxdo-settings-button.primary:hover { background: #0056b3; border-color: #0056b3; } html[style*="color-scheme: dark"] .linuxdo-settings-button { background: #3a3a3a; border-color: #555; color: #fff; } html[style*="color-scheme: dark"] .linuxdo-settings-button:hover { background: #444; } .linuxdo-settings-description { font-size: 12px; color: #666; margin-top: 4px; } html[style*="color-scheme: dark"] .linuxdo-settings-description { color: #999; } .linuxdo-model-input-wrapper { display: flex; gap: 12px; align-items: stretch; } .linuxdo-model-input-wrapper .linuxdo-settings-input { flex: 1; display: none; } .linuxdo-model-input-wrapper.custom-input .linuxdo-settings-input { display: block; } `; /** * @description 将 CSS 样式注入到页面中。 * 优先使用 GM_addStyle API,如果不可用,则创建一个 <style> 标签并插入到 <head> 中。 * @param {string} cssText - 要注入的 CSS 样式字符串。 */ function addStyle(cssText) { if (typeof GM_addStyle !== 'undefined') { GM_addStyle(cssText); } else { const styleNode = document.createElement('style'); styleNode.appendChild(document.createTextNode(cssText)); (document.head || document.documentElement).appendChild(styleNode); } } // #endregion // #region 通用辅助函数 // ========================================================== /** * @description 调用 AI 以获取内容总结。 * @param {string} prompt - 发送给 API 的完整提示词。 * @param {string} apiKey -用户的 AI Key。 * @param {string} [model='gemini-2.5-flash-lite'] - 要使用的 Gemini 模型名称。 * @returns {Promise<string>} 返回 API 生成的文本内容的 Promise。 */ async function callAiAPI(prompt, apiKey, model = 'gemini-2.5-flash-lite') { const aiMode = CONFIG.AI_MODE || DEFAULT_CONFIG.AI_MODE; const baseUrl = CONFIG.API_BASE_URL || DEFAULT_CONFIG.API_BASE_URL; // gemini if (aiMode === 'gemini') { const url = `${baseUrl}/v1beta/models/${model}:generateContent?key=${apiKey}`; const headers = { 'Content-Type': 'application/json' }; const body = JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { temperature: 0.7, // 调整生成温度 topP: 0.9, topK: 40 } }); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: url, headers: headers, data: body, onload: function (response) { try { const data = JSON.parse(response.responseText); if (data.candidates && data.candidates.length > 0) { resolve(data.candidates[0].content.parts[0].text); } else if (data.error || data.errors) { reject(new Error(`AI Error: ${data.error} ${data.errors}`)); } else { reject(new Error('AI returned an unexpected response.' + JSON.stringify(response))); } } catch (e) { reject(new Error('Failed to parse AI response: ' + e.message + '\nResponse: ' + response.responseText)); } }, onerror: function (error) { reject(new Error('GM_xmlhttpRequest failed: ' + error.statusText || 'Unknown error')); } }); }); } else if (aiMode === 'openaiCompatible') { const url = baseUrl; const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; const body = JSON.stringify({ model: model, messages: [{ role: 'user', content: prompt }] }); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: url, headers: headers, data: body, onload: function (response) { try { const data = JSON.parse(response.responseText); console.log('ai data', data); if (data.choices && data.choices.length > 0) { resolve(data.choices[0].message.content); } else if (data.error && data.errors) { reject(new Error(`AI Error: ${data.error} ${data.errors}`)); } else { reject(new Error('AI returned an unexpected response.', JSON.stringify(response))); } } catch (e) { reject(new Error('Failed to parse AI response: ' + e.message + '\nResponse: ' + response.responseText)); } }, onerror: function (error) { reject(new Error('GM_xmlhttpRequest failed: ' + error.statusText || 'Unknown error')); } }); }); } } /** * @description 处理复制操作失败后的 UI 反馈。 * @param {object} param - 参数对象。 * @param {HTMLElement} param.element - 触发复制操作的按钮元素。 * @param {Error} [param.error=new Error()] - 捕获到的错误对象。 */ function handleCopyError({ element, error = new Error() }) { element.classList.add('copy-failed'); console.error('复制失败:', error); setTimeout(() => { element.classList.remove('copy-failed'); element.blur(); // 移除焦点,重置提示 }, 3000); // 3秒后移除失败提示 } /** * @description 将指定的文本复制到用户的剪贴板。 * @param {object} param - 参数对象。 * @param {HTMLElement} param.element - 触发复制操作的按钮元素。 * @param {string} param.text - 要复制到剪贴板的文本。 */ function copyTextToClipboard({ element, text }) { navigator.clipboard.writeText(text).then(function () { console.log('文本已复制到剪贴板'); console.log(text); element.focus(); // 触发 :focus 样式显示“已复制” setTimeout(() => { element.blur(); // 移除焦点,重置提示 }, 2000); // 2秒后移除成功提示 }).catch(function (error) { handleCopyError({ element, error }); }); } /** * @description 节流函数 * @param {Function} func - 要节流的函数 * @param {number} delay - 节流的延迟时间(毫秒) * @returns {Function} 节流后的函数 */ function throttle(func, delay) { let timer; function throttled(...param) { if (timer) return; timer = setTimeout(() => { func.apply(this, param); clearTimeout(timer); timer = null; }, delay); } return throttled; } /** * 创建一个节流函数,在 wait 秒内最多执行 func 一次。 * 该函数提供一个 options 对象来决定是否应禁用前缘或后缘的调用。 * * @param {Function} func 要节流的函数。 * @param {number} wait 等待的毫秒数。 * @param {object} [options={}] 选项对象。 * @param {boolean} [options.leading=true] 指定在节流开始前(前缘)调用。 * @param {boolean} [options.trailing=true] 指定在节流结束后(后缘)调用。 * @returns {Function} 返回新的节流函数。 */ function throttleFormGemini(func, wait, options = {}) { let timeout = null; let lastArgs = null; let lastThis = null; let result; let previous = 0; // 上次执行的时间戳 // 默认开启 leading 和 trailing,trailing 默认开启以保持您之前版本的功能性 const { leading = true, trailing = true } = options; // 如果 wait 小于等于 0,则无论如何都立即执行 if (wait <= 0) { return (...args) => func.apply(this, args); } // 定时器触发时执行的函数,用于处理 trailing 调用 function later() { // 如果 leading 为 false,则重置 previous,允许在静默期后立即触发下一次 leading // 否则,将 previous 设为当前时间,作为新的节流周期的开始 previous = leading === false ? 0 : Date.now(); timeout = null; // 如果在节流期间有新的调用,则执行最后一次调用 if (lastArgs) { result = func.apply(lastThis, lastArgs); // 清理,防止内存泄漏 if (!timeout) { lastThis = lastArgs = null; } } } // 返回的节流函数 function throttled(...args) { const now = Date.now(); // 如果是第一次调用,且禁用了 leading,则记录当前时间戳作为节流周期的开始 if (!previous && leading === false) { previous = now; } // 计算距离下次可执行的时间 const remaining = wait - (now - previous); lastArgs = args; // eslint-disable-next-line @typescript-eslint/no-this-alias lastThis = this; // ---- 核心判断逻辑 ---- // 1. 时间已到 (remaining <= 0) 或 2. 系统时间被向后调整 (remaining > wait) if (remaining <= 0 || remaining > wait) { // 清除可能存在的 trailing 定时器,因为我们要立即执行 if (timeout) { clearTimeout(timeout); timeout = null; } // 更新时间戳,开始新的节流周期 previous = now; // 立即执行(leading call) result = func.apply(lastThis, lastArgs); if (!timeout) { lastThis = lastArgs = null; } } else if (!timeout && trailing !== false) { // 如果时间未到,且没有设置定时器,并且需要 trailing 调用 // 则设置一个定时器,在剩余时间后执行 later 函数 timeout = setTimeout(later, remaining); } // 返回上一次执行的结果 return result; } // 添加取消功能 throttled.cancel = () => { clearTimeout(timeout); previous = 0; timeout = lastThis = lastArgs = null; }; return throttled; } // #endregion // #region 核心数据提取 // ========================================================== /** * @description 从页面 DOM 中提取当前文章的作者、分类和标签信息。 * @returns {{username: string, category: string, tags: string}} 包含用户、分类和标签数据的对象。 */ function getUserData() { const userData = { username: '', category: '', // 统一使用 category tags: '', }; // 获取板块名称 const categoryElement = document.querySelectorAll('.topic-category .badge-category__wrapper'); if (categoryElement) { const categoryArr = Array.from(categoryElement); const lastIndex = categoryArr.length - 1; userData.category = categoryArr[lastIndex].textContent.trim(); } // 获取用户名 const postAuthorContainer = document.querySelector('.topic-meta-data, .post-stream .post:first-of-type'); if (postAuthorContainer) { const usernameElement = postAuthorContainer.querySelector('.names .first.full-name a, .username a'); if (usernameElement) { userData.username = usernameElement.textContent.trim(); } } // 获取标签 const TagsElement = document.querySelector('.list-tags'); if (TagsElement) { userData.tags = TagsElement.textContent.trim(); } return userData; } /** * @description 从页面 DOM 中提取并整合文章的完整数据。 * @param {HTMLElement} titleElement - 文章标题的 <a> 元素。 * @param {HTMLElement} articleRootElement - 文章内容的根元素 (通常是 .cooked)。 * @returns {Promise<object>} 返回一个包含文章所有数据的 Promise 对象。 */ async function getArticleData(titleElement, articleRootElement) { const userData = getUserData(); // 获取用户、分类、标签数据 const articleData = { ...userData, // 合并用户、分类和标签数据 title: '', link: '', summary: '', }; if (titleElement) { articleData.title = titleElement.textContent.trim(); articleData.link = titleElement.href || ''; } // 获取内容并进行总结 if (articleRootElement) { const clonedArticleContent = articleRootElement.cloneNode(true); // 移除不用于总结的内容元素 clonedArticleContent.querySelectorAll( 'pre, code, blockquote, img, .meta, .discourse-footnote-link, .emoji, ' + '.signature, .system-message, .post-links, .hidden' ).forEach(el => el.remove()); let fullTextContent = clonedArticleContent.textContent.trim(); fullTextContent = fullTextContent.replace(/\s*\n\s*/g, '\n').replace(/\n{2,}/g, '\n\n').trim(); if (CONFIG.USE_AI_FOR_SUMMARY && CONFIG.API_KEY) { console.log('尝试使用 AI 总结内容...'); const contentToSummarize = fullTextContent.substring(0, 4000); const customPrompt = CONFIG.CUSTOM_SUMMARY_PROMPT || DEFAULT_CONFIG.CUSTOM_SUMMARY_PROMPT; const prompt = customPrompt .replace('{maxChars}', CONFIG.LOCAL_SUMMARY_MAX_CHARS) .replace('{content}', contentToSummarize); try { articleData.summary = `[AI总结]:` + await callAiAPI(prompt, CONFIG.API_KEY, CONFIG.MODEL_NAME); console.log(CONFIG.AI_MODE, CONFIG.MODEL_NAME, '总结:', articleData.summary); articleData.summary = articleData.summary.replace(/^(.)\s*(\S+)/, '$1$2').trim(); } catch (error) { console.error('AI 总结失败:', error); articleData.summary = fullTextContent.substring(0, CONFIG.LOCAL_SUMMARY_MAX_CHARS) + (fullTextContent.length > CONFIG.LOCAL_SUMMARY_MAX_CHARS ? '...' : ''); } } else { articleData.summary = fullTextContent.substring(0, CONFIG.LOCAL_SUMMARY_MAX_CHARS) + (fullTextContent.length > CONFIG.LOCAL_SUMMARY_MAX_CHARS ? '...' : ''); if (!CONFIG.API_KEY && CONFIG.USE_AI_FOR_SUMMARY) { console.warn('未提供 AI Key 或未启用 API 总结,将使用本地简单截取。'); } } } return articleData; } // #endregion // #region UI 交互 // ========================================================== /** * @description 在文章标题旁边创建一个复制按钮并添加到页面中。 * @param {HTMLElement} titleElement - 文章标题的 <a> 元素。 * @param {HTMLElement} articleRootElement - 文章内容的根元素。 */ function addCopyButtonToArticleTitle(titleElement, articleRootElement) { // 可能导致判断不准确 重复添加copy按钮原因未知 // if (titleElement.nextElementSibling && titleElement.nextElementSibling.classList.contains('article-copy-button')) { if (titleElement.parentNode.querySelectorAll('.article-copy-button').length > 0) { // console.log('复制按钮已存在,跳过添加。'); return; } const copyButton = document.createElement('button'); copyButton.className = 'copy-button article-copy-button'; copyButton.innerHTML = /*html*/` <span data-text-initial="复制文章信息" data-text-end="已复制" data-text-failed="复制失败" class="tooltip"></span> <span> <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 6.35 6.35" y="0" x="0" height="14" width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" class="clipboard"> <g> <path fill="currentColor" d="M2.43.265c-.3 0-.548.236-.573.53h-.328a.74.74 0 0 0-.735.734v3.822a.74.74 0 0 0 .735.734H4.82a.74.74 0 0 0 .735-.734V1.529a.74.74 0 0 0-.735-.735h-.328a.58.58 0 0 0-.573-.53zm0 .529h1.49c.032 0 .049.017.049.049v.431c0 .032-.017.049-.049.049H2.43c-.032 0-.05-.017-.05-.049V.843c0-.032.018-.05.05-.05zm-.901.53h.328c.026.292.274.528.573.528h1.49a.58.58 0 0 0 .573-.529h.328a.2.2 0 0 1 .206.206v3.822a.2.2 0 0 1-.206.205H1.53a.2.2 0 0 1-.206-.205V1.529a.2.2 0 0 1 .206-.206z"> </path> </g> </svg> <svg xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 24 24" y="0" x="0" height="14" width="14" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" class="checkmark"> <g> <path data-original="#000000" fill="currentColor" d="M9.707 19.121a.997.997 0 0 1-1.414 0l-5.646-5.647a1.5 1.5 0 0 1 0-2.121l.707-.707a1.5 1.5 0 0 1 2.121 0L9 14.171l9.525-9.525a1.5 1.5 0 0 1 2.121 0l.707.707a1.5 1.5 0 0 1 0 2.121z"> </path> </g> </svg> <svg class="failedmark" xmlns="http://www.w3.org/2000/svg" height="14" width="14" viewBox="0 0 512 512"> <path fill="#FF473E" d="m330.443 256l136.765-136.765c14.058-14.058 14.058-36.85 0-50.908l-23.535-23.535c-14.058-14.058-36.85-14.058-50.908 0L256 181.557L119.235 44.792c-14.058-14.058-36.85-14.058-50.908 0L44.792 68.327c-14.058 14.058-14.058 36.85 0 50.908L181.557 256L44.792 392.765c-14.058 14.058-14.058 36.85 0 50.908l23.535 23.535c14.058 14.058 36.85 14.058 50.908 0L256 330.443l136.765 136.765c14.058 14.058 36.85 14.058 50.908 0l23.535-23.535c14.058-14.058 14.058-36.85 0-50.908z" /> </svg> </span> `; titleElement.parentNode.insertBefore(copyButton, titleElement.nextSibling); copyButton.addEventListener('click', async (e) => { e.stopPropagation(); if (copyButton.classList.contains('loading')) { return; } copyButton.classList.add('loading'); copyButton.disabled = true; try { const articleData = await getArticleData(titleElement, articleRootElement); console.log('获取到的文章数据:', articleData); let formattedText = CONFIG.ARTICLE_COPY_TEMPLATE.replace(/{{(\w+)}}/g, (match, key) => { return articleData[key] !== undefined ? articleData[key] : match; }); formattedText = formattedText.replace(/\n\n+/g, '\n\n').trim(); copyTextToClipboard({ element: copyButton, text: formattedText }); } catch (error) { handleCopyError({ element: copyButton, error }); } finally { copyButton.classList.remove('loading'); copyButton.disabled = false; } }); } // #endregion // #region 设置界面 // ========================================================== /** * @description 创建设置界面的 HTML 结构。 * @returns {HTMLDialogElement} 返回创建的 dialog 元素。 */ function createSettingsModal() { const dialog = document.createElement('dialog'); dialog.className = 'linuxdo-settings-dialog'; dialog.innerHTML = ` <div class="linuxdo-settings-content"> <div class="linuxdo-settings-header"> <h2 class="linuxdo-settings-title">LinuxDo 分享助手设置</h2> <button class="linuxdo-settings-close" type="button">×</button> </div> <form class="linuxdo-settings-form" method="dialog"> <div class="linuxdo-settings-field"> <div class="linuxdo-settings-checkbox-wrapper"> <input type="checkbox" id="useGeminiApi" class="linuxdo-settings-checkbox" ${CONFIG.USE_AI_FOR_SUMMARY ? 'checked' : ''}> <label for="useGeminiApi" class="linuxdo-settings-label" style="color:#7d0000;font-size:16px">启用 AI 自动总结</label> </div> <div class="linuxdo-settings-description">开启后将使用 AI 对文章内容进行智能总结</div> </div> <div class="linuxdo-settings-field"> <label for="aiMode" class="linuxdo-settings-label">AI 模式</label> <select id="aiMode" class="linuxdo-settings-select linuxdo-settings-input"> <option value="gemini" ${CONFIG.AI_MODE === 'gemini' ? 'selected' : ''}>Gemini</option> <option value="openaiCompatible" ${CONFIG.AI_MODE === 'openaiCompatible' ? 'selected' : ''}>OpenAI Compatible</option> </select> </div> <div class="linuxdo-settings-field"> <label for="geminiApiKey" class="linuxdo-settings-label">API Key</label> <input type="password" id="geminiApiKey" class="linuxdo-settings-input" value="${CONFIG.API_KEY}" placeholder="请输入您的 API Key"> </div> <div class="linuxdo-settings-field"> <label for="geminiApiBaseUrl" class="linuxdo-settings-label">API地址</label> <input type="text" id="geminiApiBaseUrl" class="linuxdo-settings-input" value="${CONFIG.API_BASE_URL}" placeholder="https://generativelanguage.googleapis.com"> <div class="linuxdo-settings-description">官方key填 https://generativelanguage.googleapis.com</div> <div class="linuxdo-settings-description">gpt-load填 http://ip:port/proxy/customPath</div> <div class="linuxdo-settings-description">获取Gemini官方key<a href="https://aistudio.google.com/apikey" target="_blank">点击获取</a></div> <div class="linuxdo-settings-description">openaiCompatible模式下,地址为全量(一般为baseUrl + /v1/chat/completions)</div> </div> <div class="linuxdo-settings-field"> <label for="geminiModel" class="linuxdo-settings-label">AI 模型</label> <input type="text" id="geminiModelInput" class="linuxdo-settings-input" value="${CONFIG.MODEL_NAME}" placeholder="输入模型名称"> </div> <div class="linuxdo-settings-field"> <label for="localSummaryMaxChars" class="linuxdo-settings-label">总结后的最大字符数maxChars</label> <input type="number" id="localSummaryMaxChars" class="linuxdo-settings-input" value="${CONFIG.LOCAL_SUMMARY_MAX_CHARS}" placeholder="140" min="1" max="10000" /> <div class="linuxdo-settings-description">设置总结后粘贴板的最大字符数,范围:1-10000</div> </div> <div class="linuxdo-settings-field"> <label for="customPrompt" class="linuxdo-settings-label">自定义总结 Prompt</label> <textarea id="customPrompt" class="linuxdo-settings-textarea" placeholder="输入自定义的总结提示词">${CONFIG.CUSTOM_SUMMARY_PROMPT}</textarea> <div class="linuxdo-settings-description">{maxChars} 总结后粘贴板的最大字符数(未启用AI总结时则为正文截断字符数)</div> <div class="linuxdo-settings-description">可以使用 {content} 作为占位符,代表帖子正文内容</div> </div> <div class="linuxdo-settings-buttons"> <button type="button" class="linuxdo-settings-button" id="cancelSettings">取消</button> <button type="button" class="linuxdo-settings-button primary" id="saveSettings">保存</button> </div> </form> </div> `; return dialog; } /** * @description 为设置界面的所有可交互元素绑定事件监听器。 * @param {HTMLDialogElement} dialog - 设置界面的 dialog 元素。 */ function bindSettingsEvents(dialog) { const closeBtn = dialog.querySelector('.linuxdo-settings-close'); const cancelBtn = dialog.querySelector('#cancelSettings'); const saveBtn = dialog.querySelector('#saveSettings'); const modelWrapper = dialog.querySelector('.linuxdo-model-input-wrapper'); const closeDialog = () => { if (typeof dialog.close === 'function') { dialog.setAttribute('closing', ''); setTimeout(() => { dialog.close(); dialog.remove(); }, 200); } else { dialog.remove(); const backdrop = document.querySelector('.dialog-backdrop-fallback'); if (backdrop) backdrop.remove(); } }; closeBtn.addEventListener('click', closeDialog); cancelBtn.addEventListener('click', closeDialog); dialog.addEventListener('cancel', (e) => { e.preventDefault(); closeDialog(); }); saveBtn.addEventListener('click', (e) => { e.preventDefault(); const useGeminiApi = dialog.querySelector('#useGeminiApi').checked; const aiMode = dialog.querySelector('#aiMode').value; const apiKey = dialog.querySelector('#geminiApiKey').value.trim(); const apiBaseUrl = dialog.querySelector('#geminiApiBaseUrl').value.trim(); const localSummaryMaxChars = parseInt(dialog.querySelector('#localSummaryMaxChars').value.trim()) || DEFAULT_CONFIG.LOCAL_SUMMARY_MAX_CHARS; const customPrompt = dialog.querySelector('#customPrompt').value.trim(); const modelValue = dialog.querySelector('#geminiModelInput').value.trim(); setConfig('USE_AI_FOR_SUMMARY', useGeminiApi); setConfig('AI_MODE', aiMode); setConfig('API_KEY', apiKey); setConfig('API_BASE_URL', apiBaseUrl || DEFAULT_CONFIG.API_BASE_URL); setConfig('MODEL_NAME', modelValue || DEFAULT_CONFIG.MODEL_NAME); setConfig('LOCAL_SUMMARY_MAX_CHARS', localSummaryMaxChars); setConfig('CUSTOM_SUMMARY_PROMPT', customPrompt || DEFAULT_CONFIG.CUSTOM_SUMMARY_PROMPT); const originalText = saveBtn.textContent; saveBtn.textContent = '已保存 ✓'; saveBtn.disabled = true; setTimeout(() => { closeDialog(); }, 300); }); } /** * @description 显示设置界面模态框。 */ function showSettingsModal() { if (window !== window.top) { console.log('在 iframe 中,跳过显示设置界面'); return; } const existingDialog = document.querySelector('.linuxdo-settings-dialog'); if (existingDialog) { existingDialog.remove(); } const dialog = createSettingsModal(); document.body.appendChild(dialog); bindSettingsEvents(dialog); if (typeof dialog.showModal === 'function') { dialog.showModal(); } else { dialog.style.display = 'block'; dialog.style.position = 'fixed'; dialog.style.top = '50%'; dialog.style.left = '50%'; dialog.style.transform = 'translate(-50%, -50%)'; dialog.style.zIndex = '10000'; const backdrop = document.createElement('div'); backdrop.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.6); z-index: 9999; `; backdrop.className = 'dialog-backdrop-fallback'; document.body.appendChild(backdrop); console.warn('浏览器不支持 dialog 元素,使用降级方案'); } } // #endregion // #region 脚本初始化与执行 // ========================================================== /** * @description 脚本的主要初始化函数。 * 负责查找页面上的关键元素,并在找到后调用函数添加复制按钮。 */ function initializeScript() { if (window !== window.top) { console.log("在 iframe 中,跳过脚本初始化"); return; } // console.log("油猴脚本已尝试初始化。"); const titleLinkElement = document.querySelector('h1[data-topic-id] a.fancy-title'); const articleRootElement = document.querySelector('.cooked'); const userDataContainer = document.querySelector('.topic-meta-data'); const categoryBadge = document.querySelector('.topic-category .badge-category__wrapper'); const tagsElement = document.querySelector('.list-tags'); if (titleLinkElement && articleRootElement && userDataContainer && categoryBadge) { if (titleLinkElement.parentNode && titleLinkElement.parentNode.tagName === 'H1') { const parentH1 = titleLinkElement.parentNode; if (!parentH1.style.display || !parentH1.style.display.includes('flex')) { parentH1.style.display = 'flex'; parentH1.style.alignItems = 'center'; parentH1.style.gap = '8px'; // console.log('已调整 H1 父元素样式为 flex。'); } } addCopyButtonToArticleTitle(titleLinkElement, articleRootElement); } else { console.log('部分所需元素未找到,等待DOM更新:', { title: !!titleLinkElement, content: !!articleRootElement, userData: !!userDataContainer, category: !!categoryBadge, tags: !!tagsElement }); } } // 脚本执行入口 if (window === window.top) { // 添加复制按钮函数增加节流 let initializeScriptThrottleFormGemini = throttleFormGemini(initializeScript, 300); // 注入样式 addStyle(copyBtnStyle); // 注册油猴菜单命令 GM_registerMenuCommand('设置', showSettingsModal); // 使用 MutationObserver 监听 DOM 变化,以适应动态加载内容的单页应用 (SPA) const observer = new MutationObserver((mutationsList, observerInstance) => { initializeScriptThrottleFormGemini(); }); observer.observe(document.body, { childList: true, subtree: true }); // 初始加载时也尝试运行一次 if (document.readyState === 'loading') { window.addEventListener('DOMContentLoaded', initializeScriptThrottleFormGemini); } else { initializeScriptThrottleFormGemini(); } } else { // console.log("在 iframe 中,跳过脚本功能初始化"); } // #endregion })();