archiving_answers

提取知乎回答,直答总结,并按核心内容自动分成4类

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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);

})();