// ==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 的内容,脚本无法运行。');
}
})();