从linux do论坛页面获取文章的板块、标题、链接、标签和内容总结,并在标题旁添加复制按钮。支持设置界面配置。
ของเมื่อวันที่
// ==UserScript==
// @name 从linux do获取论坛文章数据与复制
// @namespace http://tampermonkey.net/
// @version 0.15.15
// @description 从linux do论坛页面获取文章的板块、标题、链接、标签和内容总结,并在标题旁添加复制按钮。支持设置界面配置。
// @author @Loveyless https://github.com/Loveyless/linuxdo-share
// @match *://*.linux.do/*
// @match *://*.idcflare.com/*
// @match *://*.nodeloc.com/*
// @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 = {
// 是否启用简洁模式:标题+摘要合并一段,隐藏作者/板块/标签,链接单独一行
COMPACT_MODE: false,
// 主题列表分栏数(0/空表示关闭,最大 5;未设置时默认 2)
TWO_COLUMN_LAYOUT: 2,
// 主题列表瀑布流展示(仅分栏数>0时生效)
TOPIC_LIST_WATERFALL: false,
// 是否启用 AI 进行内容总结
USE_AI_FOR_SUMMARY: false,
// AI Key,如果 USE_AI_FOR_SUMMARY 为 true,则需要填写此项
API_KEY: '',
// API 地址 (OpenAI Compatible 格式,完整地址如 https://api.openai.com/v1/chat/completions)
API_BASE_URL: 'https://api.openai.com/v1/chat/completions',
// 模型名称
MODEL_NAME: 'gpt-4o-mini',
// 总结后的最大字符数
LOCAL_SUMMARY_MAX_CHARS: 90,
// 自定义总结 Prompt
CUSTOM_SUMMARY_PROMPT: `你是一个信息获取专家,可以精准的总结文章的精华内容和重点,请对以下文章内容进行归纳总结,回复不要有对我的问候语,或者(你好这是我的总结)(总结)等类似废话,直接返回你的总结,长度不超过{maxChars}个字符(或尽可能短,保持中文语义完整): {content}`,
// 文章复制模板
ARTICLE_COPY_TEMPLATE: [
`{{title}}`,
`@{{username}}-{{category}}/{{tags}}`,
``,
`{{summary}}`,
`{{link}}`,
].join('\n')
};
/**
* @description 脚本内部使用的常量配置
*/
const SCRIPT_CONSTANTS = {
// API 请求超时时间 (毫秒)
API_TIMEOUT_MS: 30000,
// AI 总结时发送的最大内容长度
AI_CONTENT_MAX_LENGTH: 4000,
// 脚本初始化节流时间 (毫秒)
INIT_THROTTLE_MS: 300,
// 复制成功提示显示时间 (毫秒)
COPY_SUCCESS_DURATION_MS: 2000,
// 复制失败提示显示时间 (毫秒)
COPY_FAILURE_DURATION_MS: 3000,
// 设置保存后关闭延迟 (毫秒)
SETTINGS_CLOSE_DELAY_MS: 300,
// Dialog 关闭动画时间 (毫秒)
DIALOG_CLOSE_ANIMATION_MS: 200,
// 历史复制内容:存储空间不足时的保底条数
COPY_HISTORY_FALLBACK_LIMIT: 20
};
// #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;
--tooltip-border-radius: 4px;
--tooltip-font-family: JetBrains Mono, Consolas, Menlo, Roboto Mono, monospace;
--tooltip-font-size: 12px;
--tooltip-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;
--tooltip-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(--tooltip-text-color);
background: var(--tooltip-bg);
padding: var(--tooltip-padding-y) var(--tooltip-padding-x);
border-radius: var(--tooltip-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 时隐藏对勾和叉号 */
}
/* 设置界面样式 - shadcn 风格(简约) */
.linuxdo-settings-dialog {
--ld-bg: #ffffff;
--ld-fg: #0f172a;
--ld-muted: #f1f5f9;
--ld-muted-fg: #64748b;
--ld-card: #ffffff;
--ld-border: rgba(15, 23, 42, 0.12);
--ld-ring: rgba(59, 130, 246, 0.45);
--ld-primary: #0f172a;
--ld-primary-fg: #ffffff;
border: none;
padding: 0;
width: min(720px, calc(100vw - 24px));
max-height: min(85vh, 760px);
background: transparent;
overflow: visible;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
}
.linuxdo-settings-dialog,
.linuxdo-settings-dialog * {
box-sizing: border-box;
}
html[style*="color-scheme: dark"] .linuxdo-settings-dialog {
--ld-bg: #0b1220;
--ld-fg: #e2e8f0;
--ld-muted: rgba(148, 163, 184, 0.12);
--ld-muted-fg: #94a3b8;
--ld-card: #0f172a;
--ld-border: rgba(148, 163, 184, 0.18);
--ld-ring: rgba(59, 130, 246, 0.55);
--ld-primary: #e2e8f0;
--ld-primary-fg: #0b1220;
}
.linuxdo-settings-dialog::backdrop {
background: rgba(2, 6, 23, 0.65);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.linuxdo-settings-content {
background: var(--ld-bg);
color: var(--ld-fg);
border: 1px solid var(--ld-border);
border-radius: 12px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
max-height: inherit;
overflow: hidden;
}
.linuxdo-settings-dialog[closing] .linuxdo-settings-content {
animation: ldDialogOut 0.16s ease-in forwards;
}
@keyframes ldDialogOut {
to { opacity: 0; transform: translateY(6px) scale(0.985); }
}
.linuxdo-settings-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--ld-border);
background: var(--ld-bg);
flex-shrink: 0;
}
.linuxdo-settings-title {
font-size: 16px;
font-weight: 600;
line-height: 1.25;
margin: 0;
letter-spacing: -0.01em;
}
.linuxdo-settings-subtitle {
margin: 6px 0 0;
font-size: 12px;
color: var(--ld-muted-fg);
line-height: 1.3;
}
.linuxdo-settings-close {
appearance: none;
border: 1px solid var(--ld-border);
background: transparent;
color: var(--ld-muted-fg);
border-radius: 10px;
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
flex-shrink: 0;
line-height: 1;
}
.linuxdo-settings-close:hover {
background: var(--ld-muted);
color: var(--ld-fg);
}
.linuxdo-settings-close:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--ld-ring);
}
.linuxdo-settings-form {
padding: 18px;
overflow-y: auto;
flex: 1;
}
.linuxdo-settings-section + .linuxdo-settings-section {
margin-top: 18px;
}
.linuxdo-settings-section-header {
margin-bottom: 10px;
}
.linuxdo-settings-section-title {
font-size: 12px;
font-weight: 600;
color: var(--ld-muted-fg);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 4px;
}
.linuxdo-settings-section-desc {
margin: 0;
font-size: 12px;
color: var(--ld-muted-fg);
}
.linuxdo-settings-card {
border: 1px solid var(--ld-border);
border-radius: 12px;
background: var(--ld-card);
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.linuxdo-settings-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.linuxdo-settings-item-text {
min-width: 0;
flex: 1;
}
.linuxdo-settings-item-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--ld-fg);
margin: 0;
}
.linuxdo-settings-description {
font-size: 12px;
color: var(--ld-muted-fg);
margin-top: 6px;
line-height: 1.4;
}
.linuxdo-settings-separator {
height: 1px;
width: 100%;
background: var(--ld-border);
}
.linuxdo-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.linuxdo-settings-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.linuxdo-settings-label {
font-size: 11px;
font-weight: 500;
color: var(--ld-fg);
}
.linuxdo-settings-input,
.linuxdo-settings-textarea {
width: 100%;
border: 1px solid var(--ld-border);
background: var(--ld-bg);
color: var(--ld-fg);
border-radius: 9px;
padding: 7px 9px;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
outline: none;
transition: box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.linuxdo-settings-control {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.linuxdo-settings-input.small {
width: 68px;
text-align: center;
padding: 6px 8px;
}
.linuxdo-settings-unit {
font-size: 11px;
color: var(--ld-muted-fg);
line-height: 1;
}
.linuxdo-settings-input::placeholder,
.linuxdo-settings-textarea::placeholder {
color: var(--ld-muted-fg);
}
.linuxdo-settings-input:focus-visible,
.linuxdo-settings-textarea:focus-visible {
box-shadow: 0 0 0 3px var(--ld-ring);
border-color: rgba(59, 130, 246, 0.65);
}
.linuxdo-settings-textarea {
resize: vertical;
min-height: 90px;
}
/* Switch */
.linuxdo-settings-switch {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.linuxdo-settings-switch input {
opacity: 0;
width: 0;
height: 0;
}
.linuxdo-settings-switch-slider {
position: absolute;
inset: 0;
border-radius: 999px;
border: 1px solid var(--ld-border);
background: var(--ld-muted);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.linuxdo-settings-switch-slider:before {
position: absolute;
content: "";
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 999px;
background: var(--ld-bg);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
transition: transform 0.15s ease, background 0.15s ease;
}
.linuxdo-settings-switch input:checked + .linuxdo-settings-switch-slider {
background: var(--ld-primary);
border-color: var(--ld-primary);
}
.linuxdo-settings-switch input:checked + .linuxdo-settings-switch-slider:before {
transform: translateX(20px);
background: var(--ld-primary-fg);
}
.linuxdo-settings-switch input:focus-visible + .linuxdo-settings-switch-slider {
box-shadow: 0 0 0 3px var(--ld-ring);
}
.linuxdo-settings-footer {
display: flex;
gap: 10px;
padding: 14px 18px;
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--ld-border);
background: var(--ld-bg);
flex-shrink: 0;
}
.linuxdo-settings-button {
flex: 1;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--ld-border);
background: transparent;
color: var(--ld-fg);
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: inherit;
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
}
.linuxdo-settings-button:hover {
background: var(--ld-muted);
}
.linuxdo-settings-button:active {
transform: translateY(1px);
}
.linuxdo-settings-button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--ld-ring);
}
.linuxdo-settings-button.primary {
background: var(--ld-primary);
border-color: var(--ld-primary);
color: var(--ld-primary-fg);
}
.linuxdo-settings-button.primary:hover {
filter: brightness(0.98);
}
@media (max-width: 520px) {
.linuxdo-settings-dialog {
width: calc(100vw - 16px);
max-height: 92vh;
}
.linuxdo-settings-form {
padding: 14px;
}
.linuxdo-settings-header,
.linuxdo-settings-footer {
padding-left: 14px;
padding-right: 14px;
}
.linuxdo-settings-grid {
grid-template-columns: 1fr;
}
}
/* 主题列表两栏布局 */
html.linuxdo-two-column-layout {
--ld-list-bg: #ffffff;
--ld-list-fg: #0f172a;
--ld-list-muted: #64748b;
--ld-list-border: rgba(15, 23, 42, 0.12);
--ld-list-card: #ffffff;
--ld-list-ring: rgba(59, 130, 246, 0.18);
}
html[style*="color-scheme: dark"].linuxdo-two-column-layout {
--ld-list-bg: #0b1220;
--ld-list-fg: #e2e8f0;
--ld-list-muted: #94a3b8;
--ld-list-border: rgba(148, 163, 184, 0.18);
--ld-list-card: #0f172a;
--ld-list-ring: rgba(59, 130, 246, 0.28);
}
html.linuxdo-two-column-layout table.topic-list {
display: block !important;
width: 100% !important;
background: transparent !important;
}
html.linuxdo-two-column-layout table.topic-list thead {
display: block !important;
padding: 0 0 8px;
}
html.linuxdo-two-column-layout table.topic-list thead tr {
display: block !important;
}
html.linuxdo-two-column-layout table.topic-list thead th {
display: block !important;
border: none !important;
padding: 0 2px 10px !important;
color: var(--ld-list-muted) !important;
font-size: 12px !important;
font-weight: 600 !important;
letter-spacing: 0.04em;
text-transform: uppercase;
}
html.linuxdo-two-column-layout table.topic-list thead th.posters,
html.linuxdo-two-column-layout table.topic-list thead th.num,
html.linuxdo-two-column-layout table.topic-list thead th.posts,
html.linuxdo-two-column-layout table.topic-list thead th.views,
html.linuxdo-two-column-layout table.topic-list thead th.activity,
html.linuxdo-two-column-layout table.topic-list thead th.age {
display: none !important;
}
html.linuxdo-two-column-layout tbody.topic-list-body {
display: grid !important;
grid-template-columns: repeat(var(--ld-topic-columns, 2), minmax(0, 1fr));
gap: 12px;
padding: 0 !important;
border-top: none !important;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr:not(.topic-list-item) {
grid-column: 1 / -1;
}
/* 瀑布流(Masonry):使用多列布局模拟 */
html.linuxdo-two-column-layout.linuxdo-topic-waterfall tbody.topic-list-body {
display: block !important;
column-count: var(--ld-topic-columns, 2);
column-gap: 12px;
}
html.linuxdo-two-column-layout.linuxdo-topic-waterfall tbody.topic-list-body > tr.topic-list-item {
break-inside: avoid;
margin-bottom: 12px !important;
}
html.linuxdo-two-column-layout.linuxdo-topic-waterfall tbody.topic-list-body > tr:not(.topic-list-item) {
column-span: all;
break-inside: avoid;
margin-bottom: 12px;
}
@media (max-width: 900px) {
html.linuxdo-two-column-layout tbody.topic-list-body {
grid-template-columns: 1fr;
}
html.linuxdo-two-column-layout.linuxdo-topic-waterfall tbody.topic-list-body {
column-count: 1;
}
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item {
display: flex !important;
flex-direction: column;
background: var(--ld-list-card);
border: 1px solid var(--ld-list-border);
border-radius: 12px;
padding: 12px 12px 10px;
margin: 0 !important;
overflow: hidden;
transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
/* 禁用 Discourse 的 selected 高亮效果(两栏模式) */
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item.selected,
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item.selected > td {
background: var(--ld-list-card) !important;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item.selected {
border-color: var(--ld-list-border) !important;
box-shadow: none !important;
outline: none !important;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item.selected:hover {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 0 0 3px var(--ld-list-ring);
}
html[style*="color-scheme: dark"].linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item.selected:hover {
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.35), 0 0 0 3px var(--ld-list-ring);
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item:hover {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08), 0 0 0 3px var(--ld-list-ring);
}
html[style*="color-scheme: dark"].linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item:hover {
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.35), 0 0 0 3px var(--ld-list-ring);
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item > td {
display: block !important;
width: 100% !important;
padding: 0 !important;
border: none !important;
background: transparent !important;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item > td:not(.main-link) {
display: none !important;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item td.main-link {
min-width: 0;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item td.main-link.topic-list-data {
box-shadow: none !important;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item td.main-link .link-top-line {
display: block;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item td.main-link a.raw-topic-link {
color: var(--ld-list-fg) !important;
font-size: 15px;
font-weight: 600;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-decoration: none;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item td.main-link a.raw-topic-link:hover {
text-decoration: underline;
}
html.linuxdo-two-column-layout tbody.topic-list-body > tr.topic-list-item td.main-link .link-bottom-line {
margin-top: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
html.linuxdo-two-column-layout .linuxdo-topic-meta {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--ld-list-border);
display: flex;
align-items: center;
gap: 8px;
color: var(--ld-list-muted);
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
}
html.linuxdo-two-column-layout .linuxdo-topic-meta-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
border: 1px solid var(--ld-list-border);
background: var(--ld-list-bg);
}
html.linuxdo-two-column-layout .linuxdo-topic-meta-avatar img {
width: 22px;
height: 22px;
display: block;
}
html.linuxdo-two-column-layout .linuxdo-topic-meta-item {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
html.linuxdo-two-column-layout .linuxdo-topic-meta-item + .linuxdo-topic-meta-item::before {
content: "·";
margin: 0 6px 0 0;
color: var(--ld-list-muted);
}
/* 历史复制内容(搜索框右侧) */
.linuxdo-copy-history-trigger {
--ld-h-bg: #ffffff;
--ld-h-fg: #0f172a;
--ld-h-muted: #f1f5f9;
--ld-h-muted-fg: #64748b;
--ld-h-border: rgba(15, 23, 42, 0.12);
--ld-h-ring: rgba(59, 130, 246, 0.45);
appearance: none;
border: 1px solid var(--ld-h-border);
background: transparent;
color: var(--ld-h-muted-fg);
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-left: 2px;
margin-right: 2px;
transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
html[style*="color-scheme: dark"] .linuxdo-copy-history-trigger {
--ld-h-bg: #0b1220;
--ld-h-fg: #e2e8f0;
--ld-h-muted: rgba(148, 163, 184, 0.12);
--ld-h-muted-fg: #94a3b8;
--ld-h-border: rgba(148, 163, 184, 0.18);
--ld-h-ring: rgba(59, 130, 246, 0.55);
}
.linuxdo-copy-history-trigger:hover {
background: var(--ld-h-muted);
color: var(--ld-h-fg);
}
.linuxdo-copy-history-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--ld-h-ring);
}
.linuxdo-copy-history-trigger svg {
width: 16px;
height: 16px;
display: block;
}
.linuxdo-copy-history-popover {
--ld-h-bg: #ffffff;
--ld-h-fg: #0f172a;
--ld-h-muted: #f1f5f9;
--ld-h-muted-fg: #64748b;
--ld-h-border: rgba(15, 23, 42, 0.12);
--ld-h-ring: rgba(59, 130, 246, 0.18);
position: fixed;
z-index: 100000;
width: min(520px, calc(100vw - 24px));
max-height: min(70vh, 520px);
overflow: auto;
background: var(--ld-h-bg);
color: var(--ld-h-fg);
border: 1px solid var(--ld-h-border);
border-radius: 12px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
padding: 10px;
display: none;
}
html[style*="color-scheme: dark"] .linuxdo-copy-history-popover {
--ld-h-bg: #0b1220;
--ld-h-fg: #e2e8f0;
--ld-h-muted: rgba(148, 163, 184, 0.12);
--ld-h-muted-fg: #94a3b8;
--ld-h-border: rgba(148, 163, 184, 0.18);
--ld-h-ring: rgba(59, 130, 246, 0.24);
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.52);
}
.linuxdo-copy-history-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 2px 2px 10px;
border-bottom: 1px solid var(--ld-h-border);
margin-bottom: 8px;
}
.linuxdo-copy-history-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ld-h-muted-fg);
}
.linuxdo-copy-history-hint {
font-size: 12px;
color: var(--ld-h-muted-fg);
white-space: nowrap;
}
.linuxdo-copy-history-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.linuxdo-copy-history-item {
border: 1px solid var(--ld-h-border);
border-radius: 12px;
padding: 10px;
background: transparent;
transition: background 0.15s ease, box-shadow 0.15s ease;
}
.linuxdo-copy-history-item:hover {
background: var(--ld-h-muted);
box-shadow: 0 0 0 3px var(--ld-h-ring);
}
.linuxdo-copy-history-item-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.linuxdo-copy-history-item-text {
min-width: 0;
flex: 1;
}
.linuxdo-copy-history-item-title {
font-size: 13px;
font-weight: 600;
line-height: 1.35;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.linuxdo-copy-history-item-snippet {
margin-top: 4px;
font-size: 12px;
color: var(--ld-h-muted-fg);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.linuxdo-copy-history-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.linuxdo-copy-history-btn {
appearance: none;
border: 1px solid var(--ld-h-border);
background: transparent;
color: var(--ld-h-fg);
border-radius: 10px;
padding: 6px 10px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
white-space: nowrap;
}
.linuxdo-copy-history-btn:hover {
background: var(--ld-h-bg);
box-shadow: 0 0 0 3px var(--ld-h-ring);
}
.linuxdo-copy-history-btn:active {
transform: translateY(1px);
}
.linuxdo-copy-history-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--ld-h-ring);
}
.linuxdo-copy-history-empty {
padding: 18px 8px;
text-align: center;
font-size: 12px;
color: var(--ld-h-muted-fg);
}
/* 话题页:回到顶部(可拖动) */
.linuxdo-back-to-top {
--ld-btt-bg: #ffffff;
--ld-btt-fg: #0f172a;
--ld-btt-muted: #f1f5f9;
--ld-btt-muted-fg: #64748b;
--ld-btt-border: rgba(15, 23, 42, 0.12);
--ld-btt-ring: rgba(59, 130, 246, 0.45);
position: fixed;
z-index: 100000;
width: 42px;
height: 42px;
border-radius: 999px;
border: 1px solid var(--ld-btt-border);
background: var(--ld-btt-bg);
color: var(--ld-btt-fg);
display: none;
align-items: center;
justify-content: center;
cursor: grab;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12);
touch-action: none;
user-select: none;
transition: transform 0.05s ease, box-shadow 0.15s ease, background 0.15s ease;
}
html[style*="color-scheme: dark"] .linuxdo-back-to-top {
--ld-btt-bg: #0b1220;
--ld-btt-fg: #e2e8f0;
--ld-btt-muted: rgba(148, 163, 184, 0.12);
--ld-btt-muted-fg: #94a3b8;
--ld-btt-border: rgba(148, 163, 184, 0.18);
--ld-btt-ring: rgba(59, 130, 246, 0.55);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.45);
}
.linuxdo-back-to-top:hover {
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18), 0 0 0 3px var(--ld-btt-ring);
background: var(--ld-btt-bg);
}
.linuxdo-back-to-top:active {
cursor: grabbing;
transform: translateY(1px);
}
.linuxdo-back-to-top:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--ld-btt-ring);
}
.linuxdo-back-to-top svg {
width: 18px;
height: 18px;
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 转义 HTML 特殊字符,防止 XSS 攻击。
* @param {string} str - 要转义的字符串。
* @returns {string} 转义后的安全字符串。
*/
function escapeHtml(str) {
if (typeof str !== 'string') return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/**
* @description 将文本规整为单行(用于简洁模式输出)。
* @param {string} text - 原始文本。
* @returns {string} 单行文本。
*/
function normalizeToSingleLine(text) {
return (text || '')
.replace(/\r\n/g, '\n')
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* @description 去除 AI 总结前缀,输出更干净。
* @param {string} summary - 总结文本。
* @returns {string} 去前缀后的总结文本。
*/
function stripAiSummaryPrefix(summary) {
return (summary || '').replace(/^\[AI\s*总结\]\s*:?\s*/i, '');
}
/**
* @description 根据配置格式化复制内容。
* @param {object} articleData - 文章数据。
* @returns {string} 最终要复制的文本。
*/
function formatCopiedText(articleData) {
if (CONFIG.COMPACT_MODE) {
const title = normalizeToSingleLine(articleData && articleData.title);
const summary = normalizeToSingleLine(stripAiSummaryPrefix(articleData && articleData.summary));
const combined = title && summary ? `${title}:${summary}` : (title || summary);
return [combined, articleData && articleData.link].filter(Boolean).join('\n').trim();
}
const template = CONFIG.ARTICLE_COPY_TEMPLATE || DEFAULT_CONFIG.ARTICLE_COPY_TEMPLATE;
let formattedText = String(template).replace(/{{(\w+)}}/g, (match, key) => {
return articleData[key] !== undefined ? articleData[key] : match;
});
return formattedText.replace(/\n\n+/g, '\n\n').trim();
}
// #region 历史复制内容
// ==========================================================
const COPY_HISTORY_STORAGE_KEY = 'COPY_HISTORY_V1';
let copyHistoryPopoverEl = null;
let copyHistoryHideTimer = null;
let copyHistoryGlobalListenersBound = false;
/**
* @description 读取历史复制内容列表。
* @returns {Array<{id: string, title: string, summary: string, link: string, text: string, createdAt: number}>}
*/
function getCopyHistoryItems() {
const raw = GM_getValue(COPY_HISTORY_STORAGE_KEY, []);
if (!Array.isArray(raw)) return [];
return raw
.filter((item) => item && typeof item === 'object')
.map((item) => ({
id: String(item.id || ''),
title: String(item.title || ''),
summary: String(item.summary || ''),
link: String(item.link || ''),
text: String(item.text || ''),
createdAt: Number(item.createdAt || 0),
}))
.filter((item) => item.id && item.text);
}
/**
* @description 尝试持久化历史;若存储空间不足,则回退保留最近 N 条。
* @param {Array} items - 历史列表。
* @returns {Array} 实际保存的历史列表。
*/
function persistCopyHistoryItems(items) {
try {
GM_setValue(COPY_HISTORY_STORAGE_KEY, items);
return items;
} catch (error) {
console.warn('保存历史复制内容失败,尝试回退条数:', error);
let trimmed = items.slice(0, SCRIPT_CONSTANTS.COPY_HISTORY_FALLBACK_LIMIT);
while (trimmed.length > 0) {
try {
GM_setValue(COPY_HISTORY_STORAGE_KEY, trimmed);
return trimmed;
} catch (e) {
trimmed = trimmed.slice(0, trimmed.length - 1);
}
}
try {
GM_setValue(COPY_HISTORY_STORAGE_KEY, []);
} catch (_) {
// ignore
}
return [];
}
}
/**
* @description 写入一条历史记录(新记录置顶,按链接去重)。
* @param {object} param - 参数对象。
* @param {object} param.articleData - 文章数据。
* @param {string} param.copiedText - 最终复制文本。
*/
function addCopyHistoryItem({ articleData, copiedText }) {
const title = String((articleData && articleData.title) || '').trim();
const link = String((articleData && articleData.link) || '').trim();
const summary = String(stripAiSummaryPrefix((articleData && articleData.summary) || '')).trim();
const text = String(copiedText || '').trim();
if (!text) return;
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const nextItem = { id, title, summary, link, text, createdAt: Date.now() };
const existing = getCopyHistoryItems();
const deduped = existing.filter((it) => !(link && it.link === link));
const next = [nextItem, ...deduped];
const saved = persistCopyHistoryItems(next);
if (copyHistoryPopoverEl && copyHistoryPopoverEl.style.display !== 'none') {
renderCopyHistoryPopover(saved);
}
}
function ensureCopyHistoryPopover() {
if (copyHistoryPopoverEl && document.body.contains(copyHistoryPopoverEl)) return copyHistoryPopoverEl;
const popover = document.createElement('div');
popover.className = 'linuxdo-copy-history-popover';
popover.setAttribute('role', 'menu');
popover.setAttribute('aria-label', '历史复制内容');
document.body.appendChild(popover);
popover.addEventListener('mouseenter', () => {
if (copyHistoryHideTimer) {
clearTimeout(copyHistoryHideTimer);
copyHistoryHideTimer = null;
}
});
popover.addEventListener('mouseleave', () => {
scheduleHideCopyHistoryPopover();
});
popover.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action][data-id]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const action = btn.getAttribute('data-action');
const id = btn.getAttribute('data-id');
const items = getCopyHistoryItems();
const item = items.find((x) => x.id === id);
if (!item) return;
if (action === 'copy') {
copyTextToClipboard({ element: btn, text: item.text });
} else if (action === 'open') {
if (item.link) window.open(item.link, '_blank', 'noopener,noreferrer');
}
});
window.addEventListener('scroll', () => hideCopyHistoryPopover(), true);
window.addEventListener('resize', () => hideCopyHistoryPopover(), true);
copyHistoryPopoverEl = popover;
return popover;
}
function scheduleHideCopyHistoryPopover() {
if (copyHistoryHideTimer) clearTimeout(copyHistoryHideTimer);
copyHistoryHideTimer = setTimeout(() => hideCopyHistoryPopover(), 150);
}
function hideCopyHistoryPopover() {
if (!copyHistoryPopoverEl) return;
copyHistoryPopoverEl.style.display = 'none';
}
function positionCopyHistoryPopover(triggerEl) {
if (!copyHistoryPopoverEl || !triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
const popoverRect = copyHistoryPopoverEl.getBoundingClientRect();
const gap = 8;
let top = rect.bottom + gap;
let left = rect.right - popoverRect.width;
const maxLeft = window.innerWidth - popoverRect.width - 12;
const minLeft = 12;
if (left > maxLeft) left = maxLeft;
if (left < minLeft) left = minLeft;
const maxTop = window.innerHeight - popoverRect.height - 12;
if (top > maxTop) top = Math.max(12, rect.top - popoverRect.height - gap);
copyHistoryPopoverEl.style.top = `${Math.round(top)}px`;
copyHistoryPopoverEl.style.left = `${Math.round(left)}px`;
}
function renderCopyHistoryPopover(items) {
const popover = ensureCopyHistoryPopover();
const list = Array.isArray(items) ? items : getCopyHistoryItems();
const header = `
<div class="linuxdo-copy-history-header">
<div class="linuxdo-copy-history-title">历史复制</div>
<div class="linuxdo-copy-history-hint">点击复制 / 打开链接</div>
</div>
`;
if (!list || list.length === 0) {
popover.innerHTML = header + `<div class="linuxdo-copy-history-empty">暂无历史记录</div>`;
return;
}
const html = list
.slice(0, Math.max(list.length, 0))
.map((item) => {
const safeTitle = escapeHtml(item.title || '(无标题)');
const snippet = escapeHtml((item.summary || item.text || '').trim());
return `
<div class="linuxdo-copy-history-item" role="menuitem">
<div class="linuxdo-copy-history-item-top">
<div class="linuxdo-copy-history-item-text">
<div class="linuxdo-copy-history-item-title">${safeTitle}</div>
<div class="linuxdo-copy-history-item-snippet">${snippet}</div>
</div>
<div class="linuxdo-copy-history-actions">
<button type="button" class="linuxdo-copy-history-btn" data-action="copy" data-id="${escapeHtml(item.id)}">再次复制</button>
<button type="button" class="linuxdo-copy-history-btn" data-action="open" data-id="${escapeHtml(item.id)}">打开链接</button>
</div>
</div>
</div>
`;
})
.join('');
popover.innerHTML = header + `<div class="linuxdo-copy-history-list">${html}</div>`;
}
function findHeaderSearchActionsContainer() {
const input = document.getElementById('header-search-input');
if (input) {
const searchInput = input.closest('.search-input') || input.closest('.search-input-wrapper');
const searching = searchInput ? searchInput.querySelector('.searching') : null;
if (searching) return searching;
}
return document.querySelector('.floating-search-input-wrapper .searching');
}
/**
* @description 在搜索框右侧注入历史按钮(hover 展开)。
*/
function ensureCopyHistoryTrigger() {
const container = findHeaderSearchActionsContainer();
if (!container) return;
if (container.querySelector('.linuxdo-copy-history-trigger')) return;
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'linuxdo-copy-history-trigger';
trigger.title = '历史复制内容';
trigger.setAttribute('aria-label', '历史复制内容');
trigger.innerHTML = /*html*/`
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="M12 8v4l3 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 12a9 9 0 1 0 3-6.7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M3 3v4h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
const popover = ensureCopyHistoryPopover();
trigger.addEventListener('mouseenter', () => {
if (copyHistoryHideTimer) {
clearTimeout(copyHistoryHideTimer);
copyHistoryHideTimer = null;
}
renderCopyHistoryPopover();
popover.style.display = 'block';
positionCopyHistoryPopover(trigger);
});
trigger.addEventListener('mouseleave', () => {
scheduleHideCopyHistoryPopover();
});
trigger.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const isOpen = popover.style.display !== 'none';
if (isOpen) {
hideCopyHistoryPopover();
} else {
renderCopyHistoryPopover();
popover.style.display = 'block';
positionCopyHistoryPopover(trigger);
}
});
if (!copyHistoryGlobalListenersBound) {
copyHistoryGlobalListenersBound = true;
document.addEventListener('click', (e) => {
if (!copyHistoryPopoverEl) return;
if (copyHistoryPopoverEl.style.display === 'none') return;
const anyTrigger = document.querySelector('.linuxdo-copy-history-trigger');
if (anyTrigger && (e.target === anyTrigger || anyTrigger.contains(e.target))) return;
if (copyHistoryPopoverEl.contains(e.target)) return;
hideCopyHistoryPopover();
}, true);
}
container.appendChild(trigger);
}
// #endregion
// #region 回到顶部按钮(话题页)
// ==========================================================
const BACK_TO_TOP_POSITION_STORAGE_KEY = `BACK_TO_TOP_POSITION_V1_${window.location.host}`;
let backToTopButtonEl = null;
let suppressBackToTopClick = false;
function isTopicPageForBackToTop() {
const path = window.location.pathname || '';
return /^\/t\/topic\/\d+(?:\/\d+)?/.test(path);
}
function getBackToTopSavedPosition() {
const pos = GM_getValue(BACK_TO_TOP_POSITION_STORAGE_KEY, null);
if (!pos || typeof pos !== 'object') return null;
const left = Number(pos.left);
const top = Number(pos.top);
if (!Number.isFinite(left) || !Number.isFinite(top)) return null;
return { left, top };
}
function clampBackToTopPosition(pos, size = 42) {
const min = 8;
const maxLeft = Math.max(min, window.innerWidth - size - min);
const maxTop = Math.max(min, window.innerHeight - size - min);
return {
left: Math.min(Math.max(pos.left, min), maxLeft),
top: Math.min(Math.max(pos.top, min), maxTop),
};
}
function applyBackToTopPosition(el, pos) {
const safe = clampBackToTopPosition(pos, el ? el.offsetWidth || 42 : 42);
el.style.left = `${Math.round(safe.left)}px`;
el.style.top = `${Math.round(safe.top)}px`;
el.style.right = 'auto';
el.style.bottom = 'auto';
}
function saveBackToTopPosition(pos) {
try {
GM_setValue(BACK_TO_TOP_POSITION_STORAGE_KEY, pos);
} catch (error) {
console.warn('保存回到顶部按钮位置失败:', error);
}
}
function tryScrollToTopByTimeline() {
const t = document.getElementsByClassName('timeline-padding');
if (t && t[0]) {
try {
t[0].style.height = 0;
t[0].click();
return true;
} catch (error) {
console.warn('timeline-padding 回到顶部执行失败:', error);
}
}
return false;
}
function ensureBackToTopButton() {
if (!isTopicPageForBackToTop()) {
if (backToTopButtonEl) backToTopButtonEl.style.display = 'none';
return;
}
if (!backToTopButtonEl || !document.body.contains(backToTopButtonEl)) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'linuxdo-back-to-top';
btn.setAttribute('aria-label', '回到顶部');
btn.title = '回到顶部';
btn.innerHTML = /*html*/`
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5l-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 5l7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 5v14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
`;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (suppressBackToTopClick) {
suppressBackToTopClick = false;
return;
}
window.scrollTo(0, 0);
tryScrollToTopByTimeline();
});
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
const onPointerMove = (ev) => {
if (!dragging) return;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
if (Math.abs(dx) + Math.abs(dy) > 3) suppressBackToTopClick = true;
applyBackToTopPosition(btn, { left: startLeft + dx, top: startTop + dy });
};
const stopDrag = () => {
if (!dragging) return;
dragging = false;
const left = Number.parseFloat(btn.style.left) || 0;
const top = Number.parseFloat(btn.style.top) || 0;
saveBackToTopPosition({ left, top });
};
btn.addEventListener('pointerdown', (ev) => {
if (ev.button !== 0) return;
dragging = true;
startX = ev.clientX;
startY = ev.clientY;
startLeft = Number.parseFloat(btn.style.left) || 0;
startTop = Number.parseFloat(btn.style.top) || 0;
btn.setPointerCapture(ev.pointerId);
});
btn.addEventListener('pointermove', onPointerMove);
btn.addEventListener('pointerup', (ev) => {
stopDrag();
try { btn.releasePointerCapture(ev.pointerId); } catch (_) { /* ignore */ }
});
btn.addEventListener('pointercancel', () => stopDrag());
window.addEventListener('resize', () => {
if (!backToTopButtonEl || backToTopButtonEl.style.display === 'none') return;
const left = Number.parseFloat(backToTopButtonEl.style.left) || 0;
const top = Number.parseFloat(backToTopButtonEl.style.top) || 0;
applyBackToTopPosition(backToTopButtonEl, { left, top });
});
document.body.appendChild(btn);
backToTopButtonEl = btn;
const saved = getBackToTopSavedPosition();
if (saved) {
applyBackToTopPosition(btn, saved);
} else {
const defaultPos = {
left: Math.max(8, window.innerWidth - 42 - 18),
top: Math.max(8, window.innerHeight - 42 - 90),
};
applyBackToTopPosition(btn, defaultPos);
}
}
backToTopButtonEl.style.display = 'inline-flex';
}
// #endregion
/**
* @description 调用 AI 以获取内容总结 (OpenAI Compatible 格式)。
* @param {string} prompt - 发送给 API 的完整提示词。
* @param {string} apiKey - 用户的 API Key。
* @param {string} [model='gpt-4o-mini'] - 要使用的模型名称。
* @returns {Promise<string>} 返回 API 生成的文本内容的 Promise。
*/
async function callAiAPI(prompt, apiKey, model = 'gpt-4o-mini') {
const url = CONFIG.API_BASE_URL || DEFAULT_CONFIG.API_BASE_URL;
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,
timeout: SCRIPT_CONSTANTS.API_TIMEOUT_MS, // API 请求超时
onload: function (response) {
// 检查 HTTP 状态码
if (response.status < 200 || response.status >= 300) {
reject(new Error(`HTTP Error: ${response.status} ${response.statusText}`));
return;
}
try {
const data = JSON.parse(response.responseText);
if (data.choices && data.choices.length > 0) {
resolve(data.choices[0].message.content);
} else if (data.error) {
reject(new Error(`AI Error: ${JSON.stringify(data.error)}`));
} else {
reject(new Error(`AI returned an unexpected response: ${JSON.stringify(data)}`));
}
} 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'}`));
},
ontimeout: function () {
reject(new Error(`API request timed out after ${SCRIPT_CONSTANTS.API_TIMEOUT_MS / 1000} seconds`));
}
});
});
}
/**
* @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(); // 移除焦点,重置提示
}, SCRIPT_CONSTANTS.COPY_FAILURE_DURATION_MS);
}
/**
* @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(); // 移除焦点,重置提示
}, SCRIPT_CONSTANTS.COPY_SUCCESS_DURATION_MS);
}).catch(function (error) {
handleCopyError({ element, error });
});
}
/**
* 创建一个节流函数,在 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 throttleInit(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 categoryElements = document.querySelectorAll('.topic-category .badge-category__wrapper');
if (categoryElements.length > 0) {
const lastIndex = categoryElements.length - 1;
userData.category = categoryElements[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 中提取当前登录用户的用户名(用于分享链接参数 u)。
* @returns {string} 用户名(小写);未获取到则返回空字符串。
*/
function getSharerUsername() {
const currentUserToggle = document.getElementById('toggle-current-user');
if (!currentUserToggle) return '';
const avatarImg = currentUserToggle.querySelector('img.avatar');
if (avatarImg) {
const src = avatarImg.getAttribute('src') || '';
const match = src.match(/\/user_avatar\/[^/]+\/([^/]+)\//i);
if (match) return match[1].trim().toLowerCase();
}
return '';
}
/**
* @description 将 u=<username> 拼接到链接中(兼容已有查询参数)。
* @param {string} link - 原始链接。
* @param {string} username - 用户名(建议已转小写)。
* @returns {string} 拼接后的链接。
*/
function appendSharerParamToLink(link, username) {
if (!link || !username) return link || '';
try {
const url = new URL(link, window.location.origin);
url.searchParams.set('u', username);
return url.toString();
} catch (error) {
const separator = link.includes('?') ? '&' : '?';
return `${link}${separator}u=${encodeURIComponent(username)}`;
}
}
/**
* @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 || '';
articleData.link = appendSharerParamToLink(articleData.link, getSharerUsername());
}
// 获取内容并进行总结
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, SCRIPT_CONSTANTS.AI_CONTENT_MAX_LENGTH);
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('OpenAI Compatible', 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> 元素。
*/
function addCopyButtonToArticleTitle(titleElement) {
// 可能导致判断不准确 重复添加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 {
// 每次点击时重新获取当前页面的DOM元素,避免SPA页面切换后闭包持有旧引用
const currentTitleElement = document.querySelector('h1[data-topic-id] a.fancy-title');
const currentArticleRootElement = document.querySelector('.cooked');
const articleData = await getArticleData(currentTitleElement, currentArticleRootElement);
console.log('获取到的文章数据:', articleData);
const formattedText = formatCopiedText(articleData);
addCopyHistoryItem({ articleData, copiedText: formattedText });
copyTextToClipboard({ element: copyButton, text: formattedText });
} catch (error) {
handleCopyError({ element: copyButton, error });
} finally {
copyButton.classList.remove('loading');
copyButton.disabled = false;
}
});
}
// #endregion
// #region 主题列表布局
// ==========================================================
/**
* @description 清理两栏布局的增强 DOM,恢复为默认样式。
*/
function cleanupTwoColumnLayout() {
document.documentElement.classList.remove('linuxdo-two-column-layout', 'linuxdo-topic-waterfall');
document.documentElement.style.removeProperty('--ld-topic-columns');
document.querySelectorAll('.linuxdo-topic-meta').forEach((el) => el.remove());
document.querySelectorAll('tr.topic-list-item[data-linuxdo-two-column-enhanced="1"]').forEach((tr) => {
tr.removeAttribute('data-linuxdo-two-column-enhanced');
});
}
/**
* @description 获取主题列表分栏数(兼容旧版 boolean 配置)。
* @returns {number} 0 表示关闭;否则为 1~5。
*/
function getTopicListColumns() {
const rawValue = CONFIG.TWO_COLUMN_LAYOUT;
if (typeof rawValue === 'boolean') {
return rawValue ? 2 : 0;
}
const parsed = Number.parseInt(String(rawValue || '').trim(), 10);
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
return Math.min(parsed, 5);
}
/**
* @description 在主题列表页启用/刷新两栏布局。
* 将回复/浏览/活动/发帖人收纳到标题下方,避免挤压标题区域。
*/
function applyTwoColumnLayoutToTopicLists() {
const columns = getTopicListColumns();
if (columns <= 0) {
cleanupTwoColumnLayout();
return;
}
document.documentElement.classList.add('linuxdo-two-column-layout');
document.documentElement.style.setProperty('--ld-topic-columns', String(columns));
if (CONFIG.TOPIC_LIST_WATERFALL) {
document.documentElement.classList.add('linuxdo-topic-waterfall');
} else {
document.documentElement.classList.remove('linuxdo-topic-waterfall');
}
const topicListBodies = document.querySelectorAll('tbody.topic-list-body');
if (!topicListBodies || topicListBodies.length === 0) return;
topicListBodies.forEach((tbody) => {
const rows = tbody.querySelectorAll('tr.topic-list-item');
if (!rows || rows.length === 0) return;
rows.forEach((row) => {
if (row.getAttribute('data-linuxdo-two-column-enhanced') === '1') {
return;
}
const mainLinkCell = row.querySelector('td.main-link');
if (!mainLinkCell) return;
const meta = document.createElement('div');
meta.className = 'linuxdo-topic-meta';
// a: 发帖人头像(取 posters 列的第一个 a)
const avatarAnchor = row.querySelector('td.posters a');
const avatarImg = avatarAnchor ? avatarAnchor.querySelector('img') : null;
if (avatarAnchor && avatarImg) {
const avatar = document.createElement('a');
avatar.className = 'linuxdo-topic-meta-avatar';
avatar.href = avatarAnchor.getAttribute('href') || '#';
avatar.setAttribute('aria-label', '发帖人');
const title = avatarAnchor.getAttribute('title');
if (title) avatar.title = title;
avatar.appendChild(avatarImg.cloneNode(true));
meta.appendChild(avatar);
}
const addMetaItem = (text, title) => {
const value = (text || '').trim();
if (!value) return;
const span = document.createElement('span');
span.className = 'linuxdo-topic-meta-item';
span.textContent = value;
if (title) span.title = title;
meta.appendChild(span);
};
// b: 回复数(posts-map 列的第一个 a)
const repliesEl = row.querySelector('td.num.posts-map a.badge-posts .number') || row.querySelector('td.num.posts-map .number');
addMetaItem(repliesEl ? repliesEl.textContent : '', '回复');
// c: 浏览量
const viewsEl = row.querySelector('td.num.views .number') || row.querySelector('td.views .number');
addMetaItem(viewsEl ? viewsEl.textContent : '', '浏览');
// 活动时间(relative-date)
const activityEl = row.querySelector('td.activity .relative-date') || row.querySelector('td.age .relative-date');
addMetaItem(activityEl ? activityEl.textContent : '', '活动');
if (!meta.firstChild) return;
// 插入到主列底部(category/tags 下方)
const bottomLine = mainLinkCell.querySelector('.link-bottom-line');
if (bottomLine && bottomLine.parentNode) {
bottomLine.parentNode.insertBefore(meta, bottomLine.nextSibling);
} else {
mainLinkCell.appendChild(meta);
}
row.setAttribute('data-linuxdo-two-column-enhanced', '1');
});
});
}
// #endregion
// #region 设置界面
// ==========================================================
/**
* @description 创建设置界面的 HTML 结构。
* @returns {HTMLDialogElement} 返回创建的 dialog 元素。
*/
function createSettingsModal() {
const dialog = document.createElement('dialog');
dialog.className = 'linuxdo-settings-dialog';
const topicListColumns = getTopicListColumns();
const topicListColumnsInputValue = topicListColumns > 0 ? String(topicListColumns) : '';
dialog.innerHTML = `
<div class="linuxdo-settings-content">
<div class="linuxdo-settings-header">
<div>
<h2 class="linuxdo-settings-title">设置</h2>
<p class="linuxdo-settings-subtitle">Linux do 分享助手</p>
</div>
<button class="linuxdo-settings-close" type="button" aria-label="关闭">×</button>
</div>
<form class="linuxdo-settings-form" method="dialog">
<section class="linuxdo-settings-section">
<div class="linuxdo-settings-section-header">
<h3 class="linuxdo-settings-section-title">复制内容</h3>
<p class="linuxdo-settings-section-desc">控制复制到剪贴板的格式与密度</p>
</div>
<div class="linuxdo-settings-card">
<div class="linuxdo-settings-item">
<div class="linuxdo-settings-item-text">
<label class="linuxdo-settings-item-label" for="compactMode">简洁模式</label>
<div class="linuxdo-settings-description">开启后:标题与摘要合并为一段;隐藏作者/板块/标签;链接单独一行且无空行</div>
</div>
<label class="linuxdo-settings-switch">
<input type="checkbox" id="compactMode" ${CONFIG.COMPACT_MODE ? 'checked' : ''}>
<span class="linuxdo-settings-switch-slider"></span>
</label>
</div>
</div>
</section>
<section class="linuxdo-settings-section">
<div class="linuxdo-settings-section-header">
<h3 class="linuxdo-settings-section-title">列表布局</h3>
<p class="linuxdo-settings-section-desc">仅影响主题列表页(最新 / 分类 / 标签等)</p>
</div>
<div class="linuxdo-settings-card">
<div class="linuxdo-settings-item">
<div class="linuxdo-settings-item-text">
<label class="linuxdo-settings-item-label" for="twoColumnLayout">两栏布局</label>
<div class="linuxdo-settings-description">填写分栏数(最大 5,默认 2);填 0 或留空可关闭。开启后列表以卡片展示,并将发帖人/回复/浏览/活动信息收纳到标题下方</div>
</div>
<div class="linuxdo-settings-control">
<input type="number" id="twoColumnLayout" class="linuxdo-settings-input small" value="${topicListColumnsInputValue}" placeholder="2" min="0" max="5" step="1" inputmode="numeric">
<span class="linuxdo-settings-unit">列</span>
</div>
</div>
<div class="linuxdo-settings-separator"></div>
<div class="linuxdo-settings-item" id="topicListWaterfallRow" style="${topicListColumns > 0 ? '' : 'display:none;'}">
<div class="linuxdo-settings-item-text">
<label class="linuxdo-settings-item-label" for="topicListWaterfall">瀑布流</label>
<div class="linuxdo-settings-description">仅在分栏数大于 0 时显示/生效;开启后以瀑布流方式排列卡片</div>
</div>
<label class="linuxdo-settings-switch">
<input type="checkbox" id="topicListWaterfall" ${CONFIG.TOPIC_LIST_WATERFALL ? 'checked' : ''}>
<span class="linuxdo-settings-switch-slider"></span>
</label>
</div>
</div>
</section>
<section class="linuxdo-settings-section">
<div class="linuxdo-settings-section-header">
<h3 class="linuxdo-settings-section-title">AI 总结</h3>
<p class="linuxdo-settings-section-desc">可选:使用 OpenAI Compatible 接口</p>
</div>
<div class="linuxdo-settings-card">
<div class="linuxdo-settings-item">
<div class="linuxdo-settings-item-text">
<label class="linuxdo-settings-item-label" for="useAiForSummary">启用 AI 总结</label>
<div class="linuxdo-settings-description">开启后将使用 AI 对文章内容进行智能总结</div>
</div>
<label class="linuxdo-settings-switch">
<input type="checkbox" id="useAiForSummary" ${CONFIG.USE_AI_FOR_SUMMARY ? 'checked' : ''}>
<span class="linuxdo-settings-switch-slider"></span>
</label>
</div>
<div class="linuxdo-settings-separator"></div>
<div class="linuxdo-settings-grid">
<div class="linuxdo-settings-field">
<label class="linuxdo-settings-label" for="apiKey">API Key</label>
<input type="password" id="apiKey" class="linuxdo-settings-input" value="${escapeHtml(CONFIG.API_KEY)}" placeholder="输入 API Key" autocomplete="off">
</div>
<div class="linuxdo-settings-field">
<label class="linuxdo-settings-label" for="modelName">模型</label>
<input type="text" id="modelName" class="linuxdo-settings-input" value="${escapeHtml(CONFIG.MODEL_NAME)}" placeholder="gpt-4o-mini">
</div>
</div>
<div class="linuxdo-settings-field">
<label class="linuxdo-settings-label" for="apiBaseUrl">API 地址</label>
<input type="text" id="apiBaseUrl" class="linuxdo-settings-input" value="${escapeHtml(CONFIG.API_BASE_URL)}" placeholder="https://api.openai.com/v1/chat/completions">
<div class="linuxdo-settings-description">使用 OpenAI 兼容格式的完整 API 地址</div>
</div>
</div>
</section>
<section class="linuxdo-settings-section">
<div class="linuxdo-settings-section-header">
<h3 class="linuxdo-settings-section-title">总结设置</h3>
<p class="linuxdo-settings-section-desc">控制粘贴板正文长度与 Prompt</p>
</div>
<div class="linuxdo-settings-card">
<div class="linuxdo-settings-grid">
<div class="linuxdo-settings-field">
<label class="linuxdo-settings-label" for="localSummaryMaxChars">最大字符数</label>
<input type="number" id="localSummaryMaxChars" class="linuxdo-settings-input" value="${CONFIG.LOCAL_SUMMARY_MAX_CHARS}" placeholder="90" min="1" max="10000">
<div class="linuxdo-settings-description">范围:1 - 10000</div>
</div>
</div>
<div class="linuxdo-settings-field">
<label class="linuxdo-settings-label" for="customPrompt">自定义 Prompt</label>
<textarea id="customPrompt" class="linuxdo-settings-textarea" placeholder="输入自定义的总结提示词">${escapeHtml(CONFIG.CUSTOM_SUMMARY_PROMPT)}</textarea>
<div class="linuxdo-settings-description">使用 {maxChars} 表示最大字符数,{content} 表示文章内容</div>
</div>
</div>
</section>
</form>
<div class="linuxdo-settings-footer">
<button type="button" class="linuxdo-settings-button" id="cancelSettings">取消</button>
<button type="button" class="linuxdo-settings-button primary" id="saveSettings">保存</button>
</div>
</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 twoColumnLayoutInput = dialog.querySelector('#twoColumnLayout');
const topicListWaterfallRow = dialog.querySelector('#topicListWaterfallRow');
const topicListWaterfallInput = dialog.querySelector('#topicListWaterfall');
const updateTopicListWaterfallVisibility = () => {
if (!twoColumnLayoutInput || !topicListWaterfallRow) return;
const parsed = Number.parseInt(String(twoColumnLayoutInput.value || '').trim(), 10);
const enabled = Number.isFinite(parsed) && parsed > 0;
topicListWaterfallRow.style.display = enabled ? '' : 'none';
if (!enabled && topicListWaterfallInput) {
topicListWaterfallInput.checked = false;
}
};
if (twoColumnLayoutInput && topicListWaterfallRow) {
twoColumnLayoutInput.addEventListener('input', updateTopicListWaterfallVisibility);
twoColumnLayoutInput.addEventListener('change', updateTopicListWaterfallVisibility);
updateTopicListWaterfallVisibility();
}
const closeDialog = () => {
if (typeof dialog.close === 'function') {
dialog.setAttribute('closing', '');
setTimeout(() => {
dialog.close();
dialog.remove();
}, SCRIPT_CONSTANTS.DIALOG_CLOSE_ANIMATION_MS);
} 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 compactMode = dialog.querySelector('#compactMode').checked;
const twoColumnLayoutRaw = dialog.querySelector('#twoColumnLayout').value;
let twoColumnLayout = Number.parseInt(String(twoColumnLayoutRaw || '').trim(), 10);
if (!Number.isFinite(twoColumnLayout) || twoColumnLayout <= 0) twoColumnLayout = 0;
if (twoColumnLayout > 5) twoColumnLayout = 5;
let topicListWaterfall = false;
if (topicListWaterfallInput) topicListWaterfall = !!topicListWaterfallInput.checked;
if (twoColumnLayout <= 0) topicListWaterfall = false;
const useAiForSummary = dialog.querySelector('#useAiForSummary').checked;
const apiKey = dialog.querySelector('#apiKey').value.trim();
const apiBaseUrl = dialog.querySelector('#apiBaseUrl').value.trim();
const localSummaryMaxChars = parseInt(dialog.querySelector('#localSummaryMaxChars').value.trim()) || DEFAULT_CONFIG.LOCAL_SUMMARY_MAX_CHARS;
const customPrompt = dialog.querySelector('#customPrompt').value.trim();
const modelName = dialog.querySelector('#modelName').value.trim();
setConfig('COMPACT_MODE', compactMode);
setConfig('TWO_COLUMN_LAYOUT', twoColumnLayout);
setConfig('TOPIC_LIST_WATERFALL', topicListWaterfall);
setConfig('USE_AI_FOR_SUMMARY', useAiForSummary);
setConfig('API_KEY', apiKey);
setConfig('API_BASE_URL', apiBaseUrl || DEFAULT_CONFIG.API_BASE_URL);
setConfig('MODEL_NAME', modelName || DEFAULT_CONFIG.MODEL_NAME);
setConfig('LOCAL_SUMMARY_MAX_CHARS', localSummaryMaxChars);
setConfig('CUSTOM_SUMMARY_PROMPT', customPrompt || DEFAULT_CONFIG.CUSTOM_SUMMARY_PROMPT);
applyTwoColumnLayoutToTopicLists();
const originalText = saveBtn.textContent;
saveBtn.textContent = '已保存';
saveBtn.disabled = true;
setTimeout(() => {
closeDialog();
}, SCRIPT_CONSTANTS.SETTINGS_CLOSE_DELAY_MS);
});
}
/**
* @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("油猴脚本已尝试初始化。");
// 主题列表布局(两栏)
applyTwoColumnLayoutToTopicLists();
// 顶部搜索框:历史复制内容
ensureCopyHistoryTrigger();
// 话题页:回到顶部按钮
ensureBackToTopButton();
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);
} else if (document.querySelector('h1[data-topic-id]')) {
console.log('部分所需元素未找到,等待DOM更新:', {
title: !!titleLinkElement,
content: !!articleRootElement,
userData: !!userDataContainer,
category: !!categoryBadge,
tags: !!tagsElement
});
}
}
// 脚本执行入口
if (window === window.top) {
// 添加复制按钮函数增加节流
let throttledInitialize = throttleInit(initializeScript, SCRIPT_CONSTANTS.INIT_THROTTLE_MS);
// 注入样式
addStyle(copyBtnStyle);
// 注册油猴菜单命令
GM_registerMenuCommand('设置', showSettingsModal);
// 使用 MutationObserver 监听 DOM 变化,以适应动态加载内容的单页应用 (SPA)
const observer = new MutationObserver((mutationsList, observerInstance) => {
throttledInitialize();
});
observer.observe(document.body, { childList: true, subtree: true });
// 初始加载时也尝试运行一次
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', throttledInitialize);
} else {
throttledInitialize();
}
} else {
// console.log("在 iframe 中,跳过脚本功能初始化");
}
// #endregion
})();