从linux do获取论坛文章数据与复制

从linux do论坛页面获取文章的板块、标题、链接、标签和内容总结,并在标题旁添加复制按钮。支持设置界面配置。

当前为 2025-09-05 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          从linux do获取论坛文章数据与复制
// @namespace     http://tampermonkey.net/
// @version       0.11
// @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%;
        }
        input[type].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;
            height: 20px;
            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 callGeminiAPI(prompt, apiKey, model = 'gemini-2.5-flash-lite') {
    const baseUrl = getConfig('API_BASE_URL') || DEFAULT_CONFIG.API_BASE_URL;
    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) {
              reject(new Error(`AI Error: ${data.error.message}`));
            } else {
              reject(new Error('AI returned an unexpected 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 callGeminiAPI(prompt, CONFIG.API_KEY, CONFIG.MODEL_NAME);
          console.log('AI 总结:', 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">&times;</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" ${getConfig('USE_AI_FOR_SUMMARY') ? 'checked' : ''}>
              <label for="useGeminiApi" class="linuxdo-settings-label" style="color:#7d0000">启用 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" ${getConfig('AI_MODE') === 'gemini' ? 'selected' : ''}>Gemini</option>
              <option value="openaiCompatible" ${getConfig('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="${getConfig('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="${getConfig('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>

          <div class="linuxdo-settings-field">
            <label for="geminiModel" class="linuxdo-settings-label">AI 模型</label>
            <input type="text" id="geminiModelInput" class="linuxdo-settings-input" value="${getConfig('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="${getConfig('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="输入自定义的总结提示词">${getConfig('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

})();