您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
(1) 使用 AI 为知乎收藏夹一键生成描述。(2) 使用 AI 整理与重分类收藏夹。(3) [todo]替换知乎收藏按钮,直接用AI辅助分类
// ==UserScript== // @name 知乎收藏夹 Pro // @license MIT // @namespace http://tampermonkey.net/ // @version 0.4.3 // @description (1) 使用 AI 为知乎收藏夹一键生成描述。(2) 使用 AI 整理与重分类收藏夹。(3) [todo]替换知乎收藏按钮,直接用AI辅助分类 // @author https://github.com/ienone // @match https://www.zhihu.com/collection/* // @match https://www.zhihu.com/collections/mine* // @match https://www.zhihu.com/people/*/collections* // @icon https://static.zhihu.com/heifetz/favicon.ico // @connect api.deepseek.com // @connect zhuanlan.zhihu.com // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_getResourceText // @resource CHART_JS https://cdn.jsdelivr.net/npm/chart.js // ==/UserScript== /* jshint esversion: 11 */ (function() { 'use strict'; const ZHIHU_BLUE = '#056DE8'; let moveHistory = []; // 用于存储所有成功的移动操作 let progressDashboardState = {}; // 存储仪表盘的所有状态 let chartInstances = {}; // 存储Chart.js实例 /** * 生成一系列颜色用于图表 */ function generateColors(count) { const colors = []; const baseHue = 200; // 知乎蓝的色相 for (let i = 0; i < count; i++) { // 使用黄金分割角来生成视觉上分散的颜色 const hue = (baseHue + (i * 137.508)) % 360; colors.push(`hsl(${hue}, 70%, 60%)`); } return colors; } // --- 1. 自定义 CSS --- GM_addStyle(` /* --- Shimmer 加载动画效果 --- */ .zcp-shimmer { position: relative; overflow: hidden; } .zcp-shimmer::after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(100deg, rgba(255,255,255,0) 20%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0) 80%); transform: translateX(-100%); animation: zcp-shimmer-animation 1.5s infinite; } @keyframes zcp-shimmer-animation { 100% { transform: translateX(100%); } } /* --- AI 功能按钮 --- */ button#zcp-ai-btn.zcp-ai-button { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; height: 36px; background-color: ${ZHIHU_BLUE} !important; color: white !important; border: none !important; border-radius: 12px; cursor: pointer; padding: 0 16px; margin-left: 12px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.08) !important; transition: transform 0.2s ease, box-shadow 0.2s ease !important; } button#zcp-ai-btn.zcp-ai-button:hover { transform: translateY(-2px) !important; box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12), 0 3px 6px rgba(0, 0, 0, 0.1) !important; } button#zcp-ai-btn.zcp-ai-button:active { transform: translateY(1px) !important; box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.2) !important; } button#zcp-ai-btn.zcp-ai-button:disabled { background-color: #A0A0A0 !important; cursor: not-allowed !important; transform: none !important; box-shadow: none !important; opacity: 0.8; } button#zcp-ai-btn.zcp-ai-button .zcp-ai-icon { width: 20px; height: 20px; margin-right: 6px; fill: currentColor; } .zcp-spinner { width: 22px; height: 22px; border: 3px solid rgba(255, 255, 255, 0.3); border-radius: 50%; border-top-color: #ffffff; animation: zcp-spin 1s ease-in-out infinite; } @keyframes zcp-spin { to { transform: rotate(360deg); } } /* --- 模态框样式 --- */ .zcp-modal-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.1); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); display: flex; justify-content: center; align-items: center; z-index: 9999; } .zcp-modal-container { background-color: #ffffff; padding: 28px; border-radius: 24px; box-shadow: 0 12px 40px rgba(0,0,0,0.15); width: 90%; max-width: 520px; } .zcp-modal-header { font-size: 22px; color: #1a1a1a; font-weight: 600; margin-bottom: 24px; text-align: center; } /* --- [修改] 供用户修改的文本框样式 --- */ .zcp-modal-content textarea { width: 100%; min-height: 120px; border-radius: 12px; border: 1px solid #EAEAEA; padding: 14px; font-size: 16px; resize: vertical; box-sizing: border-box; background-color: #FDFDFD; color: #333; /* [修改] 为常态文本框添加入下沉效果 */ box-shadow: inset 0 2px 4px rgba(0,0,0,0.06); transition: box-shadow 0.2s, border-color 0.2s, background-color 0.2s; scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE and Edge */ } .zcp-modal-content textarea::-webkit-scrollbar { display: none; } /* [修改] 输入时下沉效果加深 */ .zcp-modal-content textarea:focus { outline: none; border-color: #D0D0D0; background-color: #fff; /* 下沉效果更明显 */ box-shadow: inset 0 3px 6px rgba(0,0,0,0.08); } .zcp-modal-actions { display: flex; justify-content: flex-end; margin-top: 24px; gap: 12px; } /* --- 模态框按钮 --- */ .zcp-modal-button { padding: 10px 24px; border-radius: 10px; border: none; cursor: pointer; font-size: 14px; font-weight: 500; transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s; } /* [修改] 统一并强化所有按钮的按下(active)效果 */ .zcp-modal-button:active { transform: translateY(1px); /* 轻微下移 */ } .zcp-modal-button.primary { background-color: ${ZHIHU_BLUE}; color: white; box-shadow: 0 2px 5px rgba(5, 109, 232, 0.3); } .zcp-modal-button.primary:hover { opacity: 0.9; box-shadow: 0 4px 8px rgba(5, 109, 232, 0.35); } .zcp-modal-button.primary:active { /* 使用内阴影来创建清晰的“按下”感 */ box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.2); } .zcp-modal-button.secondary { background-color: #f0f2f5; color: #333; border: 1px solid #EAEAEA; } .zcp-modal-button.secondary:hover { border-color: #DDD; background-color: #E9E9E9; } .zcp-modal-button.secondary:active { background-color: #E2E2E2; /* 使用内阴影来创建清晰的“按下”感 */ box-shadow: inset 0 2px 4px rgba(0,0,0,0.08); } /* --- 功能二:整理 UI --- */ /* 整理入口按钮 */ button#zcp-organize-btn { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; height: 36px; background-color: ${ZHIHU_BLUE} !important; color: white !important; border: none !important; border-radius: 12px; cursor: pointer; padding: 0 16px; margin-right: 20px; /* 与右侧按钮拉开距离 */ margin-left: auto; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.08) !important; transition: transform 0.2s ease, box-shadow 0.2s ease !important; } button#zcp-organize-btn:hover { transform: translateY(-2px) !important; box-shadow: 0 5px 10px rgba(0, 0, 0, 0.12), 0 3px 6px rgba(0, 0, 0, 0.1) !important; } button#zcp-organize-btn:active { transform: translateY(1px) !important; box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.2) !important; } button#zcp-organize-btn .zcp-ai-icon { width: 18px; height: 18px; fill: currentColor; margin-right: 6px; } button#zcp-organize-btn:disabled { background-color: #A0A0A0 !important; cursor: not-allowed !important; transform: none !important; box-shadow: none !important; opacity: 0.8; } /* 整理模态框 - 设置界面 */ .zcp-organize-settings .zcp-fieldset { margin-bottom: 20px; border: 1px solid #e9e9e9; padding: 12px 16px; border-radius: 12px; background: #fcfcfc; } .zcp-organize-settings legend { font-weight: 600; padding: 0 8px; color: #333; } .zcp-collection-list { max-height: 150px; overflow-y: auto; padding: 5px; } .zcp-collection-list label { display: block; margin-bottom: 8px; cursor: pointer; padding: 4px 8px; border-radius: 6px; transition: background-color 0.2s; } .zcp-collection-list label:hover { background-color: #f0f2f5; } .zcp-collection-list input { margin-right: 10px; } /* --- 统一自定义复选框样式 --- */ .zcp-custom-checkbox { display: inline-flex; align-items: center; cursor: pointer; gap: 8px; padding: 4px; /* 增加点击区域 */ border-radius: 6px; transition: background-color 0.2s; } .zcp-custom-checkbox:hover { background-color: #f0f2f5; } .zcp-custom-checkbox input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; } .zcp-checkbox-visual { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 16px; /* 统一尺寸 */ height: 16px; border: 2px solid #ccc; /* 默认边框 */ border-radius: 5px; background-color: transparent; transition: all 0.2s ease-out; } .zcp-custom-checkbox:hover .zcp-checkbox-visual { border-color: #999; } /* 选中时,用伪元素创建内部小圆角正方形 */ .zcp-checkbox-visual::after { content: ''; display: block; width: 12px; height: 12px; background-color: ${ZHIHU_BLUE}; border-radius: 2px; /* 小圆角正方形 */ transform: scale(0); transition: transform 0.2s ease-in-out; } .zcp-custom-checkbox input[type="checkbox"]:checked + .zcp-checkbox-visual::after { transform: scale(1); } /* 针对收藏夹列表调整间距和大小 */ .zcp-collection-list .zcp-custom-checkbox { width: 100%; gap: 12px; } .zcp-options-grid { display: flex; justify-content: space-between; align-items: center; gap: 16px; } .zcp-options-grid input[type="number"] { border-radius: 8px; border: 1px solid #EAEAEA; padding: 6px 8px; font-size: 14px; box-sizing: border-box; background-color: #FDFDFD; box-shadow: inset 0 2px 4px rgba(0,0,0,0.06); transition: box-shadow 0.2s, border-color 0.2s; text-align: center; -moz-appearance: textfield; /* Firefox */ } .zcp-options-grid input[type="number"]::-webkit-outer-spin-button, .zcp-options-grid input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .zcp-options-grid input[type="number"]:focus { outline: none; border-color: #D0D0D0; box-shadow: inset 0 3px 6px rgba(0,0,0,0.08); } /* 模态框尺寸过渡动画 */ .zcp-modal-container { transition: max-width 0.5s ease-in-out, max-height 0.5s ease-in-out; } .zcp-modal-container.dashboard-mode { max-width: 1200px; width: 95%; } /* 整理模态框 - 仪表盘(Dashboard)总布局 */ .zcp-dashboard-container { display: flex; gap: 24px; height: 60vh; /* 建议高度 */ min-height: 500px; } .zcp-dashboard-left { width: 35%; display: flex; flex-direction: column; gap: 15px; } .zcp-dashboard-right { width: 65%; display: flex; flex-direction: column; } /* 图表容器样式 */ .zcp-chart-container { background: #f9f9f9; border: 1px solid #eee; border-radius: 12px; padding: 15px; flex-grow: 1; display: flex; flex-direction: column; } .zcp-chart-container h3 { margin: 0 0 10px 5px; font-size: 14px; font-weight: 600; color: #333; } .zcp-chart-wrapper { position: relative; flex-grow: 1; } .zcp-chart-container canvas { cursor: pointer; } /* 日志区域新样式 */ .zcp-progress-log { height: 100%; /* 占满右侧所有可用空间 */ overflow-y: auto; background-color: #fdfdfd; border: 1px solid #eee; border-radius: 12px; padding: 10px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 13px; line-height: 1.6; color: #444; } /* 日志条目新样式 */ .zcp-log-item { padding: 8px 12px; border-bottom: 1px solid #f0f0f0; display: flex; flex-direction: column; gap: 4px; } .zcp-log-item:last-child { border-bottom: none; } .zcp-log-title { font-weight: 600; color: #1a1a1a; } .zcp-log-title a { color: inherit; text-decoration: none; transition: color 0.2s; } .zcp-log-title a:hover { color: ${ZHIHU_BLUE}; text-decoration: underline; } .zcp-log-path { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #666; } .zcp-log-path .zcp-log-collection { background: #f0f2f5; padding: 2px 6px; border-radius: 4px; } .zcp-log-path .zcp-log-arrow { color: #999; } .zcp-log-path .zcp-log-status-text { font-style: italic; } .zcp-log-item.status-error .zcp-log-path .zcp-log-status-text { color: #dc3545; font-weight: bold; } /* 状态颜色应用 */ .zcp-log-item.status-success .zcp-log-path .zcp-log-collection.target { background-color: #e6ffed; border: 1px solid #b7eb8f; } .zcp-log-item.status-skipped .zcp-log-path { color: #087a91; } .zcp-log-item.status-dryrun .zcp-log-path { color: #6c757d; } .zcp-undo-btn, .zcp-redo-btn { background: #e9e9e9; border: 1px solid #ddd; color: #555; padding: 2px 8px; font-size: 11px; border-radius: 5px; cursor: pointer; transition: all 0.2s; margin-left: auto; /* 推到最右边 */ } .zcp-undo-btn:hover { background: #dcdcdc; border-color: #ccc; } .zcp-redo-btn { background: #e6f7ff; border: 1px solid #91d5ff; color: #096dd9; } .zcp-redo-btn:hover { background: #bae7ff; border-color: #69c0ff; } .zcp-undo-btn:disabled, .zcp-redo-btn:disabled { background: #f5f5f5; color: #aaa; cursor: not-allowed; border-color: #eee; } /* 底部按钮区域样式 */ .zcp-modal-actions.dashboard-mode { justify-content: space-between; align-items: center; } .zcp-modal-actions .zcp-progress-stats { font-size: 13px; color: #666; } `); // --- 2. 核心 API 调用与工具函数 --- /** * 获取知乎 API 请求所需的 headers */ function getZhihuApiHeaders() { const xsrfToken = document.cookie.split('; ').find(row => row.startsWith('_xsrf='))?.split('=')[1]; if (!xsrfToken) { throw new Error('无法找到 _xsrf token,请确保您已登录知乎。'); } return { 'Content-Type': 'application/json', 'x-xsrftoken': xsrfToken, }; } /** * 调用 DeepSeek API */ async function callDeepSeek(prompt) { const apiKey = await GM_getValue('deepseek_api_key', ''); if (!apiKey) { alert('请先在油猴脚本菜单中设置 DeepSeek API Key!'); throw new Error('DeepSeek API Key 未设置'); } return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://api.deepseek.com/chat/completions', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, }, data: JSON.stringify({ model: 'deepseek-chat', messages: [ { "role": "system", "content": "你是一位专业的知识库管理员和内容分析专家。" }, { "role": "user", "content": prompt } ], temperature: 0.7, }), onload: function(response) { if (response.status >= 200 && response.status < 300) { const result = JSON.parse(response.responseText); resolve(result.choices[0].message.content.trim()); } else { console.error('DeepSeek API Error:', response); reject(`API 请求失败: ${response.status}`); } }, onerror: function(error) { console.error('Network Error calling DeepSeek:', error); reject('网络错误,无法连接到 DeepSeek API'); } }); }); } // --- 功能二:API 封装与工具函数 --- /** * 从页面 URL 中获取当前用户的 ID */ function getUserId() { const match = window.location.pathname.match(/\/people\/([^/]+)/); if (!match) throw new Error("无法在URL中找到用户ID"); return match[1]; } /** * "API"-1: 获取用户所有收藏夹 (已修改为从页面DOM抓取,不再使用API) */ async function fetchAllUserCollections() { console.log('[知乎收藏夹 Pro] 正在从当前页面抓取收藏夹列表...'); const itemElements = document.querySelectorAll('.SelfCollectionItem-innerContainer'); if (itemElements.length === 0) { throw new Error('在当前页面上没有找到任何收藏夹。请确保您在 "我的收藏" (collections/mine) 页面,且收藏夹列表已完全加载。'); } const allCollections = Array.from(itemElements).map(item => { const titleElement = item.querySelector('a.SelfCollectionItem-title'); const descriptionElement = item.querySelector('.SelfCollectionItem-description'); if (!titleElement || !titleElement.href) { console.warn('[知乎收藏夹 Pro] 跳过一个无效的收藏夹元素 (缺少标题链接)。'); return null; // 跳过缺少标题链接的元素 } // 提取标题:通过获取第一个文本节点来提取 // 例如,从 `<a>文化<span>...</span></a>` 中正确提取 "文化" const title = (titleElement.childNodes[0] && titleElement.childNodes[0].nodeType === Node.TEXT_NODE) ? titleElement.childNodes[0].nodeValue.trim() : titleElement.textContent.trim(); // 从链接中提取 URL 和收藏夹 ID const url = titleElement.href; const idMatch = url.match(/\/collection\/(\d+)/); const id = idMatch ? idMatch[1] : null; // 提取描述,如果存在的话 const description = descriptionElement ? descriptionElement.textContent.trim() : ''; if (id && title) { return { id, title, description }; } console.warn(`[知乎收藏夹 Pro] 解析一个收藏夹时失败,标题: "${title}", URL: "${url}"`); return null; }).filter(Boolean); // 过滤掉所有解析失败的 null 条目 if (allCollections.length === 0) { throw new Error('成功找到收藏夹的HTML元素,但未能解析出任何有效的收藏夹信息。页面结构可能已更新。'); } console.log(`[知乎收藏夹 Pro] 成功从页面抓取 ${allCollections.length} 个收藏夹。`); return allCollections; } /** * API-2: 获取单个收藏夹的所有内容 */ async function fetchCollectionItems(collectionId) { let allItems = []; let nextUrl = `/api/v4/collections/${collectionId}/items?limit=20&offset=0`; while (nextUrl) { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: nextUrl, headers: getZhihuApiHeaders(), onload: res => { if (res.status === 200) resolve(JSON.parse(res.responseText)); else reject(new Error(`获取收藏夹内容失败: ${res.status}`)); }, onerror: err => reject(new Error('网络错误')) }); }); allItems = allItems.concat(response.data); nextUrl = response.paging.is_end ? null : response.paging.next; } return allItems; } /** * API-3: 抓取文章/回答正文 */ async function scrapeContent(url) { const html = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, onload: res => { if (res.status === 200) resolve(res.responseText); else reject(new Error(`抓取内容失败: ${res.status} for ${url}`)); }, onerror: err => reject(new Error('网络错误')) }); }); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const contentElement = doc.querySelector('.RichText.ztext'); return contentElement ? contentElement.innerText.trim() : '正文抓取失败'; } /** * API-4: 添加内容到收藏夹 */ async function addToCollection(contentId, contentType, targetCollectionId) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `/api/v4/collections/${targetCollectionId}/contents?content_id=${contentId}&content_type=${contentType}`, headers: getZhihuApiHeaders(), data: '{}', onload: res => { if (res.status === 200 && JSON.parse(res.responseText).success) resolve(true); else reject(new Error(`添加失败: ${res.responseText}`)); }, onerror: err => reject(new Error('网络错误')) }); }); } /** * API-5: 从收藏夹移除内容 */ async function removeFromCollection(contentId, contentType, sourceCollectionId) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'DELETE', url: `/api/v4/collections/${sourceCollectionId}/contents/${contentId}?content_type=${contentType}`, headers: getZhihuApiHeaders(), onload: res => { if (res.status === 200 && JSON.parse(res.responseText).success) resolve(true); else reject(new Error(`移除失败: ${res.responseText}`)); }, onerror: err => reject(new Error('网络错误')) }); }); } /** * 为整理功能构建 AI Prompt */ function buildOrganizationPrompt(articleContent, articleTitle, targetCollections) { const collectionInfo = targetCollections .map(c => `- ${c.title}: ${c.description || '无描述'}`) .join('\n'); return `你是一位图书管理员,任务是将一篇文章精准地分类到一个最合适的收藏夹中。 这是待分类的文章: 标题:${articleTitle} 正文摘要(前500字):${articleContent.substring(0, 500)}... 这是你的可用收藏夹列表和它们的简介: ${collectionInfo} 请分析文章内容,并从上面的列表中,选择一个最匹配的收藏夹。 你的回答必须只包含你选择的收藏夹的 **完整标题**,不要添加任何解释、引号或其他文字。 例如,如果最匹配的是“技术视野”,你就只回答“技术视野”。 你选择的收藏夹标题是:`; } // --- 功能二:UI 渲染与流程控制 --- /** * 注入“整理”按钮 */ function injectOrganizeButton(container) { const organizeButton = document.createElement('button'); organizeButton.id = 'zcp-organize-btn'; organizeButton.className = 'zcp-ai-button'; const svgIcon = `<svg class="zcp-ai-icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M399.825455 247.901091a23.272727 23.272727 0 0 1 43.659636 0L493.381818 382.743273a23.272727 23.272727 0 0 0 13.730909 13.730909l134.842182 49.896727a23.272727 23.272727 0 0 1 0 43.659636L507.112727 539.927273a23.272727 23.272727 0 0 0-13.730909 13.730909l-49.896727 134.842182a23.272727 23.272727 0 0 1-43.659636 0l-49.896728-134.842182a23.272727 23.272727 0 0 0-13.730909-13.730909l-134.842182-49.896728a23.272727 23.272727 0 0 1 0-43.659636l134.842182-49.896727a23.272727 23.272727 0 0 0 13.730909-13.730909L399.825455 247.901091zM738.769455 584.890182a9.309091 9.309091 0 0 1 17.454545 0l27.461818 74.333091a9.309091 9.309091 0 0 0 5.538909 5.492363l74.286546 27.461819a9.309091 9.309091 0 0 1 0 17.50109l-74.286546 27.461819a9.309091 9.309091 0 0 0-5.492363 5.538909l-27.508364 74.286545a9.309091 9.309091 0 0 1-17.454545 0l-27.508364-74.286545a9.309091 9.309091 0 0 0-5.492364-5.492364l-74.333091-27.508364a9.309091 9.309091 0 0 1 0-17.454545l74.333091-27.508364a9.309091 9.309091 0 0 0 5.492364-5.492363l27.461818-74.333091z"></path></svg>`; organizeButton.innerHTML = `${svgIcon}<span>整理</span>`; container.appendChild(organizeButton); organizeButton.addEventListener('click', handleOrganizeClick); // 定位到“新建收藏夹”按钮 const newCollectionButton = container.querySelector('.CollectionsHeader-addFavlistButton, .css-10dextj'); // 兼容新旧class if (newCollectionButton) { // 将按钮插入到“新建收藏夹”按钮之前 container.insertBefore(organizeButton, newCollectionButton); } else { // 如果找不到,作为备选方案,还是添加到容器末尾 container.appendChild(organizeButton); } organizeButton.addEventListener('click', handleOrganizeClick); } /** * 点击“整理”按钮后的处理 */ async function handleOrganizeClick(event) { const button = event.currentTarget; button.disabled = true; button.querySelector('span').textContent = '加载中...'; try { const collections = await fetchAllUserCollections(); showOrganizeSettingsModal(collections); } catch (error) { alert(`加载收藏夹列表失败: ${error.message}`); } finally { button.disabled = false; button.querySelector('span').textContent = '整理'; } } /** * 显示整理的设置模态框 */ function showOrganizeSettingsModal(collections) { const overlay = document.createElement('div'); overlay.className = 'zcp-modal-overlay'; overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); }); const createCollectionCheckbox = (c, name) => ` <label class="zcp-custom-checkbox" title="${c.description || '无描述'}"> <input type="checkbox" name="${name}" value="${c.id}"> <span class="zcp-checkbox-visual"></span> <span>${c.title}</span> </label>`; const collectionOptions = collections.map(c => createCollectionCheckbox(c, 'source-collection')).join(''); const targetCollectionOptions = collections.map(c => createCollectionCheckbox(c, 'target-collection')).join(''); overlay.innerHTML = ` <div class="zcp-modal-container"> <div class="zcp-modal-header">整理收藏夹</div> <div class="zcp-modal-content zcp-organize-settings"> <fieldset class="zcp-fieldset"> <legend>1. 选择源收藏夹 (待整理)</legend> <div class="zcp-collection-list" id="zcp-source-list">${collectionOptions}</div> </fieldset> <fieldset class="zcp-fieldset"> <legend>2. 选择目标收藏夹 (分类目的地)</legend> <div class="zcp-collection-list" id="zcp-target-list">${targetCollectionOptions}</div> </fieldset> <fieldset class="zcp-fieldset"> <legend>3. 设置</legend> <div class="zcp-options-grid"> <label class="zcp-custom-checkbox"> <input type="checkbox" id="zcp-dry-run" checked> <span class="zcp-checkbox-visual"></span> <span>试运行 (Dry Run)</span> </label> <label style="display: flex; align-items: center; gap: 8px;"> <span>并发数:</span> <input type="number" id="zcp-concurrency" value="3" min="1" max="5" style="width: 50px;"> </label> </div> </fieldset> </div> <div class="zcp-modal-actions"> <button id="zcp-cancel-btn" class="zcp-modal-button secondary">取消</button> <button id="zcp-start-btn" class="zcp-modal-button primary">开始整理</button> </div> </div>`; document.body.appendChild(overlay); const closeModal = () => document.body.removeChild(overlay); overlay.querySelector('#zcp-cancel-btn').addEventListener('click', closeModal); overlay.querySelector('#zcp-start-btn').addEventListener('click', () => { startOrganizationProcess(collections, overlay); }); } /** * 开始整理流程,构建任务队列并启动 workers */ async function startOrganizationProcess(allCollectionsData, modalOverlay) { // 1. 从UI获取设置 const getCheckedValues = name => Array.from(modalOverlay.querySelectorAll(`input[name="${name}"]:checked`)).map(cb => cb.value); const sourceIds = getCheckedValues('source-collection'); const targetIds = getCheckedValues('target-collection'); const isDryRun = modalOverlay.querySelector('#zcp-dry-run').checked; const concurrency = parseInt(modalOverlay.querySelector('#zcp-concurrency').value, 10); if (sourceIds.length === 0 || targetIds.length === 0) { alert('请至少选择一个源收藏夹和一个目标收藏夹!'); return; } const startBtn = modalOverlay.querySelector('#zcp-start-btn'); const originalBtnText = startBtn.textContent; // 保存原始文本 // [修改] 禁用按钮,更新文本,并添加 Shimmer 效果 startBtn.disabled = true; startBtn.textContent = '正在构建任务...'; startBtn.classList.add('zcp-shimmer'); // 重置历史记录 moveHistory = []; // 2. 构建任务队列 const taskQueue = []; try { for (const sourceId of sourceIds) { const items = await fetchCollectionItems(sourceId); items.forEach((item, index) => { if (item.content) { taskQueue.push({ id: `task-${sourceId}-${index}`, contentId: item.content.id, contentType: item.content.type, title: item.content.question ? item.content.question.title : item.content.title, url: item.content.url.replace('http:', 'https:'), sourceCollectionId: sourceId, status: 'pending', // 初始状态 }); } }); } } catch(e) { alert(`构建任务失败: ${e.message}`); // [修改] 在出错时恢复按钮状态 startBtn.disabled = false; startBtn.textContent = originalBtnText; // 恢复原始文本 startBtn.classList.remove('zcp-shimmer'); // 移除流光效果 return; } if (taskQueue.length === 0) { alert('选中的源收藏夹中没有内容可供整理。'); startBtn.disabled = false; startBtn.textContent = '开始整理'; return; } // 3. 初始化仪表盘的全局状态对象 const sourceCollections = allCollectionsData.filter(c => sourceIds.includes(c.id)); const targetCollections = allCollectionsData.filter(c => targetIds.includes(c.id)); // 合并并去重所有涉及的收藏夹 const allInvolvedCollections = [...sourceCollections, ...targetCollections] .filter((v, i, a) => a.findIndex(t => (t.id === v.id)) === i); progressDashboardState = { stats: { moved: 0, skipped: 0, error: 0, pending: taskQueue.length }, collections: {}, // 用于存储增减统计 logs: taskQueue.map(t => ({ ...t, message: '' })), // 扩展任务队列为日志对象 // 将 filter 从字符串改为对象,以支持更复杂的筛选 filter: { type: 'all', value: null }, isDryRun: isDryRun, allCollectionsData: allCollectionsData, // 缓存所有收藏夹信息 targetCollectionsData: targetCollections // 缓存目标收藏夹信息 }; allInvolvedCollections.forEach(c => { progressDashboardState.collections[c.id] = { title: c.title, added: 0, removed: 0 }; }); // 4. 切换到仪表盘UI并初始化 await showDashboardUI(modalOverlay); // 5. 启动 Workers const workers = []; for (let i = 0; i < concurrency; i++) { // 将 taskQueue 传递给 worker workers.push(worker(taskQueue)); } await Promise.all(workers); // 6. 任务结束 const bulkActionButton = modalOverlay.querySelector('#zcp-bulk-action-btn'); if (bulkActionButton) { if (moveHistory.length > 0 && !progressDashboardState.isDryRun) { bulkActionButton.textContent = '一键撤销'; bulkActionButton.dataset.action = 'undo'; // [重要] 设置初始状态 bulkActionButton.disabled = false; // 添加点击事件处理器 bulkActionButton.addEventListener('click', () => handleBulkAction(modalOverlay)); } else { bulkActionButton.textContent = '全部完成'; bulkActionButton.disabled = true; // 保持禁用 } } } async function showDashboardUI(modalOverlay) { const modalContainer = modalOverlay.querySelector('.zcp-modal-container'); // 触发模态框放大动画 modalContainer.classList.add('dashboard-mode'); modalContainer.innerHTML = ` <div class="zcp-modal-header">整理进度</div> <div class="zcp-modal-content zcp-dashboard-container"> <div class="zcp-dashboard-left"> <div class="zcp-chart-container"> <h3>整理状态</h3> <div class="zcp-chart-wrapper"><canvas id="zcp-status-chart"></canvas></div> </div> <div class="zcp-chart-container"> <h3>收藏夹增减情况</h3> <div class="zcp-chart-wrapper"><canvas id="zcp-collections-chart"></canvas></div> </div> </div> <div class="zcp-dashboard-right"> <div class="zcp-progress-log"></div> </div> </div> <div class="zcp-modal-actions dashboard-mode"> <div class="zcp-progress-stats">0 / ${progressDashboardState.logs.length}</div> <div style="display: flex; gap: 12px;"> <button id="zcp-bulk-action-btn" class="zcp-modal-button secondary" disabled>处理中...</button> <button id="zcp-close-progress-btn" class="zcp-modal-button primary">关闭</button> </div> </div>`; modalOverlay.querySelector('#zcp-close-progress-btn').addEventListener('click', () => document.body.removeChild(modalOverlay)); // 为日志容器添加事件委托,修复撤销/重做按钮的点击事件 const logContainer = modalOverlay.querySelector('.zcp-progress-log'); if (logContainer) { logContainer.addEventListener('click', handleToggleActionClick); } // 检查 Chart 是否真的存在,以防 @require 失败 if (typeof Chart === 'undefined') { console.error("Chart.js 未能通过 @require 加载!请检查油猴脚本设置或网络。"); modalContainer.querySelector('.zcp-dashboard-left').innerHTML = '<p style="color:red;">无法加载图表库,请检查油猴脚本设置或网络。</p>'; return; } // 直接初始化图表 initializeCharts(); updateDashboardUI(); // 首次渲染 } function initializeCharts() { // 为两个图表添加了统一的、正确的点击处理逻辑 const resetFilter = () => { progressDashboardState.filter = { type: 'all', value: null }; updateDashboardUI(); }; // 状态饼图 const statusCtx = document.getElementById('zcp-status-chart').getContext('2d'); chartInstances.status = new Chart(statusCtx, { type: 'doughnut', data: { labels: ['已移动', '已跳过', '错误', '待处理'], datasets: [{ data: [0, 0, 0, 0], backgroundColor: ['#28a745', '#087a91', '#dc3545', '#cccccc'] }] }, options: { responsive: true, maintainAspectRatio: false, onClick: (e, elements) => { if (elements.length > 0) { // 点击了扇区 const label = chartInstances.status.data.labels[elements[0].index]; const filterMap = {'已移动': 'success', '已跳过': 'skipped', '错误': 'error', '待处理': 'pending', '建议移动': 'dryrun'}; const statusValue = filterMap[label]; if (statusValue) { progressDashboardState.filter = { type: 'status', value: statusValue }; updateDashboardUI(); } } else { // 点击了空白处 resetFilter(); } } } }); // 收藏夹柱状图 const collectionsCtx = document.getElementById('zcp-collections-chart').getContext('2d'); const collectionLabels = Object.values(progressDashboardState.collections).map(c => c.title); const collectionColors = generateColors(collectionLabels.length); chartInstances.collections = new Chart(collectionsCtx, { type: 'bar', data: { labels: collectionLabels, // 分别表示移入和移出 // 使用静态颜色 datasets: [ { label: '移入', data: [], backgroundColor: ZHIHU_BLUE, // 为“移入”设置固定的知乎蓝 borderColor: '#045bc7' // 设置一个匹配的、稍暗的边框色 }, { label: '移出', data: [], backgroundColor: '#ff9c38', // 为“移出”设置固定的警示橙色 borderColor: '#e08321' // 设置一个匹配的、稍暗的边框色 } ] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'y', // 设置坐标轴为堆叠模式,使正负条形图对齐 scales: { x: { stacked: true, grid: { color: '#f0f0f0' } }, y: { stacked: true, grid: { display: false } } }, plugins: { // 重新显示图例,并自定义工具提示 legend: { display: true, position: 'top', }, tooltip: { callbacks: { label: function(context) { // 为负值(移出)取绝对值显示 return ` ${context.dataset.label}: ${Math.abs(context.raw)}`; } } } }, onClick: (e, elements) => { // 移除试运行禁用,允许在任何模式下筛选 if (elements.length > 0) { const collectionTitle = chartInstances.collections.data.labels[elements[0].index]; const collectionId = Object.keys(progressDashboardState.collections).find( id => progressDashboardState.collections[id].title === collectionTitle ); if (collectionId) { progressDashboardState.filter = { type: 'collection', value: collectionId }; updateDashboardUI(); } } else { resetFilter(); } } } }); } function updateDashboardUI() { if (typeof Chart === 'undefined') return; const state = progressDashboardState; // 更新状态图 const statusData = chartInstances.status.data.datasets[0].data; statusData[0] = state.stats.moved; statusData[1] = state.stats.skipped; statusData[2] = state.stats.error; statusData[3] = state.stats.pending; chartInstances.status.update(); // 更新收藏夹图 const collectionsChart = chartInstances.collections; const collectionsData = Object.values(state.collections); collectionsChart.data.datasets[0].data = collectionsData.map(c => c.added); collectionsChart.data.datasets[1].data = collectionsData.map(c => -c.removed); // 移出数据设为负值 collectionsChart.update(); // 更新日志 const logContainer = document.querySelector('.zcp-progress-log'); if (!logContainer) return; const filter = state.filter; let filteredLogs; if (filter.type === 'all' || !filter.type) { filteredLogs = state.logs; } else if (filter.type === 'status') { if (filter.value === 'dryrun') { // '建议移动' 包含了所有 dryrun 状态下非 skipped 的条目 filteredLogs = state.logs.filter(log => log.status === 'dryrun'); } else { filteredLogs = state.logs.filter(log => log.status === filter.value); } } else if (filter.type === 'collection') { filteredLogs = state.logs.filter(log => log.sourceCollectionId === filter.value || log.targetCollectionId === filter.value ); } else { filteredLogs = state.logs; } logContainer.innerHTML = filteredLogs.map(log => { const sourceTitle = state.allCollectionsData.find(c => c.id === log.sourceCollectionId)?.title || '未知'; let pathHtml = ''; if (log.status === 'success' || (log.status === 'dryrun' && log.targetCollectionId)) { const targetTitle = state.allCollectionsData.find(c => c.id === log.targetCollectionId)?.title || '未知'; const dryRunTag = log.status === 'dryrun' ? '[试运行] ' : ''; pathHtml = ` <div class="zcp-log-path"> <span class="zcp-log-collection source">${sourceTitle}</span> <span class="zcp-log-arrow">→</span> <span class="zcp-log-collection target">${targetTitle}</span> ${log.status === 'success' ? ` <button class="${moveHistory.find(m => m.contentId === log.contentId)?.undone ? 'zcp-redo-btn' : 'zcp-undo-btn'}" data-content-id="${log.contentId}"> ${moveHistory.find(m => m.contentId === log.contentId)?.undone ? '重做' : '撤销'} </button>` : `<span class="zcp-log-status-text">${dryRunTag}</span>`} </div>`; } else if (log.status === 'skipped') { pathHtml = `<div class="zcp-log-path"><span class="zcp-log-status-text">分类未变: ${sourceTitle}</span></div>`; } else if (log.status === 'error') { pathHtml = `<div class="zcp-log-path"><span class="zcp-log-status-text">错误: ${log.message}</span></div>`; } return `<div class="zcp-log-item status-${log.status}" id="${log.id}"> <div class="zcp-log-title"><a href="${log.url}" target="_blank">${log.title}</a></div> ${pathHtml} </div>`; }).join(''); // 更新进度条 const completed = state.stats.moved + state.stats.skipped + state.stats.error; document.querySelector('.zcp-progress-stats').textContent = `${completed} / ${state.logs.length}`; } async function handleToggleActionClick(event) { const button = event.target.closest('.zcp-undo-btn, .zcp-redo-btn'); // 使用 closest 确保点到图标也能触发 if (!button) return; const { contentId } = button.dataset; const moveRecord = moveHistory.find(m => m.contentId === contentId); if (!moveRecord) return; const isUndoAction = button.classList.contains('zcp-undo-btn'); const originalText = button.textContent; button.disabled = true; button.textContent = isUndoAction ? '撤销中...' : '重做中...'; button.classList.add('zcp-shimmer'); try { if (isUndoAction) { // 执行撤销:移回 source await addToCollection(moveRecord.contentId, moveRecord.contentType, moveRecord.sourceCollectionId); await removeFromCollection(moveRecord.contentId, moveRecord.contentType, moveRecord.targetCollectionId); // 更新UI和状态 button.textContent = '重做'; button.classList.remove('zcp-undo-btn'); button.classList.add('zcp-redo-btn'); moveRecord.undone = true; // 标记为已撤销 } else { // 执行重做:移到 target await addToCollection(moveRecord.contentId, moveRecord.contentType, moveRecord.targetCollectionId); await removeFromCollection(moveRecord.contentId, moveRecord.contentType, moveRecord.sourceCollectionId); // 更新UI和状态 button.textContent = '撤销'; button.classList.remove('zcp-redo-btn'); button.classList.add('zcp-undo-btn'); moveRecord.undone = false; // 标记为未撤销(即已重做) } } catch (error) { console.error(`${originalText}失败:`, error); button.textContent = `${originalText}失败`; } finally { button.disabled = false; button.classList.remove('zcp-shimmer'); // } } // 处理一键撤销/重做的函数 async function handleBulkAction(modalOverlay) { const bulkButton = modalOverlay.querySelector('#zcp-bulk-action-btn'); const action = bulkButton.dataset.action; // 'undo' or 'redo' if (!action) return; bulkButton.disabled = true; bulkButton.textContent = action === 'undo' ? '正在一键撤销...' : '正在一键重做...'; bulkButton.classList.add('zcp-shimmer'); // const itemsToProcess = moveHistory.filter(move => action === 'undo' ? !move.undone : move.undone); let successCount = 0; let hasError = false; for (const move of itemsToProcess) { try { if (action === 'undo') { await addToCollection(move.contentId, move.contentType, move.sourceCollectionId); await removeFromCollection(move.contentId, move.contentType, move.targetCollectionId); move.undone = true; } else { // redo await addToCollection(move.contentId, move.contentType, move.targetCollectionId); await removeFromCollection(move.contentId, move.contentType, move.sourceCollectionId); move.undone = false; } successCount++; } catch (error) { console.error(`批量${action}失败于: ${move.contentId}`, error); hasError = true; break; // 一旦出错就停止 } } console.log(`成功批量${action}了 ${successCount} 项。`); // 批量更新UI updateDashboardUI(); // 更新按钮状态 if (hasError) { bulkButton.textContent = `操作中断,请重试`; } else { if (action === 'undo') { bulkButton.textContent = '一键重做'; bulkButton.dataset.action = 'redo'; } else { bulkButton.textContent = '一键撤销'; bulkButton.dataset.action = 'undo'; } } bulkButton.disabled = false; bulkButton.classList.remove('zcp-shimmer'); } /** * Worker 函数,并发处理任务 */ async function worker(taskQueue) { let task; // 使用更稳健的循环模式,将“取任务”和“检查是否存在”合并 // 当 taskQueue 为空时, taskQueue.shift() 返回 undefined, 循环会自动终止。 // 杜绝了多个 worker 竞争最后一个任务的 race condition。 while ((task = taskQueue.shift())) { const logEntry = progressDashboardState.logs.find(l => l.id === task.id); try { const contentText = await scrapeContent(task.url); if (contentText === '正文抓取失败') throw new Error('正文抓取失败'); const targetCollections = Object.values(progressDashboardState.collections) .map(c => progressDashboardState.allCollectionsData.find(ac => ac.title === c.title)); const prompt = buildOrganizationPrompt(contentText, task.title, targetCollections); const recommendedTitle = await callDeepSeek(prompt); const targetCollection = progressDashboardState.allCollectionsData.find(c => c.title === recommendedTitle); if (!targetCollection) throw new Error(`AI返回无效收藏夹名: "${recommendedTitle}"`); logEntry.targetCollectionId = targetCollection.id; if (targetCollection.id === task.sourceCollectionId) { logEntry.status = 'skipped'; } else if (progressDashboardState.isDryRun) { logEntry.status = 'dryrun'; } else { await addToCollection(task.contentId, task.contentType, targetCollection.id); await removeFromCollection(task.contentId, task.contentType, task.sourceCollectionId); logEntry.status = 'success'; moveHistory.push({ contentId: task.contentId, contentType: task.contentType, sourceCollectionId: task.sourceCollectionId, targetCollectionId: targetCollection.id, undone: false }); } } catch (error) { console.error(`任务失败 [${task.title}]:`, error); logEntry.status = 'error'; logEntry.message = error.message; } finally { // 更新统计数据 progressDashboardState.stats.pending--; // 简化和修正统计逻辑 if (logEntry.status === 'success' || (logEntry.status === 'dryrun' && logEntry.targetCollectionId !== task.sourceCollectionId)) { // 在 dryrun 模式下,只有当目标与源不同时,才算作 '建议移动' progressDashboardState.stats.moved++; progressDashboardState.collections[task.sourceCollectionId].removed++; if(logEntry.targetCollectionId) { progressDashboardState.collections[logEntry.targetCollectionId].added++; } } else if (logEntry.status === 'skipped') { progressDashboardState.stats.skipped++; } else if (logEntry.status === 'error') { progressDashboardState.stats.error++; } // 为了让饼图标签在试运行时显示正确,临时修改 if (progressDashboardState.isDryRun) { chartInstances.status.data.labels[0] = '建议移动'; } // 调度UI更新 requestAnimationFrame(updateDashboardUI); } } } // --- 3. 功能一:AI 生成描述 --- /** * 脚本主入口,检测页面并注入按钮 */ function init() { const path = window.location.pathname; const observer = new MutationObserver((mutationsList, obs) => { // 路由分发 if (path.startsWith('/collections/mine')) { // 使用稳定、可读的 CSS class 选择器 const actionsContainer = document.querySelector('.CollectionsHeader-mainContent'); if (actionsContainer && !document.getElementById('zcp-organize-btn')) { injectOrganizeButton(actionsContainer); obs.disconnect(); } } // 匹配个人收藏夹列表页: /people/xxx/collections else if (path.includes('/people/') && path.includes('/collections')) { // 优先选用新版 class let actionsContainer = document.querySelector('.CollectionsHeader-mainContent'); if (!actionsContainer) { actionsContainer = document.querySelector('.Profile-main .Profile-sideColumn'); } if (actionsContainer && !document.getElementById('zcp-organize-btn')) { // 如果是 Profile-sideColumn,插入一个容器 if (actionsContainer.classList.contains('Profile-sideColumn')) { const btnContainer = document.createElement('div'); btnContainer.style.marginBottom = '12px'; actionsContainer.prepend(btnContainer); injectOrganizeButton(btnContainer); } else { injectOrganizeButton(actionsContainer); } obs.disconnect(); } } // 匹配单个收藏夹详情页: /collection/xxx else if (path.startsWith('/collection/')) { const actionsContainer = document.querySelector('.CollectionDetailPageHeader-actions'); if (actionsContainer && !document.getElementById('zcp-ai-btn')) { injectAIButton(actionsContainer); obs.disconnect(); } } }); observer.observe(document.body, { childList: true, subtree: true }); } /** * 注入 AI 按钮到页面 */ function injectAIButton(container) { const aiButton = document.createElement('button'); aiButton.id = 'zcp-ai-btn'; aiButton.className = 'zcp-ai-button'; aiButton.title = " AI 生成描述"; // 使用您提供的新 SVG 图标,并进行优化 const svgIcon = ` <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor"> <path d="M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z"/> </svg> `; const textSpan = document.createElement('span'); textSpan.textContent = 'AI 描述'; textSpan.style.fontSize = '14px'; textSpan.style.display = 'inline-flex'; textSpan.style.alignItems = 'center'; // -------------------- aiButton.innerHTML = svgIcon; aiButton.appendChild(textSpan); container.appendChild(aiButton); aiButton.addEventListener('click', handleGenerateDescription); } /** * 点击 AI 按钮后的主处理函数 */ async function handleGenerateDescription(event) { const button = event.currentTarget; const originalContent = button.innerHTML; // 步骤 1: 立即锁定按钮尺寸并显示加载动画 const { width, height } = button.getBoundingClientRect(); button.style.width = `${width}px`; button.style.height = `${height}px`; button.innerHTML = `<div class="zcp-spinner"></div>`; button.disabled = true; await new Promise(resolve => setTimeout(resolve, 50)); // 步骤 3: 现在开始执行耗时的抓取和 AI 调用 console.log('%c[知乎收藏夹 Pro] 开始生成描述...', 'color: white; background-color: #056DE8; padding: 2px 5px; border-radius: 3px;'); try { console.log("开始采集文章..."); const articles = await scrapeCollectionPage(); if (articles.length === 0) { alert('未能采集到页面上的任何文章内容,请确保页面上有文章列表。'); // finally 块会自动处理按钮的恢复 return; } console.log(`%c[知乎收藏夹 Pro] 数据采集完成:`, 'color: #056DE8; font-weight: bold;'); console.log(`- 成功采集到 ${articles.length} 篇文章,将用于生成描述。`); console.log("构建 Prompt..."); const prompt = buildDescriptionPrompt(articles); console.log("调用 AI API..."); const generatedDescription = await callDeepSeek(prompt); console.log("AI 已生成描述。"); showDescriptionConfirmModal(generatedDescription); } catch (error) { console.error('生成描述失败:', error); alert(`生成描述时出错: ${error.message || error}`); } finally { // 步骤 4: 无论成功或失败,最后都恢复按钮的原始状态 button.innerHTML = originalContent; button.disabled = false; button.style.width = ''; button.style.height = ''; } } /** * 从收藏夹页面抓取文章数据 */ async function scrapeCollectionPage() { const articlesData = []; // 使用更稳定的 class 选择器 const itemElements = document.querySelectorAll('.CollectionDetailPageItem'); const itemsToProcess = Array.from(itemElements).slice(0, 18); // 最多处理18篇(1页) // 添加日志,告知开始处理单篇文章 console.log('- 开始逐篇处理文章内容...'); for (const item of itemsToProcess) { try { // 如果文章内容是折叠的,点击“阅读全文” const moreButton = item.querySelector('.ContentItem-more'); if (moreButton) { // 使用一个 Promise 来等待内容加载 await new Promise(resolve => { const contentObserver = new MutationObserver(() => { // 当 "阅读全文" 按钮消失时,我们认为内容已加载 if (!item.querySelector('.ContentItem-more')) { contentObserver.disconnect(); resolve(); } }); contentObserver.observe(item, { childList: true, subtree: true }); moreButton.click(); // 设置一个超时,防止无限等待 setTimeout(() => { contentObserver.disconnect(); resolve(); }, 2000); }); } const titleElement = item.querySelector('.ContentItem-title a'); const contentElement = item.querySelector('.RichText.ztext'); if (titleElement && contentElement) { const title = titleElement.innerText.trim(); const content = contentElement.innerText.trim(); // 打印单篇文章的标题和字数 console.log(` - 已处理: "${title}" (内容字数: ${content.length})`); articlesData.push({ title, content }); } } catch (e) { console.warn("处理单个文章时出错,已跳过:", e); } } return articlesData; } /** * 构建发送给 AI 的 Prompt */ function buildDescriptionPrompt(articles) { let articleText = articles .map(a => `- 标题: ${a.title}\n 正文摘要: ${a.content.substring(0, 250).replace(/\s+/g, ' ')}...`) .join('\n\n'); return `根据以下来自知乎收藏夹的文章标题和正文,为这个收藏夹生成一段话精炼的描述。不要列举介绍各篇文章的主题,宏观一点。介绍是给自己看的,直接介绍,开头不需要诸如"本收藏夹……",不需要诸如"特别适合xxx的人"的话,字数不超过50字,不需要在最后输出字数统计。 文章列表: ${articleText} 请生成描述:`; } /** * 显示包含 AI 生成描述的确认模态框 */ function showDescriptionConfirmModal(description) { // 创建模态框 const overlay = document.createElement('div'); overlay.className = 'zcp-modal-overlay'; overlay.innerHTML = ` <div class="zcp-modal-container"> <div class="zcp-modal-header">AI 生成的描述</div> <div class="zcp-modal-content"> <textarea id="zcp-desc-textarea">${description}</textarea> </div> <div class="zcp-modal-actions"> <button id="zcp-cancel-btn" class="zcp-modal-button secondary">取消</button> <button id="zcp-apply-btn" class="zcp-modal-button primary">应用</button> </div> </div> `; document.body.appendChild(overlay); // 添加事件监听 const closeModal = () => document.body.removeChild(overlay); overlay.querySelector('#zcp-cancel-btn').addEventListener('click', closeModal); overlay.querySelector('#zcp-apply-btn').addEventListener('click', async () => { const newDescription = overlay.querySelector('#zcp-desc-textarea').value; try { await applyDescriptionChange(newDescription); alert("收藏夹描述更新成功!"); closeModal(); } catch (e) { alert(`更新失败: ${e.message}`); } }); } /** * 调用知乎 API,应用描述更改 */ async function applyDescriptionChange(newDescription) { const collectionId = window.location.pathname.split('/').pop(); const title = document.querySelector('.CollectionDetailPageHeader-title').innerText; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'PUT', url: `/api/v4/collections/${collectionId}`, headers: getZhihuApiHeaders(), data: JSON.stringify({ title: title, // 知乎API要求必须同时提交标题 description: newDescription, }), onload: function(response) { if (response.status === 200) { const descElement = document.querySelector('.CollectionDetailPageHeader-description'); if (descElement) { descElement.innerText = newDescription; } else { // 如果原先没有描述,刷新页面以显示新描述 window.location.reload(); } resolve(response); } else { console.error('更新收藏夹描述失败:', response); try { const errorInfo = JSON.parse(response.responseText); reject(new Error(errorInfo.error.message || `HTTP ${response.status}`)); } catch(e) { reject(new Error(`请求失败,HTTP 状态码: ${response.status}`)); } } }, onerror: (err) => reject(new Error('网络请求错误')) }); }); } // --- 4. 用户设置 --- GM_registerMenuCommand('设置 DeepSeek API Key', () => { const currentKey = GM_getValue('deepseek_api_key', ''); const newKey = prompt('请输入你的 DeepSeek API Key:', currentKey); if (newKey !== null) { // 允许用户设置为空 GM_setValue('deepseek_api_key', newKey); alert('API Key 已保存!'); } }); // --- 启动脚本 (处理 Chart.js 的异步加载) --- // 1. 读取 Chart.js 的代码 const chartJsCode = GM_getResourceText('CHART_JS'); if (chartJsCode) { try { // 2. 在脚本的沙箱环境中直接执行代码 eval(chartJsCode); // 3. 验证一下 if (typeof Chart !== 'undefined') { console.log('[知乎收藏夹 Pro] Chart.js 库加载并执行成功!'); // 4. 立即启动主逻辑 init(); } else { throw new Error('Chart object not found after eval.'); } } catch (e) { console.error('[知乎收藏夹 Pro] 执行依赖库时出错:', e); alert('知乎收藏夹 Pro:加载依赖库时发生错误,请查看控制台。'); } } else { alert('知乎收藏夹 Pro:无法获取依赖库 Chart.js 的内容,脚本无法运行。'); } })();