提取知乎回答,直答总结,并按核心内容自动分成4类
// ==UserScript==
// @name archiving_answers
// @namespace https://zhihu.com/
// @version 1.1
// @description 提取知乎回答,直答总结,并按核心内容自动分成4类
// @author Archimon
// @license MIT
// @match https://www.zhihu.com/question/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function () {
'use strict';
const STORAGE_TOKEN = 'zhida_summarizer_token';
let accessToken = GM_getValue(STORAGE_TOKEN, '');
let isProcessing = false;
let abortFlag = false;
let summaryResults = []; // { author, vote, timeStr, timeISO, summary, url, category? }
let answersData = [];
// ---------- 样式 ----------
GM_addStyle(`
#zhida-summarizer-panel {
position: fixed; right: 16px; top: 80px; width: 420px; max-height: 80vh;
background: #fff; border: 1px solid #dcdcdc; border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12); z-index: 2147483640;
display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
resize: both; overflow: hidden;
}
#zhida-summarizer-header {
padding: 10px 14px; background: #056de8; color: white; font-weight: 600;
border-radius: 12px 12px 0 0; display: flex; justify-content: space-between; align-items: center; cursor: move;
}
#zhida-summarizer-header button { background: none; border: none; color: white; font-size: 18px; cursor: pointer; }
#zhida-summarizer-body {
flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px;
}
#zhida-summarizer-token-row { display: flex; gap: 6px; align-items: center; font-size: 13px; }
#zhida-summarizer-token-row input { flex: 1; padding: 4px 8px; border: 1px solid #ccc; border-radius: 6px; }
#zhida-summarizer-controls { display: flex; gap: 8px; flex-wrap: wrap; }
#zhida-summarizer-controls button { padding: 6px 12px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; background: #f0f0f0; }
#zhida-summarizer-start { background: #10b981; color: white; }
#zhida-summarizer-stop { background: #ef4444; color: white; }
#zhida-summarizer-classify { background: #8b5cf6; color: white; display: none; }
#zhida-summarizer-sort { display: flex; gap: 8px; font-size: 13px; align-items: center; }
#zhida-summarizer-filter { display: flex; gap: 6px; align-items: center; font-size: 13px; margin-top: 4px; }
#zhida-summarizer-progress { font-size: 13px; color: #6b7280; }
.zhida-summary-card {
border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px; font-size: 13px; transition: background 0.2s;
}
.zhida-summary-meta { display: flex; justify-content: space-between; color: #6b7280; margin-bottom: 4px; font-size: 12px; }
.zhida-summary-content { color: #1f2937; white-space: pre-wrap; line-height: 1.5; }
.zhida-summary-error { color: #ef4444; }
.zhida-category-tag { display: inline-block; background: #e0e7ff; color: #3730a3; padding: 1px 6px; border-radius: 10px; font-size: 11px; margin-right: 4px; }
.zhida-category-header { font-weight: 600; margin-top: 12px; color: #4f46e5; cursor: pointer; }
#zhida-summarizer-move-hint { font-size: 11px; color: white; margin-left: 8px; opacity: 0.7; }
`);
// ---------- DOM 构建 ----------
const panel = document.createElement('div');
panel.id = 'zhida-summarizer-panel';
panel.innerHTML = `
<div id="zhida-summarizer-header">
<span>📄 回答总结器 <span id="zhida-summarizer-move-hint">(可拖动)</span></span>
<button id="zhida-summarizer-toggle">−</button>
</div>
<div id="zhida-summarizer-body">
<div id="zhida-summarizer-token-row">
<input type="password" id="zhida-token" placeholder="知乎直答 Access Secret" />
<button id="zhida-save-token">保存</button>
</div>
<div id="zhida-summarizer-controls">
<button id="zhida-summarizer-start">🚀 开始总结</button>
<button id="zhida-summarizer-stop" style="display:none;">⏹ 停止</button>
<button id="zhida-summarizer-classify">📊 内容分类</button>
</div>
<div id="zhida-summarizer-progress"></div>
<div id="zhida-summarizer-sort" style="display:none;">
<span>排序:</span>
<button data-sort="votes">👍 赞数</button>
<button data-sort="time">🕒 时间</button>
</div>
<div id="zhida-summarizer-filter" style="display:none;">
<span>分类:</span>
<select id="zhida-category-select">
<option value="all">全部</option>
</select>
</div>
<div id="zhida-summarizer-results"></div>
</div>
`;
document.body.appendChild(panel);
// 元素引用
const tokenInput = document.getElementById('zhida-token');
const saveTokenBtn = document.getElementById('zhida-save-token');
const startBtn = document.getElementById('zhida-summarizer-start');
const stopBtn = document.getElementById('zhida-summarizer-stop');
const classifyBtn = document.getElementById('zhida-summarizer-classify');
const progressDiv = document.getElementById('zhida-summarizer-progress');
const sortDiv = document.getElementById('zhida-summarizer-sort');
const filterDiv = document.getElementById('zhida-summarizer-filter');
const resultsDiv = document.getElementById('zhida-summarizer-results');
const toggleBtn = document.getElementById('zhida-summarizer-toggle');
const bodyDiv = document.getElementById('zhida-summarizer-body');
const categorySelect = document.getElementById('zhida-category-select');
tokenInput.value = accessToken;
saveTokenBtn.addEventListener('click', () => {
accessToken = tokenInput.value.trim();
GM_setValue(STORAGE_TOKEN, accessToken);
alert('Token 已保存');
});
// 折叠展开
toggleBtn.addEventListener('click', () => {
if (bodyDiv.style.display === 'none') {
bodyDiv.style.display = '';
toggleBtn.textContent = '−';
} else {
bodyDiv.style.display = 'none';
toggleBtn.textContent = '+';
}
});
// 拖动
let drag = false, ox, oy;
panel.querySelector('#zhida-summarizer-header').addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
drag = true;
const rect = panel.getBoundingClientRect();
ox = e.clientX - rect.left;
oy = e.clientY - rect.top;
panel.style.transition = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!drag) return;
panel.style.left = (e.clientX - ox) + 'px';
panel.style.top = (e.clientY - oy) + 'px';
panel.style.right = 'auto';
});
document.addEventListener('mouseup', () => {
drag = false;
panel.style.transition = '';
});
// ---------- 回答提取(去重修复) ----------
function extractAnswers() {
// 使用知乎当前稳定选择器,并避免重复
const answerCards = document.querySelectorAll('.AnswerItem');
const processedElements = new Set();
const results = [];
const MAX_ANSWERS = 30;
answerCards.forEach((card) => {
if (results.length >= MAX_ANSWERS) return;
if (processedElements.has(card)) return;
processedElements.add(card);
const authorEl = card.querySelector('.AuthorInfo-name, .UserLink-link, [itemprop="author"] [itemprop="name"]');
const author = authorEl ? authorEl.textContent.trim() : '匿名用户';
const voteEl = card.querySelector('.VoteButton--up, [class*="VoteButton"]');
let vote = 0;
if (voteEl) {
const t = voteEl.textContent.trim().replace(/[^0-9]/g, '');
vote = parseInt(t, 10) || 0;
}
const contentEl = card.querySelector('.RichText, [itemprop="text"]');
const fullText = contentEl ? contentEl.textContent.trim() : '';
let timeISO = null, timeStr = '未知时间';
const timeEl = card.querySelector('time');
if (timeEl) {
timeISO = timeEl.getAttribute('datetime') || null;
timeStr = timeEl.textContent.trim();
} else {
const dateEl = card.querySelector('[data-tooltip]');
if (dateEl) {
const tooltip = dateEl.getAttribute('data-tooltip');
if (tooltip && /\d{4}-\d{2}-\d{2}/.test(tooltip)) {
timeISO = tooltip.replace(' ', 'T') + ':00';
timeStr = tooltip;
}
}
}
let url = '';
const linkEl = card.querySelector('a[href*="/answer/"]');
if (linkEl) url = linkEl.href;
if (fullText) {
results.push({ author, vote, content: fullText, timeStr, timeISO, url });
}
});
return results;
}
// ---------- 调用直答 ----------
function callZhida(messages, stream = false) {
if (!accessToken) throw new Error('Token 未配置');
const timestamp = Math.floor(Date.now() / 1000);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://developer.zhihu.com/v1/chat/completions',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'X-Request-Timestamp': String(timestamp)
},
data: JSON.stringify({
model: 'zhida-fast-1p5',
messages: messages,
stream: false
}),
timeout: 30000,
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
if (data.error) reject(data.error.message);
else resolve(data.choices[0].message.content);
} catch (e) { reject('解析失败'); }
},
onerror: () => reject('网络错误'),
ontimeout: () => reject('超时')
});
});
}
function summarizeSingle(text, index) {
const prompt = `请用一段话(不超过150字)总结以下知乎回答的核心观点,不要添加额外评论:\n\n${text.slice(0, 2000)}`;
return callZhida([{ role: 'user', content: prompt }]);
}
// ---------- 智能分类 ----------
async function classifySummaries() {
if (summaryResults.length === 0) {
alert('暂无总结,请先完成总结');
return;
}
progressDiv.textContent = '正在进行内容分类...';
classifyBtn.disabled = true;
// 构建带有索引的摘要列表
const summaryList = summaryResults.map((item, idx) => `${idx}. ${item.summary}`).join('\n');
const prompt = `以下是我整理的知乎回答摘要,请根据核心内容(如立场、主题、观点倾向等)将它们分成4个类别,并为每个类别起一个简短名称。\n要求:\n1. 输出一个JSON数组,每个元素包含 "category" (类别名称) 和 "indices" (属于该类别的摘要序号数组,从0开始)。\n2. 只输出JSON,不要有额外文字。\n\n摘要列表:\n${summaryList}`;
try {
const response = await callZhida([{ role: 'user', content: prompt }]);
// 尝试解析 AI 返回的 JSON
let categories = null;
try {
// 防止 AI 返回带代码块标记
const jsonStr = response.replace(/```json|```/g, '').trim();
categories = JSON.parse(jsonStr);
} catch (e) {
// 备选:简单文本解析
progressDiv.textContent = '分类结果解析失败,请重试';
classifyBtn.disabled = false;
return;
}
// 将分类信息合并到 summaryResults
const indexToCategory = {};
categories.forEach(cat => {
if (Array.isArray(cat.indices)) {
cat.indices.forEach(i => {
indexToCategory[i] = cat.category;
});
}
});
summaryResults.forEach((item, idx) => {
item.category = indexToCategory[idx] || '未分类';
});
// 更新分类下拉菜单
const uniqueCats = [...new Set(summaryResults.map(r => r.category))];
categorySelect.innerHTML = '<option value="all">全部</option>';
uniqueCats.forEach(cat => {
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat;
categorySelect.appendChild(opt);
});
filterDiv.style.display = 'flex';
progressDiv.textContent = `分类完成,共分为 ${uniqueCats.length} 种类别`;
renderResults(summaryResults, 'votes', categorySelect.value);
} catch (err) {
progressDiv.textContent = `分类失败:${err}`;
} finally {
classifyBtn.disabled = false;
}
}
// ---------- 渲染与排序 ----------
function renderResults(list, sortBy, filterCategory = 'all') {
let filtered = list;
if (filterCategory !== 'all') {
filtered = list.filter(r => r.category === filterCategory);
}
const sorted = [...filtered].sort((a, b) => {
if (sortBy === 'votes') return b.vote - a.vote;
if (sortBy === 'time') {
const ta = a.timeISO ? new Date(a.timeISO).getTime() : 0;
const tb = b.timeISO ? new Date(b.timeISO).getTime() : 0;
return tb - ta || 0;
}
return 0;
});
resultsDiv.innerHTML = sorted.map(r => `
<div class="zhida-summary-card ${r.error ? 'zhida-summary-error' : ''}">
<div class="zhida-summary-meta">
<span>
${r.category ? `<span class="zhida-category-tag">${r.category}</span>` : ''}
<strong>${r.author}</strong> 👍 ${r.vote} · ${r.timeStr}
</span>
${r.url ? `<a href="${r.url}" target="_blank" style="font-size:12px;">查看原文</a>` : ''}
</div>
<div class="zhida-summary-content">${r.summary}</div>
</div>
`).join('');
}
// 排序事件
sortDiv.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
const type = e.target.dataset.sort;
if (type) renderResults(summaryResults, type, categorySelect.value);
}
});
// 分类筛选事件
categorySelect.addEventListener('change', () => {
renderResults(summaryResults, 'votes', categorySelect.value); // 保持当前排序?简单默认赞数
});
// 开始总结
async function startSummarization() {
if (!accessToken) { alert('请先填入并保存 Access Secret'); return; }
if (isProcessing) return;
isProcessing = true;
abortFlag = false;
startBtn.style.display = 'none';
stopBtn.style.display = 'inline-block';
classifyBtn.style.display = 'none';
progressDiv.textContent = '正在提取回答...';
resultsDiv.innerHTML = '';
summaryResults = [];
filterDiv.style.display = 'none';
answersData = extractAnswers();
if (answersData.length === 0) {
progressDiv.textContent = '未找到任何回答,请滚动页面加载更多';
resetButtons();
return;
}
progressDiv.textContent = `已提取 ${answersData.length} 个回答,开始总结...`;
for (let i = 0; i < answersData.length; i++) {
if (abortFlag) break;
const ans = answersData[i];
progressDiv.textContent = `总结第 ${i+1}/${answersData.length} 个 (${ans.author})...`;
try {
const summary = await summarizeSingle(ans.content, i);
summaryResults.push({
author: ans.author,
vote: ans.vote,
timeStr: ans.timeStr,
timeISO: ans.timeISO,
summary: summary,
url: ans.url,
});
} catch (err) {
summaryResults.push({
author: ans.author,
vote: ans.vote,
timeStr: ans.timeStr,
timeISO: ans.timeISO,
summary: `❌ ${err}`,
url: ans.url,
error: true
});
}
if (i < answersData.length - 1 && !abortFlag) {
await new Promise(r => setTimeout(r, 1000)); // 延迟1秒
}
}
progressDiv.textContent = abortFlag ? '已停止' : `总结完成,共 ${summaryResults.length} 个回答`;
sortDiv.style.display = 'flex';
classifyBtn.style.display = 'inline-block'; // 显示分类按钮
renderResults(summaryResults, 'votes');
resetButtons();
}
function resetButtons() {
isProcessing = false;
startBtn.style.display = 'inline-block';
stopBtn.style.display = 'none';
}
// 停止
stopBtn.addEventListener('click', () => {
abortFlag = true;
progressDiv.textContent = '正在停止...';
});
// 分类按钮
classifyBtn.addEventListener('click', classifySummaries);
// 启动
startBtn.addEventListener('click', startSummarization);
})();